mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
38 Commits
docs/weekl
...
drjkl/unpr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec15c44db4 | ||
|
|
3cced08fa9 | ||
|
|
4f3f4cdcbf | ||
|
|
b0f8af5992 | ||
|
|
a1c9e913a8 | ||
|
|
0885ce742a | ||
|
|
5fea1ec3a1 | ||
|
|
6073ba35a8 | ||
|
|
6049332d4e | ||
|
|
5de9eaccf4 | ||
|
|
dde845bbfd | ||
|
|
1154dec2ff | ||
|
|
e8af61e25d | ||
|
|
c79e8a2251 | ||
|
|
7eac8f474b | ||
|
|
8d90f501e8 | ||
|
|
eb269aeee6 | ||
|
|
75b5606890 | ||
|
|
6140d22423 | ||
|
|
69c4ab6c32 | ||
|
|
b4a5462cbd | ||
|
|
13d237d6c5 | ||
|
|
ad4ee8dee0 | ||
|
|
5d0a6e2caa | ||
|
|
8cfc3b5c02 | ||
|
|
18f0fde481 | ||
|
|
b403d7a134 | ||
|
|
9c7b45cb71 | ||
|
|
35f3d84b57 | ||
|
|
19c5b1c3b4 | ||
|
|
348b5ae909 | ||
|
|
3cabdc967b | ||
|
|
a3200d8bfe | ||
|
|
34bc7107ce | ||
|
|
d557908e77 | ||
|
|
87ccc07e6d | ||
|
|
f02d6208c8 | ||
|
|
d54054bb1e |
@@ -458,12 +458,13 @@ export class NodeReference {
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
|
||||
// Try multiple positions to avoid DOM widget interference
|
||||
const clickPositions = [
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
|
||||
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
|
||||
]
|
||||
// Click the enter_subgraph title button (top-right of title bar).
|
||||
// This is more reliable than dblclick on the node body because
|
||||
// promoted DOM widgets can overlay the body and intercept events.
|
||||
const buttonPos = {
|
||||
x: nodePos.x + nodeSize.width - 15,
|
||||
y: nodePos.y - titleHeight / 2
|
||||
}
|
||||
|
||||
const checkIsInSubgraph = async () => {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
@@ -473,20 +474,13 @@ export class NodeReference {
|
||||
}
|
||||
|
||||
await expect(async () => {
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvas.click({
|
||||
position: buttonPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
// Double-click to enter subgraph
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
}
|
||||
if (await checkIsInSubgraph()) return
|
||||
throw new Error('Not in subgraph yet')
|
||||
}).toPass({ timeout: 5000, intervals: [100, 200, 500] })
|
||||
}
|
||||
|
||||
@@ -61,6 +61,32 @@ vi.mock('@/services/myService', () => ({
|
||||
}))
|
||||
```
|
||||
|
||||
### Partial object mocks with `satisfies`
|
||||
|
||||
When mocking a class instance with only the properties your test needs, use
|
||||
`satisfies Partial<Omit<T, 'constructor'>> as unknown as T`. This validates
|
||||
the mock's shape against the real type while allowing the incomplete cast.
|
||||
|
||||
The `Omit<..., 'constructor'>` is needed because class types expose a
|
||||
`constructor` property whose type (`LGraphNodeConstructor`, etc.) conflicts
|
||||
with the plain object's `Function` constructor.
|
||||
|
||||
```typescript
|
||||
// ✅ Shape-checked partial mock
|
||||
function mockSubgraphNode(proxyWidgets?: NodeProperty) {
|
||||
return {
|
||||
properties: { proxyWidgets }
|
||||
} satisfies Partial<
|
||||
Omit<SubgraphNode, 'constructor'>
|
||||
> as unknown as SubgraphNode
|
||||
}
|
||||
|
||||
// ❌ Unchecked — typos and shape mismatches slip through
|
||||
function mockSubgraphNode(proxyWidgets?: unknown): SubgraphNode {
|
||||
return { properties: { proxyWidgets } } as unknown as SubgraphNode
|
||||
}
|
||||
```
|
||||
|
||||
### Configure mocks in tests
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
@@ -67,17 +66,6 @@ function isWidgetShownOnParents(
|
||||
): boolean {
|
||||
if (!parents.length) return false
|
||||
const proxyWidgets = parseProxyWidgets(parents[0].properties.proxyWidgets)
|
||||
|
||||
// For proxy widgets (already promoted), check using overlay information
|
||||
if (isProxyWidget(widget)) {
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widget._overlay.nodeId == nodeId &&
|
||||
widget._overlay.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
|
||||
// For regular widgets (not yet promoted), check using node ID and widget name
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widgetNode.id == nodeId && widget.name === widgetName
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -82,26 +81,13 @@ watch(
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
const proxyWidgetsOrder = proxyWidgets.value
|
||||
const { widgets = [] } = node
|
||||
|
||||
// Map proxyWidgets to actual proxy widgets in the correct order
|
||||
const result: NodeWidgetsList = []
|
||||
for (const [nodeId, widgetName] of proxyWidgetsOrder) {
|
||||
// Find the proxy widget that matches this nodeId and widgetName
|
||||
const widget = widgets.find((w) => {
|
||||
// Check if this is a proxy widget with _overlay
|
||||
if (isProxyWidget(w)) {
|
||||
return (
|
||||
String(w._overlay.nodeId) === nodeId &&
|
||||
w._overlay.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
// For non-proxy widgets (like linked widgets), match by name
|
||||
return w.name === widgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
}
|
||||
const interiorNode = node.subgraph.getNodeById(nodeId)
|
||||
if (!interiorNode) continue
|
||||
const widget = interiorNode.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) result.push({ node: interiorNode, widget })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import {
|
||||
demoteWidget,
|
||||
promoteWidget
|
||||
@@ -57,32 +56,7 @@ async function handleRename() {
|
||||
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
// For proxy widgets (already promoted), we need to find the original interior node and widget
|
||||
if (isProxyWidget(widget)) {
|
||||
const subgraph = parents[0].subgraph
|
||||
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
return
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return
|
||||
}
|
||||
|
||||
demoteWidget(interiorNode, originalWidget, parents)
|
||||
} else {
|
||||
// For regular widgets (not yet promoted), use them directly
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
demoteWidget(node, widget, parents)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
@@ -63,14 +61,8 @@ const enhancedWidget = computed(() => {
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
let sourceNode: LGraphNode | null = node
|
||||
if (isProxyWidget(widget)) {
|
||||
const { graph, nodeId } = widget._overlay
|
||||
sourceNode = getNodeByExecutionId(graph, nodeId)
|
||||
}
|
||||
if (!sourceNode) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
@@ -100,7 +92,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
|
||||
const trimmedLabel = newValue.trim()
|
||||
|
||||
const success = renameWidget(widget, node, trimmedLabel, parents)
|
||||
const success = renameWidget(widget, node, trimmedLabel)
|
||||
|
||||
if (success) {
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
|
||||
@@ -70,11 +70,6 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
if (!activeNode.value) return []
|
||||
const node = activeNode.value
|
||||
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
|
||||
if (id === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === name)
|
||||
if (!widget) return []
|
||||
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[id]
|
||||
if (!wNode?.widgets) return []
|
||||
const widget = wNode.widgets.find((w) => w.name === name)
|
||||
@@ -169,13 +164,27 @@ function showAll() {
|
||||
widgets.push(...toAdd)
|
||||
proxyWidgets.value = widgets
|
||||
}
|
||||
function getSlotPromotedKeys(node: SubgraphNode): Set<string> {
|
||||
return new Set(
|
||||
node.subgraph.inputNode.slots
|
||||
.flatMap((slot) => slot.linkIds)
|
||||
.flatMap((linkId) => {
|
||||
const link = node.subgraph.getLink(linkId)
|
||||
if (!link) return []
|
||||
const { inputNode, input } = link.resolve(node.subgraph)
|
||||
if (!inputNode || !input?.widget?.name) return []
|
||||
return [`${inputNode.id}:${input.widget.name}`]
|
||||
})
|
||||
)
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
const slotPromoted = getSlotPromotedKeys(node)
|
||||
proxyWidgets.value = proxyWidgets.value.filter(
|
||||
(propertyItem) =>
|
||||
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
|
||||
propertyItem[0] === '-1'
|
||||
slotPromoted.has(`${propertyItem[0]}:${propertyItem[1]}`)
|
||||
)
|
||||
}
|
||||
function showRecommended() {
|
||||
|
||||
@@ -3,8 +3,16 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import {
|
||||
getSharedWidgetEnhancements,
|
||||
useGraphNodeManager
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
@@ -74,3 +82,36 @@ describe('Node Reactivity', () => {
|
||||
expect(widgetValue.value).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSharedWidgetEnhancements', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns nodeType when sourceNodeId is provided for a subgraph node', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('KSampler', 'KSampler')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
|
||||
const result = getSharedWidgetEnhancements(
|
||||
subgraphNode,
|
||||
widget,
|
||||
String(interiorNode.id)
|
||||
)
|
||||
|
||||
expect(result.nodeType).toBe('KSampler')
|
||||
})
|
||||
|
||||
it('returns undefined nodeType when sourceNodeId is omitted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
|
||||
const result = getSharedWidgetEnhancements(subgraphNode, widget)
|
||||
|
||||
expect(result.nodeType).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -27,7 +26,8 @@ import type {
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
LGraphTriggerParam,
|
||||
NodeId
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -121,9 +121,9 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
|
||||
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
|
||||
function getNodeType(node: LGraphNode, sourceNodeId?: NodeId) {
|
||||
if (!node.isSubgraphNode() || !sourceNodeId) return undefined
|
||||
const subNode = node.subgraph.getNodeById(sourceNodeId)
|
||||
return subNode?.type
|
||||
}
|
||||
|
||||
@@ -146,14 +146,15 @@ interface SharedWidgetEnhancements {
|
||||
*/
|
||||
export function getSharedWidgetEnhancements(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
widget: IBaseWidget,
|
||||
sourceNodeId?: NodeId
|
||||
): SharedWidgetEnhancements {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
return {
|
||||
controlWidget: getControlWidget(widget),
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
||||
nodeType: getNodeType(node, widget)
|
||||
nodeType: getNodeType(node, sourceNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +196,17 @@ function safeWidgetMapper(
|
||||
return function (widget) {
|
||||
try {
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const nodeId =
|
||||
'sourceNodeId' in widget ? String(widget.sourceNodeId) : node.id
|
||||
const widgetName =
|
||||
'sourceWidgetName' in widget
|
||||
? String(widget.sourceWidgetName)
|
||||
: widget.name
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(
|
||||
node,
|
||||
widget,
|
||||
nodeId
|
||||
)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
@@ -219,20 +230,10 @@ function safeWidgetMapper(
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
: undefined
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const localId = isProxyWidget(widget)
|
||||
? widget._overlay?.nodeId
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const name = isProxyWidget(widget)
|
||||
? widget._overlay.widgetName
|
||||
: widget.name
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
name,
|
||||
name: widgetName,
|
||||
type: widget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
|
||||
74
src/core/graph/subgraph/PromotedDomWidgetAdapter.ts
Normal file
74
src/core/graph/subgraph/PromotedDomWidgetAdapter.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
import type { PromotedWidgetSlot } from './PromotedWidgetSlot'
|
||||
|
||||
/**
|
||||
* Properties delegated to the PromotedWidgetSlot instead of the inner widget.
|
||||
*/
|
||||
type SlotManagedKey = 'y' | 'last_y' | 'computedHeight'
|
||||
const SLOT_MANAGED = new Set<string>([
|
||||
'y',
|
||||
'last_y',
|
||||
'computedHeight'
|
||||
] satisfies SlotManagedKey[])
|
||||
|
||||
/**
|
||||
* Creates a Proxy-based adapter that makes an interior DOM widget appear to
|
||||
* belong to the SubgraphNode (host).
|
||||
*
|
||||
* `DomWidgets.vue` positions DOM widgets using `widget.node.pos` and
|
||||
* `widget.y`. This proxy overrides those to reference the host node and the
|
||||
* PromotedWidgetSlot's positional state, so the DOM element renders at the
|
||||
* correct location on the parent graph.
|
||||
*
|
||||
* Only ONE of {adapter, interior widget} should be registered in
|
||||
* `domWidgetStore` at a time.
|
||||
*/
|
||||
export function createPromotedDomWidgetAdapter<V extends object | string>(
|
||||
inner: BaseDOMWidget<V>,
|
||||
hostNode: LGraphNode,
|
||||
slot: PromotedWidgetSlot
|
||||
): BaseDOMWidget<V> & { readonly innerWidget: BaseDOMWidget<V> } {
|
||||
const adapterId = generateUUID()
|
||||
|
||||
type Adapted = BaseDOMWidget<V> & { readonly innerWidget: BaseDOMWidget<V> }
|
||||
|
||||
return new Proxy(inner as Adapted, {
|
||||
get(target, prop, receiver) {
|
||||
switch (prop) {
|
||||
case 'id':
|
||||
return adapterId
|
||||
case 'node':
|
||||
return hostNode
|
||||
case 'promoted':
|
||||
case 'serialize':
|
||||
case 'computedDisabled':
|
||||
return false
|
||||
case 'innerWidget':
|
||||
return target
|
||||
case 'isVisible':
|
||||
return function isVisible() {
|
||||
return !target.hidden && hostNode.isWidgetVisible(receiver)
|
||||
}
|
||||
}
|
||||
|
||||
if (SLOT_MANAGED.has(prop as string))
|
||||
return (slot as IBaseWidget)[prop as SlotManagedKey]
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
|
||||
set(target, prop, value) {
|
||||
if (SLOT_MANAGED.has(prop as string)) {
|
||||
const widget: IBaseWidget = slot
|
||||
widget[prop as SlotManagedKey] = value
|
||||
return true
|
||||
}
|
||||
|
||||
return Reflect.set(target, prop, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
599
src/core/graph/subgraph/PromotedWidgetSlot.test.ts
Normal file
599
src/core/graph/subgraph/PromotedWidgetSlot.test.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PromotedWidgetSlot } from '@/core/graph/subgraph/PromotedWidgetSlot'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/lib/litegraph/src/widgets/widgetMap', () => ({
|
||||
toConcreteWidget: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
function createMockSubgraphNode(
|
||||
subgraphNodes: Record<string, LGraphNode> = {}
|
||||
): SubgraphNode {
|
||||
const subgraph = {
|
||||
getNodeById: vi.fn((id: string) => subgraphNodes[id] ?? null)
|
||||
} as unknown as LGraph
|
||||
|
||||
return {
|
||||
subgraph,
|
||||
isSubgraphNode: () => true,
|
||||
id: 99,
|
||||
type: 'graph/subgraph',
|
||||
graph: {} as LGraph,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
properties: {}
|
||||
} as unknown as SubgraphNode
|
||||
}
|
||||
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe('PromotedWidgetSlot', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('sets name from sourceNodeId and sourceWidgetName', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot.name).toBe('5: seed')
|
||||
expect(slot.sourceNodeId).toBe('5')
|
||||
expect(slot.sourceWidgetName).toBe('seed')
|
||||
})
|
||||
|
||||
it('is not promoted (purple border only shows on source node)', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot.promoted).toBe(false)
|
||||
})
|
||||
|
||||
it('has serialize set to false', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot.serialize).toBe(false)
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
it('resolves type from interior widget', () => {
|
||||
const interiorWidget = createMockWidget({ type: 'number' })
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(slot.type).toBe('number')
|
||||
})
|
||||
|
||||
it('returns button type when interior node is missing', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(slot.type).toBe('button')
|
||||
})
|
||||
|
||||
it('returns button type when interior widget is missing', () => {
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [createMockWidget({ name: 'other_widget' })]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(slot.type).toBe('button')
|
||||
})
|
||||
})
|
||||
|
||||
describe('value', () => {
|
||||
it('reads value from WidgetValueStore', () => {
|
||||
const interiorWidget = createMockWidget()
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 12345,
|
||||
options: {},
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot.value).toBe(12345)
|
||||
})
|
||||
|
||||
it('returns undefined when widget state is not in store', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(slot.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes value to WidgetValueStore', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.value = 99999
|
||||
|
||||
expect(state.value).toBe(99999)
|
||||
})
|
||||
})
|
||||
|
||||
describe('label', () => {
|
||||
it('returns store label when available', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
label: 'Custom Label',
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot.label).toBe('Custom Label')
|
||||
})
|
||||
|
||||
it('falls back to name when store has no label', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot.label).toBe('5: seed')
|
||||
})
|
||||
|
||||
it('writes label to WidgetValueStore', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.label = 'Renamed'
|
||||
|
||||
expect(state.label).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('clears label in WidgetValueStore when set to undefined', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
label: 'Old Label',
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.label = undefined
|
||||
|
||||
expect(state.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates the interior node input label when setting label', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
const interiorInput = {
|
||||
name: 'seed',
|
||||
widget: { name: 'seed' },
|
||||
label: undefined
|
||||
}
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [createMockWidget()],
|
||||
inputs: [interiorInput]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.label = 'Renamed'
|
||||
|
||||
expect(state.label).toBe('Renamed')
|
||||
expect(interiorInput.label).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('clears the interior node input label when label is set to undefined', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
label: 'Old',
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
const interiorInput = {
|
||||
name: 'seed',
|
||||
widget: { name: 'seed' },
|
||||
label: 'Old'
|
||||
}
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [createMockWidget()],
|
||||
inputs: [interiorInput]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.label = undefined
|
||||
|
||||
expect(state.label).toBeUndefined()
|
||||
expect(interiorInput.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not throw when setting label while disconnected', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(() => {
|
||||
slot.label = 'Renamed'
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('type and options accessors', () => {
|
||||
it('defines type as an accessor on the instance, not the prototype', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
const descriptor = Object.getOwnPropertyDescriptor(slot, 'type')
|
||||
expect(descriptor).toBeDefined()
|
||||
expect(descriptor!.get).toBeDefined()
|
||||
})
|
||||
|
||||
it('type accessor returns resolved value even if BaseWidget data property existed', () => {
|
||||
const interiorWidget = createMockWidget({ type: 'slider' })
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
// Verify no own data property for 'type' exists (only accessor)
|
||||
const descriptor = Object.getOwnPropertyDescriptor(slot, 'type')
|
||||
expect(descriptor?.value).toBeUndefined()
|
||||
expect(descriptor?.get).toBeDefined()
|
||||
expect(slot.type).toBe('slider')
|
||||
})
|
||||
|
||||
it('defines options as an accessor on the instance, not the prototype', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
const descriptor = Object.getOwnPropertyDescriptor(slot, 'options')
|
||||
expect(descriptor).toBeDefined()
|
||||
expect(descriptor!.get).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('options', () => {
|
||||
it('delegates to interior widget options', () => {
|
||||
const interiorWidget = createMockWidget({
|
||||
options: { step: 10, min: 0, max: 100 }
|
||||
})
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(slot.options.step).toBe(10)
|
||||
expect(slot.options.min).toBe(0)
|
||||
expect(slot.options.max).toBe(100)
|
||||
})
|
||||
|
||||
it('returns empty object when disconnected', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(slot.options).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawWidget', () => {
|
||||
function createMockCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 50 })),
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
globalAlpha: 1,
|
||||
translate: vi.fn()
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
it('uses drawTruncatingText for disconnected placeholder', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
const spy = vi.spyOn(
|
||||
slot as unknown as { drawTruncatingText: (...args: unknown[]) => void },
|
||||
'drawTruncatingText'
|
||||
)
|
||||
|
||||
const ctx = createMockCtx()
|
||||
slot.drawWidget(ctx, { width: 200, showText: true })
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears computedDisabled on concrete widget before drawing', () => {
|
||||
const interiorWidget = createMockWidget({ type: 'number' })
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
const concreteWidget = {
|
||||
y: 0,
|
||||
computedDisabled: true,
|
||||
promoted: true,
|
||||
drawWidget: vi.fn(function (this: { computedDisabled?: boolean }) {
|
||||
expect(this.computedDisabled).toBe(false)
|
||||
})
|
||||
} as unknown as BaseWidget<IBaseWidget>
|
||||
|
||||
vi.mocked(toConcreteWidget).mockReturnValueOnce(concreteWidget)
|
||||
|
||||
const ctx = createMockCtx()
|
||||
slot.drawWidget(ctx, { width: 200, showText: true })
|
||||
|
||||
expect(concreteWidget.drawWidget).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mutate concrete widget y/last_y during rendering', () => {
|
||||
const interiorWidget = createMockWidget({ type: 'number' })
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.y = 100
|
||||
slot.last_y = 90
|
||||
|
||||
const originalY = 10
|
||||
const originalLastY = 5
|
||||
|
||||
const concreteWidget = {
|
||||
y: originalY,
|
||||
last_y: originalLastY,
|
||||
drawWidget: vi.fn()
|
||||
} as unknown as BaseWidget<IBaseWidget>
|
||||
|
||||
vi.mocked(toConcreteWidget).mockReturnValueOnce(concreteWidget)
|
||||
|
||||
const ctx = createMockCtx()
|
||||
slot.drawWidget(ctx, { width: 200, showText: true })
|
||||
|
||||
// y/last_y should never have been mutated
|
||||
expect(concreteWidget.y).toBe(originalY)
|
||||
expect(concreteWidget.last_y).toBe(originalLastY)
|
||||
|
||||
// ctx.translate should be used instead of mutating widget state
|
||||
expect(ctx.save).toHaveBeenCalled()
|
||||
expect(ctx.translate).toHaveBeenCalledWith(0, slot.y - originalY)
|
||||
expect(ctx.restore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mutate concrete widget y/last_y even when drawWidget throws', () => {
|
||||
const interiorWidget = createMockWidget({ type: 'number' })
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.y = 100
|
||||
slot.last_y = 90
|
||||
|
||||
const concreteWidget = {
|
||||
y: 10,
|
||||
last_y: 5,
|
||||
drawWidget: vi.fn(() => {
|
||||
throw new Error('render failure')
|
||||
})
|
||||
} as unknown as BaseWidget<IBaseWidget>
|
||||
|
||||
vi.mocked(toConcreteWidget).mockReturnValueOnce(concreteWidget)
|
||||
|
||||
const ctx = createMockCtx()
|
||||
expect(() =>
|
||||
slot.drawWidget(ctx, { width: 200, showText: true })
|
||||
).toThrow('render failure')
|
||||
|
||||
// Widget state was never mutated — ctx.translate is used instead
|
||||
expect(concreteWidget.y).toBe(10)
|
||||
expect(concreteWidget.last_y).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onClick', () => {
|
||||
it('does not throw when disconnected', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(() =>
|
||||
slot.onClick({
|
||||
e: {} as never,
|
||||
node: subNode,
|
||||
canvas: {} as never
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('callback', () => {
|
||||
it('delegates to interior widget callback', () => {
|
||||
const interiorCallback = vi.fn()
|
||||
const interiorWidget = createMockWidget({ callback: interiorCallback })
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [interiorWidget]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.callback?.(42)
|
||||
|
||||
expect(interiorCallback).toHaveBeenCalledWith(
|
||||
42,
|
||||
undefined,
|
||||
interiorNode,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('does not throw when disconnected', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
|
||||
expect(() => slot.callback?.(42)).not.toThrow()
|
||||
})
|
||||
|
||||
it('can be reassigned as a property', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
const customCallback = vi.fn()
|
||||
|
||||
slot.callback = customCallback
|
||||
slot.callback?.(99)
|
||||
|
||||
expect(customCallback).toHaveBeenCalledWith(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('calls disposeDomAdapter', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
const spy = vi.spyOn(slot, 'disposeDomAdapter')
|
||||
|
||||
slot.dispose()
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('_displayValue', () => {
|
||||
it('returns string representation of value', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget({
|
||||
nodeId: '5',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
disabled: false,
|
||||
promoted: true
|
||||
})
|
||||
|
||||
const interiorNode = {
|
||||
id: '5',
|
||||
widgets: [createMockWidget()]
|
||||
} as unknown as LGraphNode
|
||||
const subNode = createMockSubgraphNode({ '5': interiorNode })
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot._displayValue).toBe('42')
|
||||
})
|
||||
|
||||
it('returns Disconnected when interior node is missing', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
expect(slot._displayValue).toBe('Disconnected')
|
||||
})
|
||||
|
||||
it('is never computedDisabled (promoted slots stay interactive)', () => {
|
||||
const subNode = createMockSubgraphNode()
|
||||
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
|
||||
slot.computedDisabled = true
|
||||
expect(slot.computedDisabled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
305
src/core/graph/subgraph/PromotedWidgetSlot.ts
Normal file
305
src/core/graph/subgraph/PromotedWidgetSlot.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
DrawWidgetOptions,
|
||||
WidgetEventOptions
|
||||
} from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { isDOMWidget, isComponentWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import { createPromotedDomWidgetAdapter } from './PromotedDomWidgetAdapter'
|
||||
|
||||
type WidgetValue = IBaseWidget['value']
|
||||
|
||||
/**
|
||||
* A lightweight widget slot for canvas rendering of promoted subgraph widgets.
|
||||
*
|
||||
* Owns positional state (y, last_y, width) and delegates value/type/drawing
|
||||
* to the resolved interior widget via the WidgetValueStore.
|
||||
*
|
||||
* When the interior node/widget no longer exists (disconnected state),
|
||||
* it renders a "Disconnected" placeholder.
|
||||
*/
|
||||
export class PromotedWidgetSlot extends BaseWidget<IBaseWidget> {
|
||||
override readonly isPromotedSlot = true
|
||||
readonly sourceNodeId: NodeId
|
||||
readonly sourceWidgetName: string
|
||||
private readonly subgraphNode: SubgraphNode
|
||||
|
||||
/**
|
||||
* When the interior widget is a DOM widget, this adapter is registered in
|
||||
* `domWidgetStore` so that `DomWidgets.vue` positions the DOM element on the
|
||||
* SubgraphNode rather than the interior node.
|
||||
*/
|
||||
private domAdapter?: BaseDOMWidget<object | string>
|
||||
|
||||
constructor(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNodeId: NodeId,
|
||||
sourceWidgetName: string,
|
||||
slotName?: string
|
||||
) {
|
||||
const name = slotName ?? `${sourceNodeId}: ${sourceWidgetName}`
|
||||
super(
|
||||
{
|
||||
name,
|
||||
type: 'button',
|
||||
value: undefined,
|
||||
options: {},
|
||||
y: 0,
|
||||
serialize: false
|
||||
},
|
||||
subgraphNode
|
||||
)
|
||||
this.sourceNodeId = sourceNodeId
|
||||
this.sourceWidgetName = sourceWidgetName
|
||||
this.subgraphNode = subgraphNode
|
||||
|
||||
// BaseWidget constructor assigns `this.type` and `this.options` as own
|
||||
// data properties. Override them with instance-level accessors that
|
||||
// delegate to the resolved interior widget.
|
||||
Object.defineProperty(this, 'type', {
|
||||
get: () => this.resolve()?.widget.type ?? 'button',
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
Object.defineProperty(this, 'options', {
|
||||
get: () => this.resolve()?.widget.options ?? {},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
|
||||
// The SubgraphNode's input slots are internally linked, which causes
|
||||
// `updateComputedDisabled()` to set `computedDisabled = true` on all
|
||||
// matching widgets. The promoted slot should always remain interactive.
|
||||
Object.defineProperty(this, 'computedDisabled', {
|
||||
get: () => false,
|
||||
set: () => {},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
|
||||
this.callback = (value, canvas, _node, pos, e) => {
|
||||
const resolved = this.resolve()
|
||||
if (!resolved) return
|
||||
resolved.widget.callback?.(value, canvas, resolved.node, pos, e)
|
||||
}
|
||||
|
||||
this.syncDomAdapter()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates to the interior widget's `computeLayoutSize` so that
|
||||
* `_arrangeWidgets` treats this slot as a growable widget (e.g. textarea)
|
||||
* and allocates the correct height on the SubgraphNode.
|
||||
*
|
||||
* Assigned dynamically in the constructor via `syncLayoutSize` because
|
||||
* `computeLayoutSize` is an optional method on the base class — it must
|
||||
* either exist or not exist, not return `undefined`.
|
||||
*/
|
||||
declare computeLayoutSize?: (node: LGraphNode) => {
|
||||
minHeight: number
|
||||
maxHeight?: number
|
||||
minWidth: number
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies `computeLayoutSize` from the interior widget when it has one
|
||||
* (e.g. textarea / DOM widgets), so `_arrangeWidgets` allocates the
|
||||
* correct growable height on the SubgraphNode.
|
||||
*/
|
||||
private syncLayoutSize(): void {
|
||||
const interiorWidget = this.resolve()?.widget
|
||||
if (interiorWidget?.computeLayoutSize) {
|
||||
this.computeLayoutSize = (node) => interiorWidget.computeLayoutSize!(node)
|
||||
} else {
|
||||
this.computeLayoutSize = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private resolve(): {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
} | null {
|
||||
try {
|
||||
const node = this.subgraphNode.subgraph.getNodeById(this.sourceNodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((w) => w.name === this.sourceWidgetName)
|
||||
if (!widget) return null
|
||||
return { node, widget }
|
||||
} catch {
|
||||
// May fail during construction if the subgraph is not yet fully wired
|
||||
// (e.g. in tests or during deserialization).
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private get widgetState() {
|
||||
return useWidgetValueStore().getWidget(
|
||||
stripGraphPrefix(this.sourceNodeId),
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
override get value(): WidgetValue {
|
||||
return this.widgetState?.value as WidgetValue
|
||||
}
|
||||
|
||||
override set value(v: WidgetValue) {
|
||||
const state = this.widgetState
|
||||
if (!state) return
|
||||
|
||||
state.value = v
|
||||
}
|
||||
|
||||
override get label(): string | undefined {
|
||||
return this.widgetState?.label ?? this.name
|
||||
}
|
||||
|
||||
override set label(v: string | undefined) {
|
||||
const state = this.widgetState
|
||||
if (!state) return
|
||||
|
||||
state.label = v
|
||||
|
||||
// Also sync the label on the corresponding input slot
|
||||
const resolved = this.resolve()
|
||||
const input = resolved?.node.inputs?.find(
|
||||
(inp) => inp.widget?.name === this.sourceWidgetName
|
||||
)
|
||||
if (!input) return
|
||||
|
||||
input.label = v
|
||||
}
|
||||
|
||||
override get promoted(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override get _displayValue(): string {
|
||||
if (this.computedDisabled) return ''
|
||||
if (!this.resolve()) return 'Disconnected'
|
||||
const v = this.value
|
||||
return v != null ? String(v) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or removes the DOM adapter based on whether the resolved interior
|
||||
* widget is a DOM widget. Call after construction and whenever the interior
|
||||
* widget might change (e.g. reconnection).
|
||||
*
|
||||
* Only one of {adapter, interior widget} is active in `domWidgetStore` at a
|
||||
* time. The adapter is registered and the interior is deactivated, so
|
||||
* `DomWidgets.vue` never mounts two `DomWidget.vue` instances for the same
|
||||
* `HTMLElement`.
|
||||
*/
|
||||
syncDomAdapter(): void {
|
||||
const resolved = this.resolve()
|
||||
if (!resolved) return
|
||||
|
||||
const interiorWidget = resolved.widget
|
||||
const isDom =
|
||||
isDOMWidget(interiorWidget) || isComponentWidget(interiorWidget)
|
||||
|
||||
if (isDom && !this.domAdapter) {
|
||||
const domWidget = interiorWidget as BaseDOMWidget<object | string>
|
||||
const adapter = createPromotedDomWidgetAdapter(
|
||||
domWidget,
|
||||
this.subgraphNode,
|
||||
this
|
||||
)
|
||||
this.domAdapter = adapter
|
||||
|
||||
const store = useDomWidgetStore()
|
||||
// Start invisible — `updateWidgets()` will set `visible: true` on the
|
||||
// first canvas draw when the SubgraphNode is in the current graph.
|
||||
// This prevents a race where both adapter and interior DomWidget.vue
|
||||
// instances try to mount the same HTMLElement during `onMounted`.
|
||||
store.registerWidget(adapter, { visible: false })
|
||||
} else if (!isDom && this.domAdapter) {
|
||||
this.disposeDomAdapter()
|
||||
}
|
||||
|
||||
this.syncLayoutSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the DOM adapter from the store.
|
||||
*/
|
||||
disposeDomAdapter(): void {
|
||||
if (!this.domAdapter) return
|
||||
|
||||
useDomWidgetStore().unregisterWidget(this.domAdapter.id)
|
||||
this.domAdapter = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all resources held by this slot.
|
||||
* Called when the SubgraphNode is removed from the graph.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.disposeDomAdapter()
|
||||
}
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
// Lazily create the DOM adapter if it wasn't ready at construction time.
|
||||
// During deserialization the interior widget may not exist yet when the
|
||||
// PromotedWidgetSlot constructor runs, so syncDomAdapter() is retried here
|
||||
// on every draw until it succeeds.
|
||||
if (!this.domAdapter) {
|
||||
this.syncDomAdapter()
|
||||
}
|
||||
|
||||
const resolved = this.resolve()
|
||||
|
||||
const concrete = resolved
|
||||
? toConcreteWidget(resolved.widget, resolved.node, false)
|
||||
: null
|
||||
|
||||
if (concrete) {
|
||||
// Suppress promoted border and disabled state: the purple outline and
|
||||
// linked-disabled flag should only apply on the source node inside the
|
||||
// subgraph, not on the SubgraphNode.
|
||||
const wasPromoted = concrete.promoted
|
||||
concrete.promoted = false
|
||||
concrete.computedDisabled = false
|
||||
|
||||
concrete.computedHeight = this.computedHeight
|
||||
ctx.save()
|
||||
ctx.translate(0, this.y - concrete.y)
|
||||
concrete.drawWidget(ctx, options)
|
||||
ctx.restore()
|
||||
|
||||
concrete.promoted = wasPromoted
|
||||
} else {
|
||||
this.drawWidgetShape(ctx, options)
|
||||
if (options.showText !== false) {
|
||||
if (!resolved) ctx.fillStyle = LiteGraph.WIDGET_DISABLED_TEXT_COLOR
|
||||
this.drawTruncatingText({
|
||||
ctx,
|
||||
...options,
|
||||
leftPadding: 0,
|
||||
rightPadding: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClick(options: WidgetEventOptions): void {
|
||||
const resolved = this.resolve()
|
||||
if (!resolved) return
|
||||
|
||||
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
|
||||
concrete?.onClick(options)
|
||||
}
|
||||
}
|
||||
210
src/core/graph/subgraph/promotedWidgetRegistration.test.ts
Normal file
210
src/core/graph/subgraph/promotedWidgetRegistration.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PromotedWidgetSlot } from '@/core/graph/subgraph/PromotedWidgetSlot'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { registerPromotedWidgetSlots } from './promotedWidgetRegistration'
|
||||
|
||||
vi.mock('@/core/graph/subgraph/proxyWidgetUtils', () => ({
|
||||
promoteRecommendedWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
function createMockCanvas() {
|
||||
return {
|
||||
canvas: { addEventListener: vi.fn() },
|
||||
setDirty: vi.fn()
|
||||
} as unknown as LGraphCanvas
|
||||
}
|
||||
|
||||
function createMockSubgraphNode(widgets: IBaseWidget[] = []): SubgraphNode {
|
||||
const base = {
|
||||
widgets,
|
||||
inputs: [],
|
||||
properties: { proxyWidgets: [] },
|
||||
_setConcreteSlots: vi.fn(),
|
||||
arrange: vi.fn()
|
||||
} satisfies Partial<Omit<SubgraphNode, 'constructor' | 'isSubgraphNode'>>
|
||||
|
||||
return {
|
||||
...base,
|
||||
isSubgraphNode: () => true
|
||||
} as unknown as SubgraphNode
|
||||
}
|
||||
|
||||
describe('registerPromotedWidgetSlots', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('onConfigure – syncPromotedWidgets', () => {
|
||||
it('assigning to properties.proxyWidgets triggers widget reconstruction', () => {
|
||||
const canvas = createMockCanvas()
|
||||
registerPromotedWidgetSlots(canvas)
|
||||
|
||||
const nativeWidget = {
|
||||
name: 'steps',
|
||||
type: 'number',
|
||||
value: 20,
|
||||
options: {},
|
||||
y: 0
|
||||
} satisfies Partial<IBaseWidget> as unknown as IBaseWidget
|
||||
const node = createMockSubgraphNode([nativeWidget])
|
||||
|
||||
const serialisedNode = {
|
||||
properties: {}
|
||||
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
|
||||
|
||||
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
|
||||
|
||||
// After onConfigure, proxyWidgets is a getter/setter property
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
node.properties,
|
||||
'proxyWidgets'
|
||||
)
|
||||
expect(descriptor?.set).toBeDefined()
|
||||
expect(descriptor?.get).toBeDefined()
|
||||
|
||||
// Assign promoted widgets via the setter
|
||||
node.properties.proxyWidgets = [['42', 'seed']]
|
||||
|
||||
// The setter should have created a PromotedWidgetSlot
|
||||
const promotedSlots = node.widgets.filter(
|
||||
(w) => w instanceof PromotedWidgetSlot
|
||||
)
|
||||
expect(promotedSlots).toHaveLength(1)
|
||||
expect(promotedSlots[0].sourceNodeId).toBe('42')
|
||||
expect(promotedSlots[0].sourceWidgetName).toBe('seed')
|
||||
|
||||
// The setter should have called _setConcreteSlots and arrange
|
||||
expect(node._setConcreteSlots).toHaveBeenCalled()
|
||||
expect(node.arrange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves native widgets not in the proxy list', () => {
|
||||
const canvas = createMockCanvas()
|
||||
registerPromotedWidgetSlots(canvas)
|
||||
|
||||
const nativeWidget = {
|
||||
name: 'steps',
|
||||
type: 'number',
|
||||
value: 20,
|
||||
options: {},
|
||||
y: 0
|
||||
} satisfies Partial<IBaseWidget> as unknown as IBaseWidget
|
||||
|
||||
const node = createMockSubgraphNode([nativeWidget])
|
||||
const serialisedNode = {
|
||||
properties: {}
|
||||
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
|
||||
|
||||
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
|
||||
|
||||
// Promote a different widget; native 'steps' should remain
|
||||
node.properties.proxyWidgets = [['42', 'seed']]
|
||||
|
||||
const nativeWidgets = node.widgets.filter(
|
||||
(w) => !(w instanceof PromotedWidgetSlot)
|
||||
)
|
||||
expect(nativeWidgets).toHaveLength(1)
|
||||
expect(nativeWidgets[0].name).toBe('steps')
|
||||
})
|
||||
|
||||
it('re-orders native widgets listed in the proxy list with id -1', () => {
|
||||
const canvas = createMockCanvas()
|
||||
registerPromotedWidgetSlots(canvas)
|
||||
|
||||
const nativeWidget = {
|
||||
name: 'steps',
|
||||
type: 'number',
|
||||
value: 20,
|
||||
options: {},
|
||||
y: 0
|
||||
} satisfies Partial<IBaseWidget> as unknown as IBaseWidget
|
||||
|
||||
const node = createMockSubgraphNode([nativeWidget])
|
||||
const serialisedNode = {
|
||||
properties: {}
|
||||
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
|
||||
|
||||
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
|
||||
|
||||
// Use -1 to reference native widgets
|
||||
node.properties.proxyWidgets = [['-1', 'steps']]
|
||||
|
||||
// Native widget should be placed via the proxy list ordering
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
expect(node.widgets[0].name).toBe('steps')
|
||||
expect(node.widgets[0]).toBe(nativeWidget)
|
||||
})
|
||||
|
||||
it('reuses existing PromotedWidgetSlot instances on re-sync', () => {
|
||||
const canvas = createMockCanvas()
|
||||
registerPromotedWidgetSlots(canvas)
|
||||
|
||||
const node = createMockSubgraphNode()
|
||||
const serialisedNode = {
|
||||
properties: {}
|
||||
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
|
||||
|
||||
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
|
||||
|
||||
// First sync: create a slot
|
||||
node.properties.proxyWidgets = [['42', 'seed']]
|
||||
const firstSlot = node.widgets.find(
|
||||
(w) => w instanceof PromotedWidgetSlot
|
||||
)
|
||||
expect(firstSlot).toBeDefined()
|
||||
|
||||
// Second sync with same entry: should reuse the same instance
|
||||
node.properties.proxyWidgets = [['42', 'seed']]
|
||||
const secondSlot = node.widgets.find(
|
||||
(w) => w instanceof PromotedWidgetSlot
|
||||
)
|
||||
expect(secondSlot).toBe(firstSlot)
|
||||
})
|
||||
|
||||
it('disposes only removed slots during reconciliation', () => {
|
||||
const canvas = createMockCanvas()
|
||||
registerPromotedWidgetSlots(canvas)
|
||||
|
||||
const node = createMockSubgraphNode()
|
||||
const serialisedNode = {
|
||||
properties: {}
|
||||
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
|
||||
|
||||
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
|
||||
|
||||
// Create two slots
|
||||
node.properties.proxyWidgets = [
|
||||
['42', 'seed'],
|
||||
['43', 'steps']
|
||||
]
|
||||
const slots = node.widgets.filter(
|
||||
(w) => w instanceof PromotedWidgetSlot
|
||||
) as PromotedWidgetSlot[]
|
||||
expect(slots).toHaveLength(2)
|
||||
|
||||
const disposeSpy0 = vi.spyOn(slots[0], 'disposeDomAdapter')
|
||||
const disposeSpy1 = vi.spyOn(slots[1], 'disposeDomAdapter')
|
||||
|
||||
// Remove only the second slot
|
||||
node.properties.proxyWidgets = [['42', 'seed']]
|
||||
|
||||
// First slot should NOT have been disposed (reused)
|
||||
expect(disposeSpy0).not.toHaveBeenCalled()
|
||||
// Second slot should have been disposed (removed)
|
||||
expect(disposeSpy1).toHaveBeenCalled()
|
||||
|
||||
// Only one promoted slot remains
|
||||
const remaining = node.widgets.filter(
|
||||
(w) => w instanceof PromotedWidgetSlot
|
||||
)
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0]).toBe(slots[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
246
src/core/graph/subgraph/promotedWidgetRegistration.ts
Normal file
246
src/core/graph/subgraph/promotedWidgetRegistration.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { PromotedWidgetSlot } from '@/core/graph/subgraph/PromotedWidgetSlot'
|
||||
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { NodeId, NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
let registered = false
|
||||
|
||||
/**
|
||||
* Registers the promoted widget system using PromotedWidgetSlot instances.
|
||||
* Sets up:
|
||||
* - `subgraph-opened` event: syncs `promoted` flags on interior widgets
|
||||
* - `subgraph-converted` event: auto-promotes recommended widgets
|
||||
* - `onConfigure` override: creates PromotedWidgetSlot instances in widgets[]
|
||||
*
|
||||
* Prototype patching is necessary because `onConfigure` must be set before
|
||||
* SubgraphNode construction (called during `configure()` in the constructor).
|
||||
*/
|
||||
export function registerPromotedWidgetSlots(canvas: LGraphCanvas) {
|
||||
if (registered) return
|
||||
registered = true
|
||||
|
||||
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
|
||||
const { subgraph, fromNode } = e.detail
|
||||
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
|
||||
for (const node of subgraph.nodes) {
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget instanceof PromotedWidgetSlot) continue
|
||||
widget.promoted = proxyWidgets.some(
|
||||
([n, w]) => node.id == n && widget.name == w
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
canvas.canvas.addEventListener<'subgraph-converted'>(
|
||||
'subgraph-converted',
|
||||
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
|
||||
)
|
||||
SubgraphNode.prototype.onConfigure = onConfigure
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs the promoted widget slots on a subgraph node based on
|
||||
* a serialized proxy widgets list.
|
||||
*
|
||||
* This replaces the previous side-effecting property setter pattern where
|
||||
* assigning to `properties.proxyWidgets` would trigger widget reconstruction.
|
||||
*/
|
||||
function slotKey(nodeId: NodeId, widgetName: string): string {
|
||||
return `${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a legacy `-1` proxy entry to the actual interior node/widget
|
||||
* by following the subgraph input wiring.
|
||||
*/
|
||||
function resolveLegacyEntry(
|
||||
subgraphNode: SubgraphNode,
|
||||
widgetName: string
|
||||
): [string, string] | null {
|
||||
const subgraph = subgraphNode.subgraph
|
||||
const inputSlot = subgraph?.inputNode?.slots.find(
|
||||
(s) => s.name === widgetName
|
||||
)
|
||||
if (!inputSlot || !subgraph) return null
|
||||
|
||||
const linkId = inputSlot.linkIds[0]
|
||||
const link = linkId != null ? subgraph.getLink(linkId) : undefined
|
||||
if (!link) return null
|
||||
|
||||
const inputNode = subgraph.getNodeById(link.target_id) ?? undefined
|
||||
if (!inputNode) return null
|
||||
|
||||
// Find input by link ID rather than target_slot, since target_slot
|
||||
// can be unreliable in compressed workflows.
|
||||
const targetInput = inputNode.inputs?.find((inp) => inp.link === linkId)
|
||||
const inputWidgetName = targetInput?.widget?.name
|
||||
if (!inputWidgetName) return null
|
||||
|
||||
return [String(inputNode.id), inputWidgetName]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the promoted widget slots on a subgraph node based on
|
||||
* a serialized proxy widgets list.
|
||||
*
|
||||
* Reuses existing PromotedWidgetSlot instances when possible to preserve
|
||||
* transient state (focus, DOM adapter, active input). Only creates new
|
||||
* slots for entries that don't have an existing match, and disposes
|
||||
* slots that are no longer needed.
|
||||
*/
|
||||
function syncPromotedWidgets(
|
||||
node: LGraphNode & { isSubgraphNode(): boolean },
|
||||
property: NodeProperty
|
||||
): void {
|
||||
const canvasStore = useCanvasStore()
|
||||
const parsed = parseProxyWidgets(property)
|
||||
const subgraphNode = node as SubgraphNode
|
||||
const widgets = node.widgets ?? []
|
||||
|
||||
// Index existing PromotedWidgetSlots by key for O(1) lookup
|
||||
const existingSlots = new Map<string, PromotedWidgetSlot>()
|
||||
for (const w of widgets) {
|
||||
if (w instanceof PromotedWidgetSlot) {
|
||||
existingSlots.set(slotKey(w.sourceNodeId, w.sourceWidgetName), w)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect stubs created by _setWidget() during configure
|
||||
const stubs = widgets.filter(
|
||||
(
|
||||
w
|
||||
): w is IBaseWidget & { sourceNodeId: string; sourceWidgetName: string } =>
|
||||
!(w instanceof PromotedWidgetSlot) &&
|
||||
'sourceNodeId' in w &&
|
||||
'sourceWidgetName' in w
|
||||
)
|
||||
|
||||
// Build the desired promoted slot list, reusing existing instances
|
||||
const desired = new Set<string>()
|
||||
const orderedSlots: IBaseWidget[] = []
|
||||
|
||||
for (const [nodeId, widgetName] of parsed) {
|
||||
let resolvedNodeId = nodeId
|
||||
let resolvedWidgetName = widgetName
|
||||
|
||||
if (nodeId === '-1') {
|
||||
const resolved = resolveLegacyEntry(subgraphNode, widgetName)
|
||||
if (!resolved) continue
|
||||
;[resolvedNodeId, resolvedWidgetName] = resolved
|
||||
}
|
||||
|
||||
const key = slotKey(resolvedNodeId, resolvedWidgetName)
|
||||
if (desired.has(key)) continue
|
||||
desired.add(key)
|
||||
|
||||
const existing = existingSlots.get(key)
|
||||
if (existing) {
|
||||
orderedSlots.push(existing)
|
||||
} else {
|
||||
orderedSlots.push(
|
||||
new PromotedWidgetSlot(subgraphNode, resolvedNodeId, resolvedWidgetName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Promote stubs not covered by the parsed list
|
||||
// (e.g. old workflows that didn't serialize slot-promoted entries)
|
||||
for (const stub of stubs) {
|
||||
const key = slotKey(stub.sourceNodeId, stub.sourceWidgetName)
|
||||
if (desired.has(key)) continue
|
||||
desired.add(key)
|
||||
|
||||
const existing = existingSlots.get(key)
|
||||
if (existing) {
|
||||
orderedSlots.unshift(existing)
|
||||
} else {
|
||||
orderedSlots.unshift(
|
||||
new PromotedWidgetSlot(
|
||||
subgraphNode,
|
||||
stub.sourceNodeId,
|
||||
stub.sourceWidgetName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispose DOM adapters only on slots that are being removed
|
||||
for (const [key, slot] of existingSlots) {
|
||||
if (!desired.has(key)) {
|
||||
slot.disposeDomAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild widgets array: non-promoted widgets in original order, then promoted slots
|
||||
node.widgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w instanceof PromotedWidgetSlot) &&
|
||||
!(stubs as IBaseWidget[]).includes(w)
|
||||
)
|
||||
.concat(orderedSlots)
|
||||
|
||||
// Update input._widget references to point to PromotedWidgetSlots
|
||||
// instead of stubs they replaced.
|
||||
for (const input of subgraphNode.inputs) {
|
||||
const oldWidget = input._widget
|
||||
if (
|
||||
!oldWidget ||
|
||||
!('sourceNodeId' in oldWidget) ||
|
||||
!('sourceWidgetName' in oldWidget)
|
||||
)
|
||||
continue
|
||||
|
||||
const sid = String(oldWidget.sourceNodeId)
|
||||
const swn = String(oldWidget.sourceWidgetName)
|
||||
const replacement = orderedSlots.find(
|
||||
(w) =>
|
||||
w instanceof PromotedWidgetSlot &&
|
||||
w.sourceNodeId === sid &&
|
||||
w.sourceWidgetName === swn
|
||||
)
|
||||
if (replacement) input._widget = replacement
|
||||
}
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
node._setConcreteSlots()
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
const originalOnConfigure = SubgraphNode.prototype.onConfigure
|
||||
const onConfigure = function (
|
||||
this: LGraphNode,
|
||||
serialisedNode: ISerialisedNode
|
||||
) {
|
||||
if (!this.isSubgraphNode())
|
||||
throw new Error("Can't add promoted widgets to non-subgraphNode")
|
||||
|
||||
this.properties.proxyWidgets ??= []
|
||||
|
||||
originalOnConfigure?.call(this, serialisedNode)
|
||||
|
||||
Object.defineProperty(this.properties, 'proxyWidgets', {
|
||||
get: () =>
|
||||
this.widgets.map((w) => {
|
||||
if (w instanceof PromotedWidgetSlot)
|
||||
return [w.sourceNodeId, w.sourceWidgetName]
|
||||
if ('sourceNodeId' in w && 'sourceWidgetName' in w)
|
||||
return [String(w.sourceNodeId), String(w.sourceWidgetName)]
|
||||
return ['-1', w.name]
|
||||
}),
|
||||
set: (value: NodeProperty) => syncPromotedWidgets(this, value)
|
||||
})
|
||||
|
||||
this.refreshPromotedWidgets = () => {
|
||||
this.properties.proxyWidgets = this.properties.proxyWidgets
|
||||
}
|
||||
|
||||
if (serialisedNode.properties?.proxyWidgets) {
|
||||
syncPromotedWidgets(this, serialisedNode.properties.proxyWidgets)
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
const canvasEl: Partial<HTMLCanvasElement> = { addEventListener() {} }
|
||||
const canvas: Partial<LGraphCanvas> = { canvas: canvasEl as HTMLCanvasElement }
|
||||
registerProxyWidgets(canvas as LGraphCanvas)
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
const innerNodes = []
|
||||
for (let i = 0; i < innerNodeCount; i++) {
|
||||
const innerNode = new LGraphNode(`InnerNode${i}`)
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
return [subgraphNode, innerNodes]
|
||||
}
|
||||
|
||||
describe('Subgraph proxyWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('Can add simple widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
|
||||
['1', 'stringWidget']
|
||||
])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(2)
|
||||
for (const innerNode of innerNodes)
|
||||
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [
|
||||
['1', 'stringWidget'],
|
||||
['2', 'stringWidget']
|
||||
]
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).not.toEqual(
|
||||
subgraphNode.widgets[1].name
|
||||
)
|
||||
})
|
||||
test('Will serialize existing widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'istringWidget', 'value', () => {})
|
||||
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
const proxyWidgets = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
|
||||
proxyWidgets.push(['1', 'istringWidget'])
|
||||
subgraphNode.properties.proxyWidgets = proxyWidgets
|
||||
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
|
||||
subgraphNode.properties.proxyWidgets = [proxyWidgets[1], proxyWidgets[0]]
|
||||
expect(subgraphNode.widgets[0].name).toBe('1: istringWidget')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
innerNodes[0].widgets[0].last_y = 11
|
||||
innerNodes[0].widgets[0].computedHeight = 12
|
||||
subgraphNode.widgets[0].y = 20
|
||||
subgraphNode.widgets[0].last_y = 21
|
||||
subgraphNode.widgets[0].computedHeight = 22
|
||||
expect(innerNodes[0].widgets[0].y).toBe(10)
|
||||
expect(innerNodes[0].widgets[0].last_y).toBe(11)
|
||||
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
|
||||
})
|
||||
test('Can detach and re-attach widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
const poppedWidget = innerNodes[0].widgets.pop()
|
||||
//simulate new draw frame
|
||||
subgraphNode.widgets[0].computedHeight = 10
|
||||
expect(subgraphNode.widgets[0].value).toBe(undefined)
|
||||
innerNodes[0].widgets.push(poppedWidget!)
|
||||
subgraphNode.widgets[0].computedHeight = 10
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
const widget = innerNodes[0].widgets![0]
|
||||
|
||||
// Promote once
|
||||
promoteWidget(innerNodes[0], widget, [subgraphNode])
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
promoteWidget(innerNodes[0], widget, [subgraphNode])
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
|
||||
['1', 'stringWidget']
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
import {
|
||||
demoteWidget,
|
||||
promoteRecommendedWidgets
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* @typedef {object} Overlay - Each proxy Widget has an associated overlay object
|
||||
* Accessing a property which exists in the overlay object will
|
||||
* instead result in the action being performed on the overlay object
|
||||
* 3 properties are added for locating the proxied widget
|
||||
* @property {LGraph} graph - The graph the widget resides in. Used for widget lookup
|
||||
* @property {string} nodeId - The NodeId the proxy Widget is located on
|
||||
* @property {string} widgetName - The name of the linked widget
|
||||
*
|
||||
* @property {boolean} isProxyWidget - Always true, used as type guard
|
||||
* @property {LGraphNode} node - not included on IBaseWidget, but required for overlay
|
||||
*/
|
||||
type Overlay = Partial<IBaseWidget> & {
|
||||
graph: LGraph
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
isProxyWidget: boolean
|
||||
node?: LGraphNode
|
||||
}
|
||||
// A ProxyWidget can be treated like a normal widget.
|
||||
// the _overlay property can be used to directly access the Overlay object
|
||||
/**
|
||||
* @typedef {object} ProxyWidget - a reference to a widget that can
|
||||
* be displayed and owned by a separate node
|
||||
* @property {Overlay} _overlay - a special property to access the overlay of the widget
|
||||
* Any property that exists in the overlay will be accessed instead of the property
|
||||
* on the linked widget
|
||||
*/
|
||||
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
|
||||
export function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
|
||||
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
|
||||
}
|
||||
export function isDisconnectedWidget(w: ProxyWidget) {
|
||||
return w instanceof disconnectedWidget.constructor
|
||||
}
|
||||
|
||||
export function registerProxyWidgets(canvas: LGraphCanvas) {
|
||||
//NOTE: canvasStore hasn't been initialized yet
|
||||
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
|
||||
const { subgraph, fromNode } = e.detail
|
||||
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
|
||||
for (const node of subgraph.nodes) {
|
||||
for (const widget of node.widgets ?? []) {
|
||||
widget.promoted = proxyWidgets.some(
|
||||
([n, w]) => node.id == n && widget.name == w
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
canvas.canvas.addEventListener<'subgraph-converted'>(
|
||||
'subgraph-converted',
|
||||
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
|
||||
)
|
||||
SubgraphNode.prototype.onConfigure = onConfigure
|
||||
}
|
||||
|
||||
const originalOnConfigure = SubgraphNode.prototype.onConfigure
|
||||
const onConfigure = function (
|
||||
this: LGraphNode,
|
||||
serialisedNode: ISerialisedNode
|
||||
) {
|
||||
if (!this.isSubgraphNode())
|
||||
throw new Error("Can't add proxyWidgets to non-subgraphNode")
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
//Must give value to proxyWidgets prior to defining or it won't serialize
|
||||
this.properties.proxyWidgets ??= []
|
||||
|
||||
originalOnConfigure?.call(this, serialisedNode)
|
||||
|
||||
Object.defineProperty(this.properties, 'proxyWidgets', {
|
||||
get: () =>
|
||||
this.widgets.map((w) =>
|
||||
isProxyWidget(w)
|
||||
? [w._overlay.nodeId, w._overlay.widgetName]
|
||||
: ['-1', w.name]
|
||||
),
|
||||
set: (property: NodeProperty) => {
|
||||
const parsed = parseProxyWidgets(property)
|
||||
const { deactivateWidget, setWidget } = useDomWidgetStore()
|
||||
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
|
||||
if (isActiveGraph) {
|
||||
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
|
||||
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
|
||||
}
|
||||
}
|
||||
|
||||
const newWidgets = parsed.flatMap(([nodeId, widgetName]) => {
|
||||
if (nodeId === '-1') {
|
||||
const widget = this.widgets.find((w) => w.name === widgetName)
|
||||
return widget ? [widget] : []
|
||||
}
|
||||
const w = newProxyWidget(this, nodeId, widgetName)
|
||||
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
||||
return [w]
|
||||
})
|
||||
this.widgets = this.widgets.filter((w) => {
|
||||
if (isProxyWidget(w)) return false
|
||||
const widgetName = w.name
|
||||
return !parsed.some(([, name]) => widgetName === name)
|
||||
})
|
||||
this.widgets.push(...newWidgets)
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
this._setConcreteSlots()
|
||||
this.arrange()
|
||||
}
|
||||
})
|
||||
if (serialisedNode.properties?.proxyWidgets) {
|
||||
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
|
||||
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
|
||||
serialisedNode.widgets_values?.forEach((v, index) => {
|
||||
if (parsed[index]?.[0] !== '-1') return
|
||||
const widget = this.widgets.find((w) => w.name == parsed[index][1])
|
||||
if (v !== null && widget) widget.value = v
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function newProxyWidget(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
) {
|
||||
const name = `${nodeId}: ${widgetName}`
|
||||
const overlay = {
|
||||
//items specific for proxy management
|
||||
nodeId,
|
||||
graph: subgraphNode.subgraph,
|
||||
widgetName,
|
||||
//Items which normally exist on widgets
|
||||
afterQueued: undefined,
|
||||
computedHeight: undefined,
|
||||
isProxyWidget: true,
|
||||
last_y: undefined,
|
||||
label: name,
|
||||
name,
|
||||
node: subgraphNode,
|
||||
onRemove: undefined,
|
||||
promoted: undefined,
|
||||
serialize: false,
|
||||
width: undefined,
|
||||
y: 0
|
||||
}
|
||||
return newProxyFromOverlay(subgraphNode, overlay)
|
||||
}
|
||||
function resolveLinkedWidget(
|
||||
overlay: Overlay
|
||||
): [LGraphNode | undefined, IBaseWidget | undefined] {
|
||||
const { graph, nodeId, widgetName } = overlay
|
||||
const n = getNodeByExecutionId(graph, nodeId)
|
||||
if (!n) return [undefined, undefined]
|
||||
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
|
||||
//Slightly hacky. Force recursive resolution of nested widgets
|
||||
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
|
||||
widget.computedHeight = 20
|
||||
return [n, widget]
|
||||
}
|
||||
|
||||
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||
let backingWidget = linkedWidget ?? disconnectedWidget
|
||||
if (overlay.widgetName.startsWith('$$')) {
|
||||
overlay.node = new Proxy(subgraphNode, {
|
||||
get(_t, p) {
|
||||
if (p !== 'imgs') return Reflect.get(subgraphNode, p)
|
||||
if (!linkedNode) return []
|
||||
return linkedNode.imgs
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* A set of handlers which define widget interaction
|
||||
* Many arguments are shared between function calls
|
||||
* @param {IBaseWidget} _t - The "target" the call is originally made on.
|
||||
* This argument is never used, but must be defined for typechecking
|
||||
* @param {string} property - The name of the accessed value.
|
||||
* Checked for conditional logic, but never changed
|
||||
* @param {object} receiver - The object the result is set to
|
||||
* and the value used as 'this' if property is a get/set method
|
||||
* @param {unknown} value - only used on set calls. The thing being assigned
|
||||
*/
|
||||
const handler = {
|
||||
get(_t: IBaseWidget, property: string, receiver: object) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
let redirectedReceiver = receiver
|
||||
if (property == '_overlay') return overlay
|
||||
else if (property == 'value') redirectedReceiver = backingWidget
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
redirectedReceiver = overlay
|
||||
}
|
||||
return Reflect.get(redirectedTarget, property, redirectedReceiver)
|
||||
},
|
||||
set(_t: IBaseWidget, property: string, value: unknown) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
if (property == 'computedHeight') {
|
||||
if (overlay.widgetName.startsWith('$$') && linkedNode) {
|
||||
updatePreviews(linkedNode)
|
||||
}
|
||||
if (linkedNode && linkedWidget?.computedDisabled) {
|
||||
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
|
||||
}
|
||||
//update linkage regularly, but no more than once per frame
|
||||
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||
backingWidget = linkedWidget ?? disconnectedWidget
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
}
|
||||
return Reflect.set(redirectedTarget, property, value, redirectedTarget)
|
||||
},
|
||||
getPrototypeOf() {
|
||||
return Reflect.getPrototypeOf(backingWidget)
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(backingWidget)
|
||||
},
|
||||
has(_t: IBaseWidget, property: string) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
}
|
||||
return Reflect.has(redirectedTarget, property)
|
||||
}
|
||||
}
|
||||
const w = new Proxy(disconnectedWidget, handler)
|
||||
return w
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import {
|
||||
isProxyWidget,
|
||||
isDisconnectedWidget
|
||||
} from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
@@ -13,6 +9,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
@@ -58,7 +55,7 @@ export function demoteWidget(
|
||||
}
|
||||
|
||||
function getWidgetName(w: IBaseWidget): string {
|
||||
return isProxyWidget(w) ? w._overlay.widgetName : w.name
|
||||
return w.name
|
||||
}
|
||||
|
||||
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
|
||||
@@ -181,8 +178,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
subgraphNode.properties.proxyWidgets = subgraphNode.widgets
|
||||
.filter(isProxyWidget)
|
||||
.filter((w) => !isDisconnectedWidget(w))
|
||||
.map((w) => [w._overlay.nodeId, w._overlay.widgetName])
|
||||
const { resolvePromotedWidget } = useWidgetValueStore()
|
||||
const promotionList = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
|
||||
subgraphNode.properties.proxyWidgets = promotionList.filter(
|
||||
([nodeId, widgetName]) =>
|
||||
resolvePromotedWidget(subgraphNode.subgraph, nodeId, widgetName) !== null
|
||||
)
|
||||
}
|
||||
|
||||
45
src/core/schemas/proxyWidget.test.ts
Normal file
45
src/core/schemas/proxyWidget.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseProxyWidgets } from './proxyWidget'
|
||||
|
||||
describe('parseProxyWidgets', () => {
|
||||
it('returns empty array for null/undefined', () => {
|
||||
expect(parseProxyWidgets(null as unknown as undefined)).toEqual([])
|
||||
expect(parseProxyWidgets(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('parses valid JSON string', () => {
|
||||
const input = JSON.stringify([['widget1', 'target1']])
|
||||
expect(parseProxyWidgets(input)).toEqual([['widget1', 'target1']])
|
||||
})
|
||||
|
||||
it('passes through valid arrays', () => {
|
||||
const input = [
|
||||
['widget1', 'target1'],
|
||||
['widget2', 'target2']
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns empty array for malformed JSON strings', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(parseProxyWidgets('{not valid json')).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse proxyWidgets property as JSON:',
|
||||
'{not valid json'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('throws for invalid structure (valid JSON but wrong shape)', () => {
|
||||
expect(() => parseProxyWidgets([['only_one']])).toThrow(
|
||||
'Invalid assignment for properties.proxyWidgets'
|
||||
)
|
||||
|
||||
expect(() => parseProxyWidgets({ key: 'value' })).toThrow(
|
||||
'Invalid assignment for properties.proxyWidgets'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,16 @@ export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
export function parseProxyWidgets(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetsProperty {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = proxyWidgetsPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (property == null) return []
|
||||
if (typeof property === 'string') {
|
||||
try {
|
||||
property = JSON.parse(property)
|
||||
} catch {
|
||||
console.warn('Failed to parse proxyWidgets property as JSON:', property)
|
||||
return []
|
||||
}
|
||||
}
|
||||
const result = proxyWidgetsPropertySchema.safeParse(property)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { LGraphEventMap } from './LGraphEventMap'
|
||||
export interface SubgraphInputEventMap extends LGraphEventMap {
|
||||
'input-connected': {
|
||||
input: INodeInputSlot
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
this._widget ??= inputWidget
|
||||
this.events.dispatch('input-connected', {
|
||||
input: slot,
|
||||
node,
|
||||
widget: inputWidget
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
@@ -93,8 +91,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const linkId = subgraphInput.linkIds[0]
|
||||
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
|
||||
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
|
||||
if (widget)
|
||||
this._setWidget(subgraphInput, existingInput, widget, input?.widget)
|
||||
if (widget && inputNode)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
existingInput,
|
||||
widget,
|
||||
input?.widget,
|
||||
inputNode
|
||||
)
|
||||
return
|
||||
}
|
||||
const input = this.addInput(name, type)
|
||||
@@ -108,7 +112,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
'removing-input',
|
||||
(e) => {
|
||||
const widget = e.detail.input._widget
|
||||
if (widget) this.ensureWidgetRemoved(widget)
|
||||
if (widget && this.widgets.includes(widget))
|
||||
this.ensureWidgetRemoved(widget)
|
||||
|
||||
this.removeInput(e.detail.index)
|
||||
this.setDirtyCanvas(true, true)
|
||||
@@ -205,8 +210,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const widget = subgraphInput._widget
|
||||
if (!widget) return
|
||||
|
||||
const widgetLocator = e.detail.input.widget
|
||||
this._setWidget(subgraphInput, input, widget, widgetLocator)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
e.detail.input.widget,
|
||||
e.detail.node
|
||||
)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -218,7 +228,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
if (connectedWidgets.length > 0) return
|
||||
|
||||
this.removeWidgetByName(input.name)
|
||||
if (input._widget) this.ensureWidgetRemoved(input._widget)
|
||||
|
||||
delete input.pos
|
||||
delete input.widget
|
||||
@@ -323,7 +333,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
|
||||
this._setWidget(subgraphInput, input, widget, targetInput.widget)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
targetInput.widget,
|
||||
inputNode
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -333,66 +349,52 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
widget: Readonly<IBaseWidget>,
|
||||
inputWidget: IWidgetLocator | undefined
|
||||
inputWidget: IWidgetLocator | undefined,
|
||||
interiorNode: LGraphNode
|
||||
) {
|
||||
// Use the first matching widget
|
||||
const promotedWidget =
|
||||
widget instanceof BaseWidget
|
||||
? widget.createCopyForNode(this)
|
||||
: { ...widget, node: this }
|
||||
if (widget instanceof AssetWidget)
|
||||
promotedWidget.options.nodeType ??= widget.node.type
|
||||
const sourceWidget = widget as IBaseWidget
|
||||
const stub: IBaseWidget = Object.create(null)
|
||||
|
||||
Object.assign(promotedWidget, {
|
||||
get name() {
|
||||
return subgraphInput.name
|
||||
Object.defineProperties(stub, {
|
||||
sourceNodeId: { value: String(interiorNode.id), enumerable: true },
|
||||
sourceWidgetName: { value: sourceWidget.name, enumerable: true },
|
||||
node: { value: this, enumerable: true },
|
||||
name: {
|
||||
get: () => subgraphInput.name,
|
||||
enumerable: true
|
||||
},
|
||||
set name(value) {
|
||||
console.warn(
|
||||
'Promoted widget: setting name is not allowed',
|
||||
this,
|
||||
value
|
||||
)
|
||||
type: {
|
||||
get: () => sourceWidget.type,
|
||||
enumerable: true
|
||||
},
|
||||
get localized_name() {
|
||||
return subgraphInput.localized_name
|
||||
value: {
|
||||
get: () => sourceWidget.value,
|
||||
set: (v) => {
|
||||
sourceWidget.value = v
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
set localized_name(value) {
|
||||
console.warn(
|
||||
'Promoted widget: setting localized_name is not allowed',
|
||||
this,
|
||||
value
|
||||
)
|
||||
options: {
|
||||
get: () => sourceWidget.options,
|
||||
enumerable: true
|
||||
},
|
||||
get label() {
|
||||
return subgraphInput.label
|
||||
label: {
|
||||
get: () => subgraphInput.label,
|
||||
set() {},
|
||||
enumerable: true
|
||||
},
|
||||
set label(value) {
|
||||
console.warn(
|
||||
'Promoted widget: setting label is not allowed',
|
||||
this,
|
||||
value
|
||||
)
|
||||
},
|
||||
get tooltip() {
|
||||
// Preserve the original widget's tooltip for promoted widgets
|
||||
return widget.tooltip
|
||||
},
|
||||
set tooltip(value) {
|
||||
console.warn(
|
||||
'Promoted widget: setting tooltip is not allowed',
|
||||
this,
|
||||
value
|
||||
)
|
||||
tooltip: {
|
||||
get: () => widget.tooltip,
|
||||
enumerable: true
|
||||
}
|
||||
})
|
||||
|
||||
const widgetCount = this.inputs.filter((i) => i.widget).length
|
||||
this.widgets.splice(widgetCount, 0, promotedWidget)
|
||||
this.widgets.splice(widgetCount, 0, stub)
|
||||
|
||||
// Dispatch widget-promoted event
|
||||
this.subgraph.events.dispatch('widget-promoted', {
|
||||
widget: promotedWidget,
|
||||
widget: stub,
|
||||
subgraphNode: this
|
||||
})
|
||||
|
||||
@@ -403,7 +405,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
input.widget.name = subgraphInput.name
|
||||
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
|
||||
|
||||
input._widget = promotedWidget
|
||||
input._widget = stub
|
||||
|
||||
// Trigger promoted widget slot sync for live connections.
|
||||
// During configure, refreshPromotedWidgets is a no-op.
|
||||
// After configure, it triggers syncPromotedWidgets which
|
||||
// replaces this stub with a proper PromotedWidgetSlot.
|
||||
this.refreshPromotedWidgets()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -546,6 +554,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
super.removeWidgetByName(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the promoted widget system to sync promoted widget slots.
|
||||
* Overridden per-instance by promotedWidgetRegistration.ts after configure.
|
||||
* @internal
|
||||
*/
|
||||
refreshPromotedWidgets(): void {
|
||||
// No-op by default. Overridden by promotedWidgetRegistration.
|
||||
}
|
||||
|
||||
override ensureWidgetRemoved(widget: IBaseWidget): void {
|
||||
if (this.widgets.includes(widget)) {
|
||||
this.subgraph.events.dispatch('widget-demoted', {
|
||||
@@ -560,13 +577,16 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// Clean up all subgraph event listeners
|
||||
this._eventAbortController.abort()
|
||||
|
||||
// Clean up all promoted widgets
|
||||
// Dispatch widget-demoted for all widgets so listeners can clean up
|
||||
for (const widget of this.widgets) {
|
||||
if ('isProxyWidget' in widget && widget.isProxyWidget) continue
|
||||
this.subgraph.events.dispatch('widget-demoted', {
|
||||
widget,
|
||||
subgraphNode: this
|
||||
})
|
||||
// Dispose promoted widget slots (cleans up DOM adapters)
|
||||
if ('dispose' in widget && typeof widget.dispose === 'function') {
|
||||
widget.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
for (const input of this.inputs) {
|
||||
@@ -637,9 +657,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
// force reasign so domWidgets reset ownership
|
||||
|
||||
this.properties.proxyWidgets = this.properties.proxyWidgets
|
||||
// Sync promoted widgets so DOM adapters reset ownership
|
||||
this.refreshPromotedWidgets()
|
||||
|
||||
//TODO: Consider deep cloning subgraphs here.
|
||||
//It's the safest place to prevent creation of linked subgraphs
|
||||
|
||||
@@ -372,10 +372,13 @@ export interface IBaseWidget<
|
||||
* This property is automatically computed on graph change
|
||||
* and should not be changed.
|
||||
* Promoted widgets have a colored border
|
||||
* @see /core/graph/subgraph/proxyWidget.registerProxyWidgets
|
||||
* @see /core/graph/subgraph/promotedWidgetRegistration.registerPromotedWidgetSlots
|
||||
*/
|
||||
promoted?: boolean
|
||||
|
||||
/** Whether this widget is a PromotedWidgetSlot (a lightweight proxy for an interior widget). */
|
||||
isPromotedSlot?: boolean
|
||||
|
||||
tooltip?: string
|
||||
|
||||
// TODO: Confirm this format
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('BaseWidget store integration', () => {
|
||||
expect(state?.promoted).toBe(false)
|
||||
expect(state?.label).toBeUndefined()
|
||||
|
||||
expect(widget.hidden).toBeUndefined()
|
||||
expect(widget.hidden).toBe(false)
|
||||
expect(widget.advanced).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
/** Minimum gap between label and value */
|
||||
static labelValueGap = 5
|
||||
|
||||
/** Whether this widget is a promoted slot (overridden in PromotedWidgetSlot). */
|
||||
readonly isPromotedSlot: boolean = false
|
||||
|
||||
declare computedHeight?: number
|
||||
declare serialize?: boolean
|
||||
computeLayoutSize?(node: LGraphNode): {
|
||||
@@ -90,7 +93,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
this._state.label = value
|
||||
}
|
||||
|
||||
hidden?: boolean
|
||||
get hidden(): boolean | undefined {
|
||||
return this._state.hidden
|
||||
}
|
||||
set hidden(value: boolean | undefined) {
|
||||
this._state.hidden = value ?? false
|
||||
}
|
||||
|
||||
advanced?: boolean
|
||||
|
||||
get disabled(): boolean | undefined {
|
||||
@@ -179,6 +188,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
displayValue,
|
||||
// @ts-expect-error Prevent naming conflicts with custom nodes.
|
||||
labelBaseline,
|
||||
hidden,
|
||||
label,
|
||||
disabled,
|
||||
promoted,
|
||||
@@ -193,6 +203,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
name: this.name,
|
||||
type: this.type as TWidgetType,
|
||||
value,
|
||||
hidden: hidden ?? false,
|
||||
label,
|
||||
disabled: disabled ?? false,
|
||||
promoted: promoted ?? false,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IButtonWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions } from './BaseWidget'
|
||||
|
||||
class DisconnectedWidget extends BaseWidget<IButtonWidget> {
|
||||
constructor(widget: IButtonWidget) {
|
||||
super(widget, new LGraphNode('DisconnectedPlaceholder'))
|
||||
this.disabled = true
|
||||
}
|
||||
|
||||
override drawWidget(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ width, showText = true }: DrawWidgetOptions
|
||||
) {
|
||||
ctx.save()
|
||||
this.drawWidgetShape(ctx, { width, showText })
|
||||
if (showText) {
|
||||
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
override onClick() {}
|
||||
|
||||
override get _displayValue() {
|
||||
return 'Disconnected'
|
||||
}
|
||||
}
|
||||
const conf: IButtonWidget = {
|
||||
type: 'button',
|
||||
value: undefined,
|
||||
name: 'Disconnected',
|
||||
options: {},
|
||||
y: 0,
|
||||
clicked: false
|
||||
}
|
||||
export const disconnectedWidget = new DisconnectedWidget(conf)
|
||||
@@ -234,14 +234,14 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return {
|
||||
get: () => {
|
||||
track()
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const ynode = this.ynodes.get(String(nodeId))
|
||||
const layout = ynode ? yNodeToLayout(ynode) : null
|
||||
return layout
|
||||
},
|
||||
set: (newLayout: NodeLayout | null) => {
|
||||
if (newLayout === null) {
|
||||
// Delete operation
|
||||
const existing = this.ynodes.get(nodeId)
|
||||
const existing = this.ynodes.get(String(nodeId))
|
||||
if (existing) {
|
||||
this.applyOperation({
|
||||
type: 'deleteNode',
|
||||
@@ -255,7 +255,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
} else {
|
||||
// Update operation - detect what changed
|
||||
const existing = this.ynodes.get(nodeId)
|
||||
const existing = this.ynodes.get(String(nodeId))
|
||||
if (!existing) {
|
||||
// Create operation
|
||||
this.applyOperation({
|
||||
@@ -685,7 +685,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Precise hit test only on candidates
|
||||
for (const key of candidateKeys) {
|
||||
const segmentLayout = this.linkSegmentLayouts.get(key)
|
||||
const segmentLayout = this.linkSegmentLayouts.get(String(key))
|
||||
if (!segmentLayout) continue
|
||||
|
||||
if (ctx && segmentLayout.path) {
|
||||
@@ -748,7 +748,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Check precise bounds for candidates
|
||||
for (const key of candidateSlotKeys) {
|
||||
const slotLayout = this.slotLayouts.get(key)
|
||||
const slotLayout = this.slotLayouts.get(String(key))
|
||||
if (slotLayout && pointInBounds(point, slotLayout.bounds)) {
|
||||
return slotLayout
|
||||
}
|
||||
@@ -812,7 +812,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const segmentKeys = this.linkSegmentSpatialIndex.query(bounds)
|
||||
const linkIds = new Set<LinkId>()
|
||||
for (const key of segmentKeys) {
|
||||
const segment = this.linkSegmentLayouts.get(key)
|
||||
const segment = this.linkSegmentLayouts.get(String(key))
|
||||
if (segment) {
|
||||
linkIds.add(segment.linkId)
|
||||
}
|
||||
@@ -821,7 +821,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return {
|
||||
nodes: this.queryNodesInBounds(bounds),
|
||||
links: Array.from(linkIds),
|
||||
slots: this.slotSpatialIndex.query(bounds),
|
||||
slots: this.slotSpatialIndex.query(bounds).map(String),
|
||||
reroutes: this.rerouteSpatialIndex
|
||||
.query(bounds)
|
||||
.map((key) => asRerouteId(key))
|
||||
@@ -1002,7 +1002,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
}
|
||||
|
||||
this.ynodes.set(layout.id, layoutToYNode(layout))
|
||||
this.ynodes.set(String(layout.id), layoutToYNode(layout))
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(layout.id, layout.bounds)
|
||||
@@ -1018,7 +1018,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: MoveNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
const ynode = this.ynodes.get(String(operation.nodeId))
|
||||
if (!ynode) {
|
||||
return
|
||||
}
|
||||
@@ -1046,7 +1046,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: ResizeNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
const ynode = this.ynodes.get(String(operation.nodeId))
|
||||
if (!ynode) return
|
||||
|
||||
const position = yNodeToLayout(ynode).position
|
||||
@@ -1072,7 +1072,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: SetNodeZIndexOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
const ynode = this.ynodes.get(String(operation.nodeId))
|
||||
if (!ynode) return
|
||||
|
||||
ynode.set('zIndex', operation.zIndex)
|
||||
@@ -1084,7 +1084,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = layoutToYNode(operation.layout)
|
||||
this.ynodes.set(operation.nodeId, ynode)
|
||||
this.ynodes.set(String(operation.nodeId), ynode)
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
|
||||
@@ -1097,9 +1097,9 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: DeleteNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
if (!this.ynodes.has(operation.nodeId)) return
|
||||
if (!this.ynodes.has(String(operation.nodeId))) return
|
||||
|
||||
this.ynodes.delete(operation.nodeId)
|
||||
this.ynodes.delete(String(operation.nodeId))
|
||||
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
|
||||
// During undo/redo, Vue components may still hold references to the old ref.
|
||||
// If we delete the trigger, Vue won't be notified when the node is re-created.
|
||||
@@ -1132,7 +1132,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
for (const nodeId of operation.nodeIds) {
|
||||
const data = operation.bounds[nodeId]
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const ynode = this.ynodes.get(String(nodeId))
|
||||
if (!ynode || !data) continue
|
||||
|
||||
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
|
||||
@@ -1440,7 +1440,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
|
||||
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const ynode = this.ynodes.get(String(nodeId))
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useLayoutSync() {
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!layout) continue
|
||||
|
||||
const liteNode = canvas.graph?.getNodeById(parseInt(nodeId))
|
||||
const liteNode = canvas.graph?.getNodeById(nodeId)
|
||||
if (!liteNode) continue
|
||||
|
||||
if (
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* This file contains all type definitions for the layout system
|
||||
* that manages node positions, bounds, spatial data, and operations.
|
||||
*/
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
export type { NodeId }
|
||||
|
||||
// Enum for layout source types
|
||||
export enum LayoutSource {
|
||||
Canvas = 'canvas',
|
||||
@@ -37,7 +40,6 @@ export interface NodeBoundsUpdate {
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export type NodeId = string
|
||||
export type LinkId = number
|
||||
export type RerouteId = number
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export class SpatialIndexManager {
|
||||
* Insert a node into the spatial index
|
||||
*/
|
||||
insert(nodeId: NodeId, bounds: Bounds): void {
|
||||
this.quadTree.insert(nodeId, bounds, nodeId)
|
||||
this.quadTree.insert(String(nodeId), bounds, nodeId)
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class SpatialIndexManager {
|
||||
* Update a node's bounds in the spatial index
|
||||
*/
|
||||
update(nodeId: NodeId, bounds: Bounds): void {
|
||||
this.quadTree.update(nodeId, bounds)
|
||||
this.quadTree.update(String(nodeId), bounds)
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class SpatialIndexManager {
|
||||
*/
|
||||
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
this.quadTree.update(nodeId, bounds)
|
||||
this.quadTree.update(String(nodeId), bounds)
|
||||
}
|
||||
this.invalidateCache()
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export class SpatialIndexManager {
|
||||
* Remove a node from the spatial index
|
||||
*/
|
||||
remove(nodeId: NodeId): void {
|
||||
this.quadTree.remove(nodeId)
|
||||
this.quadTree.remove(String(nodeId))
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
@@ -283,11 +284,11 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
useVueElementTracking(() => String(nodeData.id), 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(nodeData.id)
|
||||
return selectedNodeIds.value.has(String(nodeData.id))
|
||||
})
|
||||
|
||||
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
@@ -343,8 +344,10 @@ onErrorCaptured((error) => {
|
||||
return false // Prevent error propagation
|
||||
})
|
||||
|
||||
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
|
||||
const { position, size, zIndex } = useNodeLayout(() => String(nodeData.id))
|
||||
const { pointerHandlers } = useNodePointerInteractions(() =>
|
||||
String(nodeData.id)
|
||||
)
|
||||
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
|
||||
const { startDrag } = useNodeDrag()
|
||||
const badges = usePartitionedBadges(nodeData)
|
||||
@@ -397,7 +400,7 @@ function initSizeStyles() {
|
||||
*/
|
||||
function handleLayoutChange(change: {
|
||||
source: LayoutSource
|
||||
nodeIds: string[]
|
||||
nodeIds: NodeId[]
|
||||
}) {
|
||||
// Only handle Canvas or External source (extensions calling setSize)
|
||||
if (
|
||||
@@ -487,7 +490,7 @@ const hasCustomContent = computed(() => {
|
||||
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
||||
|
||||
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
() => nodeData.id,
|
||||
() => String(nodeData.id),
|
||||
{
|
||||
isCollapsed
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
nodeErrors?.errors?.some(
|
||||
(error) => error.extra_info?.input_name === widget.name
|
||||
) ?? false,
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
hidden: widgetState?.hidden ?? widget.options?.hidden ?? false,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
|
||||
@@ -32,7 +32,7 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = nodeManager.value.getNode(String(nodeId))
|
||||
if (!node) return
|
||||
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
@@ -69,7 +69,7 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
if (!nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = nodeManager.value.getNode(String(nodeId))
|
||||
if (!node) return
|
||||
|
||||
// Use LiteGraph's collapse method if the state needs to change
|
||||
@@ -88,7 +88,7 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
if (!nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = nodeManager.value.getNode(String(nodeId))
|
||||
if (!node) return
|
||||
|
||||
// Update the node title in LiteGraph for persistence
|
||||
@@ -104,7 +104,7 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = nodeManager.value.getNode(String(nodeId))
|
||||
if (!node) return
|
||||
|
||||
// Prevent default context menu
|
||||
@@ -127,7 +127,7 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
const node = nodeManager.value.getNode(String(nodeId))
|
||||
if (!node) return
|
||||
|
||||
if (!multiSelect) {
|
||||
|
||||
@@ -355,7 +355,7 @@ export function useSlotLinkInteraction({
|
||||
|
||||
if (slotCandidate) {
|
||||
const key = getSlotKey(
|
||||
slotCandidate.layout.nodeId,
|
||||
String(slotCandidate.layout.nodeId),
|
||||
slotCandidate.layout.index,
|
||||
slotCandidate.layout.type === 'input'
|
||||
)
|
||||
@@ -363,7 +363,7 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
if (nodeCandidate && !slotCandidate?.compatible) {
|
||||
const key = getSlotKey(
|
||||
nodeCandidate.layout.nodeId,
|
||||
String(nodeCandidate.layout.nodeId),
|
||||
nodeCandidate.layout.index,
|
||||
nodeCandidate.layout.type === 'input'
|
||||
)
|
||||
@@ -374,7 +374,7 @@ export function useSlotLinkInteraction({
|
||||
const newCandidate = candidate?.compatible ? candidate : null
|
||||
const newCandidateKey = newCandidate
|
||||
? getSlotKey(
|
||||
newCandidate.layout.nodeId,
|
||||
String(newCandidate.layout.nodeId),
|
||||
newCandidate.layout.index,
|
||||
newCandidate.layout.type === 'input'
|
||||
)
|
||||
|
||||
@@ -58,7 +58,7 @@ function useNodeDragIndividual() {
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
// Only move other selected items if the dragged node is part of the selection
|
||||
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
|
||||
const isDraggedNodeInSelection = selectedNodes?.has(String(nodeId))
|
||||
|
||||
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { shallowRef } from 'vue'
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { registerPromotedWidgetSlots } from '@/core/graph/subgraph/promotedWidgetRegistration'
|
||||
import { st, t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
@@ -849,6 +849,8 @@ export class ComfyApp {
|
||||
.map((w) => [w.id, w])
|
||||
)
|
||||
|
||||
const newGraphNodeSet = new Set(newGraph.nodes)
|
||||
|
||||
for (const [
|
||||
widgetId,
|
||||
widgetState
|
||||
@@ -856,6 +858,10 @@ export class ComfyApp {
|
||||
if (widgetId in activeWidgets) {
|
||||
widgetState.active = true
|
||||
widgetState.widget = activeWidgets[widgetId]
|
||||
} else if (newGraphNodeSet.has(widgetState.widget.node)) {
|
||||
// Adapter widgets (e.g. PromotedDomWidgetAdapter) are not in any
|
||||
// node's widgets array but their host node IS in the graph.
|
||||
widgetState.active = true
|
||||
} else {
|
||||
widgetState.active = false
|
||||
}
|
||||
@@ -879,7 +885,7 @@ export class ComfyApp {
|
||||
}
|
||||
)
|
||||
|
||||
registerProxyWidgets(this.canvas)
|
||||
registerPromotedWidgetSlots(this.canvas)
|
||||
|
||||
this.rootGraph.start()
|
||||
|
||||
|
||||
@@ -30,11 +30,12 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
|
||||
// Register a widget with the store
|
||||
const registerWidget = <V extends object | string>(
|
||||
widget: BaseDOMWidget<V>
|
||||
widget: BaseDOMWidget<V>,
|
||||
options?: { visible?: boolean }
|
||||
) => {
|
||||
widgetStates.value.set(widget.id, {
|
||||
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
|
||||
visible: true,
|
||||
visible: options?.visible ?? true,
|
||||
readonly: false,
|
||||
zIndex: 0,
|
||||
pos: [0, 0],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
@@ -397,18 +397,29 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
if (!node.isSubgraphNode()) {
|
||||
const nodeDef = fromLGraphNode(node)
|
||||
if (!nodeDef) return undefined
|
||||
|
||||
return nodeDef.inputs[widgetName]
|
||||
}
|
||||
|
||||
// For subgraph nodes, find the interior node via the promotion list
|
||||
const entry = parseProxyWidgets(node.properties.proxyWidgets).find(
|
||||
([, name]) => name === widgetName
|
||||
)
|
||||
if (entry) {
|
||||
const [nodeId] = entry
|
||||
const subNode = node.subgraph.getNodeById(nodeId)
|
||||
if (!subNode) return undefined
|
||||
return getInputSpecForWidget(subNode, widgetName)
|
||||
}
|
||||
|
||||
// Also check slot-promoted widgets (System 1) in node.widgets
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
//TODO: resolve spec for linked
|
||||
if (!widget || !isProxyWidget(widget)) return undefined
|
||||
if (widget) {
|
||||
const nodeDef = fromLGraphNode(node)
|
||||
if (!nodeDef) return undefined
|
||||
return nodeDef.inputs[widgetName]
|
||||
}
|
||||
|
||||
const { nodeId, widgetName: subWidgetName } = widget._overlay
|
||||
const subNode = node.subgraph.getNodeById(nodeId)
|
||||
if (!subNode) return undefined
|
||||
|
||||
return getInputSpecForWidget(subNode, subWidgetName)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { WidgetState } from './widgetValueStore'
|
||||
import { useWidgetValueStore } from './widgetValueStore'
|
||||
import { stripGraphPrefix, useWidgetValueStore } from './widgetValueStore'
|
||||
|
||||
function widget<T>(
|
||||
nodeId: string,
|
||||
@@ -17,6 +21,22 @@ function widget<T>(
|
||||
return { nodeId, name, type, value, options: {}, ...extra }
|
||||
}
|
||||
|
||||
function mockWidget(name: string, type = 'number'): IBaseWidget {
|
||||
return { name, type } as IBaseWidget
|
||||
}
|
||||
|
||||
function mockNode(id: string, widgets: IBaseWidget[] = []): LGraphNode {
|
||||
return { id, widgets } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function mockSubgraph(nodes: LGraphNode[]): LGraph {
|
||||
const nodeMap = new Map(nodes.map((n) => [String(n.id), n]))
|
||||
return {
|
||||
getNodeById: (id: string | number | null | undefined) =>
|
||||
id != null ? (nodeMap.get(String(id)) ?? null) : null
|
||||
} as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('useWidgetValueStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -152,4 +172,93 @@ describe('useWidgetValueStore', () => {
|
||||
expect(store.getWidget('node-1', 'seed')?.label).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePromotedWidget', () => {
|
||||
it('returns null for missing node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const subgraph = mockSubgraph([])
|
||||
|
||||
expect(store.resolvePromotedWidget(subgraph, '99', 'seed')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for missing widget on existing node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const node = mockNode('42', [mockWidget('steps')])
|
||||
const subgraph = mockSubgraph([node])
|
||||
store.registerWidget(widget('42', 'steps', 'number', 20))
|
||||
|
||||
expect(store.resolvePromotedWidget(subgraph, '42', 'seed')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when widget exists on node but not in store', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const w = mockWidget('seed')
|
||||
const node = mockNode('42', [w])
|
||||
const subgraph = mockSubgraph([node])
|
||||
|
||||
expect(store.resolvePromotedWidget(subgraph, '42', 'seed')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns correct state, widget, and node for registered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const w = mockWidget('seed')
|
||||
const node = mockNode('42', [w])
|
||||
const subgraph = mockSubgraph([node])
|
||||
const registeredState = store.registerWidget(
|
||||
widget('42', 'seed', 'number', 12345)
|
||||
)
|
||||
|
||||
const result = store.resolvePromotedWidget(subgraph, '42', 'seed')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.state).toBe(registeredState)
|
||||
expect(result!.widget).toBe(w)
|
||||
expect(result!.node).toBe(node)
|
||||
})
|
||||
|
||||
it('state.value matches the store value (same reference)', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const w = mockWidget('seed')
|
||||
const node = mockNode('42', [w])
|
||||
const subgraph = mockSubgraph([node])
|
||||
store.registerWidget(widget('42', 'seed', 'number', 100))
|
||||
|
||||
const result = store.resolvePromotedWidget(subgraph, '42', 'seed')
|
||||
expect(result!.state.value).toBe(100)
|
||||
|
||||
result!.state.value = 200
|
||||
expect(store.getWidget('42', 'seed')?.value).toBe(200)
|
||||
})
|
||||
|
||||
it('handles stripGraphPrefix for scoped node IDs', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const w = mockWidget('cfg')
|
||||
const node = mockNode('7', [w])
|
||||
const subgraph = mockSubgraph([node])
|
||||
store.registerWidget(widget('7', 'cfg', 'number', 7.5))
|
||||
|
||||
// nodeId passed as bare '7' resolves to store key '7:cfg'
|
||||
const result = store.resolvePromotedWidget(subgraph, '7', 'cfg')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.state.value).toBe(7.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripGraphPrefix', () => {
|
||||
it('strips single prefix', () => {
|
||||
expect(stripGraphPrefix('graph1:42')).toBe('42')
|
||||
})
|
||||
|
||||
it('strips multiple prefixes', () => {
|
||||
expect(stripGraphPrefix('graph1:subgraph2:42')).toBe('42')
|
||||
})
|
||||
|
||||
it('returns bare id unchanged', () => {
|
||||
expect(stripGraphPrefix('42')).toBe('42')
|
||||
})
|
||||
|
||||
it('handles numeric input', () => {
|
||||
expect(stripGraphPrefix(42 as unknown as string)).toBe('42')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
@@ -32,6 +33,7 @@ export interface WidgetState<
|
||||
| 'type'
|
||||
| 'value'
|
||||
| 'options'
|
||||
| 'hidden'
|
||||
| 'label'
|
||||
| 'serialize'
|
||||
| 'disabled'
|
||||
@@ -69,9 +71,27 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
return widgetStates.value.get(makeKey(nodeId, widgetName))
|
||||
}
|
||||
|
||||
function resolvePromotedWidget(
|
||||
subgraph: LGraph,
|
||||
nodeId: NodeId,
|
||||
widgetName: string
|
||||
): { state: WidgetState; widget: IBaseWidget; node: LGraphNode } | null {
|
||||
const node = subgraph.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return null
|
||||
|
||||
const state = getWidget(stripGraphPrefix(nodeId), widgetName)
|
||||
if (!state) return null
|
||||
|
||||
return { state, widget, node }
|
||||
}
|
||||
|
||||
return {
|
||||
registerWidget,
|
||||
getWidget,
|
||||
getNodeWidgets
|
||||
getNodeWidgets,
|
||||
resolvePromotedWidget
|
||||
}
|
||||
})
|
||||
|
||||
73
src/utils/widgetUtil.test.ts
Normal file
73
src/utils/widgetUtil.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { renameWidget } from './widgetUtil'
|
||||
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
} satisfies Partial<IBaseWidget> as IBaseWidget
|
||||
}
|
||||
|
||||
function createMockNode(
|
||||
widgets: IBaseWidget[] = [],
|
||||
inputs: INodeInputSlot[] = []
|
||||
): LGraphNode {
|
||||
return {
|
||||
widgets,
|
||||
inputs
|
||||
} satisfies Partial<Omit<LGraphNode, 'constructor'>> as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('renameWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('sets widget label to new name', () => {
|
||||
const widget = createMockWidget()
|
||||
const node = createMockNode([widget])
|
||||
|
||||
renameWidget(widget, node, 'New Name')
|
||||
expect(widget.label).toBe('New Name')
|
||||
})
|
||||
|
||||
it('clears widget label when given empty string', () => {
|
||||
const widget = createMockWidget()
|
||||
widget.label = 'Old'
|
||||
const node = createMockNode([widget])
|
||||
|
||||
renameWidget(widget, node, '')
|
||||
expect(widget.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates matching input label', () => {
|
||||
const widget = createMockWidget()
|
||||
const input = {
|
||||
name: 'seed',
|
||||
link: null,
|
||||
widget: { name: 'seed' },
|
||||
label: undefined as string | undefined
|
||||
} satisfies Partial<INodeInputSlot> as INodeInputSlot
|
||||
const node = createMockNode([widget], [input])
|
||||
|
||||
renameWidget(widget, node, 'Renamed')
|
||||
expect(input.label).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('returns true on success', () => {
|
||||
const widget = createMockWidget()
|
||||
const node = createMockNode([widget])
|
||||
|
||||
expect(renameWidget(widget, node, 'New')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,60 +1,19 @@
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Renames a widget and its corresponding input.
|
||||
* Handles both regular widgets and proxy widgets in subgraphs.
|
||||
*
|
||||
* @param widget The widget to rename
|
||||
* @param node The node containing the widget
|
||||
* @param newLabel The new label for the widget (empty string or undefined to clear)
|
||||
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
|
||||
* @returns true if the rename was successful, false otherwise
|
||||
*/
|
||||
export function renameWidget(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode,
|
||||
newLabel: string,
|
||||
parents?: SubgraphNode[]
|
||||
newLabel: string
|
||||
): boolean {
|
||||
// For proxy widgets in subgraphs, we need to rename the original interior widget
|
||||
if (isProxyWidget(widget) && parents?.length) {
|
||||
const subgraph = parents[0].subgraph
|
||||
if (!subgraph) {
|
||||
console.error('Could not find subgraph for proxy widget')
|
||||
return false
|
||||
}
|
||||
const interiorNode = subgraph.getNodeById(widget._overlay.nodeId)
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
// Rename the original widget
|
||||
originalWidget.label = newLabel || undefined
|
||||
|
||||
// Also rename the corresponding input on the interior node
|
||||
const interiorInput = interiorNode.inputs?.find(
|
||||
(inp) => inp.widget?.name === widget._overlay.widgetName
|
||||
)
|
||||
if (interiorInput) {
|
||||
interiorInput.label = newLabel || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Always rename the widget on the current node (either regular widget or proxy widget)
|
||||
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
|
||||
|
||||
// Intentionally mutate the widget object here as it's a reference
|
||||
|
||||
Reference in New Issue
Block a user