mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-24 00:09:32 +00:00
Compare commits
8 Commits
docs/weekl
...
pysssss/dy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b053516eec | ||
|
|
6efa56daa7 | ||
|
|
67f366d734 | ||
|
|
946aea1f47 | ||
|
|
bd38a094e4 | ||
|
|
1807e0db6d | ||
|
|
e7e26ce28b | ||
|
|
24cbf4f68c |
@@ -13,6 +13,7 @@ import { ComfyTemplates } from '../helpers/templates'
|
|||||||
import { ComfyMouse } from './ComfyMouse'
|
import { ComfyMouse } from './ComfyMouse'
|
||||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||||
|
import { PropertiesPanel } from './components/PropertiesPanel'
|
||||||
import { SettingDialog } from './components/SettingDialog'
|
import { SettingDialog } from './components/SettingDialog'
|
||||||
import {
|
import {
|
||||||
NodeLibrarySidebarTab,
|
NodeLibrarySidebarTab,
|
||||||
@@ -26,32 +27,20 @@ dotenv.config()
|
|||||||
|
|
||||||
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
|
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
|
||||||
|
|
||||||
class ComfyPropertiesPanel {
|
|
||||||
readonly root: Locator
|
|
||||||
readonly panelTitle: Locator
|
|
||||||
readonly searchBox: Locator
|
|
||||||
|
|
||||||
constructor(readonly page: Page) {
|
|
||||||
this.root = page.getByTestId('properties-panel')
|
|
||||||
this.panelTitle = this.root.locator('h3')
|
|
||||||
this.searchBox = this.root.getByPlaceholder('Search...')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ComfyMenu {
|
class ComfyMenu {
|
||||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||||
private _topbar: Topbar | null = null
|
private _topbar: Topbar | null = null
|
||||||
|
|
||||||
public readonly sideToolbar: Locator
|
public readonly sideToolbar: Locator
|
||||||
public readonly propertiesPanel: ComfyPropertiesPanel
|
public readonly propertiesPanel: PropertiesPanel
|
||||||
public readonly themeToggleButton: Locator
|
public readonly themeToggleButton: Locator
|
||||||
public readonly saveButton: Locator
|
public readonly saveButton: Locator
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
constructor(public readonly page: Page) {
|
||||||
this.sideToolbar = page.locator('.side-tool-bar-container')
|
this.sideToolbar = page.locator('.side-tool-bar-container')
|
||||||
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
|
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
|
||||||
this.propertiesPanel = new ComfyPropertiesPanel(page)
|
this.propertiesPanel = new PropertiesPanel(page)
|
||||||
this.saveButton = page
|
this.saveButton = page
|
||||||
.locator('button[title="Save the current workflow"]')
|
.locator('button[title="Save the current workflow"]')
|
||||||
.nth(0)
|
.nth(0)
|
||||||
@@ -1583,6 +1572,31 @@ export class ComfyPage {
|
|||||||
return window['app'].graph.nodes
|
return window['app'].graph.nodes
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isInSubgraph(): Promise<boolean> {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
const graph = window['app'].canvas.graph
|
||||||
|
return graph?.constructor?.name === 'Subgraph'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNode(
|
||||||
|
nodeType: string,
|
||||||
|
position: Position = { x: 200, y: 200 }
|
||||||
|
): Promise<NodeReference> {
|
||||||
|
const nodeId = await this.page.evaluate(
|
||||||
|
({ nodeType, pos }) => {
|
||||||
|
const node = window['LiteGraph'].createNode(nodeType)
|
||||||
|
if (!node) throw new Error(`Failed to create node: ${nodeType}`)
|
||||||
|
window['app'].graph.add(node)
|
||||||
|
node.pos = [pos.x, pos.y]
|
||||||
|
return node.id
|
||||||
|
},
|
||||||
|
{ nodeType, pos: position }
|
||||||
|
)
|
||||||
|
await this.nextFrame()
|
||||||
|
return this.getNodeRefById(nodeId)
|
||||||
|
}
|
||||||
async waitForGraphNodes(count: number) {
|
async waitForGraphNodes(count: number) {
|
||||||
await this.page.waitForFunction((count) => {
|
await this.page.waitForFunction((count) => {
|
||||||
return window['app']?.canvas.graph?.nodes?.length === count
|
return window['app']?.canvas.graph?.nodes?.length === count
|
||||||
|
|||||||
94
browser_tests/fixtures/components/PropertiesPanel.ts
Normal file
94
browser_tests/fixtures/components/PropertiesPanel.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
export class PropertiesPanel {
|
||||||
|
readonly root: Locator
|
||||||
|
readonly panelTitle: Locator
|
||||||
|
readonly searchBox: Locator
|
||||||
|
|
||||||
|
constructor(readonly page: Page) {
|
||||||
|
this.root = page.getByTestId('properties-panel')
|
||||||
|
this.panelTitle = this.root.locator('h3')
|
||||||
|
this.searchBox = this.root.getByPlaceholder('Search...')
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureOpen() {
|
||||||
|
const isOpen = await this.root.isVisible()
|
||||||
|
if (!isOpen) {
|
||||||
|
await this.page.getByLabel('Toggle properties panel').click()
|
||||||
|
await this.root.waitFor({ state: 'visible' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
const isOpen = await this.root.isVisible()
|
||||||
|
if (isOpen) {
|
||||||
|
await this.page.getByLabel('Toggle properties panel').click()
|
||||||
|
await this.root.waitFor({ state: 'hidden' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async promoteWidget(widgetName: string) {
|
||||||
|
await this.ensureOpen()
|
||||||
|
|
||||||
|
// Check if widget is already visible in Advanced Inputs section
|
||||||
|
const widgetRow = this.root
|
||||||
|
.locator('[class*="widget-item"], [class*="input-item"]')
|
||||||
|
.filter({ hasText: widgetName })
|
||||||
|
.first()
|
||||||
|
const isAdvancedExpanded = await widgetRow.isVisible()
|
||||||
|
|
||||||
|
if (!isAdvancedExpanded) {
|
||||||
|
// Click on Advanced Inputs to expand it
|
||||||
|
const advancedInputsButton = this.root
|
||||||
|
.getByRole('button')
|
||||||
|
.filter({ hasText: /advanced inputs/i })
|
||||||
|
await advancedInputsButton.click()
|
||||||
|
await widgetRow.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and click the more options button
|
||||||
|
const moreButton = widgetRow.locator('button').filter({
|
||||||
|
has: this.page.locator('[class*="lucide--more-vertical"]')
|
||||||
|
})
|
||||||
|
await moreButton.click()
|
||||||
|
|
||||||
|
// Click "Show input" to promote the widget
|
||||||
|
await this.page.getByText('Show input').click()
|
||||||
|
|
||||||
|
// Close and reopen panel to refresh the UI state
|
||||||
|
await this.close()
|
||||||
|
await this.ensureOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
async demoteWidget(widgetName: string) {
|
||||||
|
await this.ensureOpen()
|
||||||
|
|
||||||
|
// Check if INPUTS section content is already visible
|
||||||
|
const widgetRow = this.root.locator('span').getByText(widgetName).first()
|
||||||
|
const isInputsExpanded = await widgetRow.isVisible()
|
||||||
|
|
||||||
|
if (!isInputsExpanded) {
|
||||||
|
// Click on INPUTS section to expand it (where promoted widgets appear)
|
||||||
|
const inputsButton = this.root
|
||||||
|
.getByRole('button')
|
||||||
|
.filter({ hasText: /^inputs$/i })
|
||||||
|
await inputsButton.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await widgetRow.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
|
||||||
|
// Find the more options button in the widget-item-header
|
||||||
|
const moreButton = widgetRow
|
||||||
|
.locator('xpath=ancestor::*[contains(@class, "widget-item-header")]')
|
||||||
|
.locator('button')
|
||||||
|
.filter({
|
||||||
|
has: this.page.locator('[class*="more-vertical"], [class*="lucide"]')
|
||||||
|
})
|
||||||
|
.first()
|
||||||
|
|
||||||
|
await moreButton.click()
|
||||||
|
|
||||||
|
// Click "Hide input" to demote the widget
|
||||||
|
await this.page.getByText('Hide input').click()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,11 @@ export class Topbar {
|
|||||||
await tab.locator('.close-button').click({ force: true })
|
await tab.locator('.close-button').click({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async switchToTab(index: number) {
|
||||||
|
const tabs = this.page.locator('.workflow-tabs button')
|
||||||
|
await tabs.nth(index).click()
|
||||||
|
}
|
||||||
|
|
||||||
getSaveDialog(): Locator {
|
getSaveDialog(): Locator {
|
||||||
return this.page.locator('.p-dialog-content input')
|
return this.page.locator('.p-dialog-content input')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,26 @@ class NodeWidgetReference {
|
|||||||
[this.node.id, this.index] as const
|
[this.node.id, this.index] as const
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setValue(value: unknown, useCanvasGraph = false) {
|
||||||
|
await this.node.comfyPage.page.evaluate(
|
||||||
|
([id, index, val, useCanvas]) => {
|
||||||
|
const graph = useCanvas
|
||||||
|
? window['app'].canvas.graph
|
||||||
|
: window['app'].graph
|
||||||
|
const node = graph.getNodeById(id)
|
||||||
|
if (!node) throw new Error(`Node ${id} not found.`)
|
||||||
|
const widget = node.widgets[index]
|
||||||
|
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||||
|
widget.value = val
|
||||||
|
if (widget.callback) {
|
||||||
|
widget.callback(val, window['app'].canvas, node, null, null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[this.node.id, this.index, value, useCanvasGraph] as const
|
||||||
|
)
|
||||||
|
await this.node.comfyPage.nextFrame()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export class NodeReference {
|
export class NodeReference {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -339,8 +359,43 @@ export class NodeReference {
|
|||||||
async getWidget(index: number) {
|
async getWidget(index: number) {
|
||||||
return new NodeWidgetReference(index, this)
|
return new NodeWidgetReference(index, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getWidgetByName(
|
||||||
|
name: string,
|
||||||
|
useCanvasGraph = false
|
||||||
|
): Promise<NodeWidgetReference | null> {
|
||||||
|
const index = await this.comfyPage.page.evaluate(
|
||||||
|
([id, widgetName, useCanvas]) => {
|
||||||
|
const graph = useCanvas
|
||||||
|
? window['app'].canvas.graph
|
||||||
|
: window['app'].graph
|
||||||
|
const node = graph.getNodeById(id)
|
||||||
|
if (!node?.widgets) return -1
|
||||||
|
return node.widgets.findIndex(
|
||||||
|
(w: { name: string }) => w.name === widgetName
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[this.id, name, useCanvasGraph] as const
|
||||||
|
)
|
||||||
|
if (index === -1) return null
|
||||||
|
return new NodeWidgetReference(index, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWidgets(): Promise<
|
||||||
|
Array<{ name: string; visible: boolean; value: unknown }>
|
||||||
|
> {
|
||||||
|
return await this.comfyPage.page.evaluate((id) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
if (!node?.widgets) return []
|
||||||
|
|
||||||
|
return node.widgets.map((w) => {
|
||||||
|
const isHidden = w.hidden === true || w.options?.hidden === true
|
||||||
|
return { name: w.name, visible: !isHidden, value: w.value }
|
||||||
|
})
|
||||||
|
}, this.id)
|
||||||
|
}
|
||||||
async click(
|
async click(
|
||||||
position: 'title' | 'collapse',
|
position: 'title' | 'collapse' | 'subgraph',
|
||||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||||
) {
|
) {
|
||||||
const nodePos = await this.getPosition()
|
const nodePos = await this.getPosition()
|
||||||
@@ -353,6 +408,9 @@ export class NodeReference {
|
|||||||
case 'collapse':
|
case 'collapse':
|
||||||
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
|
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
|
||||||
break
|
break
|
||||||
|
case 'subgraph':
|
||||||
|
clickPos = { x: nodePos.x + nodeSize.width - 15, y: nodePos.y - 15 }
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid click position ${position}`)
|
throw new Error(`Invalid click position ${position}`)
|
||||||
}
|
}
|
||||||
|
|||||||
324
browser_tests/tests/dynamicWidgetsSubgraph.spec.ts
Normal file
324
browser_tests/tests/dynamicWidgetsSubgraph.spec.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||||
|
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('Dynamic Combo Widgets in Subgraphs', () => {
|
||||||
|
const TEST_NODE_TYPE = 'DevToolsDynamicComboNode'
|
||||||
|
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
|
||||||
|
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||||
|
})
|
||||||
|
|
||||||
|
function subgraphWidgetName(widgetName: string): string {
|
||||||
|
return `1: ${widgetName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function widget(name: string, visible: boolean, value: unknown) {
|
||||||
|
return {
|
||||||
|
name: subgraphWidgetName(name),
|
||||||
|
visible,
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearGraph(comfyPage: ComfyPage) {
|
||||||
|
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSubgraphNode(comfyPage: ComfyPage) {
|
||||||
|
const nodes = await comfyPage.getNodeRefsByTitle('New Subgraph')
|
||||||
|
return nodes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestNodeAsSubgraph(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
mode: 'none' | 'one' | 'two' | 'three' = 'none'
|
||||||
|
) {
|
||||||
|
const testNode = await comfyPage.createNode(TEST_NODE_TYPE)
|
||||||
|
|
||||||
|
if (mode !== 'none') {
|
||||||
|
const widget = await testNode.getWidgetByName('dynamic_combo')
|
||||||
|
if (widget) await widget.setValue(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
await testNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
return await testNode.convertToSubgraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Promoted dynamic combo promotes all children with it', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await clearGraph(comfyPage)
|
||||||
|
|
||||||
|
const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two')
|
||||||
|
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', true, 0)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Demoted dynamic combo unpromotes all children with it', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await clearGraph(comfyPage)
|
||||||
|
|
||||||
|
const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two')
|
||||||
|
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', true, 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
await comfyPage.menu.propertiesPanel.demoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
const widgets = await subgraphNode.getWidgets()
|
||||||
|
const visibleWidgets = widgets.filter((w) => w.visible)
|
||||||
|
expect(visibleWidgets).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Promoted combo widgets hide and show based on combo value', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await clearGraph(comfyPage)
|
||||||
|
|
||||||
|
const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'none')
|
||||||
|
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'none')
|
||||||
|
])
|
||||||
|
|
||||||
|
const comboWidget = await subgraphNode.getWidgetByName(
|
||||||
|
subgraphWidgetName('dynamic_combo')
|
||||||
|
)
|
||||||
|
|
||||||
|
await comboWidget!.setValue('one')
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'one'),
|
||||||
|
widget('dynamic_combo.w1', true, 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
await comboWidget!.setValue('two')
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', true, 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
await comboWidget!.setValue('three')
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'three'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', true, 0),
|
||||||
|
widget('dynamic_combo.w3', true, 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
await comboWidget!.setValue('two')
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', true, 0),
|
||||||
|
widget('dynamic_combo.w3', false, undefined)
|
||||||
|
])
|
||||||
|
|
||||||
|
await comboWidget!.setValue('one')
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'one'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', false, undefined),
|
||||||
|
widget('dynamic_combo.w3', false, undefined)
|
||||||
|
])
|
||||||
|
|
||||||
|
await comboWidget!.setValue('none')
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'none'),
|
||||||
|
widget('dynamic_combo.w1', false, undefined),
|
||||||
|
widget('dynamic_combo.w2', false, undefined),
|
||||||
|
widget('dynamic_combo.w3', false, undefined)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Promoted combo maintains state after workflow reload', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await clearGraph(comfyPage)
|
||||||
|
|
||||||
|
const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two')
|
||||||
|
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
const w1 = await subgraphNode.getWidgetByName(
|
||||||
|
subgraphWidgetName('dynamic_combo.w1')
|
||||||
|
)
|
||||||
|
const w2 = await subgraphNode.getWidgetByName(
|
||||||
|
subgraphWidgetName('dynamic_combo.w2')
|
||||||
|
)
|
||||||
|
await w1!.setValue(123)
|
||||||
|
await w2!.setValue(456)
|
||||||
|
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 123),
|
||||||
|
widget('dynamic_combo.w2', true, 456)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Click on node to ensure changes are committed before switching
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.topbar.switchToTab(0)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const reloadedSubgraph = await getSubgraphNode(comfyPage)
|
||||||
|
expect(await reloadedSubgraph.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 123),
|
||||||
|
widget('dynamic_combo.w2', true, 456)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Hidden children remain hidden after workflow reload when combo is none', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await clearGraph(comfyPage)
|
||||||
|
|
||||||
|
const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two')
|
||||||
|
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
const comboWidget = await subgraphNode.getWidgetByName(
|
||||||
|
subgraphWidgetName('dynamic_combo')
|
||||||
|
)
|
||||||
|
await comboWidget!.setValue('none')
|
||||||
|
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'none'),
|
||||||
|
widget('dynamic_combo.w1', false, undefined),
|
||||||
|
widget('dynamic_combo.w2', false, undefined)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Click on node to ensure changes are committed before switching
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.topbar.switchToTab(0)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const reloadedSubgraph = await getSubgraphNode(comfyPage)
|
||||||
|
expect(await reloadedSubgraph.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'none'),
|
||||||
|
widget('dynamic_combo.w1', false, undefined),
|
||||||
|
widget('dynamic_combo.w2', false, undefined)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Children appear when combo changes after workflow reload', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await clearGraph(comfyPage)
|
||||||
|
|
||||||
|
const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'none')
|
||||||
|
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'none')
|
||||||
|
])
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.topbar.switchToTab(0)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const reloadedSubgraph = await getSubgraphNode(comfyPage)
|
||||||
|
const comboWidget = await reloadedSubgraph.getWidgetByName(
|
||||||
|
subgraphWidgetName('dynamic_combo')
|
||||||
|
)
|
||||||
|
await comboWidget!.setValue('two')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
expect(await reloadedSubgraph.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', true, 0)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Dynamic combo children created inside subgraph are auto-promoted', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await clearGraph(comfyPage)
|
||||||
|
|
||||||
|
const testNode = await comfyPage.createNode(TEST_NODE_TYPE)
|
||||||
|
await testNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const subgraphNode = await testNode.convertToSubgraph()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo')
|
||||||
|
|
||||||
|
expect(await subgraphNode.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'none')
|
||||||
|
])
|
||||||
|
|
||||||
|
await subgraphNode.click('subgraph')
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.isInSubgraph(), { timeout: 5000 })
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const innerNodes = await comfyPage.getNodeRefsByType(TEST_NODE_TYPE, true)
|
||||||
|
const innerNode = innerNodes[0]
|
||||||
|
const innerComboWidget = await innerNode.getWidgetByName(
|
||||||
|
'dynamic_combo',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
await innerComboWidget!.setValue('two', true)
|
||||||
|
|
||||||
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect
|
||||||
|
.poll(() => comfyPage.isInSubgraph(), { timeout: 5000 })
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
const outerSubgraph = await getSubgraphNode(comfyPage)
|
||||||
|
expect(await outerSubgraph.getWidgets()).toEqual([
|
||||||
|
widget('dynamic_combo', true, 'two'),
|
||||||
|
widget('dynamic_combo.w1', true, 0),
|
||||||
|
widget('dynamic_combo.w2', true, 0)
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,7 +14,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
|||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
|
||||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||||
import { GetNodeParentGroupKey } from '../shared'
|
import { GetNodeParentGroupKey, getWidgetGroupKey } from '../shared'
|
||||||
|
import WidgetGroup from './WidgetGroup.vue'
|
||||||
import WidgetItem from './WidgetItem.vue'
|
import WidgetItem from './WidgetItem.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -84,6 +85,34 @@ function isWidgetShownOnParents(
|
|||||||
|
|
||||||
const isEmpty = computed(() => widgets.value.length === 0)
|
const isEmpty = computed(() => widgets.value.length === 0)
|
||||||
|
|
||||||
|
type WidgetEntry = { widget: IBaseWidget; node: LGraphNode }
|
||||||
|
type WidgetGroup = {
|
||||||
|
key: string
|
||||||
|
items: WidgetEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group widgets by their group key (for dynamic widget grouping).
|
||||||
|
* Widgets with the same group key are placed together in a single group.
|
||||||
|
*/
|
||||||
|
const groupedWidgets = computed((): WidgetGroup[] => {
|
||||||
|
const groups: WidgetGroup[] = []
|
||||||
|
const keyToGroup = new Map<string, WidgetGroup>()
|
||||||
|
|
||||||
|
for (const entry of widgets.value) {
|
||||||
|
const key = getWidgetGroupKey(entry.widget)
|
||||||
|
let group = keyToGroup.get(key)
|
||||||
|
if (!group) {
|
||||||
|
group = { key, items: [] }
|
||||||
|
keyToGroup.set(key, group)
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
group.items.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
const displayLabel = computed(
|
const displayLabel = computed(
|
||||||
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
|
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
|
||||||
)
|
)
|
||||||
@@ -167,17 +196,22 @@ defineExpose({
|
|||||||
class="space-y-2 rounded-lg px-4 pt-1 relative"
|
class="space-y-2 rounded-lg px-4 pt-1 relative"
|
||||||
>
|
>
|
||||||
<TransitionGroup name="list-scale">
|
<TransitionGroup name="list-scale">
|
||||||
<WidgetItem
|
<WidgetGroup
|
||||||
v-for="{ widget, node } in widgets"
|
v-for="group in groupedWidgets"
|
||||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
:key="group.key"
|
||||||
:widget="widget"
|
|
||||||
:node="node"
|
|
||||||
:is-draggable="isDraggable"
|
:is-draggable="isDraggable"
|
||||||
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
>
|
||||||
:show-node-name="showNodeName"
|
<WidgetItem
|
||||||
:parents="parents"
|
v-for="{ widget, node } in group.items"
|
||||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||||
/>
|
:widget="widget"
|
||||||
|
:node="node"
|
||||||
|
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
||||||
|
:show-node-name="showNodeName"
|
||||||
|
:parents="parents"
|
||||||
|
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||||
|
/>
|
||||||
|
</WidgetGroup>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</PropertiesAccordionItem>
|
</PropertiesAccordionItem>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
|
|||||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
|
|
||||||
import { searchWidgets } from '../shared'
|
import { getWidgetGroupKey, searchWidgets } from '../shared'
|
||||||
import type { NodeWidgetsList } from '../shared'
|
import type { NodeWidgetsList } from '../shared'
|
||||||
import SectionWidgets from './SectionWidgets.vue'
|
import SectionWidgets from './SectionWidgets.vue'
|
||||||
|
|
||||||
@@ -103,6 +103,57 @@ const widgetsList = computed((): NodeWidgetsList => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the group key for a widget by its proxyWidgets entry.
|
||||||
|
* Returns the parent widget name if this is a child, otherwise the widget's own name.
|
||||||
|
*/
|
||||||
|
function getGroupKeyForEntry(widgetName: string): string {
|
||||||
|
const { widgets = [] } = node
|
||||||
|
|
||||||
|
// Find the actual widget to check dynamicWidgetParent
|
||||||
|
const widget = widgets.find((w) => {
|
||||||
|
if (isProxyWidget(w)) {
|
||||||
|
return w._overlay.widgetName === widgetName
|
||||||
|
}
|
||||||
|
return w.name === widgetName
|
||||||
|
})
|
||||||
|
|
||||||
|
if (widget) {
|
||||||
|
return getWidgetGroupKey(widget)
|
||||||
|
}
|
||||||
|
return widgetName
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyWidgetGroup = {
|
||||||
|
key: string
|
||||||
|
indices: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a list of groups from proxyWidgets.
|
||||||
|
* Each group contains the indices of widgets that belong together.
|
||||||
|
* Groups are ordered by the first occurrence of their members.
|
||||||
|
*/
|
||||||
|
function buildProxyWidgetGroups(pw: [string, string][]): ProxyWidgetGroup[] {
|
||||||
|
const groups: ProxyWidgetGroup[] = []
|
||||||
|
const keyToGroup = new Map<string, ProxyWidgetGroup>()
|
||||||
|
|
||||||
|
for (let i = 0; i < pw.length; i++) {
|
||||||
|
const [, widgetName] = pw[i]
|
||||||
|
const key = getGroupKeyForEntry(widgetName)
|
||||||
|
|
||||||
|
let group = keyToGroup.get(key)
|
||||||
|
if (!group) {
|
||||||
|
group = { key, indices: [] }
|
||||||
|
keyToGroup.set(key, group)
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
group.indices.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||||
const interiorNodes = node.subgraph.nodes
|
const interiorNodes = node.subgraph.nodes
|
||||||
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
|
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
|
||||||
@@ -178,11 +229,42 @@ function setDraggableState() {
|
|||||||
this.draggableItem as HTMLElement
|
this.draggableItem as HTMLElement
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update proxyWidgets order
|
// Build groups from proxyWidgets
|
||||||
|
// Each draggable item corresponds to a group (container or single widget)
|
||||||
const pw = proxyWidgets.value
|
const pw = proxyWidgets.value
|
||||||
const [w] = pw.splice(oldPosition, 1)
|
const groups = buildProxyWidgetGroups(pw)
|
||||||
pw.splice(newPosition, 0, w)
|
|
||||||
proxyWidgets.value = pw
|
if (oldPosition >= groups.length || newPosition >= groups.length) {
|
||||||
|
console.error('[TabSubgraphInputs] position out of bounds')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the group being moved
|
||||||
|
const movedGroup = groups[oldPosition]
|
||||||
|
const movedIndices = movedGroup.indices
|
||||||
|
|
||||||
|
// Extract the entries being moved (in their original order)
|
||||||
|
const movedEntries: [string, string][] = movedIndices.map((i) => pw[i])
|
||||||
|
|
||||||
|
const newPw: [string, string][] = []
|
||||||
|
const reorderedGroups = [...groups]
|
||||||
|
reorderedGroups.splice(oldPosition, 1)
|
||||||
|
reorderedGroups.splice(newPosition, 0, movedGroup)
|
||||||
|
|
||||||
|
// Flatten back to proxyWidgets, preserving entry order within each group
|
||||||
|
for (const group of reorderedGroups) {
|
||||||
|
if (group === movedGroup) {
|
||||||
|
// Use the entries we extracted earlier
|
||||||
|
newPw.push(...movedEntries)
|
||||||
|
} else {
|
||||||
|
// Add entries from this group in their original order
|
||||||
|
for (const idx of group.indices) {
|
||||||
|
newPw.push(pw[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyWidgets.value = newPw
|
||||||
canvasStore.canvas?.setDirty(true, true)
|
canvasStore.canvas?.setDirty(true, true)
|
||||||
triggerRef(proxyWidgets)
|
triggerRef(proxyWidgets)
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/components/rightSidePanel/parameters/WidgetGroup.vue
Normal file
26
src/components/rightSidePanel/parameters/WidgetGroup.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { isDraggable = false } = defineProps<{
|
||||||
|
isDraggable?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'widget-group rounded-lg',
|
||||||
|
isDraggable &&
|
||||||
|
'draggable-item drag-handle group cursor-grab bg-comfy-menu-bg [&.is-draggable]:cursor-grabbing [&.is-draggable]:outline-4 [&.is-draggable]:outline-comfy-menu-bg [&.is-draggable]:outline-offset-0 [&.is-draggable]:opacity-70 [&_.widget-item-header]:pointer-events-none'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<!-- Drag handle indicator -->
|
||||||
|
<div
|
||||||
|
v-if="isDraggable"
|
||||||
|
class="pointer-events-none mt-1.5 mx-auto max-w-40 w-1/2 h-1 rounded-lg bg-transparent transition-colors duration-150 group-hover:bg-interface-stroke group-[.is-draggable]:bg-component-node-widget-background-highlighted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -23,7 +23,6 @@ import WidgetActions from './WidgetActions.vue'
|
|||||||
const {
|
const {
|
||||||
widget,
|
widget,
|
||||||
node,
|
node,
|
||||||
isDraggable = false,
|
|
||||||
hiddenFavoriteIndicator = false,
|
hiddenFavoriteIndicator = false,
|
||||||
showNodeName = false,
|
showNodeName = false,
|
||||||
parents = [],
|
parents = [],
|
||||||
@@ -31,7 +30,6 @@ const {
|
|||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
widget: IBaseWidget
|
widget: IBaseWidget
|
||||||
node: LGraphNode
|
node: LGraphNode
|
||||||
isDraggable?: boolean
|
|
||||||
hiddenFavoriteIndicator?: boolean
|
hiddenFavoriteIndicator?: boolean
|
||||||
showNodeName?: boolean
|
showNodeName?: boolean
|
||||||
parents?: SubgraphNode[]
|
parents?: SubgraphNode[]
|
||||||
@@ -104,22 +102,11 @@ const displayLabel = customRef((track, trigger) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="
|
class="widget-item col-span-full grid grid-cols-subgrid rounded-lg group drag-handle"
|
||||||
cn(
|
|
||||||
'widget-item col-span-full grid grid-cols-subgrid rounded-lg group',
|
|
||||||
isDraggable &&
|
|
||||||
'draggable-item !will-change-auto drag-handle cursor-grab bg-comfy-menu-bg [&.is-draggable]:cursor-grabbing outline-comfy-menu-bg [&.is-draggable]:outline-4 [&.is-draggable]:outline-offset-0 [&.is-draggable]:opacity-70'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<!-- widget header -->
|
<!-- widget header -->
|
||||||
<div
|
<div
|
||||||
:class="
|
class="widget-item-header min-h-8 flex items-center justify-between gap-1 mb-1.5 min-w-0"
|
||||||
cn(
|
|
||||||
'min-h-8 flex items-center justify-between gap-1 mb-1.5 min-w-0',
|
|
||||||
isDraggable && 'pointer-events-none'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<EditableText
|
<EditableText
|
||||||
v-if="widget.name"
|
v-if="widget.name"
|
||||||
@@ -169,15 +156,5 @@ const displayLabel = customRef((track, trigger) => {
|
|||||||
:node-type="node.type"
|
:node-type="node.type"
|
||||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||||
/>
|
/>
|
||||||
<!-- Drag handle -->
|
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'pointer-events-none mt-1.5 mx-auto max-w-40 w-1/2 h-1 rounded-lg bg-transparent transition-colors duration-150',
|
|
||||||
'group-hover:bg-interface-stroke group-[.is-draggable]:bg-component-node-widget-background-highlighted',
|
|
||||||
!isDraggable && 'opacity-0'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
|||||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||||
import { describe, expect, it, beforeEach } from 'vitest'
|
import { describe, expect, it, beforeEach } from 'vitest'
|
||||||
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
|
import {
|
||||||
|
flatAndCategorizeSelectedItems,
|
||||||
|
getWidgetGroupKey,
|
||||||
|
searchWidgets
|
||||||
|
} from './shared'
|
||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
|
|
||||||
describe('searchWidgets', () => {
|
describe('searchWidgets', () => {
|
||||||
@@ -188,3 +192,109 @@ describe('flatAndCategorizeSelectedItems', () => {
|
|||||||
expect(result.all).not.toContain(unknownItem)
|
expect(result.all).not.toContain(unknownItem)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getWidgetGroupKey', () => {
|
||||||
|
it('should return parent name for child widgets', () => {
|
||||||
|
const widget = {
|
||||||
|
name: 'dynamic_combo.w1',
|
||||||
|
type: 'number',
|
||||||
|
dynamicWidgetParent: 'dynamic_combo'
|
||||||
|
} as IBaseWidget
|
||||||
|
|
||||||
|
expect(getWidgetGroupKey(widget)).toBe('dynamic_combo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return widget name for parent widgets (dynamic combo roots)', () => {
|
||||||
|
const widget = {
|
||||||
|
name: 'dynamic_combo',
|
||||||
|
type: 'combo',
|
||||||
|
dynamicWidgetRoot: true
|
||||||
|
} as IBaseWidget
|
||||||
|
|
||||||
|
expect(getWidgetGroupKey(widget)).toBe('dynamic_combo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return widget name for regular widgets', () => {
|
||||||
|
const widget = {
|
||||||
|
name: 'regular_widget',
|
||||||
|
type: 'number'
|
||||||
|
} as IBaseWidget
|
||||||
|
|
||||||
|
expect(getWidgetGroupKey(widget)).toBe('regular_widget')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return widget name if dynamicWidgetParent is empty string', () => {
|
||||||
|
const widget = {
|
||||||
|
name: 'some_widget',
|
||||||
|
type: 'number',
|
||||||
|
dynamicWidgetParent: ''
|
||||||
|
} as IBaseWidget
|
||||||
|
|
||||||
|
// Empty string is falsy, so widget is treated as its own group
|
||||||
|
expect(getWidgetGroupKey(widget)).toBe('some_widget')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use _overlay.widgetName for proxy widgets (parent)', () => {
|
||||||
|
// Proxy widgets have names with node ID prefix like "1: dynamic_combo"
|
||||||
|
const proxyWidget = {
|
||||||
|
name: '1: dynamic_combo',
|
||||||
|
type: 'combo',
|
||||||
|
dynamicWidgetRoot: true,
|
||||||
|
_overlay: { widgetName: 'dynamic_combo', nodeId: '1' }
|
||||||
|
} as unknown as IBaseWidget
|
||||||
|
|
||||||
|
// Should return base name so it matches children's dynamicWidgetParent
|
||||||
|
expect(getWidgetGroupKey(proxyWidget)).toBe('dynamic_combo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should group proxy parent and children together', () => {
|
||||||
|
// Parent proxy widget
|
||||||
|
const parentProxy = {
|
||||||
|
name: '1: dynamic_combo',
|
||||||
|
type: 'combo',
|
||||||
|
dynamicWidgetRoot: true,
|
||||||
|
_overlay: { widgetName: 'dynamic_combo', nodeId: '1' }
|
||||||
|
} as unknown as IBaseWidget
|
||||||
|
|
||||||
|
// Child proxy widget
|
||||||
|
const childProxy = {
|
||||||
|
name: '1: dynamic_combo.w1',
|
||||||
|
type: 'number',
|
||||||
|
dynamicWidgetParent: 'dynamic_combo',
|
||||||
|
_overlay: { widgetName: 'dynamic_combo.w1', nodeId: '1' }
|
||||||
|
} as unknown as IBaseWidget
|
||||||
|
|
||||||
|
// Both should return 'dynamic_combo' so they're in the same group
|
||||||
|
expect(getWidgetGroupKey(parentProxy)).toBe('dynamic_combo')
|
||||||
|
expect(getWidgetGroupKey(childProxy)).toBe('dynamic_combo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should group disconnected child widgets using overlay.dynamicWidgetParent', () => {
|
||||||
|
// Disconnected child widget - dynamicWidgetParent is stored in overlay
|
||||||
|
// because the backing widget (disconnectedWidget) doesn't have this property
|
||||||
|
const disconnectedChild = {
|
||||||
|
name: '1: dynamic_combo.child_widget',
|
||||||
|
type: 'button', // disconnectedWidget type
|
||||||
|
// No dynamicWidgetParent on widget itself
|
||||||
|
_overlay: {
|
||||||
|
widgetName: 'dynamic_combo.child_widget',
|
||||||
|
nodeId: '1',
|
||||||
|
dynamicWidgetParent: 'dynamic_combo' // Stored in overlay
|
||||||
|
}
|
||||||
|
} as unknown as IBaseWidget
|
||||||
|
|
||||||
|
// Should use overlay.dynamicWidgetParent
|
||||||
|
expect(getWidgetGroupKey(disconnectedChild)).toBe('dynamic_combo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not group widgets without dynamicWidgetParent in overlay', () => {
|
||||||
|
// Regular widget without dynamicWidgetParent in widget or overlay
|
||||||
|
const regularWidget = {
|
||||||
|
name: 'some_widget',
|
||||||
|
type: 'number',
|
||||||
|
_overlay: { widgetName: 'some_widget', nodeId: '1' }
|
||||||
|
} as unknown as IBaseWidget
|
||||||
|
|
||||||
|
expect(getWidgetGroupKey(regularWidget)).toBe('some_widget')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -250,6 +250,41 @@ function repeatItems<T>(items: T[]): T[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base widget name, stripping any node ID prefix.
|
||||||
|
* Proxy widgets on SubgraphNodes have names like "1: widgetName".
|
||||||
|
*/
|
||||||
|
function getBaseWidgetName(widget: IBaseWidget): string {
|
||||||
|
// Check if it's a proxy widget with _overlay
|
||||||
|
const overlay = (widget as { _overlay?: { widgetName?: string } })._overlay
|
||||||
|
if (overlay?.widgetName) {
|
||||||
|
return overlay.widgetName
|
||||||
|
}
|
||||||
|
return widget.name
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWidgetGroupKey(widget: IBaseWidget): string {
|
||||||
|
// Check dynamicWidgetParent on the widget (works for connected widgets)
|
||||||
|
if (widget.dynamicWidgetParent) {
|
||||||
|
return widget.dynamicWidgetParent
|
||||||
|
}
|
||||||
|
|
||||||
|
// For proxy widgets, check the overlay for dynamicWidgetParent
|
||||||
|
// This handles disconnected widgets where the backing widget doesn't have the property
|
||||||
|
// as the actual widget doesn't exist, and is the disconnected widget.
|
||||||
|
const overlay = (
|
||||||
|
widget as {
|
||||||
|
_overlay?: { dynamicWidgetParent?: string; widgetName?: string }
|
||||||
|
}
|
||||||
|
)._overlay
|
||||||
|
if (overlay?.dynamicWidgetParent) {
|
||||||
|
return overlay.dynamicWidgetParent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use base name to match children's dynamicWidgetParent values
|
||||||
|
return getBaseWidgetName(widget)
|
||||||
|
}
|
||||||
|
|
||||||
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
demoteWidget,
|
demoteWidget,
|
||||||
|
isDynamicComboChild,
|
||||||
promoteRecommendedWidgets
|
promoteRecommendedWidgets
|
||||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||||
@@ -37,6 +38,12 @@ type Overlay = Partial<IBaseWidget> & {
|
|||||||
widgetName: string
|
widgetName: string
|
||||||
isProxyWidget: boolean
|
isProxyWidget: boolean
|
||||||
node?: LGraphNode
|
node?: LGraphNode
|
||||||
|
/** Hidden state for disconnected dynamic combo children */
|
||||||
|
hidden?: boolean
|
||||||
|
/** Flag to trigger re-resolution when source node's widgets change */
|
||||||
|
needsResolve?: boolean
|
||||||
|
/** Cached dynamicWidgetParent for grouping when widget is disconnected */
|
||||||
|
dynamicWidgetParent?: string
|
||||||
}
|
}
|
||||||
// A ProxyWidget can be treated like a normal widget.
|
// A ProxyWidget can be treated like a normal widget.
|
||||||
// the _overlay property can be used to directly access the Overlay object
|
// the _overlay property can be used to directly access the Overlay object
|
||||||
@@ -142,11 +149,21 @@ function newProxyWidget(
|
|||||||
widgetName: string
|
widgetName: string
|
||||||
) {
|
) {
|
||||||
const name = `${nodeId}: ${widgetName}`
|
const name = `${nodeId}: ${widgetName}`
|
||||||
const overlay = {
|
|
||||||
|
// Determine dynamicWidgetParent from widget name pattern (parentName.childName)
|
||||||
|
// This ensures grouping works even when the backing widget is disconnected
|
||||||
|
let dynamicWidgetParent: string | undefined
|
||||||
|
const dotIndex = widgetName.indexOf('.')
|
||||||
|
if (dotIndex !== -1) {
|
||||||
|
dynamicWidgetParent = widgetName.slice(0, dotIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay: Overlay = {
|
||||||
//items specific for proxy management
|
//items specific for proxy management
|
||||||
nodeId,
|
nodeId,
|
||||||
graph: subgraphNode.subgraph,
|
graph: subgraphNode.subgraph,
|
||||||
widgetName,
|
widgetName,
|
||||||
|
dynamicWidgetParent,
|
||||||
//Items which normally exist on widgets
|
//Items which normally exist on widgets
|
||||||
afterQueued: undefined,
|
afterQueued: undefined,
|
||||||
computedHeight: undefined,
|
computedHeight: undefined,
|
||||||
@@ -169,9 +186,15 @@ function resolveLinkedWidget(
|
|||||||
const n = getNodeByExecutionId(graph, nodeId)
|
const n = getNodeByExecutionId(graph, nodeId)
|
||||||
if (!n) return [undefined, undefined]
|
if (!n) return [undefined, undefined]
|
||||||
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
|
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
|
||||||
//Slightly hacky. Force recursive resolution of nested widgets
|
// Slightly hacky. Force recursive resolution of nested widgets
|
||||||
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
|
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
|
||||||
widget.computedHeight = 20
|
widget.computedHeight = 20
|
||||||
|
|
||||||
|
// Cache dynamicWidgetParent in overlay for use when widget becomes disconnected
|
||||||
|
if (widget?.dynamicWidgetParent) {
|
||||||
|
overlay.dynamicWidgetParent = widget.dynamicWidgetParent
|
||||||
|
}
|
||||||
|
|
||||||
return [n, widget]
|
return [n, widget]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +211,18 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateHiddenState() {
|
||||||
|
const shouldHide =
|
||||||
|
backingWidget === disconnectedWidget &&
|
||||||
|
linkedNode !== undefined &&
|
||||||
|
isDynamicComboChild(linkedNode, overlay.widgetName)
|
||||||
|
if (overlay.hidden !== shouldHide) {
|
||||||
|
overlay.hidden = shouldHide
|
||||||
|
subgraphNode.setDirtyCanvas(true, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A set of handlers which define widget interaction
|
* A set of handlers which define widget interaction
|
||||||
* Many arguments are shared between function calls
|
* Many arguments are shared between function calls
|
||||||
@@ -201,6 +236,13 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
|||||||
*/
|
*/
|
||||||
const handler = {
|
const handler = {
|
||||||
get(_t: IBaseWidget, property: string, receiver: object) {
|
get(_t: IBaseWidget, property: string, receiver: object) {
|
||||||
|
// Re-resolve when marked dirty (source node's widgets changed)
|
||||||
|
if (property === 'hidden' && overlay.needsResolve) {
|
||||||
|
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||||
|
backingWidget = linkedWidget ?? disconnectedWidget
|
||||||
|
overlay.needsResolve = false
|
||||||
|
updateHiddenState()
|
||||||
|
}
|
||||||
let redirectedTarget: object = backingWidget
|
let redirectedTarget: object = backingWidget
|
||||||
let redirectedReceiver = receiver
|
let redirectedReceiver = receiver
|
||||||
if (property == '_overlay') return overlay
|
if (property == '_overlay') return overlay
|
||||||
@@ -220,9 +262,10 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
|||||||
if (linkedNode && linkedWidget?.computedDisabled) {
|
if (linkedNode && linkedWidget?.computedDisabled) {
|
||||||
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
|
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
|
||||||
}
|
}
|
||||||
//update linkage regularly, but no more than once per frame
|
// Update linkage regularly, but no more than once per frame
|
||||||
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||||
backingWidget = linkedWidget ?? disconnectedWidget
|
backingWidget = linkedWidget ?? disconnectedWidget
|
||||||
|
updateHiddenState()
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||||
redirectedTarget = overlay
|
redirectedTarget = overlay
|
||||||
|
|||||||
@@ -16,31 +16,106 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|||||||
import { useLitegraphService } from '@/services/litegraphService'
|
import { useLitegraphService } from '@/services/litegraphService'
|
||||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||||
|
|
||||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type' | 'widgets'>
|
||||||
|
|
||||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||||
|
|
||||||
function getProxyWidgets(node: SubgraphNode) {
|
function getProxyWidgets(node: SubgraphNode) {
|
||||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all child widgets of a dynamic combo parent by name.
|
||||||
|
*/
|
||||||
|
function getChildWidgets(
|
||||||
|
node: PartialNode,
|
||||||
|
parentWidgetName: string
|
||||||
|
): IBaseWidget[] {
|
||||||
|
return (
|
||||||
|
node.widgets?.filter((w) => w.dynamicWidgetParent === parentWidgetName) ??
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a widget is a child of a dynamic combo root.
|
||||||
|
*/
|
||||||
|
export function isDynamicComboChild(
|
||||||
|
node: LGraphNode,
|
||||||
|
widgetName: string
|
||||||
|
): boolean {
|
||||||
|
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||||
|
if (widget) return !!widget.dynamicWidgetParent
|
||||||
|
|
||||||
|
// Widget doesn't exist (disconnected) - parse name to find parent
|
||||||
|
// because the widget doesnt exist, we dont have any concrete flag for if
|
||||||
|
// this is a child of a dynamic combo, so we need to parse the name to find the parent
|
||||||
|
const dotIndex = widgetName.indexOf('.')
|
||||||
|
if (dotIndex === -1) return false
|
||||||
|
const parentName = widgetName.slice(0, dotIndex)
|
||||||
|
const parentWidget = node.widgets?.find((w) => w.name === parentName)
|
||||||
|
return !!parentWidget?.dynamicWidgetRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a widget is a child of a promoted dynamic combo.
|
||||||
|
*/
|
||||||
|
function isChildOfPromotedDynamicCombo(
|
||||||
|
node: LGraphNode,
|
||||||
|
widget: IBaseWidget
|
||||||
|
): boolean {
|
||||||
|
if (!widget.dynamicWidgetParent) return false
|
||||||
|
const parentWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === widget.dynamicWidgetParent
|
||||||
|
)
|
||||||
|
return !!parentWidget?.promoted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a widget and all its dynamic combo children (if it's a root).
|
||||||
|
*/
|
||||||
|
function getWidgetWithChildren(
|
||||||
|
node: PartialNode,
|
||||||
|
widget: IBaseWidget
|
||||||
|
): IBaseWidget[] {
|
||||||
|
const widgets = [widget]
|
||||||
|
if (widget.dynamicWidgetRoot && node.widgets) {
|
||||||
|
widgets.push(...getChildWidgets(node, widget.name))
|
||||||
|
}
|
||||||
|
return widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch promote multiple widgets to proxy on all parent SubgraphNodes.
|
||||||
|
* Only adds widgets that don't already exist in proxyWidgets.
|
||||||
|
*/
|
||||||
|
function promoteWidgetsToProxy(
|
||||||
|
node: PartialNode,
|
||||||
|
widgets: IBaseWidget[],
|
||||||
|
parents: SubgraphNode[]
|
||||||
|
) {
|
||||||
|
for (const parent of parents) {
|
||||||
|
const existing = getProxyWidgets(parent)
|
||||||
|
const toAdd = widgets.filter(
|
||||||
|
(w) => !existing.some(matchesPropertyItem([node, w]))
|
||||||
|
)
|
||||||
|
if (!toAdd.length) continue
|
||||||
|
parent.properties.proxyWidgets = [
|
||||||
|
...existing,
|
||||||
|
...toAdd.map((w) => widgetItemToProperty([node, w]))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
for (const w of widgets) {
|
||||||
|
w.promoted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function promoteWidget(
|
export function promoteWidget(
|
||||||
node: PartialNode,
|
node: PartialNode,
|
||||||
widget: IBaseWidget,
|
widget: IBaseWidget,
|
||||||
parents: SubgraphNode[]
|
parents: SubgraphNode[]
|
||||||
) {
|
) {
|
||||||
for (const parent of parents) {
|
promoteWidgetsToProxy(node, getWidgetWithChildren(node, widget), parents)
|
||||||
const existingProxyWidgets = getProxyWidgets(parent)
|
|
||||||
// Prevent duplicate promotion
|
|
||||||
if (existingProxyWidgets.some(matchesPropertyItem([node, widget]))) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const proxyWidgets = [
|
|
||||||
...existingProxyWidgets,
|
|
||||||
widgetItemToProperty([node, widget])
|
|
||||||
]
|
|
||||||
parent.properties.proxyWidgets = proxyWidgets
|
|
||||||
}
|
|
||||||
widget.promoted = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function demoteWidget(
|
export function demoteWidget(
|
||||||
@@ -48,13 +123,17 @@ export function demoteWidget(
|
|||||||
widget: IBaseWidget,
|
widget: IBaseWidget,
|
||||||
parents: SubgraphNode[]
|
parents: SubgraphNode[]
|
||||||
) {
|
) {
|
||||||
|
const widgetsToDemote = getWidgetWithChildren(node, widget)
|
||||||
for (const parent of parents) {
|
for (const parent of parents) {
|
||||||
const proxyWidgets = getProxyWidgets(parent).filter(
|
const proxyWidgets = getProxyWidgets(parent).filter(
|
||||||
(widgetItem) => !matchesPropertyItem([node, widget])(widgetItem)
|
(widgetItem) =>
|
||||||
|
!widgetsToDemote.some((w) => matchesPropertyItem([node, w])(widgetItem))
|
||||||
)
|
)
|
||||||
parent.properties.proxyWidgets = proxyWidgets
|
parent.properties.proxyWidgets = proxyWidgets
|
||||||
}
|
}
|
||||||
widget.promoted = false
|
for (const w of widgetsToDemote) {
|
||||||
|
w.promoted = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
|
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
|
||||||
@@ -68,6 +147,66 @@ export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
|
|||||||
return [`${n.id}`, w.name]
|
return [`${n.id}`, w.name]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all SubgraphNodes that contain the given node's graph.
|
||||||
|
* Returns empty array if node is in root graph or graph is undefined.
|
||||||
|
*/
|
||||||
|
function getSubgraphParents(node: LGraphNode): SubgraphNode[] {
|
||||||
|
const graph = node.graph
|
||||||
|
if (!graph || graph.isRootGraph) return []
|
||||||
|
|
||||||
|
return graph.rootGraph.nodes.filter(
|
||||||
|
(n): n is SubgraphNode => n.type === graph.id && n.isSubgraphNode()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark proxy widgets pointing to this node as needing re-checking.
|
||||||
|
* Called when a node's widgets change (e.g., dynamic combo value change).
|
||||||
|
*/
|
||||||
|
export function invalidateProxyWidgetsForNode(node: LGraphNode) {
|
||||||
|
const parents = getSubgraphParents(node)
|
||||||
|
const nodeId = `${node.id}`
|
||||||
|
|
||||||
|
for (const parent of parents) {
|
||||||
|
for (const widget of parent.widgets) {
|
||||||
|
if (isProxyWidget(widget) && widget._overlay.nodeId === nodeId) {
|
||||||
|
widget._overlay.needsResolve = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-promote child widgets of a dynamic combo when the parent is promoted.
|
||||||
|
*/
|
||||||
|
export function autoPromoteDynamicChildren(
|
||||||
|
node: LGraphNode,
|
||||||
|
parentWidget: IBaseWidget
|
||||||
|
) {
|
||||||
|
const parents = getSubgraphParents(node)
|
||||||
|
if (!parents.length) return
|
||||||
|
|
||||||
|
// Check if the parent widget is actually promoted on any parent SubgraphNode.
|
||||||
|
// This is more reliable than checking parentWidget.promoted, which may not
|
||||||
|
// be set after workflow reload (the flag is only synced when navigating into
|
||||||
|
// the subgraph).
|
||||||
|
const nodeId = String(node.id)
|
||||||
|
const promotedOnParents = parents.filter((parent) =>
|
||||||
|
getProxyWidgets(parent).some(
|
||||||
|
([id, name]) => id === nodeId && name === parentWidget.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!promotedOnParents.length) return
|
||||||
|
|
||||||
|
const childWidgets = getChildWidgets(node, parentWidget.name)
|
||||||
|
promoteWidgetsToProxy(node, childWidgets, promotedOnParents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parent SubgraphNodes based on current navigation context.
|
||||||
|
*/
|
||||||
function getParentNodes(): SubgraphNode[] {
|
function getParentNodes(): SubgraphNode[] {
|
||||||
//NOTE: support for determining parents of a subgraph is limited
|
//NOTE: support for determining parents of a subgraph is limited
|
||||||
//This function will require rework to properly support linked subgraphs
|
//This function will require rework to properly support linked subgraphs
|
||||||
@@ -108,6 +247,8 @@ export function addWidgetPromotionOptions(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
else {
|
else {
|
||||||
|
if (isChildOfPromotedDynamicCombo(node, widget)) return
|
||||||
|
|
||||||
options.unshift({
|
options.unshift({
|
||||||
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
|
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
@@ -127,9 +268,12 @@ export function tryToggleWidgetPromotion() {
|
|||||||
const promotableParents = parents.filter(
|
const promotableParents = parents.filter(
|
||||||
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
|
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
|
||||||
)
|
)
|
||||||
if (promotableParents.length > 0)
|
if (promotableParents.length > 0) {
|
||||||
promoteWidget(node, widget, promotableParents)
|
promoteWidget(node, widget, promotableParents)
|
||||||
else demoteWidget(node, widget, parents)
|
} else {
|
||||||
|
if (isChildOfPromotedDynamicCombo(node, widget)) return
|
||||||
|
demoteWidget(node, widget, parents)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const recommendedNodes = [
|
const recommendedNodes = [
|
||||||
'CLIPTextEncode',
|
'CLIPTextEncode',
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { remove } from 'es-toolkit'
|
|||||||
import { shallowReactive } from 'vue'
|
import { shallowReactive } from 'vue'
|
||||||
|
|
||||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||||
|
import {
|
||||||
|
autoPromoteDynamicChildren,
|
||||||
|
invalidateProxyWidgetsForNode
|
||||||
|
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||||
import type {
|
import type {
|
||||||
ISlotType,
|
ISlotType,
|
||||||
INodeInputSlot,
|
INodeInputSlot,
|
||||||
@@ -126,7 +130,13 @@ function dynamicComboWidget(
|
|||||||
ensureWidgetForInput(node, newInput)
|
ensureWidgetForInput(node, newInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const childWidgets = node.widgets!.filter(isInGroup)
|
||||||
|
for (const child of childWidgets) {
|
||||||
|
child.dynamicWidgetParent = widget.name
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
widget.dynamicWidgetRoot = true
|
||||||
|
|
||||||
const inputInsertionPoint =
|
const inputInsertionPoint =
|
||||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||||
@@ -179,6 +189,12 @@ function dynamicComboWidget(
|
|||||||
if (!node.graph) return
|
if (!node.graph) return
|
||||||
node._setConcreteSlots()
|
node._setConcreteSlots()
|
||||||
node.arrange()
|
node.arrange()
|
||||||
|
|
||||||
|
// Auto-promote new child widgets if parent dynamic combo is promoted
|
||||||
|
autoPromoteDynamicChildren(node, widget)
|
||||||
|
// Mark proxy widgets as needing re-checking for hidden state
|
||||||
|
invalidateProxyWidgetsForNode(node)
|
||||||
|
|
||||||
app.canvas?.setDirty(true, true)
|
app.canvas?.setDirty(true, true)
|
||||||
}
|
}
|
||||||
//A little hacky, but onConfigure won't work.
|
//A little hacky, but onConfigure won't work.
|
||||||
|
|||||||
@@ -361,6 +361,16 @@ export interface IBaseWidget<
|
|||||||
|
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, this widget is a dynamic combo root that can have child widgets.
|
||||||
|
*/
|
||||||
|
dynamicWidgetRoot?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the parent dynamic combo widget that owns this child widget.
|
||||||
|
*/
|
||||||
|
dynamicWidgetParent?: string
|
||||||
|
|
||||||
// TODO: Confirm this format
|
// TODO: Confirm this format
|
||||||
callback?(
|
callback?(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .nodes import (
|
|||||||
NodeWithUnionInput,
|
NodeWithUnionInput,
|
||||||
NodeWithValidation,
|
NodeWithValidation,
|
||||||
NodeWithV2ComboInput,
|
NodeWithV2ComboInput,
|
||||||
|
NodeWithDynamicCombo,
|
||||||
ObjectPatchNode,
|
ObjectPatchNode,
|
||||||
RemoteWidgetNode,
|
RemoteWidgetNode,
|
||||||
RemoteWidgetNodeWithControlAfterRefresh,
|
RemoteWidgetNodeWithControlAfterRefresh,
|
||||||
@@ -55,6 +56,7 @@ __all__ = [
|
|||||||
"NodeWithUnionInput",
|
"NodeWithUnionInput",
|
||||||
"NodeWithValidation",
|
"NodeWithValidation",
|
||||||
"NodeWithV2ComboInput",
|
"NodeWithV2ComboInput",
|
||||||
|
"NodeWithDynamicCombo",
|
||||||
"ObjectPatchNode",
|
"ObjectPatchNode",
|
||||||
"RemoteWidgetNode",
|
"RemoteWidgetNode",
|
||||||
"RemoteWidgetNodeWithControlAfterRefresh",
|
"RemoteWidgetNodeWithControlAfterRefresh",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .inputs import (
|
|||||||
NodeWithUnionInput,
|
NodeWithUnionInput,
|
||||||
NodeWithValidation,
|
NodeWithValidation,
|
||||||
NodeWithV2ComboInput,
|
NodeWithV2ComboInput,
|
||||||
|
NodeWithDynamicCombo,
|
||||||
SimpleSlider,
|
SimpleSlider,
|
||||||
NODE_CLASS_MAPPINGS as inputs_class_mappings,
|
NODE_CLASS_MAPPINGS as inputs_class_mappings,
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as inputs_display_name_mappings,
|
NODE_DISPLAY_NAME_MAPPINGS as inputs_display_name_mappings,
|
||||||
@@ -81,6 +82,7 @@ __all__ = [
|
|||||||
"NodeWithUnionInput",
|
"NodeWithUnionInput",
|
||||||
"NodeWithValidation",
|
"NodeWithValidation",
|
||||||
"NodeWithV2ComboInput",
|
"NodeWithV2ComboInput",
|
||||||
|
"NodeWithDynamicCombo",
|
||||||
"ObjectPatchNode",
|
"ObjectPatchNode",
|
||||||
"RemoteWidgetNode",
|
"RemoteWidgetNode",
|
||||||
"RemoteWidgetNodeWithControlAfterRefresh",
|
"RemoteWidgetNodeWithControlAfterRefresh",
|
||||||
|
|||||||
@@ -303,6 +303,91 @@ class NodeWithV2ComboInput:
|
|||||||
return (combo_input,)
|
return (combo_input,)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeWithDynamicCombo:
|
||||||
|
"""
|
||||||
|
Test node with DynamicCombo that shows/hides widgets based on selection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"first_widget": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"dynamic_combo": (
|
||||||
|
"COMFY_DYNAMICCOMBO_V3",
|
||||||
|
{
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"key": "none",
|
||||||
|
"inputs": {"required": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "one",
|
||||||
|
"inputs": {
|
||||||
|
"required": {
|
||||||
|
"w1": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "two",
|
||||||
|
"inputs": {
|
||||||
|
"required": {
|
||||||
|
"w1": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"w2": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "three",
|
||||||
|
"inputs": {
|
||||||
|
"required": {
|
||||||
|
"w1": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"w2": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"w3": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"last_widget": (
|
||||||
|
"INT",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("INT",)
|
||||||
|
FUNCTION = "execute"
|
||||||
|
CATEGORY = "DevTools/Testing"
|
||||||
|
DESCRIPTION = "Test node for dynamic combo widget behavior"
|
||||||
|
|
||||||
|
def execute(self, **kwargs):
|
||||||
|
print(kwargs)
|
||||||
|
return (1,)
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"DevToolsLongComboDropdown": LongComboDropdown,
|
"DevToolsLongComboDropdown": LongComboDropdown,
|
||||||
"DevToolsNodeWithOptionalInput": NodeWithOptionalInput,
|
"DevToolsNodeWithOptionalInput": NodeWithOptionalInput,
|
||||||
@@ -318,6 +403,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
|
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
|
||||||
"DevToolsNodeWithValidation": NodeWithValidation,
|
"DevToolsNodeWithValidation": NodeWithValidation,
|
||||||
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
|
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
|
||||||
|
"DevToolsDynamicComboNode": NodeWithDynamicCombo,
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
@@ -335,6 +421,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"DevToolsNodeWithSeedInput": "Node With Seed Input",
|
"DevToolsNodeWithSeedInput": "Node With Seed Input",
|
||||||
"DevToolsNodeWithValidation": "Node With Validation",
|
"DevToolsNodeWithValidation": "Node With Validation",
|
||||||
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
|
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
|
||||||
|
"DevToolsDynamicComboNode": "Dynamic Combo Node",
|
||||||
}
|
}
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -352,6 +439,7 @@ __all__ = [
|
|||||||
"NodeWithSeedInput",
|
"NodeWithSeedInput",
|
||||||
"NodeWithValidation",
|
"NodeWithValidation",
|
||||||
"NodeWithV2ComboInput",
|
"NodeWithV2ComboInput",
|
||||||
|
"NodeWithDynamicCombo",
|
||||||
"NODE_CLASS_MAPPINGS",
|
"NODE_CLASS_MAPPINGS",
|
||||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user