Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
e154bba37e feat: add proxyWidgetSelector support to SubgraphNode 2026-04-03 18:51:54 -04:00
5 changed files with 84 additions and 95 deletions

View File

@@ -1,88 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe('Workflow reopen overwrites unsaved changes', () => {
test('Re-loading same workflow file should not silently discard unsaved edits', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'Issue #10766 — dragging the same workflow file again overwrites unsaved changes without warning'
})
// Step 1: Load a workflow from file (establishes a "source" filename)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
// Step 2: Read the original KSampler seed value
const originalSeed = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
return node?.widgets?.find((w) => w.name === 'seed')?.value as number
})
// Step 3: Modify the seed to a distinct value and trigger change tracking
const modifiedSeed = originalSeed === 99999 ? 88888 : 99999
await comfyPage.page.evaluate((newSeed) => {
const app = window.app!
const node = app.graph.nodes.find((n) => n.type === 'KSampler')
const widget = node?.widgets?.find((w) => w.name === 'seed')
if (widget) widget.value = newSeed
app.graph.setDirtyCanvas(true, true)
// Trigger change tracking so isModified reflects the edit — this is
// what happens on mouseup / keyup during normal user interaction.
const store = (app.extensionManager as WorkspaceStore).workflow
store.activeWorkflow?.changeTracker?.checkState?.()
}, modifiedSeed)
await comfyPage.nextFrame()
// Capture workflow count before reload for relative comparison
const countBeforeReload = await comfyPage.workflow.getOpenWorkflowCount()
// Step 4: Re-load the SAME workflow via loadGraphData with the same
// filename. This mirrors what handleFile() does when the user drags
// the same file onto the canvas a second time.
const seedAfterReload = await comfyPage.page.evaluate(async (seed) => {
const app = window.app!
const workflow = JSON.parse(JSON.stringify(app.graph.serialize()))
// Find seed widget index by name rather than hardcoding position
const liveNode = app.graph.nodes.find((n) => n.type === 'KSampler')
const seedIndex =
liveNode?.widgets?.findIndex((w) => w.name === 'seed') ?? 0
const ksNode = workflow.nodes.find(
(n: { type: string }) => n.type === 'KSampler'
)
if (ksNode?.widgets_values) {
ksNode.widgets_values[seedIndex] = seed
}
// This is the exact call handleFile makes — same filename triggers
// isSameActiveWorkflowLoad in afterLoadNewGraph.
await app.loadGraphData(workflow, true, true, 'single_ksampler', {})
const reloadedNode = app.graph.nodes.find((n) => n.type === 'KSampler')
return reloadedNode?.widgets?.find((w) => w.name === 'seed')
?.value as number
}, originalSeed)
const countAfterReload = await comfyPage.workflow.getOpenWorkflowCount()
// The unsaved edits must not be silently discarded.
// Acceptable outcomes:
// a) The modified seed value is preserved (user stays on modified tab)
// b) A new tab was opened for the re-loaded file
const changesPreserved = seedAfterReload === modifiedSeed
const newTabOpened = countAfterReload === countBeforeReload + 1
expect(
changesPreserved || newTabOpened,
`Unsaved changes were silently discarded. ` +
`Seed was ${modifiedSeed} before reload, became ${seedAfterReload} after. ` +
`Tabs before: ${countBeforeReload}, after: ${countAfterReload}.`
).toBe(true)
})
})

View File

@@ -16,7 +16,10 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -74,6 +77,7 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: IWidgetOptions['values']
}
/** Input specification from node definition */
spec?: InputSpec
@@ -222,7 +226,8 @@ function safeWidgetMapper(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
read_only: widget.options.read_only,
values: widget.options.values
}
}

View File

@@ -10,6 +10,15 @@ const proxyWidgetTupleSchema = z.union([
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export interface ProxyWidgetSelector {
name?: string
selected: string
options: {
label: string
widgets?: string[][]
}[]
}
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {

View File

@@ -45,6 +45,7 @@ import {
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type { ProxyWidgetSelector } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
makePromotionEntryKey,
@@ -116,6 +117,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
private _selectorWidget: IBaseWidget | null = null
// Declared as accessor via Object.defineProperty in constructor.
// TypeScript doesn't allow overriding a property with get/set syntax,
@@ -297,6 +299,69 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return views
}
private _getWidgetsWithSelector(): IBaseWidget[] {
const views = this._getPromotedViews()
const selector = this.properties.proxyWidgetSelector as
| ProxyWidgetSelector
| undefined
if (!selector?.options?.length || !this._selectorWidget) return views
const selectedOption =
selector.options.find((opt) => opt.label === selector.selected) ??
selector.options[0]
if (!selectedOption?.widgets) return [this._selectorWidget, ...views]
const allGroupedKeys = new Set(
selector.options.flatMap((opt) =>
(opt.widgets ?? []).map(([nid, wn]) => `${nid}:${wn}`)
)
)
const selectedKeys = new Set(
selectedOption.widgets.map(([nid, wn]) => `${nid}:${wn}`)
)
const filteredViews = views.filter((v) => {
const key = `${v.sourceNodeId}:${v.sourceWidgetName}`
return !allGroupedKeys.has(key) || selectedKeys.has(key)
})
return [this._selectorWidget, ...filteredViews]
}
private _initSelectorWidget(): void {
const selector = this.properties.proxyWidgetSelector as
| ProxyWidgetSelector
| undefined
if (!selector?.options?.length) {
this._selectorWidget = null
return
}
const validLabels = selector.options.map((o) => o.label)
if (!validLabels.includes(selector.selected)) {
selector.selected = validLabels[0]
}
this._selectorWidget = {
name: selector.name ?? 'selector',
type: 'combo',
value: selector.selected,
y: 0,
serialize: false,
options: {
values: validLabels
},
callback: (value: unknown) => {
selector.selected = String(value)
this._selectorWidget!.value = String(value)
this._invalidatePromotedViewsCache()
const minSize = this.computeSize()
this.setSize([this.size[0], minSize[1]])
this.graph?.setDirtyCanvas(true, true)
}
}
}
private _invalidatePromotedViewsCache(): void {
this._cacheVersion++
}
@@ -695,7 +760,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Synthetic widgets getter — SubgraphNodes have no native widgets.
Object.defineProperty(this, 'widgets', {
get: () => this._getPromotedViews(),
get: () => this._getWidgetsWithSelector(),
set: () => {
if (import.meta.env.DEV)
console.warn(
@@ -1097,6 +1162,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.properties.proxyWidgets = serialized
}
this._initSelectorWidget()
// Check all inputs for connected widgets
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot

View File

@@ -442,13 +442,9 @@ export const useWorkflowService = () => {
//
// This prevents accidental duplicate tabs when startup/load flows
// invoke loadGraphData more than once for the same workflow name.
//
// However, if the active workflow has unsaved modifications, open a
// new tab instead of silently overwriting (fixes #10766).
const isSameActiveWorkflowLoad =
!!existingWorkflow &&
workflowStore.isActive(existingWorkflow) &&
!existingWorkflow.isModified &&
(existingWorkflow.activeState?.id === undefined ||
workflowData.id === undefined ||
existingWorkflow.activeState.id === workflowData.id)