Skip to content

Commit 0016d9e

Browse files
AustinMrozactions-user
authored andcommitted
Subgraph widget promotion - Part 2 (#5617)
Implements proxyWidget support on subgraph nodes. This registers a special proxyWidgets property on subgraph nodes which is directly mapped to the proxyWidgets displayed on the node. Each proxyWidget directly maps to a real widget inside the subgraph. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5617-Subgraph-widget-promotion-Part-2-2716d73d3650813d8621fefdce6ae518) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <[email protected]>
1 parent adee9b2 commit 0016d9e

File tree

6 files changed

+337
-3
lines changed

6 files changed

+337
-3
lines changed

knip.config.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ const config: KnipConfig = {
2525
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
2626
'src/types/comfyRegistryTypes.ts',
2727
// Used by a custom node (that should move off of this)
28-
'src/scripts/ui/components/splitButton.ts',
29-
// Staged for for use with subgraph widget promotion
30-
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts'
28+
'src/scripts/ui/components/splitButton.ts'
3129
],
3230
compilers: {
3331
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { useNodeImage } from '@/composables/node/useNodeImage'
2+
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
3+
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
4+
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
5+
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
6+
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
7+
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
8+
import { DOMWidgetImpl } from '@/scripts/domWidget'
9+
import { useDomWidgetStore } from '@/stores/domWidgetStore'
10+
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
11+
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
12+
13+
/**
14+
* @typedef {object} Overlay - Each proxy Widget has an associated overlay object
15+
* Accessing a property which exists in the overlay object will
16+
* instead result in the action being performed on the overlay object
17+
* 3 properties are added for locating the proxied widget
18+
* @property {LGraph} graph - The graph the widget resides in. Used for widget lookup
19+
* @property {string} nodeId - The NodeId the proxy Widget is located on
20+
* @property {string} widgetName - The name of the linked widget
21+
*
22+
* @property {boolean} isProxyWidget - Always true, used as type guard
23+
* @property {LGraphNode} node - not included on IBaseWidget, but required for overlay
24+
*/
25+
type Overlay = Partial<IBaseWidget> & {
26+
graph: LGraph
27+
nodeId: string
28+
widgetName: string
29+
isProxyWidget: boolean
30+
node?: LGraphNode
31+
}
32+
// A ProxyWidget can be treated like a normal widget.
33+
// the _overlay property can be used to directly access the Overlay object
34+
/**
35+
* @typedef {object} ProxyWidget - a reference to a widget that can
36+
* be displayed and owned by a separate node
37+
* @property {Overlay} _overlay - a special property to access the overlay of the widget
38+
* Any property that exists in the overlay will be accessed instead of the property
39+
* on the linked widget
40+
*/
41+
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
42+
function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
43+
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
44+
}
45+
46+
const originalOnConfigure = SubgraphNode.prototype.onConfigure
47+
SubgraphNode.prototype.onConfigure = function (serialisedNode) {
48+
if (!this.isSubgraphNode())
49+
throw new Error("Can't add proxyWidgets to non-subgraphNode")
50+
51+
const canvasStore = useCanvasStore()
52+
//Must give value to proxyWidgets prior to defining or it won't serialize
53+
this.properties.proxyWidgets ??= '[]'
54+
let proxyWidgets = this.properties.proxyWidgets
55+
56+
originalOnConfigure?.call(this, serialisedNode)
57+
58+
Object.defineProperty(this.properties, 'proxyWidgets', {
59+
get: () => {
60+
return proxyWidgets
61+
},
62+
set: (property: string) => {
63+
const parsed = parseProxyWidgets(property)
64+
const { deactivateWidget, setWidget } = useDomWidgetStore()
65+
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
66+
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
67+
}
68+
this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
69+
for (const [nodeId, widgetName] of parsed) {
70+
const w = addProxyWidget(this, `${nodeId}`, widgetName)
71+
if (w instanceof DOMWidgetImpl) setWidget(w)
72+
}
73+
proxyWidgets = property
74+
canvasStore.canvas?.setDirty(true, true)
75+
this._setConcreteSlots()
76+
this.arrange()
77+
}
78+
})
79+
this.properties.proxyWidgets = proxyWidgets
80+
}
81+
82+
function addProxyWidget(
83+
subgraphNode: SubgraphNode,
84+
nodeId: string,
85+
widgetName: string
86+
) {
87+
const name = `${nodeId}: ${widgetName}`
88+
const overlay = {
89+
nodeId,
90+
widgetName,
91+
graph: subgraphNode.subgraph,
92+
name,
93+
label: name,
94+
isProxyWidget: true,
95+
y: 0,
96+
last_y: undefined,
97+
width: undefined,
98+
computedHeight: undefined,
99+
afterQueued: undefined,
100+
onRemove: undefined,
101+
node: subgraphNode
102+
}
103+
return addProxyFromOverlay(subgraphNode, overlay)
104+
}
105+
function resolveLinkedWidget(
106+
overlay: Overlay
107+
): [LGraphNode | undefined, IBaseWidget | undefined] {
108+
const { graph, nodeId, widgetName } = overlay
109+
const n = getNodeByExecutionId(graph, nodeId)
110+
if (!n) return [undefined, undefined]
111+
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
112+
}
113+
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
114+
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
115+
let backingWidget = linkedWidget ?? disconnectedWidget
116+
if (overlay.widgetName == '$$canvas-image-preview')
117+
overlay.node = new Proxy(subgraphNode, {
118+
get(_t, p) {
119+
if (p !== 'imgs') return Reflect.get(subgraphNode, p)
120+
if (!linkedNode) return []
121+
const images =
122+
useNodeOutputStore().getNodeOutputs(linkedNode)?.images ?? []
123+
if (images !== linkedNode.images) {
124+
linkedNode.images = images
125+
useNodeImage(linkedNode).showPreview()
126+
}
127+
return linkedNode.imgs
128+
}
129+
})
130+
/**
131+
* A set of handlers which define widget interaction
132+
* Many arguments are shared between function calls
133+
* @param {IBaseWidget} _t - The "target" the call is originally made on.
134+
* This argument is never used, but must be defined for typechecking
135+
* @param {string} property - The name of the accessed value.
136+
* Checked for conditional logic, but never changed
137+
* @param {object} receiver - The object the result is set to
138+
* and the vlaue used as 'this' if property is a get/set method
139+
* @param {unknown} value - only used on set calls. The thing being assigned
140+
*/
141+
const handler = {
142+
get(_t: IBaseWidget, property: string, receiver: object) {
143+
let redirectedTarget: object = backingWidget
144+
let redirectedReceiver = receiver
145+
if (property == '_overlay') return overlay
146+
else if (property == 'value') redirectedReceiver = backingWidget
147+
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
148+
redirectedTarget = overlay
149+
redirectedReceiver = overlay
150+
}
151+
return Reflect.get(redirectedTarget, property, redirectedReceiver)
152+
},
153+
set(_t: IBaseWidget, property: string, value: unknown, receiver: object) {
154+
let redirectedTarget: object = backingWidget
155+
let redirectedReceiver = receiver
156+
if (property == 'value') redirectedReceiver = backingWidget
157+
else if (property == 'computedHeight') {
158+
//update linkage regularly, but no more than once per frame
159+
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
160+
backingWidget = linkedWidget ?? disconnectedWidget
161+
}
162+
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
163+
redirectedTarget = overlay
164+
redirectedReceiver = overlay
165+
}
166+
return Reflect.set(redirectedTarget, property, value, redirectedReceiver)
167+
},
168+
getPrototypeOf() {
169+
return Reflect.getPrototypeOf(backingWidget)
170+
},
171+
ownKeys() {
172+
return Reflect.ownKeys(backingWidget)
173+
},
174+
has(_t: IBaseWidget, property: string) {
175+
let redirectedTarget: object = backingWidget
176+
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
177+
redirectedTarget = overlay
178+
}
179+
return Reflect.has(redirectedTarget, property)
180+
}
181+
}
182+
const w = new Proxy(disconnectedWidget, handler)
183+
subgraphNode.widgets.push(w)
184+
return w
185+
}

src/core/schemas/proxyWidget.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { z } from 'zod'
2+
import { fromZodError } from 'zod-validation-error'
3+
4+
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
5+
6+
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
7+
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
8+
9+
export function parseProxyWidgets(
10+
property: NodeProperty | undefined
11+
): ProxyWidgetsProperty {
12+
if (typeof property !== 'string') {
13+
throw new Error(
14+
'Invalid assignment for properties.proxyWidgets:\nValue must be a string'
15+
)
16+
}
17+
const parsed = JSON.parse(property)
18+
const result = proxyWidgetsPropertySchema.safeParse(parsed)
19+
if (result.success) return result.data
20+
21+
const error = fromZodError(result.error)
22+
throw new Error(`Invalid assignment for properties.proxyWidgets:\n${error}`)
23+
}

src/scripts/widgets.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import '@/core/graph/subgraph/proxyWidget'
12
import { t } from '@/i18n'
23
import { type LGraphNode, isComboWidget } from '@/lib/litegraph/src/litegraph'
34
import type {

src/stores/domWidgetStore.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
5757
if (state) state.active = false
5858
}
5959

60+
const setWidget = (widget: BaseDOMWidget) => {
61+
const state = widgetStates.value.get(widget.id)
62+
if (!state) return
63+
state.active = true
64+
state.widget = widget
65+
}
66+
6067
const clear = () => {
6168
widgetStates.value.clear()
6269
}
@@ -69,6 +76,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
6976
unregisterWidget,
7077
activateWidget,
7178
deactivateWidget,
79+
setWidget,
7280
clear
7381
}
7482
})
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, test, vi } from 'vitest'
2+
3+
import '@/core/graph/subgraph/proxyWidget'
4+
//import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
5+
6+
import { LGraphNode, type SubgraphNode } from '@/lib/litegraph/src/litegraph'
7+
8+
import {
9+
createTestSubgraph,
10+
createTestSubgraphNode
11+
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
12+
13+
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
14+
useCanvasStore: () => ({})
15+
}))
16+
vi.mock('@/stores/domWidgetStore', () => ({
17+
useDomWidgetStore: () => ({ widgetStates: new Map() })
18+
}))
19+
20+
function setupSubgraph(
21+
innerNodeCount: number = 0
22+
): [SubgraphNode, LGraphNode[]] {
23+
const subgraph = createTestSubgraph()
24+
const subgraphNode = createTestSubgraphNode(subgraph)
25+
subgraphNode._internalConfigureAfterSlots()
26+
const graph = subgraphNode.graph
27+
graph.add(subgraphNode)
28+
const innerNodes = []
29+
for (let i = 0; i < innerNodeCount; i++) {
30+
const innerNode = new LGraphNode(`InnerNode${i}`)
31+
subgraph.add(innerNode)
32+
innerNodes.push(innerNode)
33+
}
34+
return [subgraphNode, innerNodes]
35+
}
36+
37+
describe('Subgraph proxyWidgets', () => {
38+
test('Can add simple widget', () => {
39+
const [subgraphNode, innerNodes] = setupSubgraph(1)
40+
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
41+
subgraphNode.properties.proxyWidgets = JSON.stringify([
42+
['1', 'stringWidget']
43+
])
44+
expect(subgraphNode.widgets.length).toBe(1)
45+
expect(subgraphNode.properties.proxyWidgets).toBe(
46+
JSON.stringify([['1', 'stringWidget']])
47+
)
48+
})
49+
test('Can add multiple widgets with same name', () => {
50+
const [subgraphNode, innerNodes] = setupSubgraph(2)
51+
for (const innerNode of innerNodes)
52+
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
53+
subgraphNode.properties.proxyWidgets = JSON.stringify([
54+
['1', 'stringWidget'],
55+
['2', 'stringWidget']
56+
])
57+
expect(subgraphNode.widgets.length).toBe(2)
58+
expect(subgraphNode.widgets[0].name).not.toEqual(
59+
subgraphNode.widgets[1].name
60+
)
61+
})
62+
test('Will not modify existing widgets', () => {
63+
const [subgraphNode, innerNodes] = setupSubgraph(1)
64+
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
65+
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
66+
subgraphNode.properties.proxyWidgets = JSON.stringify([
67+
['1', 'stringWidget']
68+
])
69+
expect(subgraphNode.widgets.length).toBe(2)
70+
subgraphNode.properties.proxyWidgets = JSON.stringify([])
71+
expect(subgraphNode.widgets.length).toBe(1)
72+
})
73+
test('Will mirror changes to value', () => {
74+
const [subgraphNode, innerNodes] = setupSubgraph(1)
75+
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
76+
subgraphNode.properties.proxyWidgets = JSON.stringify([
77+
['1', 'stringWidget']
78+
])
79+
expect(subgraphNode.widgets.length).toBe(1)
80+
expect(subgraphNode.widgets[0].value).toBe('value')
81+
innerNodes[0].widgets![0].value = 'test'
82+
expect(subgraphNode.widgets[0].value).toBe('test')
83+
subgraphNode.widgets[0].value = 'test2'
84+
expect(innerNodes[0].widgets![0].value).toBe('test2')
85+
})
86+
test('Will not modify position or sizing of existing widgets', () => {
87+
const [subgraphNode, innerNodes] = setupSubgraph(1)
88+
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
89+
subgraphNode.properties.proxyWidgets = JSON.stringify([
90+
['1', 'stringWidget']
91+
])
92+
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
93+
innerNodes[0].widgets[0].y = 10
94+
innerNodes[0].widgets[0].last_y = 11
95+
innerNodes[0].widgets[0].computedHeight = 12
96+
subgraphNode.widgets[0].y = 20
97+
subgraphNode.widgets[0].last_y = 21
98+
subgraphNode.widgets[0].computedHeight = 22
99+
expect(innerNodes[0].widgets[0].y).toBe(10)
100+
expect(innerNodes[0].widgets[0].last_y).toBe(11)
101+
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
102+
})
103+
test('Can detatch and re-attach widgets', () => {
104+
const [subgraphNode, innerNodes] = setupSubgraph(1)
105+
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
106+
subgraphNode.properties.proxyWidgets = JSON.stringify([
107+
['1', 'stringWidget']
108+
])
109+
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
110+
expect(subgraphNode.widgets[0].value).toBe('value')
111+
const poppedWidget = innerNodes[0].widgets.pop()
112+
//simulate new draw frame
113+
subgraphNode.widgets[0].computedHeight = 10
114+
expect(subgraphNode.widgets[0].value).toBe(undefined)
115+
innerNodes[0].widgets.push(poppedWidget!)
116+
subgraphNode.widgets[0].computedHeight = 10
117+
expect(subgraphNode.widgets[0].value).toBe('value')
118+
})
119+
})

0 commit comments

Comments
 (0)