mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
Compare commits
25 Commits
chore/load
...
fix/promot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5abb36cdc | ||
|
|
6966a6053c | ||
|
|
8eef299941 | ||
|
|
6adca6bfbe | ||
|
|
91a754f600 | ||
|
|
10f3350956 | ||
|
|
edb2e530af | ||
|
|
1c749b3907 | ||
|
|
9ef65eda7a | ||
|
|
4a8fd059fe | ||
|
|
e6e1b5ff05 | ||
|
|
0ddec541c5 | ||
|
|
c439282915 | ||
|
|
278a1eddb6 | ||
|
|
2330807b06 | ||
|
|
894768a4b6 | ||
|
|
210384bbf7 | ||
|
|
288f52d033 | ||
|
|
8402c2ae96 | ||
|
|
7f0ab180b9 | ||
|
|
735d639d64 | ||
|
|
71a4098aa9 | ||
|
|
c601aab2c3 | ||
|
|
fd9e732b7f | ||
|
|
8a923a2094 |
@@ -272,7 +272,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["5", "string_a"],
|
||||
["9", "string_a"],
|
||||
["11", "value"],
|
||||
["9", "value"],
|
||||
["10", "string_a"]
|
||||
|
||||
@@ -94,6 +94,50 @@ export class VueNodeHelpers {
|
||||
await this.page.mouse.click(50, 50)
|
||||
}
|
||||
|
||||
private async fitNodeInViewport(nodeId: string): Promise<void> {
|
||||
await this.page.evaluate((id) => {
|
||||
const app = window.app
|
||||
const canvas = app?.canvas
|
||||
const node = canvas?.graph?.getNodeById(id)
|
||||
if (!canvas || !node) {
|
||||
throw new Error(`Node ${id} not found`)
|
||||
}
|
||||
|
||||
const [x, y, width, height] = node.getBounding()
|
||||
canvas.ds.fitToBounds([x - 20, y - 20, width + 40, height + 80], {
|
||||
zoom: 1
|
||||
})
|
||||
canvas.setDirty(true, true)
|
||||
}, nodeId)
|
||||
|
||||
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
|
||||
}
|
||||
|
||||
private getVisibleClickPosition(
|
||||
box: NonNullable<Awaited<ReturnType<Locator['boundingBox']>>>
|
||||
) {
|
||||
const viewport = this.page.viewportSize()
|
||||
if (!viewport) {
|
||||
return { x: box.width / 2, y: box.height * 0.75 }
|
||||
}
|
||||
|
||||
const visibleLeft = Math.max(box.x, 0)
|
||||
const visibleRight = Math.min(box.x + box.width, viewport.width)
|
||||
const visibleTop = Math.max(box.y, 0)
|
||||
const visibleBottom = Math.min(box.y + box.height, viewport.height)
|
||||
|
||||
if (visibleLeft >= visibleRight || visibleTop >= visibleBottom) {
|
||||
throw new Error(
|
||||
'subgraph-enter-button has no visible viewport intersection'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
x: visibleLeft - box.x + (visibleRight - visibleLeft) / 2,
|
||||
y: Math.max(1, Math.min(box.height - 2, visibleBottom - box.y - 2))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Delete key
|
||||
*/
|
||||
@@ -187,24 +231,43 @@ export class VueNodeHelpers {
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not
|
||||
* provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const hostNode = nodeId
|
||||
? this.getNodeLocator(nodeId)
|
||||
: this.nodes
|
||||
.filter({
|
||||
has: this.page.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
})
|
||||
.first()
|
||||
const resolvedNodeId =
|
||||
nodeId ?? (await hostNode.getAttribute('data-node-id'))
|
||||
const editButton = hostNode.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
// Click at the bottom 25% of the button which is the genuinely visible
|
||||
// and unobstructed area outside the node body boundary.
|
||||
const box = await editButton.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(
|
||||
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
|
||||
)
|
||||
const clickEnterButton = async () => {
|
||||
// The footer tab sits below the node body and can be partially clipped
|
||||
// by the viewport. Click inside the visible slice of the button instead
|
||||
// of using the raw bounding-box center, which can land on the page root.
|
||||
const box = await editButton.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(
|
||||
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
|
||||
)
|
||||
}
|
||||
|
||||
await editButton.click({
|
||||
position: this.getVisibleClickPosition(box)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await clickEnterButton()
|
||||
} catch (error) {
|
||||
if (!resolvedNodeId) throw error
|
||||
await this.fitNodeInViewport(resolvedNodeId)
|
||||
await clickEnterButton()
|
||||
}
|
||||
await editButton.click({
|
||||
position: { x: box.width / 2, y: box.height * 0.75 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,4 +191,59 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for #10612: promoted widget indicator ring missing on
|
||||
* nested subgraph nodes.
|
||||
*
|
||||
* Uses the 3-level nested fixture (subgraph-nested-promotion):
|
||||
* Root → Sub 0 (node 5) → Sub 1 (node 6) → Sub 2 (node 9)
|
||||
*
|
||||
* Node 6 (Sub 1) has proxyWidgets promoting widgets from inner nodes,
|
||||
* and those promotions are also promoted up to node 5 (Sub 0). When
|
||||
* navigating into Sub 0, node 6 should show the promoted ring on its
|
||||
* widgets. The fixture's proxyWidgets entries are scoped to Sub 1's
|
||||
* local graph, so the nested `string_a` promotion correctly points at
|
||||
* inner node 9 instead of the outer SubgraphNode 5.
|
||||
*/
|
||||
test.describe(
|
||||
'Promoted indicator on 3-level nested subgraphs (#10612)',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
test('Intermediate SubgraphNode shows promoted ring inside parent subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// At root level, node 5 (Sub 0) is the outermost SubgraphNode.
|
||||
// Its widgets are not promoted further, so no ring expected.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const outerRings = outerNode.locator(`.${PROMOTED_BORDER_CLASS}`)
|
||||
await comfyExpect(outerRings).toHaveCount(0)
|
||||
|
||||
// Exercise the same enter-subgraph control users click.
|
||||
await comfyPage.vueNodes.enterSubgraph('5')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 6 (Sub 1) has proxyWidgets promoted up to Sub 0
|
||||
// (node 5). Those promoted widgets should carry the ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('6')
|
||||
await comfyExpect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediateRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(intermediateRings).not.toHaveCount(0, {
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -70,6 +70,43 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('infers the leaf-node disambiguator for nested subgraph entries', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: 'number' }]
|
||||
})
|
||||
|
||||
const samplerNode = new LGraphNode('Sampler')
|
||||
const samplerInput = samplerNode.addInput('seed', 'number')
|
||||
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
|
||||
samplerInput.widget = { name: 'noise_seed' }
|
||||
innerSubgraph.add(samplerNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const nestedNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(nestedNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(nestedNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(nestedNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: String(samplerNode.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a single legacy prefix from widget name', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
|
||||
@@ -6,34 +6,30 @@ const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
|
||||
|
||||
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
|
||||
|
||||
function canResolve(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): boolean {
|
||||
return (
|
||||
resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
).status === 'resolved'
|
||||
)
|
||||
}
|
||||
|
||||
function tryResolveCandidate(
|
||||
function resolveCandidate(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
|
||||
return undefined
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
)
|
||||
if (result.status !== 'resolved') return undefined
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(sourceNodeId)
|
||||
const inferredDisambiguator =
|
||||
disambiguator ??
|
||||
(sourceNode?.isSubgraphNode() ? String(result.resolved.node.id) : undefined)
|
||||
|
||||
return {
|
||||
sourceWidgetName: widgetName,
|
||||
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
|
||||
...(inferredDisambiguator && {
|
||||
disambiguatingSourceNodeId: inferredDisambiguator
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +55,7 @@ function resolveLegacyPrefixedEntry(
|
||||
]
|
||||
|
||||
for (const disambiguator of disambiguators) {
|
||||
const resolved = tryResolveCandidate(
|
||||
const resolved = resolveCandidate(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
remaining,
|
||||
@@ -76,18 +72,19 @@ export function normalizeLegacyProxyWidgetEntry(
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetSource {
|
||||
if (
|
||||
canResolve(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
) {
|
||||
const exactMatch = resolveCandidate(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
if (exactMatch) {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
sourceWidgetName: exactMatch.sourceWidgetName,
|
||||
...(exactMatch.disambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: exactMatch.disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,14 +95,14 @@ export function normalizeLegacyProxyWidgetEntry(
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
|
||||
const patchDisambiguatingSourceNodeId =
|
||||
const normalizedDisambiguatingSourceNodeId =
|
||||
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
|
||||
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
|
||||
...(patchDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
|
||||
...(normalizedDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: normalizedDisambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
152
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal file
152
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: {
|
||||
rootGraph: {
|
||||
id: 'graph-test'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const PROMOTED_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
const WidgetStub = defineComponent({
|
||||
props: { widget: { type: Object, default: undefined } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', {
|
||||
'data-testid': 'widget-stub',
|
||||
class: props.widget?.borderStyle
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
getComponent: () => WidgetStub,
|
||||
shouldExpand: () => false,
|
||||
shouldRenderAsVue: () => true
|
||||
})
|
||||
)
|
||||
|
||||
function createMockWidget(
|
||||
overrides: Partial<SafeWidgetData> = {}
|
||||
): SafeWidgetData {
|
||||
return {
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
options: undefined,
|
||||
callback: undefined,
|
||||
spec: undefined,
|
||||
isDOMWidget: false,
|
||||
slotMetadata: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createMockNodeData(
|
||||
nodeType: string,
|
||||
widgets: SafeWidgetData[],
|
||||
id: string
|
||||
): VueNodeData {
|
||||
return {
|
||||
id,
|
||||
type: nodeType,
|
||||
widgets,
|
||||
title: 'Test Node',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(nodeData: VueNodeData, setupStores?: () => void) {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
setupStores?.()
|
||||
|
||||
return render(NodeWidgets, {
|
||||
props: { nodeData },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { InputSlot: true },
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('promoted widget indicator on nested subgraphs', () => {
|
||||
test('shows promoted ring when promotion includes disambiguatingSourceNodeId', async () => {
|
||||
// Scenario: SubBNode (id=3) inside SubA promotes a widget from
|
||||
// ConcreteNode (id=1). During hydration, SubgraphNode.configure
|
||||
// resolves the concrete node and stores the promotion WITH
|
||||
// disambiguatingSourceNodeId so that the exact key matches the
|
||||
// renderer's lookup.
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
|
||||
renderComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
|
||||
const widgets = screen.getAllByTestId('widget-stub')
|
||||
const hasPromotedRing = widgets.some((el) =>
|
||||
el.classList.contains(PROMOTED_CLASS)
|
||||
)
|
||||
expect(hasPromotedRing).toBe(true)
|
||||
})
|
||||
|
||||
test('shows promoted ring via base-key lookup when disambiguator is unknown', async () => {
|
||||
// Legacy callers (e.g. BaseWidget) may not have the disambiguator.
|
||||
// The dual-indexed base key ensures the ring still shows.
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'test_node',
|
||||
isDOMWidget: false
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
|
||||
renderComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
|
||||
const widgets = screen.getAllByTestId('widget-stub')
|
||||
const hasPromotedRing = widgets.some((el) =>
|
||||
el.classList.contains(PROMOTED_CLASS)
|
||||
)
|
||||
expect(hasPromotedRing).toBe(true)
|
||||
})
|
||||
})
|
||||
31
src/core/graph/subgraph/promotionLookup.ts
Normal file
31
src/core/graph/subgraph/promotionLookup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import type { Pinia } from 'pinia'
|
||||
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
let cachedPromotionStore: ReturnType<typeof usePromotionStore> | undefined
|
||||
let cachedPinia: Pinia | undefined
|
||||
|
||||
function getPromotionStore() {
|
||||
const activePinia = getActivePinia()
|
||||
if (!cachedPromotionStore || cachedPinia !== activePinia) {
|
||||
cachedPromotionStore = usePromotionStore(activePinia)
|
||||
cachedPinia = activePinia
|
||||
}
|
||||
return cachedPromotionStore
|
||||
}
|
||||
|
||||
export function isWidgetPromoted(
|
||||
graphId: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): boolean {
|
||||
return getPromotionStore().isWidgetPromoted(
|
||||
graphId,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
|
||||
import { t } from '@/i18n'
|
||||
import { drawTextInArea } from '@/lib/litegraph/src/draw'
|
||||
import { cachedMeasureText } from '@/lib/litegraph/src/utils/textMeasureCache'
|
||||
@@ -17,7 +18,6 @@ import type {
|
||||
NodeBindable,
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -211,10 +211,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
if (
|
||||
graphId &&
|
||||
!suppressPromotedOutline &&
|
||||
usePromotionStore().isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
isWidgetPromoted(graphId, String(this.node.id), this.name)
|
||||
)
|
||||
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
|
||||
return this.advanced
|
||||
|
||||
@@ -247,6 +247,46 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses slotName when ancestor promotion keeps the host widget alias', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:10',
|
||||
storeNodeId: 'inner-subgraph:10',
|
||||
storeName: 'string_a',
|
||||
slotName: 'value_1'
|
||||
})
|
||||
|
||||
usePromotionStore().promote('graph-test', '5', {
|
||||
sourceNodeId: '6',
|
||||
sourceWidgetName: 'value_1',
|
||||
disambiguatingSourceNodeId: '10'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '6',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [promotedWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(
|
||||
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply promoted border styling to outermost widgets', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -167,7 +167,6 @@ export function computeProcessedWidgets({
|
||||
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
@@ -253,7 +252,7 @@ export function computeProcessedWidgets({
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const promotionSourceNodeId = widget.storeName
|
||||
const disambiguatingSourceNodeId = widget.storeName
|
||||
? String(bareWidgetId)
|
||||
: undefined
|
||||
|
||||
@@ -270,17 +269,23 @@ export function computeProcessedWidgets({
|
||||
? { ...mergedOptions, disabled: true }
|
||||
: mergedOptions
|
||||
|
||||
const borderStyle =
|
||||
// Nested SubgraphNode promotions are keyed by the subgraph slot name.
|
||||
// storeName still identifies the concrete leaf widget and is only used
|
||||
// as the disambiguator when multiple promoted views share that slot.
|
||||
const sourceWidgetName = widget.slotName ?? widget.name
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: hostNodeId,
|
||||
sourceWidgetName: widget.storeName ?? widget.name,
|
||||
disambiguatingSourceNodeId: promotionSourceNodeId
|
||||
})
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
isWidgetPromoted(
|
||||
graphId,
|
||||
hostNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
const borderStyle = isPromoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
const linkedUpstream: LinkedUpstreamInfo | undefined =
|
||||
slotMetadata?.linked && slotMetadata.originNodeId
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
|
||||
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
|
||||
const isWidgetPromotedMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
@@ -12,10 +12,8 @@ vi.mock('@/stores/domWidgetStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/promotionStore', () => ({
|
||||
usePromotionStore: () => ({
|
||||
isPromotedByAny: isPromotedByAnyMock
|
||||
})
|
||||
vi.mock('@/core/graph/subgraph/promotionLookup', () => ({
|
||||
isWidgetPromoted: isWidgetPromotedMock
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
@@ -120,7 +118,7 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
})
|
||||
|
||||
test('draws promoted outline for visible promoted widgets', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(true)
|
||||
isWidgetPromotedMock.mockReturnValue(true)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
@@ -138,16 +136,17 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
|
||||
sourceNodeId: '-1',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(isWidgetPromotedMock).toHaveBeenCalledWith(
|
||||
'root-graph-id',
|
||||
'-1',
|
||||
'seed'
|
||||
)
|
||||
expect(ctx.strokeRect).toHaveBeenCalledOnce()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
test('does not draw promoted outline when widget is not promoted', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(false)
|
||||
isWidgetPromotedMock.mockReturnValue(false)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
@@ -187,7 +186,7 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
|
||||
expect(isWidgetPromotedMock).not.toHaveBeenCalled()
|
||||
expect(ctx.strokeRect).not.toHaveBeenCalled()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat'
|
||||
import { type Component, toRaw } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isWidgetPromoted } from '@/core/graph/subgraph/promotionLookup'
|
||||
import {
|
||||
LGraphNode,
|
||||
LegacyWidget,
|
||||
@@ -13,7 +14,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
export interface BaseDOMWidget<
|
||||
@@ -125,8 +125,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
declare readonly name: string
|
||||
declare readonly options: DOMWidgetOptions<V>
|
||||
declare callback?: (value: V) => void
|
||||
readonly promotionStore = usePromotionStore()
|
||||
|
||||
readonly id: string
|
||||
|
||||
constructor(obj: {
|
||||
@@ -189,11 +187,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
this.promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
graphId && isWidgetPromoted(graphId, String(this.node.id), this.name)
|
||||
if (!isPromoted) {
|
||||
this.options.onDraw?.(this)
|
||||
return
|
||||
|
||||
@@ -193,6 +193,29 @@ describe(usePromotionStore, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetPromoted', () => {
|
||||
it('matches exact disambiguated promotion keys', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(store.isWidgetPromoted(graphA, '3', 'text', '1')).toBe(true)
|
||||
expect(store.isWidgetPromoted(graphA, '3', 'text', '2')).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the base key when disambiguatingSourceNodeId is omitted', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(store.isWidgetPromoted(graphA, '3', 'text')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPromotions', () => {
|
||||
it('replaces existing entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
@@ -817,7 +840,7 @@ describe(usePromotionStore, () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
|
||||
it('isPromotedByAny with disambiguatingSourceNodeId matches exact key and base key', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
@@ -837,12 +860,14 @@ describe(usePromotionStore, () => {
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(false)
|
||||
// Base-key lookup succeeds because dual-indexing keeps a ref count
|
||||
// on the base key for callers that lack a disambiguator.
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text'
|
||||
})
|
||||
).toBe(false)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {
|
||||
|
||||
@@ -7,8 +7,12 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
|
||||
|
||||
function makePromotionBaseKey(source: PromotedWidgetSource): string {
|
||||
return `${source.sourceNodeId}:${source.sourceWidgetName}`
|
||||
}
|
||||
|
||||
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
|
||||
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
|
||||
const base = makePromotionBaseKey(source)
|
||||
return source.disambiguatingSourceNodeId
|
||||
? `${base}:${source.disambiguatingSourceNodeId}`
|
||||
: base
|
||||
@@ -40,14 +44,33 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
return nextRefCounts
|
||||
}
|
||||
|
||||
function _incrementKey(refCounts: Map<string, number>, key: string): void {
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function _decrementKey(refCounts: Map<string, number>, key: string): void {
|
||||
const current = refCounts.get(key)
|
||||
if (current === undefined) return
|
||||
|
||||
if (current <= 1) {
|
||||
refCounts.delete(key)
|
||||
} else {
|
||||
refCounts.set(key, current - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function _incrementKeys(
|
||||
graphId: UUID,
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = makePromotionEntryKey(e)
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
_incrementKey(refCounts, makePromotionEntryKey(e))
|
||||
// Also index the base key so callers without a disambiguator can
|
||||
// still query whether any widget with this name is promoted.
|
||||
if (e.disambiguatingSourceNodeId) {
|
||||
_incrementKey(refCounts, makePromotionBaseKey(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +80,9 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = makePromotionEntryKey(e)
|
||||
const count = (refCounts.get(key) ?? 1) - 1
|
||||
if (count <= 0) {
|
||||
refCounts.delete(key)
|
||||
} else {
|
||||
refCounts.set(key, count)
|
||||
_decrementKey(refCounts, makePromotionEntryKey(e))
|
||||
if (e.disambiguatingSourceNodeId) {
|
||||
_decrementKey(refCounts, makePromotionBaseKey(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,11 +210,31 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
graphRefCounts.value.delete(graphId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a widget is promoted by any subgraph node in the given
|
||||
* graph. When disambiguatingSourceNodeId is provided, does an exact-key
|
||||
* lookup; otherwise falls back to a base-key lookup (which succeeds if
|
||||
* any widget with that name on the source node is promoted).
|
||||
*/
|
||||
function isWidgetPromoted(
|
||||
graphId: UUID,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): boolean {
|
||||
return isPromotedByAny(graphId, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getPromotionsRef,
|
||||
getPromotions,
|
||||
isPromoted,
|
||||
isPromotedByAny,
|
||||
isWidgetPromoted,
|
||||
setPromotions,
|
||||
promote,
|
||||
demote,
|
||||
|
||||
Reference in New Issue
Block a user