mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 12:44:23 +00:00
Compare commits
25 Commits
coderabbit
...
drjkl/no-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4b6c70c5 | ||
|
|
93fdbab098 | ||
|
|
3471de82fb | ||
|
|
e797fc6c3a | ||
|
|
8623384c79 | ||
|
|
d01424f99e | ||
|
|
0b99394a34 | ||
|
|
2a4102690d | ||
|
|
574c8d37d9 | ||
|
|
7809c2faf8 | ||
|
|
3e462b663a | ||
|
|
54a3b035b8 | ||
|
|
2082f5f43b | ||
|
|
c69f009775 | ||
|
|
749425d1ea | ||
|
|
2157901a74 | ||
|
|
b78de7c41f | ||
|
|
3e8b0f56e9 | ||
|
|
386e2d2796 | ||
|
|
914ceae014 | ||
|
|
c87de750ca | ||
|
|
c0fc78554a | ||
|
|
1f50a78530 | ||
|
|
5098fb8fa7 | ||
|
|
c0b1ed9770 |
@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
function removeSelectedWidgetId(widgetId: WidgetId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
const widgetId = widget.widgetId
|
||||
if (!widgetId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
[entry.widgetId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
:remove="() => removeSelectedWidgetId(entry.widgetId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
|
||||
@@ -62,7 +62,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { entityId, node, widget, config } = entry
|
||||
const { widgetId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
@@ -72,7 +72,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
return vueWidget.entityId === entityId
|
||||
return vueWidget.widgetId === widgetId
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -81,7 +81,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: entityId,
|
||||
key: widgetId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
@@ -22,14 +22,14 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { isWidgetId, parseWidgetId } from '@/world/entityIds'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
entityId: WidgetEntityId
|
||||
widgetId: WidgetId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
@@ -20,7 +20,7 @@ export type ResolvedSelection =
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
entityId: WidgetEntityId
|
||||
widgetId: WidgetId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
@@ -54,16 +54,16 @@ export function useResolvedSelectedInputs() {
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
([widgetId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetId(widgetId)) return []
|
||||
const { nodeId, name } = parseWidgetId(widgetId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
return [{ status: 'unknown', widgetId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
{ status: 'resolved', widgetId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -146,16 +147,17 @@ function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
const source = widgetPromotedSource(widgetNode, widget)
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
if (source) {
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
? source.nodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
@@ -234,7 +236,10 @@ function navigateToErrorTab() {
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
|
||||
// are addressed by widgetId; writing there keeps the displayed value in sync.
|
||||
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -245,18 +250,18 @@ function handleResetAllWidgets() {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
writeWidgetValue(widget, defaultValue)
|
||||
setWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
writeWidgetValue(widget, newValue)
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
writeWidgetValue(widget, newValue)
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
|
||||
import TabSubgraphInputs from './TabSubgraphInputs.vue'
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
|
||||
})
|
||||
|
||||
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
|
||||
rows: []
|
||||
}
|
||||
|
||||
const SectionWidgetsStub = {
|
||||
props: ['widgets', 'node', 'parents'],
|
||||
setup(props: Record<string, unknown>) {
|
||||
captured.rows = props.widgets as {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}[]
|
||||
return () => null
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostWithPromotedSeed(): {
|
||||
host: SubgraphNode
|
||||
sourceNode: LGraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const graph = host.graph as LGraph
|
||||
graph.add(host)
|
||||
|
||||
const sourceNode = new LGraphNode('Sampler')
|
||||
const input = sourceNode.addInput('seed', 'INT')
|
||||
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
|
||||
input.widget = { name: seedWidget.name }
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
|
||||
return { host, sourceNode }
|
||||
}
|
||||
|
||||
function renderPanel(node: SubgraphNode) {
|
||||
return render(TabSubgraphInputs, {
|
||||
props: { node },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SectionWidgets: SectionWidgetsStub,
|
||||
AsyncSearchInput: true,
|
||||
CollapseToggleButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TabSubgraphInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
captured.rows = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow).toBeDefined()
|
||||
expect(seedRow?.node.id).toBe(host.id)
|
||||
expect(seedRow?.widget.type).toBe('number')
|
||||
expect(seedRow?.widget.widgetId).toBe(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
)
|
||||
expect(seedRow?.widget.value).toBe(42)
|
||||
})
|
||||
|
||||
it('reflects the current host widget value from the store', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
const id = widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
useWidgetValueStore().setValue(id, 7)
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow?.widget.value).toBe(7)
|
||||
})
|
||||
|
||||
it('reflects value changes through the same descriptor without rebuilding it', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
|
||||
expect(seedRow.widget.value).toBe(42)
|
||||
|
||||
// A value edit must not require a new descriptor object: the same row
|
||||
// reflects the store change via its live getter, keeping render keys stable.
|
||||
useWidgetValueStore().setValue(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed'),
|
||||
100
|
||||
)
|
||||
expect(seedRow.widget.value).toBe(100)
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,13 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
@@ -45,32 +44,6 @@ const isAllCollapsed = computed({
|
||||
})
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(a) &&
|
||||
isPromotedWidgetView(b) &&
|
||||
a.sourceNodeId === b.sourceNodeId &&
|
||||
a.sourceWidgetName === b.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotedWidgets(): IBaseWidget[] {
|
||||
const inputWidgets = node.inputs
|
||||
.map((input) => input._widget)
|
||||
.filter((widget): widget is IBaseWidget =>
|
||||
Boolean(widget && isPromotedWidgetView(widget))
|
||||
)
|
||||
const extraWidgets = (node.widgets ?? []).filter(
|
||||
(widget) =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
!inputWidgets.some((inputWidget) =>
|
||||
isSamePromotedWidget(inputWidget, widget)
|
||||
)
|
||||
)
|
||||
|
||||
return [...inputWidgets, ...extraWidgets]
|
||||
}
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
@@ -93,7 +66,7 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
return getPromotedWidgets().map((widget) => ({ node, widget }))
|
||||
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -45,8 +47,10 @@ const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
|
||||
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
|
||||
if (!node.isSubgraphNode()) return false
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (!source) return false
|
||||
return isLinkedPromotion(node, source.nodeId, source.widgetName)
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
@@ -64,9 +68,16 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||
|
||||
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||
|
||||
const currentValue = computed(
|
||||
() =>
|
||||
(widget.widgetId &&
|
||||
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
|
||||
widget.value
|
||||
)
|
||||
|
||||
const isCurrentValueDefault = computed(() => {
|
||||
if (!hasDefault.value) return true
|
||||
return isEqual(widget.value, defaultValue.value)
|
||||
return isEqual(currentValue.value, defaultValue.value)
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
@@ -77,21 +88,15 @@ async function handleRename() {
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (source) {
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id)
|
||||
demoteWidget(
|
||||
{
|
||||
id: sourceNodeId,
|
||||
title: node.title,
|
||||
type: node.type
|
||||
},
|
||||
widget,
|
||||
[parent]
|
||||
)
|
||||
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
|
||||
demotePromotedInput(parent, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
|
||||
@@ -42,10 +44,6 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
@@ -96,43 +94,6 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock PromotedWidgetView that mirrors the real class:
|
||||
* properties like name, type, value, options are prototype getters,
|
||||
* NOT own properties — so object spread loses them.
|
||||
*/
|
||||
function createMockPromotedWidgetView(
|
||||
sourceOptions: IBaseWidget['options'] = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
): IBaseWidget {
|
||||
class MockPromotedWidgetView {
|
||||
readonly sourceNodeId = '42'
|
||||
readonly sourceWidgetName = 'ckpt_name'
|
||||
readonly serialize = false
|
||||
|
||||
get name(): string {
|
||||
return 'ckpt_name'
|
||||
}
|
||||
get type(): string {
|
||||
return 'combo'
|
||||
}
|
||||
get value(): unknown {
|
||||
return 'model_a.safetensors'
|
||||
}
|
||||
get options(): IBaseWidget['options'] {
|
||||
return sourceOptions
|
||||
}
|
||||
get label(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
get y(): number {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
|
||||
}
|
||||
|
||||
function renderWidgetItem(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode = createMockNode()
|
||||
@@ -167,7 +128,7 @@ describe('WidgetItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('promoted widget options', () => {
|
||||
describe('widget state rendering', () => {
|
||||
it('passes options from a regular widget to the widget component', () => {
|
||||
const widget = createMockWidget({
|
||||
options: { values: ['a', 'b', 'c'] }
|
||||
@@ -180,35 +141,63 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes options from a PromotedWidgetView to the widget component', () => {
|
||||
it('passes options from widget state to the widget component', () => {
|
||||
const expectedOptions = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
const widget = createMockPromotedWidgetView(expectedOptions)
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: expectedOptions
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.options).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
it('passes type from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes type from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, type: 'string' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.type).toBe('combo')
|
||||
})
|
||||
|
||||
it('passes name from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes name from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.name).toBe('ckpt_name')
|
||||
})
|
||||
|
||||
it('passes value from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes value from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, value: 'source value' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -17,11 +16,12 @@ import {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
@@ -67,35 +67,32 @@ const widgetComponent = computed(() => {
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
const source = resolvePromotedWidgetSource(node, widget)
|
||||
return source ?? { node, widget }
|
||||
}
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
const bareNodeId = stripGraphPrefix(String(node.id))
|
||||
const widgetState = widget.widgetId
|
||||
? useWidgetValueStore().getWidget(widget.widgetId)
|
||||
: graphId
|
||||
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
|
||||
: undefined
|
||||
const widgetName = widgetState?.name ?? widget.name
|
||||
const widgetType = widgetState?.type ?? widget.type
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
name: widgetName,
|
||||
type: widgetType,
|
||||
value: widgetState?.value ?? widget.value,
|
||||
label: widgetState?.label ?? widget.label,
|
||||
options: widgetState?.options ?? widget.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
|
||||
controlWidget: getControlWidget(sourceWidget)
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
|
||||
controlWidget: getControlWidget(widget)
|
||||
}
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
|
||||
if (!sourceNode) return null
|
||||
const displayNodeName = computed((): string | null => {
|
||||
if (!node) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
@@ -167,10 +164,10 @@ const displayLabel = customRef((track, trigger) => {
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
v-if="(showNodeName || hasParents) && displayNodeName"
|
||||
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
{{ displayNodeName }}
|
||||
</span>
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
|
||||
@@ -14,8 +14,9 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type DraggableList from '@/components/common/DraggableList.vue'
|
||||
@@ -167,11 +168,20 @@ describe('SubgraphEditor', () => {
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
|
||||
const reversed = [
|
||||
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
|
||||
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
|
||||
] as PromotedRow[]
|
||||
const rowFor = (sourceNode: LGraphNode) => {
|
||||
const input = host.inputs.find((input) => {
|
||||
if (!input.widgetId) return false
|
||||
const target = resolveSubgraphInputTarget(host, input.name)
|
||||
return target?.nodeId === String(sourceNode.id)
|
||||
})!
|
||||
return {
|
||||
kind: 'promoted',
|
||||
node: sourceNode,
|
||||
input,
|
||||
widget: promotedInputWidget(input)!
|
||||
}
|
||||
}
|
||||
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
|
||||
listSetter?.(reversed)
|
||||
await nextTick()
|
||||
|
||||
@@ -213,13 +223,13 @@ describe('SubgraphEditor', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const hideAllLink = within(shown).getByText('Hide all')
|
||||
await userEvent.click(hideAllLink)
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
isLinkedPromotion,
|
||||
@@ -16,8 +15,14 @@ import {
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import {
|
||||
promotedInputSource,
|
||||
promotedInputWidget
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -33,7 +38,8 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
widget: PromotedWidgetView
|
||||
input: INodeInputSlot
|
||||
widget: IBaseWidget
|
||||
}
|
||||
type PreviewRow = {
|
||||
kind: 'preview'
|
||||
@@ -54,11 +60,23 @@ const activeNode = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
|
||||
function refreshPromotedWidgets() {
|
||||
promotedWidgets.value = activeNode.value?.widgets ?? []
|
||||
const promotedRows = shallowRef<readonly PromotedRow[]>([])
|
||||
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return node.inputs.flatMap((input): PromotedRow[] => {
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) return []
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, input, widget }]
|
||||
})
|
||||
}
|
||||
watch(activeNode, refreshPromotedWidgets, { immediate: true })
|
||||
function refreshPromotedRows() {
|
||||
const node = activeNode.value
|
||||
promotedRows.value = node ? buildPromotedRows(node) : []
|
||||
}
|
||||
watch(activeNode, refreshPromotedRows, { immediate: true })
|
||||
useEventListener(
|
||||
() => activeNode.value?.subgraph.events,
|
||||
[
|
||||
@@ -68,34 +86,29 @@ useEventListener(
|
||||
'removing-input',
|
||||
'inputs-reordered'
|
||||
],
|
||||
refreshPromotedWidgets
|
||||
refreshPromotedRows
|
||||
)
|
||||
|
||||
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
|
||||
const node = activeNode.value
|
||||
return node ? promotedInputSource(node, row.input) : undefined
|
||||
}
|
||||
|
||||
const activeRows = computed<ActiveRow[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
|
||||
return [...promotedRows.value, ...getActivePreviewRows(node)]
|
||||
})
|
||||
|
||||
const activePromotedRows = computed<PromotedRow[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
return node ? getActivePromotedRows(node) : []
|
||||
return [...promotedRows.value]
|
||||
},
|
||||
set(value: PromotedRow[]) {
|
||||
updateActivePromotedRows(value, activePromotedRows.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, widget }]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
|
||||
const hostLocator = String(node.id)
|
||||
const rootGraphId = node.rootGraph.id
|
||||
@@ -130,7 +143,7 @@ function updateActivePromotedRows(
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map((row) => row.widget)
|
||||
value.map((row) => ({ widgetId: row.widget.widgetId }))
|
||||
)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
@@ -151,9 +164,11 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
})
|
||||
|
||||
function activeRowSourceKey(row: ActiveRow): string {
|
||||
return row.kind === 'promoted'
|
||||
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
|
||||
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
if (row.kind === 'promoted') {
|
||||
const source = promotedRowSource(row)
|
||||
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
|
||||
}
|
||||
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
}
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
@@ -228,18 +243,16 @@ function rowDisplayName(row: ActiveRow): string {
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
const source = promotedRowSource(row)
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(row.node.id),
|
||||
row.widget.sourceWidgetName
|
||||
)
|
||||
!!source &&
|
||||
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
|
||||
return `${row.node.id}: ${row.widget.name}`
|
||||
}
|
||||
|
||||
function rowKey(row: ActiveRow): string {
|
||||
@@ -256,7 +269,14 @@ function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
if (row.kind === 'promoted') {
|
||||
demoteWidget(row.node, row.widget, [subgraphNode])
|
||||
const source = promotedRowSource(row)
|
||||
if (source) {
|
||||
demotePromotedInput(subgraphNode, {
|
||||
sourceNodeId: source.nodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
return
|
||||
}
|
||||
if (row.realWidget) {
|
||||
@@ -274,7 +294,11 @@ function demoteRow(row: ActiveRow) {
|
||||
function promotePromotedRow(row: PromotedRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(row.node, row.widget, [subgraphNode])
|
||||
const source = promotedRowSource(row)
|
||||
const sourceWidget = source
|
||||
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
|
||||
: undefined
|
||||
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
|
||||
}
|
||||
|
||||
function promoteCandidate([node, widget]: WidgetItem) {
|
||||
|
||||
@@ -251,59 +251,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['model.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
|
||||
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
|
||||
// with that name so the slot-name filter matches.
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
interiorExecId,
|
||||
promotedWidget!.name
|
||||
)
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'other_model.safetensors',
|
||||
'model.safetensors',
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
|
||||
@@ -6,11 +6,8 @@
|
||||
* works in legacy canvas mode as well.
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
NodeSlotType
|
||||
@@ -45,24 +42,6 @@ import {
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolvePromotedExecId(
|
||||
rootGraph: LGraph,
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): string {
|
||||
if (!isPromotedWidgetView(widget)) return hostExecId
|
||||
const result = resolveConcretePromotedWidget(
|
||||
node,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved' && result.resolved.node) {
|
||||
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
|
||||
}
|
||||
return hostExecId
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
type OriginalCallbacks = {
|
||||
@@ -103,20 +82,10 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const execId = resolvePromotedExecId(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId
|
||||
)
|
||||
const widgetName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
execId,
|
||||
hostExecId,
|
||||
widget.name,
|
||||
widget.name,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -47,9 +45,10 @@ describe('Node Reactivity', () => {
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
||||
const id = widgetId(graph.id, node.id, 'testnum')
|
||||
expect(store.getWidget(id)?.value).toBe(2)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(id)
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
@@ -74,7 +73,7 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const widgetValue = computed(() => state.value)
|
||||
@@ -211,105 +210,32 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
|
||||
// Subgraph input named "value" promotes an interior "prompt" widget. The
|
||||
// projected widget's name is the input slot name "value"; the interior
|
||||
// source widget name "prompt" is carried separately for backend lookups.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
const interiorInput = interiorNode.addInput('value', 'STRING')
|
||||
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
interiorInput.widget = { name: 'prompt' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
'value'
|
||||
)
|
||||
|
||||
// Host the promoted view on a regular node so we can control widgets
|
||||
// directly (SubgraphNode.widgets is a synthetic getter).
|
||||
const graph = new LGraph()
|
||||
const hostNode = new LGraphNode('host')
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceNodeId = '9999'
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.sourceWidgetName).toBe('prompt')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
@@ -448,8 +374,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
expect(mappedWidget?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -484,13 +410,13 @@ describe('Nested promoted widget mapping', () => {
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets?.[0]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
|
||||
expect(widgets?.[0]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'first_seed')
|
||||
)
|
||||
expect(widgets?.[1]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
|
||||
expect(widgets?.[1]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'second_seed')
|
||||
)
|
||||
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
|
||||
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -528,10 +454,11 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_name'
|
||||
(w) => w.name === 'ckpt_input'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
|
||||
// The interior node is inside subgraphNode (id=65),
|
||||
// so its execution ID should be "65:<interiorNodeId>"
|
||||
expect(promotedWidget?.sourceExecutionId).toBe(
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetSource
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidgets
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -27,10 +26,11 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -38,7 +38,8 @@ import type {
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
LGraphTriggerParam,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -60,7 +61,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
entityId?: WidgetEntityId
|
||||
widgetId?: WidgetId
|
||||
nodeId?: NodeId
|
||||
name: string
|
||||
type: string
|
||||
@@ -81,17 +82,12 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Original LiteGraph widget name used for slot metadata matching.
|
||||
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
slotName?: string
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
@@ -99,10 +95,14 @@ export interface SafeWidgetData {
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
* widget name, which can differ from the slot name (e.g. after a rename).
|
||||
*/
|
||||
sourceWidgetName?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -143,18 +143,6 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
if (!isPromotedWidgetView(widget)) return false
|
||||
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
|
||||
if (!sourceWidget) return false
|
||||
|
||||
const innerWidget = sourceWidget.widget
|
||||
return (
|
||||
('element' in innerWidget && !!innerWidget.element) ||
|
||||
('component' in innerWidget && !!innerWidget.component)
|
||||
)
|
||||
}
|
||||
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
@@ -214,73 +202,78 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
('element' in widget && !!widget.element) ||
|
||||
('component' in widget && !!widget.component)
|
||||
)
|
||||
}
|
||||
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: string
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the interior source of a promoted subgraph input to derive the
|
||||
* metadata that backend lookups key by (execution ID, interior widget name)
|
||||
* plus the source widget's control + DOM nature. Also seeds host widget state
|
||||
* if it is somehow missing. Returns undefined when the widget is not promoted.
|
||||
*/
|
||||
function resolvePromotedMetadata(
|
||||
node: SubgraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetMetadata | undefined {
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input?.widgetId) return undefined
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
const resolved =
|
||||
resolution.status === 'resolved' ? resolution.resolved : undefined
|
||||
const sourceWidget = resolved?.widget
|
||||
const sourceNode = resolved?.node
|
||||
|
||||
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
|
||||
|
||||
return {
|
||||
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
|
||||
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
sourceWidgetName: sourceWidget?.name
|
||||
}
|
||||
}
|
||||
|
||||
function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
|
||||
displayName: string
|
||||
promotedSource: PromotedWidgetSource | null
|
||||
} {
|
||||
if (!isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
displayName: widget.name,
|
||||
promotedSource: null
|
||||
}
|
||||
}
|
||||
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource: PromotedWidgetSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
promotedSource
|
||||
}
|
||||
}
|
||||
|
||||
return function (widget) {
|
||||
try {
|
||||
const { displayName, promotedSource } =
|
||||
resolvePromotedWidgetIdentity(widget)
|
||||
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo =
|
||||
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
@@ -294,67 +287,26 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = extractWidgetDisplayOptions(widget)
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const resolvedSourceResult =
|
||||
isPromotedWidgetView(widget) && promotedSource
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
resolvedSourceResult?.status === 'resolved'
|
||||
? resolvedSourceResult.resolved
|
||||
: undefined
|
||||
const sourceWidget = resolvedSource?.widget
|
||||
const sourceNode = resolvedSource?.node
|
||||
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
const promoted = node.isSubgraphNode()
|
||||
? resolvePromotedMetadata(node, widget)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = sourceWidgetName ?? displayName
|
||||
|
||||
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
|
||||
|
||||
return {
|
||||
entityId: getWidgetEntityIdForNode(node, widget),
|
||||
nodeId,
|
||||
name,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
widgetId: getWidgetIdForNode(node, widget),
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
...getSharedWidgetEnhancements(node, widget),
|
||||
...(promoted?.controlWidget && {
|
||||
controlWidget: promoted.controlWidget
|
||||
}),
|
||||
callback,
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
|
||||
options: isPromotedPseudoWidget
|
||||
? {
|
||||
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
canvasOnly: true
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
|
||||
options: extractWidgetDisplayOptions(widget),
|
||||
slotMetadata: slotInfo,
|
||||
// For promoted widgets, name is sourceWidgetName while widget.name
|
||||
// is the subgraph input slot name — store the slot name for lookups.
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
sourceExecutionId: promoted?.sourceExecutionId,
|
||||
sourceWidgetName: promoted?.sourceWidgetName,
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -370,6 +322,24 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePromotedHostWidgetState(
|
||||
id: WidgetId,
|
||||
input: INodeInputSlot,
|
||||
sourceWidget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!sourceWidget) return
|
||||
const store = useWidgetValueStore()
|
||||
if (store.getWidget(id)) return
|
||||
store.registerWidget(id, {
|
||||
type: sourceWidget.type,
|
||||
value: sourceWidget.value,
|
||||
options: cloneDeep(sourceWidget.options ?? {}),
|
||||
label: input.label ?? input.name,
|
||||
serialize: sourceWidget.serialize,
|
||||
disabled: sourceWidget.disabled
|
||||
})
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
@@ -471,14 +441,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
slotMetadata.clear()
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
|
||||
const widgets = node.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: (node.widgets ?? [])
|
||||
return widgets.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
@@ -534,7 +506,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
widget.slotMetadata = slotMetadata.get(widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,7 +784,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so promotedLabel reflects the rename
|
||||
// Re-extract widget data so the label reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
@@ -265,16 +264,8 @@ export function useMoreOptionsMenu() {
|
||||
options.push(...getImageMenuOptions(selectedNodes.value[0]))
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
const [widgetName, nodeId] = hoveredWidget.value ?? []
|
||||
const widget =
|
||||
nodeId !== undefined
|
||||
? node?.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
)
|
||||
: node?.widgets?.find((w) => w.name === widgetName)
|
||||
const [widgetName] = hoveredWidget.value ?? []
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
const widgetOptions = convertContextMenuToOptions(
|
||||
getExtraOptionsForWidget(node, widget)
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
@@ -54,4 +64,43 @@ describe('subgraph pricing', () => {
|
||||
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'uses promoted widget override from any matching internal link',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, subgraph } = subgraphWithNode
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
const apiNode = new ApiNode('api node')
|
||||
apiNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
const apiInput = apiNode.addInput('prompt', 'STRING')
|
||||
apiInput.widget = { name: 'prompt' }
|
||||
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
|
||||
|
||||
const decoyNode = new LGraphNode('decoy node')
|
||||
const decoyInput = decoyNode.addInput('prompt', 'STRING')
|
||||
decoyInput.widget = { name: 'prompt' }
|
||||
decoyNode.addWidget(
|
||||
'string',
|
||||
'prompt',
|
||||
'decoy value',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
|
||||
subgraph.add(decoyNode)
|
||||
subgraph.add(apiNode)
|
||||
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
|
||||
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const inputWidgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
|
||||
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
|
||||
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
|
||||
expect(getBadgeText(subgraphNode)).toBe('outer value')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: { linkIds?: number[] }
|
||||
}
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
componentIconSvg.src =
|
||||
@@ -95,11 +100,20 @@ export const usePriceBadge = () => {
|
||||
): ReadonlyMap<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
if (!wrapper.isSubgraphNode()) return overrides
|
||||
const innerId = String(innerNode.id)
|
||||
for (const w of wrapper.widgets ?? []) {
|
||||
if (!isPromotedWidgetView(w)) continue
|
||||
if (w.sourceNodeId !== innerId) continue
|
||||
overrides.set(w.sourceWidgetName, w.value)
|
||||
|
||||
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
|
||||
if (!input.widgetId) continue
|
||||
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
|
||||
const link = wrapper.subgraph.getLink(linkId)
|
||||
if (link?.target_id !== innerNode.id) continue
|
||||
const targetInput = innerNode.inputs[link.target_slot]
|
||||
const widgetName = targetInput?.widget?.name
|
||||
if (!widgetName) continue
|
||||
overrides.set(
|
||||
widgetName,
|
||||
useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
)
|
||||
}
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -21,8 +20,8 @@ import {
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
@@ -55,39 +54,15 @@ function addInnerNode(
|
||||
return node
|
||||
}
|
||||
|
||||
function addPromotedHostInput(
|
||||
function getPromotedInputValue(
|
||||
host: SubgraphNode,
|
||||
args: {
|
||||
inputName: string
|
||||
promotedName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
initialValue?: TWidgetValue
|
||||
}
|
||||
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
|
||||
let widgetValue: TWidgetValue = args.initialValue ?? 0
|
||||
const slot = host.addInput(args.inputName, '*')
|
||||
slot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: args.promotedName,
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourceWidgetName,
|
||||
get value() {
|
||||
return widgetValue
|
||||
},
|
||||
set value(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
},
|
||||
hydrateHostValue(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
}
|
||||
})
|
||||
return {
|
||||
setValue: (v) => {
|
||||
widgetValue = v
|
||||
},
|
||||
getValue: () => widgetValue
|
||||
}
|
||||
name: string
|
||||
): TWidgetValue | undefined {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) return undefined
|
||||
return useWidgetValueStore().getWidget(input.widgetId)?.value as
|
||||
| TWidgetValue
|
||||
| undefined
|
||||
}
|
||||
|
||||
function addPrimitiveWithTargets(
|
||||
@@ -141,29 +116,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
})
|
||||
|
||||
describe('value-widget repair', () => {
|
||||
it('alreadyLinked: applies host value to the matching promoted widget', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 0
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(99)
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -183,23 +135,23 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(host.widgets[0].value).toBe(99)
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(99)
|
||||
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
|
||||
expect(innerWidget.value).toBe(0)
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const host = buildHost()
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 7
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
|
||||
slot.widget = { name: innerWidget.name }
|
||||
})
|
||||
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
const sparse: unknown[] = []
|
||||
@@ -208,43 +160,7 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(7)
|
||||
})
|
||||
|
||||
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const a = addPromotedHostInput(host, {
|
||||
inputName: 'first_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 1
|
||||
})
|
||||
const b = addPromotedHostInput(host, {
|
||||
inputName: 'second_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 2
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(a.getValue()).toBe(1)
|
||||
expect(b.getValue()).toBe(2)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(inner.id), 'seed'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
expect(getPromotedInputValue(host, 'seed')).toBe(7)
|
||||
})
|
||||
|
||||
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
|
||||
@@ -264,31 +180,6 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(created?._widget).toBeDefined()
|
||||
})
|
||||
|
||||
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
|
||||
const slot1 = n.addInput('text', 'STRING')
|
||||
slot1.widget = { name: 'text' }
|
||||
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
|
||||
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
|
||||
|
||||
const slot2 = n.addInput('text_1', 'STRING')
|
||||
slot2.widget = { name: 'text_1' }
|
||||
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
|
||||
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
const linkedSlot = inner.inputs.find(
|
||||
(slot) => slot.link === created?.linkIds[0]
|
||||
)
|
||||
expect(linkedSlot?.name).toBe('text_1')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
@@ -361,8 +252,7 @@ describe('flushProxyWidgetMigration', () => {
|
||||
hostWidgetValues: [123]
|
||||
})
|
||||
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(123)
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(123)
|
||||
})
|
||||
|
||||
it('seeds value from the primitive widget when no host value is supplied', () => {
|
||||
@@ -375,8 +265,7 @@ describe('flushProxyWidgetMigration', () => {
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(11)
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
@@ -474,10 +363,8 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
|
||||
const widgetA = hostA.inputs.at(-1)?._widget
|
||||
const widgetB = hostB.inputs.at(-1)?._widget
|
||||
expect(widgetA?.value).toBe(11)
|
||||
expect(widgetB?.value).toBe(22)
|
||||
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
|
||||
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
findHostInputForPromotion,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
interface LegacyProxyEntrySource extends PromotedWidgetSource {
|
||||
disambiguatingSourceNodeId?: string
|
||||
@@ -90,26 +92,20 @@ export function normalizeLegacyProxyWidgetEntry(
|
||||
|
||||
function resolveSourceWidget(
|
||||
sourceNode: LGraphNode,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
sourceWidgetName: string
|
||||
): IBaseWidget | undefined {
|
||||
const widgets = sourceNode.widgets
|
||||
if (widgets && disambiguatingSourceNodeId !== undefined) {
|
||||
const byDisambiguator = widgets.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === disambiguatingSourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
if (byDisambiguator) return byDisambiguator
|
||||
// Disambiguator missed: fall back only to non-promoted same-name widgets.
|
||||
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
|
||||
const byName = widgets.find(
|
||||
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
|
||||
)
|
||||
if (byName) return byName
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const input = sourceNode.inputs.find((input) => {
|
||||
if (input.name === sourceWidgetName) return true
|
||||
const target = resolveSubgraphInputTarget(sourceNode, input.name)
|
||||
return target?.widgetName === sourceWidgetName
|
||||
})
|
||||
// Store-backed projection for a promoted input on a nested subgraph node:
|
||||
// getSlotFromWidget locates the backing slot by widgetId.
|
||||
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
|
||||
}
|
||||
|
||||
const widgets = sourceNode.widgets
|
||||
return (
|
||||
widgets?.find((w) => w.name === sourceWidgetName) ??
|
||||
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
|
||||
@@ -300,19 +296,6 @@ function classify(
|
||||
normalized.sourceWidgetName
|
||||
)
|
||||
if (linkedInput) {
|
||||
const ambiguous =
|
||||
hostNode.inputs.filter((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
!!w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === normalized.sourceNodeId &&
|
||||
w.sourceWidgetName === normalized.sourceWidgetName
|
||||
)
|
||||
}).length > 1
|
||||
if (ambiguous) {
|
||||
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
|
||||
}
|
||||
|
||||
@@ -350,8 +333,7 @@ function classify(
|
||||
|
||||
const sourceWidget = resolveSourceWidget(
|
||||
sourceNode,
|
||||
normalized.sourceWidgetName,
|
||||
normalized.disambiguatingSourceNodeId
|
||||
normalized.sourceWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { kind: 'quarantine', reason: 'missingSourceWidget' }
|
||||
@@ -373,19 +355,12 @@ function classify(
|
||||
}
|
||||
}
|
||||
|
||||
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
|
||||
if (entry.isHole) return
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
typeof widget.hydrateHostValue === 'function'
|
||||
) {
|
||||
widget.hydrateHostValue(entry.hostValue)
|
||||
return
|
||||
}
|
||||
console.error(
|
||||
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
|
||||
{ widgetName: widget.name, type: widget.type }
|
||||
)
|
||||
function applyHostValueToInput(
|
||||
input: INodeInputSlot,
|
||||
entry: PendingEntry
|
||||
): boolean {
|
||||
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
|
||||
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
|
||||
}
|
||||
|
||||
function addUniqueSubgraphInput(
|
||||
@@ -422,10 +397,9 @@ function repairAlreadyLinked(
|
||||
return { ok: false, reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
const hostInput = matches[0]
|
||||
if (!hostInput._widget) {
|
||||
if (!applyHostValueToInput(hostInput, entry)) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: hostInput.name }
|
||||
}
|
||||
|
||||
@@ -442,11 +416,7 @@ function repairCreateSubgraphInput(
|
||||
return { ok: false, reason: 'missingSourceNode' }
|
||||
}
|
||||
|
||||
const sourceWidget = resolveSourceWidget(
|
||||
sourceNode,
|
||||
sourceWidgetName,
|
||||
entry.normalized.disambiguatingSourceNodeId
|
||||
)
|
||||
const sourceWidget = resolveSourceWidget(sourceNode, sourceWidgetName)
|
||||
if (!sourceWidget) {
|
||||
return { ok: false, reason: 'missingSourceWidget' }
|
||||
}
|
||||
@@ -480,11 +450,7 @@ function repairCreateSubgraphInput(
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (!hostInput?._widget) {
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
if (hostInput) applyHostValueToInput(hostInput, entry)
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
@@ -649,22 +615,19 @@ function repairPrimitive(
|
||||
return failPrimitive('mutation failed; rolled back', { error: e })
|
||||
}
|
||||
|
||||
// Apply through the host's input mirror (PromotedWidgetView), NOT
|
||||
// `newSubgraphInput._widget`: the interior is shared across hosts.
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
const hostInputWidget = hostInput?._widget
|
||||
if (hostInputWidget) {
|
||||
if (hostInput) {
|
||||
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
|
||||
if (valueEntry) {
|
||||
applyHostValue(hostInputWidget, valueEntry)
|
||||
applyHostValueToInput(hostInput, valueEntry)
|
||||
} else {
|
||||
const primitiveValue = primitiveNode.widgets?.find(
|
||||
(w) => w.name === validated.sourceWidgetName
|
||||
)?.value as TWidgetValue | undefined
|
||||
if (primitiveValue !== undefined) {
|
||||
applyHostValue(hostInputWidget, {
|
||||
applyHostValueToInput(hostInput, {
|
||||
...validated.uniqueEntries[0],
|
||||
hostValue: primitiveValue,
|
||||
isHole: false
|
||||
|
||||
97
src/core/graph/subgraph/promotedInputWidget.ts
Normal file
97
src/core/graph/subgraph/promotedInputWidget.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
/**
|
||||
* Where a promoted subgraph input is sourced from inside the subgraph. The
|
||||
* interior node id + widget name that the host input slot forwards to. Resolved
|
||||
* by walking the live link, so it is authoritative derived data — never stored
|
||||
* on the projected widget.
|
||||
*/
|
||||
export interface PromotedSource {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a host input slot, or undefined when the slot is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputSource(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot
|
||||
): PromotedSource | undefined {
|
||||
if (!input.widgetId) return undefined
|
||||
return resolveSubgraphInputTarget(node, input.name)
|
||||
}
|
||||
|
||||
/** The host input slot backing a projected widget, matched by widgetId. */
|
||||
export function inputForWidget(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): INodeInputSlot | undefined {
|
||||
return node.getSlotFromWidget(widget)
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a widget when it is a promoted subgraph input.
|
||||
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
|
||||
* host node is a subgraph node and its backing input slot has an interior
|
||||
* source.
|
||||
*/
|
||||
export function widgetPromotedSource(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedSource | undefined {
|
||||
if (!node.isSubgraphNode()) return undefined
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input) return undefined
|
||||
return promotedInputSource(node, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a promoted subgraph input into an ordinary widget descriptor. The
|
||||
* descriptor is store-backed: type/value/options read live from
|
||||
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
|
||||
* list does not reactively rebuild — and re-key — on every value edit.
|
||||
*
|
||||
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
|
||||
* `label` is the mutable display label. Returns null when the input is not a
|
||||
* promoted widget input.
|
||||
*/
|
||||
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
|
||||
const id = input.widgetId
|
||||
if (!id) return null
|
||||
const store = useWidgetValueStore()
|
||||
return {
|
||||
name: input.name,
|
||||
label: input.label ?? input.name,
|
||||
y: 0,
|
||||
widgetId: id,
|
||||
get type() {
|
||||
return store.getWidget(id)?.type ?? 'text'
|
||||
},
|
||||
get options() {
|
||||
return store.getWidget(id)?.options ?? {}
|
||||
},
|
||||
get value() {
|
||||
const value = store.getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
},
|
||||
set value(next) {
|
||||
store.setValue(id, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
|
||||
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
return node.inputs.flatMap((input) => {
|
||||
const widget = promotedInputWidget(input)
|
||||
return widget ? [widget] : []
|
||||
})
|
||||
}
|
||||
@@ -1,31 +1,17 @@
|
||||
import type { LGraphNode } 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 { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted promotion's source identity: the interior node + widget a host
|
||||
* subgraph input was promoted from. Used by the migration/schema layer, where
|
||||
* the source is a stored tuple rather than something link-derivable.
|
||||
*/
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly entityId: WidgetEntityId
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void
|
||||
|
||||
ensureHostWidgetState(): void
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
widget: IBaseWidget
|
||||
): widget is PromotedWidgetView {
|
||||
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function createNumericInteriorNode(initialValue: number) {
|
||||
const node = new LGraphNode('Interior')
|
||||
const input = node.addInput('value', 'number')
|
||||
node.addOutput('out', 'number')
|
||||
|
||||
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
describe('PromotedWidgetView — host-wins semantics', () => {
|
||||
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior, widget: interiorWidget } =
|
||||
createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
|
||||
const viewA = hostA.widgets.find(isPromotedWidgetView)
|
||||
const viewB = hostB.widgets.find(isPromotedWidgetView)
|
||||
if (!viewA || !viewB)
|
||||
throw new Error('Expected promoted views on both hosts')
|
||||
|
||||
viewA.value = 7
|
||||
|
||||
expect(viewA.value).toBe(7)
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
expect(viewB.value).toBe(42)
|
||||
})
|
||||
|
||||
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const { node: interior } = createNumericInteriorNode(42)
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(subgraph.rootGraph.id, {
|
||||
nodeId: String(interior.id),
|
||||
name: 'widget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 200 })
|
||||
const view = host.widgets.find(isPromotedWidgetView)
|
||||
if (!view) throw new Error('Expected promoted view on host')
|
||||
|
||||
view.value = 99
|
||||
|
||||
const interiorState = widgetStore.getWidget(
|
||||
subgraph.rootGraph.id,
|
||||
String(interior.id),
|
||||
'widget'
|
||||
)
|
||||
expect(interiorState?.value).toBe(42)
|
||||
})
|
||||
})
|
||||
@@ -1,614 +0,0 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
|
||||
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
return 'mouse' in widget && typeof widget.mouse === 'function'
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
|
||||
class PromotedWidgetView implements IPromotedWidgetView {
|
||||
[symbol: symbol]: boolean
|
||||
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
private readonly graphId: string
|
||||
private yValue = 0
|
||||
private _computedDisabled = false
|
||||
|
||||
private projectedSourceNode?: LGraphNode
|
||||
private projectedSourceWidget?: IBaseWidget
|
||||
private projectedSourceWidgetType?: IBaseWidget['type']
|
||||
private projectedWidget?: BaseWidget
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
private _lastAutoSeededValue?: IBaseWidget['value']
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
}
|
||||
|
||||
get node(): SubgraphNode {
|
||||
return this.subgraphNode
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId {
|
||||
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this.yValue
|
||||
}
|
||||
|
||||
set y(value: number) {
|
||||
this.yValue = value
|
||||
this.syncDomOverride()
|
||||
}
|
||||
|
||||
get computedDisabled(): boolean {
|
||||
return this._computedDisabled
|
||||
}
|
||||
|
||||
set computedDisabled(value: boolean | undefined) {
|
||||
this._computedDisabled = value ?? false
|
||||
}
|
||||
|
||||
get type(): IBaseWidget['type'] {
|
||||
return this.resolveDeepest()?.widget.type ?? 'button'
|
||||
}
|
||||
|
||||
get options(): IBaseWidget['options'] {
|
||||
return this.resolveDeepest()?.widget.options ?? {}
|
||||
}
|
||||
|
||||
get tooltip(): string | undefined {
|
||||
return this.resolveDeepest()?.widget.tooltip
|
||||
}
|
||||
|
||||
get linkedWidgets(): IBaseWidget[] | undefined {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const hostState = this.getHostWidgetState()
|
||||
if (hostState && isWidgetValue(hostState.value)) return hostState.value
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return getWidgetState(this.entityId)
|
||||
}
|
||||
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
if (!isWidgetValue(value)) return
|
||||
|
||||
const state = this.getHostWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
this._lastAutoSeededValue = undefined
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(value)
|
||||
this._lastAutoSeededValue = undefined
|
||||
}
|
||||
|
||||
ensureHostWidgetState(): void {
|
||||
const fallback = this.fallbackEffectiveValue()
|
||||
const existing = this.getHostWidgetState()
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
this._lastAutoSeededValue !== undefined &&
|
||||
existing.value === this._lastAutoSeededValue &&
|
||||
isWidgetValue(fallback) &&
|
||||
fallback !== existing.value
|
||||
) {
|
||||
existing.value = fallback
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(fallback)
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
|
||||
private fallbackEffectiveValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private registerHostWidgetState(value: IBaseWidget['value']): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
ensureWidgetState(this.entityId, {
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
label: this.displayName,
|
||||
serialize: this.serialize,
|
||||
disabled: this.computedDisabled
|
||||
})
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName
|
||||
}
|
||||
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
const version = this.subgraphNode.inputs?.length ?? 0
|
||||
if (this._boundSlotVersion === version) return this._boundSlot
|
||||
|
||||
this._boundSlot = this.findBoundSubgraphSlot()
|
||||
this._boundSlotVersion = version
|
||||
return this._boundSlot
|
||||
}
|
||||
|
||||
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
for (const input of this.subgraphNode.inputs ?? []) {
|
||||
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
|
||||
if (!slot) continue
|
||||
|
||||
const w = input._widget
|
||||
if (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === this.sourceNodeId &&
|
||||
w.sourceWidgetName === this.sourceWidgetName
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolveDeepest()?.widget.hidden ?? false
|
||||
}
|
||||
|
||||
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeLayoutSize = resolved?.widget.computeLayoutSize
|
||||
if (!computeLayoutSize) return undefined
|
||||
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
|
||||
}
|
||||
|
||||
get computeSize(): IBaseWidget['computeSize'] {
|
||||
const resolved = this.resolveDeepest()
|
||||
const computeSize = resolved?.widget.computeSize
|
||||
if (!computeSize) return undefined
|
||||
return (width?: number) => computeSize.call(resolved.widget, width)
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
H: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) {
|
||||
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
|
||||
return
|
||||
}
|
||||
|
||||
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
|
||||
|
||||
const projected = this.getProjectedWidget(resolved)
|
||||
if (!projected || typeof projected.drawWidget !== 'function') return
|
||||
|
||||
const originalY = projected.y
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
const originalComputedDisabled = projected.computedDisabled
|
||||
|
||||
const originalLabel = projected.label
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.computedDisabled = this.computedDisabled
|
||||
projected.value = this.value
|
||||
projected.label = this.label
|
||||
|
||||
try {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
} finally {
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
projected.label = originalLabel
|
||||
}
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
pointer: CanvasPointer,
|
||||
_node: LGraphNode,
|
||||
canvas: LGraphCanvas
|
||||
): boolean {
|
||||
const resolved = this.resolveAtHost()
|
||||
if (!resolved) return false
|
||||
|
||||
const interior = resolved.widget
|
||||
if (typeof interior.onPointerDown === 'function') {
|
||||
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
|
||||
if (handled) return true
|
||||
}
|
||||
|
||||
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
|
||||
if (concrete)
|
||||
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
|
||||
|
||||
if (hasLegacyMouse(interior))
|
||||
return this.handleLegacyMouse(pointer, interior)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
callback(
|
||||
value: unknown,
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
e?: CanvasPointerEvent
|
||||
) {
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
afterQueued({
|
||||
isPartialExecution
|
||||
}: { isPartialExecution?: boolean } = {}): void {
|
||||
this.applyValueControlToHost(isPartialExecution)
|
||||
}
|
||||
|
||||
private applyValueControlToHost(isPartialExecution?: boolean): void {
|
||||
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
const next = nextValueForLinkedTarget({
|
||||
target: this,
|
||||
linkedWidgets: resolved?.widget.linkedWidgets,
|
||||
nodeId: this.subgraphNode.id,
|
||||
isPartialExecution
|
||||
})
|
||||
if (next === undefined) return
|
||||
|
||||
this.hydrateHostValue(next)
|
||||
}
|
||||
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
private resolveDeepest():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
|
||||
if (frame !== undefined && this.cachedDeepestFrame === frame)
|
||||
return this.cachedDeepestByFrame
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
if (frame !== undefined) {
|
||||
this.cachedDeepestFrame = frame
|
||||
this.cachedDeepestByFrame = resolved
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}): BaseWidget | undefined {
|
||||
const shouldRebuild =
|
||||
!this.projectedWidget ||
|
||||
this.projectedSourceNode !== resolved.node ||
|
||||
this.projectedSourceWidget !== resolved.widget ||
|
||||
this.projectedSourceWidgetType !== resolved.widget.type
|
||||
|
||||
if (!shouldRebuild) return this.projectedWidget
|
||||
|
||||
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
|
||||
if (!concrete) {
|
||||
this.projectedWidget = undefined
|
||||
this.projectedSourceNode = undefined
|
||||
this.projectedSourceWidget = undefined
|
||||
this.projectedSourceWidgetType = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
|
||||
this.projectedSourceNode = resolved.node
|
||||
this.projectedSourceWidget = resolved.widget
|
||||
this.projectedSourceWidgetType = resolved.widget.type
|
||||
return this.projectedWidget
|
||||
}
|
||||
|
||||
private bindConcretePointerHandlers(
|
||||
pointer: CanvasPointer,
|
||||
canvas: LGraphCanvas,
|
||||
concrete: BaseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
pointer.onClick = () =>
|
||||
concrete.onClick({
|
||||
e: downEvent,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
pointer.onDrag = (eMove) =>
|
||||
concrete.onDrag?.({
|
||||
e: eMove,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private handleLegacyMouse(
|
||||
pointer: CanvasPointer,
|
||||
interior: LegacyMouseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
const downPosition: Point = [
|
||||
downEvent.canvasX - this.subgraphNode.pos[0],
|
||||
downEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(downEvent, downPosition, this.subgraphNode)
|
||||
|
||||
pointer.finally = () => {
|
||||
const upEvent = pointer.eUp
|
||||
if (!upEvent) return
|
||||
|
||||
const upPosition: Point = [
|
||||
upEvent.canvasX - this.subgraphNode.pos[0],
|
||||
upEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(upEvent, upPosition, this.subgraphNode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private syncDomOverride(
|
||||
resolved:
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined = this.resolveAtHost()
|
||||
) {
|
||||
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
|
||||
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
|
||||
node: this.subgraphNode,
|
||||
widget: this
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isBaseDOMWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { id: string } {
|
||||
return 'id' in widget && ('element' in widget || 'component' in widget)
|
||||
}
|
||||
|
||||
function drawDisconnectedPlaceholder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
H: number
|
||||
) {
|
||||
const backgroundColor = readDesignToken(
|
||||
'--color-secondary-background',
|
||||
'#333'
|
||||
)
|
||||
const textColor = readDesignToken('--color-text-secondary', '#999')
|
||||
const fontSize = readDesignToken('--text-2xs', '11px')
|
||||
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
|
||||
|
||||
ctx.save()
|
||||
ctx.fillStyle = backgroundColor
|
||||
ctx.fillRect(15, y, width - 30, H)
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = `${fontSize} ${fontFamily}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function readDesignToken(token: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
|
||||
const cachedValue = designTokenCache.get(token)
|
||||
if (cachedValue) return cachedValue
|
||||
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
const resolvedValue = value || fallback
|
||||
designTokenCache.set(token, resolvedValue)
|
||||
return resolvedValue
|
||||
}
|
||||
@@ -3,22 +3,42 @@ import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
|
||||
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
}) {
|
||||
return host.inputs
|
||||
.filter((input) => input.widgetId)
|
||||
.map((input) => input.name)
|
||||
}
|
||||
|
||||
type TestPromotedWidget = IBaseWidget & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
function writePromotedInputValue(
|
||||
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
name: string,
|
||||
value: IBaseWidget['value']
|
||||
) {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
|
||||
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
|
||||
const input = host.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) throw new Error(`Missing promoted input ${name}`)
|
||||
return widget
|
||||
}
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
@@ -31,11 +51,9 @@ import {
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getWidgetName,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
@@ -168,15 +186,18 @@ describe('pruneDisconnected', () => {
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
|
||||
|
||||
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
|
||||
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
})
|
||||
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
|
||||
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: '9999',
|
||||
sourceWidgetName: 'missing-node'
|
||||
})
|
||||
const keptWidgetId = subgraphNode.inputs.find(
|
||||
(input) => input.name === 'kept'
|
||||
)?.widgetId
|
||||
if (!keptWidgetId) throw new Error('Missing kept widgetId')
|
||||
for (const input of [missingWidgetInput, missingNodeInput]) {
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
)
|
||||
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
|
||||
hostInput.widgetId = keptWidgetId
|
||||
}
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -485,79 +506,45 @@ describe('isLinkedPromotion', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function linkedWidget(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...extra
|
||||
} as unknown as IBaseWidget
|
||||
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
|
||||
const node = new LGraphNode('Source')
|
||||
const input = node.addInput(widgetName, 'STRING')
|
||||
const widget = node.addWidget('text', widgetName, '', () => {})
|
||||
input.widget = { name: widget.name }
|
||||
host.subgraph.add(node)
|
||||
promoteValueWidgetViaSubgraphInput(host, node, widget)
|
||||
return node
|
||||
}
|
||||
|
||||
function createSubgraphWithInputs(count = 1) {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: Array.from({ length: count }, (_, i) => ({
|
||||
name: `input_${i}`,
|
||||
type: 'STRING' as const
|
||||
}))
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
it('returns true for a linked promotion', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
|
||||
it('returns true when an input has a matching _widget', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no inputs exist or none match', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
it('returns false when no promotion exists', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
|
||||
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
it('returns false when sourceWidgetName does not match', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
|
||||
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when _widget is undefined on input', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
it('identifies linked widgets across different inputs', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const nodeA = promoteSource(host, 'string_a')
|
||||
const nodeB = promoteSource(host, 'value')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies multiple linked widgets across different inputs', () => {
|
||||
const subgraphNode = createSubgraphWithInputs(2)
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
|
||||
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -607,17 +594,13 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
expect(promotedInputNames(host)).toEqual(['first', 'second'])
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
|
||||
@@ -637,15 +620,13 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
subgraph.addInput('plain', 'STRING')
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
writePromotedInputValue(host, 'first', 'first value')
|
||||
writePromotedInputValue(host, 'second', 'second value')
|
||||
|
||||
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
expect(promotedInputNames(host)).toEqual(['second', 'first'])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
@@ -727,14 +708,20 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
writePromotedInputValue(host, 'text', 'first value')
|
||||
writePromotedInputValue(host, 'text_1', 'second value')
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
|
||||
const firstPromotedWidget = promotedWidgetRef(host, 'text')
|
||||
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
secondPromotedWidget,
|
||||
firstPromotedWidget
|
||||
])
|
||||
|
||||
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'text_1',
|
||||
'text'
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
@@ -775,10 +762,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
hostInput.link = 9999
|
||||
const promotedViewsBefore = host.widgets.length
|
||||
const promotedInputId = hostInput.widgetId
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(promotedViewsBefore).toBeGreaterThan(0)
|
||||
expect(promotedInputId).toBeDefined()
|
||||
|
||||
demoteWidget(interiorNode, interiorWidget, [host])
|
||||
|
||||
@@ -788,13 +775,9 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
expect(
|
||||
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
|
||||
).toBe(false)
|
||||
expect(
|
||||
host.widgets.some(
|
||||
(widget) =>
|
||||
widgetSourceNodeId(widget) === String(interiorNode.id) &&
|
||||
widget.name === interiorWidget.name
|
||||
)
|
||||
).toBe(false)
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
|
||||
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes the slot entirely when host slot has no external link', () => {
|
||||
@@ -812,12 +795,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
const { host, nodeA, widgetA, nodeB, widgetB } =
|
||||
buildDuplicateNamePromotion()
|
||||
|
||||
const promotedViewForB = host.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(promotedViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(nodeB, promotedViewForB!, [host])
|
||||
demoteWidget(nodeB, widgetB, [host])
|
||||
|
||||
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
|
||||
@@ -825,15 +803,19 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
})
|
||||
|
||||
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
|
||||
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
for (const input of innerHost.inputs) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
).toBe(true)
|
||||
}
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
|
||||
@@ -841,12 +823,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
'text_1'
|
||||
])
|
||||
|
||||
const innerViewForB = innerHost.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(innerViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(innerHost, innerViewForB!, [outerHost])
|
||||
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
|
||||
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
|
||||
@@ -863,66 +840,19 @@ describe('disambiguated nested promotion identity', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function linkedView(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: sourceWidgetName,
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function createSubgraphHost() {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
|
||||
const host = createSubgraphHost()
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
|
||||
const interiorNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner'
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const source = {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(interiorWidget)
|
||||
}
|
||||
|
||||
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
subgraph.add(innerHost)
|
||||
|
||||
const nestedSubgraphNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner',
|
||||
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
|
||||
} as unknown as LGraphNode
|
||||
subgraph.add(nestedSubgraphNode)
|
||||
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
host,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text_1')
|
||||
).ok
|
||||
).toBe(true)
|
||||
|
||||
pruneDisconnected(host)
|
||||
|
||||
@@ -956,9 +886,13 @@ describe('disambiguated nested promotion identity', () => {
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
for (const input of innerHost.inputs) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, input.name)
|
||||
).ok
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -18,7 +18,9 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
@@ -46,16 +48,47 @@ export function findHostInputForPromotion(
|
||||
sourceWidgetName: string
|
||||
) {
|
||||
return subgraphNode.inputs.find((input) => {
|
||||
const w = input._widget
|
||||
const source = input._subgraphSlot
|
||||
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
|
||||
: undefined
|
||||
return (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === sourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
source?.sourceNodeId === sourceNodeId &&
|
||||
source.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePromotionSource(
|
||||
subgraphNode: SubgraphNode,
|
||||
subgraphInput: { linkIds: readonly number[] }
|
||||
): PromotedWidgetSource | undefined {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(subgraphNode.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
|
||||
if (!targetInput) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByName(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedInputNames: readonly string[]
|
||||
@@ -78,13 +111,12 @@ export function reorderSubgraphInputsByName(
|
||||
|
||||
export function reorderSubgraphInputsByWidgetOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedWidgets: readonly IBaseWidget[]
|
||||
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
|
||||
): void {
|
||||
const remainingIndices = new Set(subgraphNode.inputs.keys())
|
||||
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
|
||||
for (const index of remainingIndices) {
|
||||
const widget = subgraphNode.inputs[index]?._widget
|
||||
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
|
||||
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
|
||||
remainingIndices.delete(index)
|
||||
return [index]
|
||||
}
|
||||
@@ -101,37 +133,48 @@ function applySubgraphInputOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const widgetValues = subgraphNode.inputs.map((input) =>
|
||||
getExplicitHostWidgetValue(input?._widget)
|
||||
)
|
||||
const widgetValues = subgraphNode.inputs.map((input) => {
|
||||
const id = input?.widgetId
|
||||
if (!id) return undefined
|
||||
const value = useWidgetValueStore().getWidget(id)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
})
|
||||
|
||||
reorderSubgraphInputs(subgraphNode, orderedIndices)
|
||||
|
||||
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
|
||||
const value = widgetValues[oldIndex]
|
||||
if (value === undefined) continue
|
||||
const widget = subgraphNode.inputs[newIndex]?._widget
|
||||
if (widget) widget.value = value
|
||||
const id = subgraphNode.inputs[newIndex]?.widgetId
|
||||
if (value === undefined || !id) continue
|
||||
useWidgetValueStore().setValue(id, value)
|
||||
}
|
||||
}
|
||||
|
||||
function getExplicitHostWidgetValue(
|
||||
widget: IBaseWidget | undefined
|
||||
): IBaseWidget['value'] | undefined {
|
||||
if (!widget) return undefined
|
||||
if (!isPromotedWidgetView(widget)) return widget.value
|
||||
function isSamePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputIndex: number,
|
||||
orderedWidget: Pick<IBaseWidget, 'widgetId'>
|
||||
): boolean {
|
||||
const input = subgraphNode.inputs[inputIndex]
|
||||
const linkedInput = input?._subgraphSlot
|
||||
if (!input || !linkedInput) return false
|
||||
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
}
|
||||
for (const linkId of linkedInput.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(left) &&
|
||||
isPromotedWidgetView(right) &&
|
||||
left.sourceNodeId === right.sourceNodeId &&
|
||||
left.sourceWidgetName === right.sourceWidgetName
|
||||
)
|
||||
const { inputNode, input: targetInput } = link.resolve(
|
||||
subgraphNode.subgraph
|
||||
)
|
||||
if (!inputNode || !targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (targetWidget === orderedWidget) return true
|
||||
|
||||
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isPreviewExposed(
|
||||
@@ -168,13 +211,9 @@ function toPromotionSource(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetSource {
|
||||
const widgetIsParentLevelView =
|
||||
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: widgetIsParentLevelView
|
||||
? widget.sourceWidgetName
|
||||
: getWidgetName(widget)
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,9 +256,41 @@ export function promoteValueWidgetViaSubgraphInput(
|
||||
return { ok: false, reason: 'connectFailed' }
|
||||
}
|
||||
|
||||
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function seedNestedPromotedInputState(
|
||||
subgraphNode: SubgraphNode,
|
||||
inputName: string,
|
||||
sourceSlot: { widgetId?: WidgetId }
|
||||
): void {
|
||||
if (!sourceSlot.widgetId) return
|
||||
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot?.name === inputName
|
||||
)
|
||||
if (!hostInput || hostInput.widgetId) return
|
||||
|
||||
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
|
||||
if (!sourceState) return
|
||||
|
||||
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
|
||||
hostInput.widget ??= { name: inputName }
|
||||
hostInput.widget.name = inputName
|
||||
hostInput.widgetId = id
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: sourceState.type,
|
||||
value: sourceState.value,
|
||||
options: cloneDeep(sourceState.options ?? {}),
|
||||
label: hostInput.label ?? inputName,
|
||||
serialize: sourceState.serialize,
|
||||
disabled: sourceState.disabled,
|
||||
isDOMWidget: sourceState.isDOMWidget
|
||||
})
|
||||
}
|
||||
|
||||
function promotePreviewViaExposure(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNode: LGraphNode,
|
||||
@@ -283,6 +354,32 @@ export function promoteWidget(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the host input projecting a linked promotion identified by source.
|
||||
* Returns true when an input was found and demoted.
|
||||
*/
|
||||
export function demotePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
if (!subgraphNode.subgraph) return false
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
subgraphNode,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (!linkedInput) return false
|
||||
|
||||
if (hostInput.link != null) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
subgraphNode.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
@@ -292,21 +389,7 @@ export function demoteWidget(
|
||||
for (const parent of parents) {
|
||||
if (!parent.subgraph) continue
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
parent,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (linkedInput) {
|
||||
const hasExternalLink = hostInput.link != null
|
||||
if (hasExternalLink) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
parent.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (demotePromotedInput(parent, source)) continue
|
||||
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
const previewStore = usePreviewExposureStore()
|
||||
@@ -505,37 +588,19 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
const removedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
const staleInputs = subgraph.inputs.filter((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return false
|
||||
const source = resolvePromotionSource(subgraphNode, input)
|
||||
if (source) return false
|
||||
|
||||
// If the SubgraphInput has any live link to an interior target slot that
|
||||
// still has a widget, the promotion is alive — even when the widget's
|
||||
// sourceNodeId points at a deeply-nested interior node that does not exist
|
||||
// directly in `subgraph` (nested SubgraphNode promotions).
|
||||
for (const linkId of input.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode) continue
|
||||
const targetInputSlot = inputNode.inputs?.find(
|
||||
(slot) => slot.link === linkId
|
||||
)
|
||||
if (!targetInputSlot) continue
|
||||
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
|
||||
}
|
||||
|
||||
const node = subgraph.getNodeById(widget.sourceNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(widget)
|
||||
return true
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === widget.sourceWidgetName
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(entry) => entry._subgraphSlot === input
|
||||
)
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(widget)
|
||||
}
|
||||
return !hasWidget
|
||||
if (!hostInput?.widgetId && !hostInput?._widget) return false
|
||||
|
||||
removedEntries.push({
|
||||
sourceNodeId: String(subgraphNode.id),
|
||||
sourceWidgetName: input.name
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
for (const input of staleInputs) {
|
||||
|
||||
@@ -2,10 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
@@ -24,15 +21,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
type PromotedWidgetStub = Pick<
|
||||
IBaseWidget,
|
||||
'name' | 'type' | 'options' | 'value' | 'y'
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
function createHostNode(id: number): SubgraphNode {
|
||||
return createTestSubgraphNode(createTestSubgraph(), { id })
|
||||
}
|
||||
@@ -47,55 +35,10 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
|
||||
return node.addWidget('text', name, `${name}-value`, () => undefined)
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
type: 'button',
|
||||
options: {},
|
||||
y: 0,
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('resolvePromotedWidgetAtHost', () => {
|
||||
test('resolves a direct concrete widget on the host subgraph node', () => {
|
||||
const host = createHostNode(100)
|
||||
const concreteNode = addNodeToHost(host, 'leaf')
|
||||
addConcreteWidget(concreteNode, 'seed')
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(concreteNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node.id).toBe(concreteNode.id)
|
||||
expect(resolved?.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns undefined when host does not contain the target node', () => {
|
||||
const host = createHostNode(100)
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
test('resolves a direct concrete source widget', () => {
|
||||
const host = createHostNode(100)
|
||||
@@ -114,106 +57,41 @@ describe('resolveConcretePromotedWidget', () => {
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('descends through nested promoted widgets to resolve concrete source', () => {
|
||||
const rootHost = createHostNode(100)
|
||||
const nestedHost = createHostNode(101)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'seed')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
|
||||
]
|
||||
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'x', type: '*' }]
|
||||
})
|
||||
const leaf = new LGraphNode('Leaf')
|
||||
const leafInput = leaf.addInput('x', '*')
|
||||
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
leafInput.widget = { name: 'seed' }
|
||||
innerSubgraph.add(leaf)
|
||||
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'y', type: '*' }]
|
||||
})
|
||||
outerSubgraph.add(innerNode)
|
||||
innerNode._internalConfigureAfterSlots()
|
||||
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
|
||||
|
||||
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'outer'
|
||||
outerNode,
|
||||
String(innerNode.id),
|
||||
'x'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.node.id).toBe(leaf.id)
|
||||
expect(result.resolved.widget.name).toBe('seed')
|
||||
})
|
||||
|
||||
test('returns cycle failure when promoted widgets form a loop', () => {
|
||||
const hostA = createHostNode(200)
|
||||
const hostB = createHostNode(201)
|
||||
const relayA = addNodeToHost(hostA, 'relayA')
|
||||
const relayB = addNodeToHost(hostB, 'relayB')
|
||||
|
||||
relayA.widgets = [
|
||||
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
|
||||
]
|
||||
relayB.widgets = [
|
||||
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'cycle'
|
||||
})
|
||||
})
|
||||
|
||||
test('does not report a cycle when different host objects share an id', () => {
|
||||
const rootHost = createHostNode(41)
|
||||
const nestedHost = createHostNode(41)
|
||||
const leafNode = addNodeToHost(nestedHost, 'leaf')
|
||||
addConcreteWidget(leafNode, 'w')
|
||||
const sourceNode = addNodeToHost(rootHost, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
|
||||
]
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
rootHost,
|
||||
String(sourceNode.id),
|
||||
'w'
|
||||
)
|
||||
|
||||
expect(result.status).toBe('resolved')
|
||||
if (result.status !== 'resolved') return
|
||||
|
||||
expect(result.resolved.node.id).toBe(leafNode.id)
|
||||
expect(result.resolved.widget.name).toBe('w')
|
||||
})
|
||||
|
||||
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
|
||||
const hosts = Array.from({ length: 102 }, (_, index) =>
|
||||
createHostNode(index + 1)
|
||||
)
|
||||
const relayNodes = hosts.map((host, index) =>
|
||||
addNodeToHost(host, `relay-${index}`)
|
||||
)
|
||||
|
||||
for (let index = 0; index < relayNodes.length - 1; index += 1) {
|
||||
relayNodes[index].widgets = [
|
||||
createPromotedWidget(
|
||||
`w-${index}`,
|
||||
String(relayNodes[index + 1].id),
|
||||
`w-${index + 1}`,
|
||||
hosts[index + 1]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
addConcreteWidget(
|
||||
relayNodes[relayNodes.length - 1],
|
||||
`w-${relayNodes.length - 1}`
|
||||
)
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hosts[0],
|
||||
String(relayNodes[0].id),
|
||||
'w-0'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failure',
|
||||
failure: 'max-depth-exceeded'
|
||||
})
|
||||
expect(result.resolved.widget.type).toBe('combo')
|
||||
})
|
||||
|
||||
test('returns invalid-host for non-subgraph host node', () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedWidgetResolutionFailure =
|
||||
| 'invalid-host'
|
||||
@@ -41,6 +40,17 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
|
||||
if (!target) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
currentHost = sourceNode
|
||||
currentNodeId = target.nodeId
|
||||
currentWidgetName = target.widgetName
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
)
|
||||
@@ -48,39 +58,15 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
}
|
||||
|
||||
if (!isPromotedWidgetView(sourceWidget)) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
return {
|
||||
status: 'resolved',
|
||||
resolved: { node: sourceNode, widget: sourceWidget }
|
||||
}
|
||||
|
||||
if (!sourceWidget.node?.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = node.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
@@ -91,20 +77,3 @@ export function resolveConcretePromotedWidget(
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
@@ -9,9 +7,7 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
const key = `widget:${nextWidgetRenderKeyId++}`
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
@@ -190,7 +191,9 @@ function dynamicComboWidget(
|
||||
const getState = () => {
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
if (!graphId) return undefined
|
||||
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
|
||||
return useWidgetValueStore().getWidget(
|
||||
widgetId(graphId, node.id, widget.name)
|
||||
)
|
||||
}
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
|
||||
applyFirstWidgetValueToGraph(this, extraLinks)
|
||||
@@ -51,16 +52,15 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return (
|
||||
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
|
||||
?.value ?? localValue
|
||||
useWidgetValueStore().getWidget(
|
||||
widgetId(app.rootGraph.id, node.id, widgetName)
|
||||
)?.value ?? localValue
|
||||
)
|
||||
},
|
||||
set(v: string) {
|
||||
localValue = v
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
widgetId(app.rootGraph.id, node.id, widgetName)
|
||||
)
|
||||
if (state) state.value = v
|
||||
updateCombo()
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { type NodeLocatorId } from '@/types'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
@@ -152,16 +153,16 @@ app.registerExtension({
|
||||
|
||||
audioUIWidget.options.getValue = () =>
|
||||
(useWidgetValueStore().getWidget(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
widgetId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
)
|
||||
)?.value as string) ?? ''
|
||||
audioUIWidget.options.setValue = (v) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
node.id,
|
||||
inputName
|
||||
widgetId(graphId, node.id, inputName)
|
||||
)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { UUID } from '@/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphData,
|
||||
@@ -296,9 +297,8 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(graphId, {
|
||||
nodeId: '10' as NodeId,
|
||||
name: 'seed',
|
||||
const seedWidgetId = widgetId(graphId, '10' as NodeId, 'seed')
|
||||
widgetValueStore.registerWidget(seedWidgetId, {
|
||||
type: 'number',
|
||||
value: 1,
|
||||
options: {},
|
||||
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
|
||||
expect(widgetValueStore.getWidget(seedWidgetId)).toEqual(
|
||||
expect.objectContaining({ value: 1 })
|
||||
)
|
||||
expect(
|
||||
@@ -316,9 +316,7 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
|
||||
).toBeUndefined()
|
||||
expect(widgetValueStore.getWidget(seedWidgetId)).toBeUndefined()
|
||||
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Rectangle } from './infrastructure/Rectangle'
|
||||
import type { CanvasColour } from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import { litegraph } from './litegraphInstance'
|
||||
import { RenderShape, TitleMode } from './types/globalEnums'
|
||||
import { cachedMeasureText } from './utils/textMeasureCache'
|
||||
|
||||
@@ -81,12 +81,12 @@ export function strokeShape(
|
||||
}: IDrawBoundingOptions = {}
|
||||
): void {
|
||||
// These param defaults are not compile-time static, and must be re-evaluated at runtime
|
||||
round_radius ??= LiteGraph.ROUND_RADIUS
|
||||
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
|
||||
round_radius ??= litegraph().ROUND_RADIUS
|
||||
color ??= litegraph().NODE_BOX_OUTLINE_COLOR
|
||||
|
||||
// Adjust area if title is transparent
|
||||
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
|
||||
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
|
||||
const height = title_height ?? litegraph().NODE_TITLE_HEIGHT
|
||||
area[1] -= height
|
||||
area[3] += height
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -362,6 +363,7 @@ export interface IWidgetLocator {
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
link: LinkId | null
|
||||
widget?: IWidgetLocator
|
||||
widgetId?: WidgetId
|
||||
alwaysVisible?: boolean
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Point,
|
||||
Size
|
||||
} from './interfaces'
|
||||
import { registerLiteGraphInstance } from './litegraphInstance'
|
||||
import { loadPolyfills } from './polyfills'
|
||||
import type { CanvasEventDetail } from './types/events'
|
||||
import type { RenderShape, TitleMode } from './types/globalEnums'
|
||||
@@ -15,6 +16,7 @@ import type { RenderShape, TitleMode } from './types/globalEnums'
|
||||
export { Subgraph } from './subgraph/Subgraph'
|
||||
|
||||
export const LiteGraph = new LiteGraphGlobal()
|
||||
registerLiteGraphInstance(LiteGraph)
|
||||
|
||||
// Load legacy polyfills
|
||||
loadPolyfills()
|
||||
|
||||
26
src/lib/litegraph/src/litegraphInstance.ts
Normal file
26
src/lib/litegraph/src/litegraphInstance.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { LiteGraphGlobal } from './LiteGraphGlobal'
|
||||
|
||||
/**
|
||||
* Late-bound holder for the {@link LiteGraphGlobal} singleton.
|
||||
*
|
||||
* This module imports `LiteGraphGlobal` as a type only, so it has no runtime
|
||||
* dependencies. Modules in the widget initialisation chain (e.g. `draw.ts`,
|
||||
* imported transitively by `BaseWidget`) can read singleton constants through
|
||||
* {@link litegraph} without importing the `litegraph` barrel — which would
|
||||
* re-enter the barrel mid-initialisation and evaluate
|
||||
* `LegacyWidget extends BaseWidget` before `BaseWidget` is defined.
|
||||
*
|
||||
* The barrel constructs the singleton and calls {@link registerLiteGraphInstance}.
|
||||
*/
|
||||
let instance: LiteGraphGlobal | null = null
|
||||
|
||||
export function registerLiteGraphInstance(value: LiteGraphGlobal): void {
|
||||
instance = value
|
||||
}
|
||||
|
||||
export function litegraph(): LiteGraphGlobal {
|
||||
if (!instance) {
|
||||
throw new Error('LiteGraph singleton accessed before initialisation')
|
||||
}
|
||||
return instance
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkId } from '@/lib/litegraph/src/LLink'
|
||||
import { InvalidLinkError } from '@/lib/litegraph/src/infrastructure/InvalidLinkError'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
|
||||
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
|
||||
import { SlotIndexError } from '@/lib/litegraph/src/infrastructure/SlotIndexError'
|
||||
@@ -183,15 +184,14 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
// Nothing connected
|
||||
const linkId = subgraphNodeInput.link
|
||||
if (linkId == null) {
|
||||
const widget = subgraphNode.getWidgetFromSlot(subgraphNodeInput)
|
||||
if (!widget) return
|
||||
const id = subgraphNodeInput.widgetId
|
||||
if (!id) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widget.value }
|
||||
widgetInfo: { value: useWidgetValueStore().getWidget(id)?.value }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
|
||||
|
||||
type TestPromotionEntry = {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
function makeView(entry: TestPromotionEntry) {
|
||||
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
|
||||
|
||||
return {
|
||||
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
|
||||
}
|
||||
}
|
||||
|
||||
describe('PromotedWidgetViewManager', () => {
|
||||
test('returns memoized array when entries reference is unchanged', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
|
||||
|
||||
const first = manager.reconcile(entries, makeView)
|
||||
const second = manager.reconcile(entries, makeView)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second[0]).toBe(first[0])
|
||||
})
|
||||
|
||||
test('preserves view identity while reflecting order changes', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const firstPass = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
const reordered = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(reordered[0]).toBe(firstPass[1])
|
||||
expect(reordered[1]).toBe(firstPass[0])
|
||||
})
|
||||
|
||||
test('deduplicates by first occurrence and clears stale cache entries', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const first = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
expect(first.map((view) => view.key)).toStrictEqual([
|
||||
'1:widgetA',
|
||||
'1:widgetB'
|
||||
])
|
||||
|
||||
manager.reconcile(
|
||||
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
|
||||
makeView
|
||||
)
|
||||
|
||||
const restored = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(restored[0]).toBe(first[1])
|
||||
expect(restored[1]).not.toBe(first[0])
|
||||
})
|
||||
|
||||
test('keeps distinct views for same source widget when viewKeys differ', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const views = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(views).toHaveLength(2)
|
||||
expect(views[0]).not.toBe(views[1])
|
||||
expect(views[0].key).toBe('1:widgetA:slotA')
|
||||
expect(views[1].key).toBe('1:widgetA:slotB')
|
||||
})
|
||||
|
||||
test('removeByViewKey removes only the targeted keyed view', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const firstPass = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
manager.removeByViewKey('1', 'widgetA', 'slotA')
|
||||
|
||||
const secondPass = manager.reconcile(
|
||||
[
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(secondPass[0]).not.toBe(firstPass[0])
|
||||
expect(secondPass[1]).toBe(firstPass[1])
|
||||
})
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
type ViewManagerEntry = PromotedWidgetSource & {
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles promoted widget entries to stable view instances.
|
||||
*
|
||||
* Keeps object identity stable by key while preserving the current
|
||||
* promotion order and deduplicating duplicate entries by first occurrence.
|
||||
*/
|
||||
export class PromotedWidgetViewManager<TView> {
|
||||
private viewCache = new Map<string, TView>()
|
||||
private cachedViews: TView[] | null = null
|
||||
private cachedEntryKeys: string[] | null = null
|
||||
|
||||
reconcile<TEntry extends ViewManagerEntry>(
|
||||
entries: readonly TEntry[],
|
||||
createView: (entry: TEntry) => TView
|
||||
): TView[] {
|
||||
const entryKeys = entries.map((entry) =>
|
||||
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
|
||||
)
|
||||
|
||||
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
|
||||
return this.cachedViews
|
||||
|
||||
const views: TView[] = []
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = this.makeKey(
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey
|
||||
)
|
||||
if (seenKeys.has(key)) continue
|
||||
seenKeys.add(key)
|
||||
|
||||
const existing = this.viewCache.get(key)
|
||||
if (existing) {
|
||||
views.push(existing)
|
||||
continue
|
||||
}
|
||||
|
||||
const nextView = createView(entry)
|
||||
this.viewCache.set(key, nextView)
|
||||
views.push(nextView)
|
||||
}
|
||||
|
||||
for (const key of this.viewCache.keys()) {
|
||||
if (!seenKeys.has(key)) this.viewCache.delete(key)
|
||||
}
|
||||
|
||||
this.cachedViews = views
|
||||
this.cachedEntryKeys = entryKeys
|
||||
return views
|
||||
}
|
||||
|
||||
getOrCreate(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
createView: () => TView,
|
||||
viewKey?: string
|
||||
): TView {
|
||||
const key = this.makeKey(sourceNodeId, sourceWidgetName, viewKey)
|
||||
const cached = this.viewCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const view = createView()
|
||||
this.viewCache.set(key, view)
|
||||
return view
|
||||
}
|
||||
|
||||
remove(sourceNodeId: string, sourceWidgetName: string): void {
|
||||
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName))
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
removeByViewKey(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
viewKey: string
|
||||
): void {
|
||||
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName, viewKey))
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.viewCache.clear()
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
invalidateMemoizedList(): void {
|
||||
this.cachedViews = null
|
||||
this.cachedEntryKeys = null
|
||||
}
|
||||
|
||||
private areEntryKeysEqual(entryKeys: string[]): boolean {
|
||||
if (!this.cachedEntryKeys) return false
|
||||
if (this.cachedEntryKeys.length !== entryKeys.length) return false
|
||||
|
||||
for (let index = 0; index < entryKeys.length; index += 1) {
|
||||
if (this.cachedEntryKeys[index] !== entryKeys[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private makeKey(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
viewKey?: string
|
||||
): string {
|
||||
const baseKey = `${sourceNodeId}:${sourceWidgetName}`
|
||||
return viewKey ? `${baseKey}:${viewKey}` : baseKey
|
||||
}
|
||||
}
|
||||
@@ -486,10 +486,6 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -101,43 +101,6 @@ describe('SubgraphNode Memory Management', () => {
|
||||
})
|
||||
|
||||
describe('Widget Promotion Memory Management', () => {
|
||||
it('should not mutate manually injected widget references', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'testInput', type: 'number' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const input = subgraphNode.inputs[0]
|
||||
const mockWidget = {
|
||||
type: 'number',
|
||||
name: 'promoted_widget',
|
||||
value: 123,
|
||||
options: {},
|
||||
y: 0,
|
||||
draw: vi.fn(),
|
||||
mouse: vi.fn(),
|
||||
computeSize: vi.fn(),
|
||||
createCopyForNode: vi.fn().mockReturnValue({
|
||||
type: 'number',
|
||||
name: 'promoted_widget',
|
||||
value: 123
|
||||
})
|
||||
} as Partial<IWidget> as IWidget
|
||||
|
||||
input._widget = mockWidget
|
||||
input.widget = { name: 'promoted_widget' }
|
||||
subgraphNode.widgets.push(mockWidget)
|
||||
|
||||
expect(input._widget).toBe(mockWidget)
|
||||
expect(input.widget).toBeDefined()
|
||||
expect(subgraphNode.widgets).toContain(mockWidget)
|
||||
|
||||
subgraphNode.removeWidget(mockWidget)
|
||||
|
||||
// removeWidget only affects managed promoted widgets, not manually injected entries.
|
||||
expect(subgraphNode.widgets).toContain(mockWidget)
|
||||
})
|
||||
|
||||
it('should not leak widgets during reconfiguration', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input1', type: 'number' }]
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* IO synchronization, and edge cases.
|
||||
*/
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -199,6 +199,35 @@ describe('SubgraphNode Synchronization', () => {
|
||||
expect(subgraphNode.outputs[0].label).toBe('newOutput')
|
||||
})
|
||||
|
||||
it('represents promoted host widgets by input widgetId and WidgetState', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'STRING')
|
||||
interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const inputWidgetId = promotedInput.widgetId
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(promotedInput._widget).toBeUndefined()
|
||||
expect(inputWidgetId).toBeDefined()
|
||||
expect('sourceNodeId' in promotedInput).toBe(false)
|
||||
expect('sourceWidgetName' in promotedInput).toBe(false)
|
||||
if (!inputWidgetId) throw new Error('Missing widgetId')
|
||||
|
||||
expect(useWidgetValueStore().getWidget(inputWidgetId)?.value).toBe(
|
||||
'initial'
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
@@ -218,7 +247,6 @@ describe('SubgraphNode Synchronization', () => {
|
||||
|
||||
const originalWidgetName = promotedInput.widget!.name
|
||||
|
||||
// Rename the subgraph input label
|
||||
subgraph.inputs[0].label = 'my_custom_prompt'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
input: subgraph.inputs[0],
|
||||
@@ -227,17 +255,14 @@ describe('SubgraphNode Synchronization', () => {
|
||||
newName: 'my_custom_prompt'
|
||||
})
|
||||
|
||||
// widget.name stays as the internal name — NOT the display label
|
||||
expect(promotedInput.widget!.name).toBe(originalWidgetName)
|
||||
|
||||
// The display label is on input.label (live-read via PromotedWidgetView.label)
|
||||
expect(promotedInput.label).toBe('my_custom_prompt')
|
||||
|
||||
// input.widget.name should still match a widget in node.widgets
|
||||
const matchingWidget = subgraphNode.widgets?.find(
|
||||
(w) => w.name === promotedInput.widget!.name
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(promotedInput.widgetId).toBeDefined()
|
||||
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
|
||||
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
|
||||
'my_custom_prompt'
|
||||
)
|
||||
expect(matchingWidget).toBeDefined()
|
||||
})
|
||||
|
||||
it('should preserve renamed label through serialize/configure round-trip', () => {
|
||||
@@ -254,10 +279,10 @@ describe('SubgraphNode Synchronization', () => {
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedWidget = subgraphNode.widgets?.[0]
|
||||
expect(promotedWidget).toBeDefined()
|
||||
const inputSlot = subgraphNode.inputs[0]
|
||||
expect(inputSlot.widgetId).toBeDefined()
|
||||
if (!inputSlot.widgetId) throw new Error('Missing widgetId')
|
||||
|
||||
// Rename via the subgraph slot (simulates right-click rename)
|
||||
subgraph.inputs[0].label = 'My Seed'
|
||||
subgraphNode.inputs[0].label = 'My Seed'
|
||||
subgraph.events.dispatch('renaming-input', {
|
||||
@@ -267,20 +292,16 @@ describe('SubgraphNode Synchronization', () => {
|
||||
newName: 'My Seed'
|
||||
})
|
||||
|
||||
// Label should be visible before round-trip
|
||||
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
|
||||
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
|
||||
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
|
||||
'My Seed'
|
||||
)
|
||||
|
||||
// Serialize and reconfigure (simulates save/reload)
|
||||
const serialized = subgraphNode.serialize()
|
||||
subgraphNode.configure(serialized)
|
||||
|
||||
// Label should survive the round-trip
|
||||
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
|
||||
expect(widgetAfterRoundTrip).toBeDefined()
|
||||
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(inputSlot.label).toBe('My Seed')
|
||||
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
|
||||
'My Seed'
|
||||
)
|
||||
})
|
||||
@@ -339,11 +360,10 @@ describe('SubgraphNode widget name collision on rename', () => {
|
||||
// Display labels: input[1] was renamed
|
||||
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
|
||||
|
||||
// Distinct _widget bindings
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[1]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).not.toBe(
|
||||
subgraphNode.inputs[1]._widget
|
||||
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[0].widgetId).not.toBe(
|
||||
subgraphNode.inputs[1].widgetId
|
||||
)
|
||||
})
|
||||
|
||||
@@ -394,11 +414,10 @@ describe('SubgraphNode widget name collision on rename', () => {
|
||||
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
|
||||
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
|
||||
|
||||
// Distinct _widget bindings survive the rename
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[1]._widget).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).not.toBe(
|
||||
subgraphNode.inputs[1]._widget
|
||||
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
|
||||
expect(subgraphNode.inputs[0].widgetId).not.toBe(
|
||||
subgraphNode.inputs[1].widgetId
|
||||
)
|
||||
})
|
||||
|
||||
@@ -437,17 +456,13 @@ describe('SubgraphNode widget name collision on rename', () => {
|
||||
newName: 'alpha'
|
||||
})
|
||||
|
||||
// Simulate onGraphConfigured check: for each input with widget,
|
||||
// find a matching widget by name. If not found, the input gets removed.
|
||||
for (const input of subgraphNode.inputs) {
|
||||
if (!input.widget) continue
|
||||
const name = input.widget.name
|
||||
const w = subgraphNode.widgets?.find((w) => w.name === name)
|
||||
// Every input should find at least one matching widget
|
||||
expect(w).toBeDefined()
|
||||
expect(input.widgetId).toBeDefined()
|
||||
if (!input.widgetId) throw new Error('Missing widgetId')
|
||||
expect(useWidgetValueStore().getWidget(input.widgetId)).toBeDefined()
|
||||
}
|
||||
|
||||
// Both inputs should survive
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(subgraphNode.inputs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -929,41 +944,6 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode promotion view keys', () => {
|
||||
it('distinguishes tuples that differ only by colon placement', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const nodeWithKeyBuilder = fromAny<
|
||||
{
|
||||
_makePromotionViewKey: (
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
inputName?: string
|
||||
) => string
|
||||
},
|
||||
unknown
|
||||
>(subgraphNode)
|
||||
|
||||
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a:b',
|
||||
'c'
|
||||
)
|
||||
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a',
|
||||
'b:c'
|
||||
)
|
||||
|
||||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode label propagation', () => {
|
||||
it('should preserve input labels from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
@@ -1008,15 +988,19 @@ describe('SubgraphNode label propagation', () => {
|
||||
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
|
||||
|
||||
expect(promotedInput.label).toBeUndefined()
|
||||
expect(promotedInput._widget).toBeDefined()
|
||||
expect(promotedInput._widget).toBeUndefined()
|
||||
expect(promotedInput.widgetId).toBeDefined()
|
||||
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
|
||||
|
||||
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
|
||||
|
||||
expect(promotedInput.label).toBe('Steps Count')
|
||||
expect(promotedInput.name).toBe('steps')
|
||||
expect(promotedInput.widget?.name).toBe(originalWidgetName)
|
||||
expect(promotedInput._widget?.label).toBe('Steps Count')
|
||||
expect(subgraphNode.widgets?.[0].label).toBe('Steps Count')
|
||||
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
|
||||
'Steps Count'
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
|
||||
nodeId: subgraphNode.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import type { BaseLGraph, LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -33,23 +34,17 @@ import type {
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createPromotedWidgetView,
|
||||
isPromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
import { PromotedWidgetViewManager } from './PromotedWidgetViewManager'
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
import { createBitmapCache } from './svgBitmapCache'
|
||||
|
||||
@@ -57,11 +52,6 @@ const workflowSvg = new Image()
|
||||
workflowSvg.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
|
||||
|
||||
type LinkedPromotionEntry = PromotedWidgetSource & {
|
||||
inputName: string
|
||||
inputKey: string
|
||||
slotName: string
|
||||
}
|
||||
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
|
||||
|
||||
export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
@@ -85,179 +75,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return true
|
||||
}
|
||||
|
||||
private _promotedViewManager =
|
||||
new PromotedWidgetViewManager<PromotedWidgetView>()
|
||||
private _cacheVersion = 0
|
||||
private _linkedEntriesCache?: {
|
||||
version: number
|
||||
entries: LinkedPromotionEntry[]
|
||||
}
|
||||
private _promotedViewsCache?: {
|
||||
version: number
|
||||
views: PromotedWidgetView[]
|
||||
}
|
||||
|
||||
declare widgets: IBaseWidget[]
|
||||
|
||||
private _resolveLinkedPromotionBySubgraphInput(
|
||||
subgraphInput: SubgraphInput
|
||||
): PromotedWidgetSource | undefined {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(this.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
|
||||
const cached = this._linkedEntriesCache
|
||||
if (cache && cached?.version === this._cacheVersion) return cached.entries
|
||||
|
||||
const linkedEntries: LinkedPromotionEntry[] = []
|
||||
|
||||
for (const input of this.inputs) {
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
if (boundWidget) {
|
||||
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
|
||||
const hasBoundSourceWidget =
|
||||
boundNode?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) === true
|
||||
if (hasBoundSourceWidget) {
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
slotName: subgraphInput.name,
|
||||
sourceNodeId: boundWidget.sourceNodeId,
|
||||
sourceWidgetName: boundWidget.sourceWidgetName
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const resolved =
|
||||
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
|
||||
if (!resolved) continue
|
||||
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
slotName: subgraphInput.name,
|
||||
...resolved
|
||||
})
|
||||
}
|
||||
|
||||
const seenEntryKeys = new Set<string>()
|
||||
const deduplicatedEntries = linkedEntries.filter((entry) => {
|
||||
const entryKey = this._makePromotionViewKey(
|
||||
entry.inputKey,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName
|
||||
)
|
||||
if (seenEntryKeys.has(entryKey)) return false
|
||||
|
||||
seenEntryKeys.add(entryKey)
|
||||
return true
|
||||
})
|
||||
|
||||
if (cache)
|
||||
this._linkedEntriesCache = {
|
||||
version: this._cacheVersion,
|
||||
entries: deduplicatedEntries
|
||||
}
|
||||
|
||||
return deduplicatedEntries
|
||||
}
|
||||
|
||||
private _getPromotedViews(): PromotedWidgetView[] {
|
||||
const cachedViews = this._promotedViewsCache
|
||||
if (cachedViews?.version === this._cacheVersion) return cachedViews.views
|
||||
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
const reconcileEntries: Array<{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey: string
|
||||
slotName: string
|
||||
}> = []
|
||||
const displayNameByViewKey = new Map<string, string>()
|
||||
for (const entry of linkedEntries) {
|
||||
const viewKey = this._makePromotionViewKey(
|
||||
entry.inputKey,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName
|
||||
)
|
||||
reconcileEntries.push({
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
slotName: entry.slotName,
|
||||
viewKey
|
||||
})
|
||||
displayNameByViewKey.set(viewKey, entry.inputName)
|
||||
}
|
||||
|
||||
const views = this._promotedViewManager.reconcile(
|
||||
reconcileEntries,
|
||||
(entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
|
||||
entry.slotName
|
||||
)
|
||||
)
|
||||
|
||||
this._promotedViewsCache = {
|
||||
version: this._cacheVersion,
|
||||
views
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
invalidatePromotedViews(): void {
|
||||
this._cacheVersion++
|
||||
}
|
||||
|
||||
private _makePromotionViewKey(
|
||||
inputKey: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
inputName = ''
|
||||
): string {
|
||||
return JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
}
|
||||
/**
|
||||
* Retained as a no-op for extension compatibility: promoted host widgets are
|
||||
* now store-backed and addressed by widgetId, so there is no view cache to
|
||||
* invalidate.
|
||||
*/
|
||||
invalidatePromotedViews(): void {}
|
||||
|
||||
private _eventAbortController = new AbortController()
|
||||
|
||||
@@ -270,7 +95,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this.graph = graph
|
||||
|
||||
Object.defineProperty(this, 'widgets', {
|
||||
get: () => this._getPromotedViews(),
|
||||
get: () => [],
|
||||
set: () => {
|
||||
if (import.meta.env.DEV)
|
||||
console.warn(
|
||||
@@ -307,13 +132,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
const widget = inputNode.getWidgetFromSlot(input)
|
||||
if (widget)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
existingInput,
|
||||
widget,
|
||||
input.widget,
|
||||
inputNode
|
||||
)
|
||||
this._setWidget(subgraphInput, existingInput, widget, input.widget)
|
||||
return
|
||||
}
|
||||
const input = this.addInput(name, type, {
|
||||
@@ -369,8 +188,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// identifier used by onGraphConfigured (widgetInputs.ts) to match
|
||||
// inputs to widgets. Changing it to the display label would cause
|
||||
// collisions when two promoted inputs share the same label.
|
||||
// Display is handled via input.label and _widget.label.
|
||||
if (input._widget) input._widget.label = newName
|
||||
if (input.widgetId) {
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (state) state.label = newName
|
||||
}
|
||||
this.invalidatePromotedViews()
|
||||
this.graph?.trigger('node:slot-label:changed', {
|
||||
nodeId: this.id,
|
||||
@@ -437,33 +259,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput.events.addEventListener(
|
||||
'input-connected',
|
||||
(e) => {
|
||||
this.invalidatePromotedViews()
|
||||
input.shape = this.getSlotShape(subgraphInput, e.detail.input)
|
||||
if (!e.detail.widget || !e.detail.node) return
|
||||
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
const hasStaleBoundWidget =
|
||||
boundWidget &&
|
||||
this.subgraph
|
||||
.getNodeById(boundWidget.sourceNodeId)
|
||||
?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) !== true
|
||||
|
||||
const shouldSetWidgetFromEvent = !input._widget || hasStaleBoundWidget
|
||||
if (shouldSetWidgetFromEvent)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
e.detail.widget,
|
||||
e.detail.input.widget,
|
||||
e.detail.node
|
||||
)
|
||||
|
||||
this.invalidatePromotedViews()
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
e.detail.widget,
|
||||
e.detail.input.widget
|
||||
)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -482,9 +286,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
if (input._widget) this.ensureWidgetRemoved(input._widget)
|
||||
if (input.widgetId) useWidgetValueStore().deleteWidget(input.widgetId)
|
||||
|
||||
delete input.pos
|
||||
delete input.widget
|
||||
delete input.widgetId
|
||||
input._widget = undefined
|
||||
this.invalidatePromotedViews()
|
||||
},
|
||||
@@ -609,12 +415,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
let valueIndex = 0
|
||||
for (const input of this.inputs) {
|
||||
const view = input._widget
|
||||
if (!view || !isPromotedWidgetView(view)) continue
|
||||
if (!input.widgetId) continue
|
||||
const value =
|
||||
quarantineValuesByInputName.get(input.name) ??
|
||||
widgetValues?.[valueIndex]
|
||||
if (value !== undefined) view.hydrateHostValue(value)
|
||||
if (value !== undefined) {
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
valueIndex += 1
|
||||
}
|
||||
}
|
||||
@@ -639,7 +446,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this._rebindInputSubgraphSlots()
|
||||
|
||||
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
|
||||
this._promotedViewManager.clear()
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
this._hydratePreviewExposures()
|
||||
@@ -683,12 +489,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
rebuildInputWidgetBindings(): void {
|
||||
this._promotedViewManager.clear()
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
for (const input of this.inputs) {
|
||||
delete input.widget
|
||||
delete input.pos
|
||||
delete input.widgetId
|
||||
input._widget = undefined
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
@@ -725,72 +531,71 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
if (widget) {
|
||||
this._setWidget(subgraphInput, input, widget, targetInput.widget)
|
||||
break
|
||||
}
|
||||
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
targetInput.widget,
|
||||
inputNode
|
||||
)
|
||||
break
|
||||
// Nested promotion: the source is itself a promoted subgraph input with
|
||||
// no concrete widget. Resolve the deepest interior widget so this input
|
||||
// still receives widget state and a widgetId.
|
||||
const nested = this._resolveNestedPromotedSource(inputNode, targetInput)
|
||||
if (nested) {
|
||||
this._setWidget(subgraphInput, input, nested.widget, targetInput.widget)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveNestedPromotedSource(
|
||||
inputNode: LGraphNode,
|
||||
targetInput: INodeInputSlot
|
||||
): { node: LGraphNode; widget: IBaseWidget } | undefined {
|
||||
if (!inputNode.isSubgraphNode() || !targetInput.widgetId) return undefined
|
||||
|
||||
const target = resolveSubgraphInputTarget(inputNode, targetInput.name)
|
||||
if (!target) return undefined
|
||||
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
inputNode,
|
||||
target.nodeId,
|
||||
target.widgetName
|
||||
)
|
||||
return resolved.status === 'resolved' ? resolved.resolved : undefined
|
||||
}
|
||||
|
||||
private _setWidget(
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
interiorWidget: Readonly<IBaseWidget>,
|
||||
inputWidget: IWidgetLocator | undefined,
|
||||
interiorNode: LGraphNode
|
||||
inputWidget: IWidgetLocator | undefined
|
||||
) {
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
const nodeId = String(interiorNode.id)
|
||||
const widgetName = interiorWidget.name
|
||||
input._widget = undefined
|
||||
|
||||
const previousView = input._widget
|
||||
|
||||
if (
|
||||
previousView &&
|
||||
isPromotedWidgetView(previousView) &&
|
||||
(previousView.sourceNodeId !== nodeId ||
|
||||
previousView.sourceWidgetName !== widgetName)
|
||||
) {
|
||||
this._removePromotedView(previousView)
|
||||
}
|
||||
|
||||
const view = this._promotedViewManager.getOrCreate(
|
||||
nodeId,
|
||||
widgetName,
|
||||
() =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? subgraphInput.name,
|
||||
subgraphInput.name
|
||||
),
|
||||
this._makePromotionViewKey(
|
||||
String(subgraphInput.id),
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? input.name
|
||||
)
|
||||
)
|
||||
|
||||
// NOTE: This code creates linked chains of prototypes for passing across
|
||||
// multiple levels of subgraphs. As part of this, it intentionally avoids
|
||||
// creating new objects. Have care when making changes.
|
||||
input.widget ??= { name: subgraphInput.name }
|
||||
input.widget.name = subgraphInput.name
|
||||
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
|
||||
|
||||
input._widget = view
|
||||
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
|
||||
input.widgetId = id
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: interiorWidget.type,
|
||||
value: interiorWidget.value,
|
||||
options: cloneDeep(interiorWidget.options ?? {}),
|
||||
label: input.label ?? subgraphInput.name,
|
||||
serialize: interiorWidget.serialize,
|
||||
disabled: interiorWidget.disabled,
|
||||
isDOMWidget:
|
||||
'isDOMWidget' in interiorWidget &&
|
||||
typeof interiorWidget.isDOMWidget === 'boolean'
|
||||
? interiorWidget.isDOMWidget
|
||||
: undefined
|
||||
})
|
||||
|
||||
this.subgraph.events.dispatch('widget-promoted', {
|
||||
widget: view,
|
||||
widget: interiorWidget,
|
||||
subgraphNode: this
|
||||
})
|
||||
}
|
||||
@@ -816,10 +621,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override getSlotFromWidget(
|
||||
widget: IBaseWidget | undefined
|
||||
): INodeInputSlot | undefined {
|
||||
if (!widget || !isPromotedWidgetView(widget))
|
||||
return super.getSlotFromWidget(widget)
|
||||
|
||||
return this.inputs.find((input) => input._widget === widget)
|
||||
if (widget?.widgetId) {
|
||||
const promotedInput = this.inputs.find(
|
||||
(input) => input.widgetId === widget.widgetId
|
||||
)
|
||||
if (promotedInput) return promotedInput
|
||||
}
|
||||
return super.getSlotFromWidget(widget)
|
||||
}
|
||||
|
||||
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
|
||||
@@ -928,83 +736,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return nodes
|
||||
}
|
||||
|
||||
private _clearDomOverrideForView(view: PromotedWidgetView): void {
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
this,
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName
|
||||
)
|
||||
if (resolved.status !== 'resolved') return
|
||||
|
||||
const interiorWidget = resolved.resolved.widget
|
||||
if (
|
||||
interiorWidget &&
|
||||
'id' in interiorWidget &&
|
||||
('element' in interiorWidget || 'component' in interiorWidget)
|
||||
) {
|
||||
useDomWidgetStore().clearPositionOverride(String(interiorWidget.id))
|
||||
}
|
||||
}
|
||||
|
||||
private _removePromotedView(view: PromotedWidgetView): void {
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
|
||||
for (const input of this.inputs) {
|
||||
if (input._widget !== view || !input._subgraphSlot) continue
|
||||
const inputName = input.label ?? input.name
|
||||
|
||||
this._promotedViewManager.removeByViewKey(
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
this._makePromotionViewKey(
|
||||
String(input._subgraphSlot.id),
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
inputName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override removeWidget(widget: IBaseWidget): void {
|
||||
this.ensureWidgetRemoved(widget)
|
||||
}
|
||||
|
||||
override ensureWidgetRemoved(widget: IBaseWidget): void {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
this._clearDomOverrideForView(widget)
|
||||
this._removePromotedView(widget)
|
||||
}
|
||||
for (const input of this.inputs) {
|
||||
if (input._widget === widget) {
|
||||
input._widget = undefined
|
||||
input.widget = undefined
|
||||
}
|
||||
}
|
||||
this.subgraph.events.dispatch('widget-demoted', {
|
||||
widget,
|
||||
subgraphNode: this
|
||||
})
|
||||
|
||||
this.invalidatePromotedViews()
|
||||
}
|
||||
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this.invalidatePromotedViews()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
this._clearDomOverrideForView(widget)
|
||||
}
|
||||
this.subgraph.events.dispatch('widget-demoted', {
|
||||
widget,
|
||||
subgraphNode: this
|
||||
})
|
||||
}
|
||||
|
||||
this._promotedViewManager.clear()
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
@@ -1074,24 +818,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
serialized.properties = serializedProperties
|
||||
|
||||
if (
|
||||
import.meta.env?.DEV &&
|
||||
this.widgets.some((w) => !isPromotedWidgetView(w))
|
||||
) {
|
||||
console.warn(
|
||||
`SubgraphNode ${this.id}: serialize() drops non-promoted host widgets ` +
|
||||
`(${this.widgets
|
||||
.filter((w) => !isPromotedWidgetView(w))
|
||||
.map((w) => w.name)
|
||||
.join(', ')}); ` +
|
||||
'expected only PromotedWidgetView instances.'
|
||||
)
|
||||
}
|
||||
|
||||
const widgetValues = this.inputs.flatMap((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return []
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
if (!input.widgetId) return []
|
||||
const value = useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
return [isWidgetValue(value) ? value : undefined]
|
||||
})
|
||||
|
||||
|
||||
@@ -8,20 +8,18 @@ import type {
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import {
|
||||
appendQuarantine,
|
||||
flushProxyWidgetMigration,
|
||||
makeQuarantineEntry
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
|
||||
@@ -77,15 +75,46 @@ function setupPromotedWidget(
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
function expectPromotedWidgetView(
|
||||
widget: unknown
|
||||
): asserts widget is PromotedWidgetView {
|
||||
expect(widget).toMatchObject({
|
||||
sourceNodeId: expect.any(String),
|
||||
sourceWidgetName: expect.any(String)
|
||||
function promotedInputs(node: {
|
||||
inputs: Array<{ widgetId?: WidgetId; name: string }>
|
||||
}) {
|
||||
return node.inputs.filter(
|
||||
(input): input is { widgetId: WidgetId; name: string } =>
|
||||
Boolean(input.widgetId)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedWidgetStates(node: {
|
||||
inputs: Array<{ widgetId?: WidgetId; name: string }>
|
||||
}) {
|
||||
return promotedInputs(node).map((input) => {
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
function promotedWidgetStateByName(
|
||||
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
name: string
|
||||
) {
|
||||
const input = node.inputs.find((input) => input.name === name)
|
||||
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
|
||||
const state = useWidgetValueStore().getWidget(input.widgetId)
|
||||
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
|
||||
return state
|
||||
}
|
||||
|
||||
function writePromotedWidgetValue(
|
||||
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
|
||||
index: number,
|
||||
value: unknown
|
||||
) {
|
||||
const input = promotedInputs(node)[index]
|
||||
if (!input) throw new Error(`Missing promoted input ${index}`)
|
||||
useWidgetValueStore().setValue(input.widgetId, value)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
@@ -101,11 +130,11 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
// The widget should be promoted to the subgraph node
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('value') // Uses subgraph input name
|
||||
expect(subgraphNode.widgets[0].type).toBe('number')
|
||||
expect(subgraphNode.widgets[0].value).toBe(42)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(promotedWidgetStateByName(subgraphNode, 'value')).toMatchObject({
|
||||
type: 'number',
|
||||
value: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('should promote all widget types', () => {
|
||||
@@ -147,13 +176,10 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// All widgets should be promoted
|
||||
expect(subgraphNode.widgets).toHaveLength(3)
|
||||
|
||||
// Check specific widget values
|
||||
expect(subgraphNode.widgets[0].value).toBe(100)
|
||||
expect(subgraphNode.widgets[1].value).toBe('test')
|
||||
expect(subgraphNode.widgets[2].value).toBe(true)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(
|
||||
promotedWidgetStates(subgraphNode).map((state) => state.value)
|
||||
).toEqual([100, 'test', true])
|
||||
})
|
||||
|
||||
it('should fire widget-promoted event when widget is promoted', () => {
|
||||
@@ -229,13 +255,11 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
// Create SubgraphNode
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Both widgets should be promoted
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('input1')
|
||||
expect(subgraphNode.widgets[0].value).toBe(10)
|
||||
|
||||
expect(subgraphNode.widgets[1].name).toBe('input2')
|
||||
expect(subgraphNode.widgets[1].value).toBe('hello')
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(promotedWidgetStateByName(subgraphNode, 'input1').value).toBe(10)
|
||||
expect(promotedWidgetStateByName(subgraphNode, 'input2').value).toBe(
|
||||
'hello'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fire widget-demoted events when node is removed', () => {
|
||||
@@ -246,7 +270,8 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(1)
|
||||
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-demoted'
|
||||
@@ -255,9 +280,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
// Remove the subgraph node
|
||||
subgraphNode.onRemoved()
|
||||
|
||||
// Should fire demoted events for all widgets
|
||||
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
|
||||
expect(demotedEvents).toHaveLength(1)
|
||||
expect(demotedEvents).toHaveLength(0)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(1)
|
||||
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
@@ -284,13 +309,13 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(1)
|
||||
|
||||
// Disconnect the link
|
||||
subgraph.inputNode.slots[0].disconnect()
|
||||
|
||||
// Widget should be removed (through event listeners)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(promotedInputs(subgraphNode)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -320,10 +345,12 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
hostNode.configure(serializedHostNode)
|
||||
|
||||
expect(hostNode.widgets).toHaveLength(1)
|
||||
expect(hostNode.widgets[0].name).toBe('batch_size')
|
||||
expect(hostNode.widgets[0].value).toBe(1)
|
||||
expect(hostNode.widgets[0].options.step).toBe(10)
|
||||
expect(hostNode.widgets).toHaveLength(0)
|
||||
expect(promotedWidgetStateByName(hostNode, 'batch_size')).toMatchObject({
|
||||
name: 'batch_size',
|
||||
value: 1,
|
||||
options: expect.objectContaining({ step: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
|
||||
@@ -364,18 +391,17 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
outerNode.configure(outerNode.serialize())
|
||||
|
||||
// Check widgets getter — stale entries should not produce views
|
||||
const widgetSourceIds = outerNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((w) => !w.name.startsWith('$$'))
|
||||
.map((w) => w.sourceNodeId)
|
||||
|
||||
expect(widgetSourceIds).not.toContain('999')
|
||||
expect(widgetSourceIds).not.toContain('998')
|
||||
expect(widgetSourceIds).toContain(keptSamplerNodeId)
|
||||
expect(outerNode.widgets).toHaveLength(0)
|
||||
expect(promotedWidgetStateByName(outerNode, 'model').value).toBe(42)
|
||||
expect(outerNode.properties.proxyWidgets).toEqual([
|
||||
['999', 'text'],
|
||||
['998', 'text'],
|
||||
[keptSamplerNodeId, 'widget']
|
||||
])
|
||||
expect(keptSamplerNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('resolves legacy prefixed proxyWidgets via the immediate child PromotedWidgetView identity', () => {
|
||||
it('resolves legacy prefixed proxyWidgets via the immediate child promoted-widget identity', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
@@ -417,12 +443,7 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
hostNode.configure(serializedHostNode)
|
||||
flushProxyWidgetMigration({ hostNode })
|
||||
|
||||
const promotedWidgets = hostNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((widget) => !widget.name.startsWith('$$'))
|
||||
|
||||
expect(promotedWidgets).toHaveLength(1)
|
||||
expect(promotedWidgets[0]?.sourceNodeId).toBe(String(nestedNode.id))
|
||||
expect(hostNode.widgets).toHaveLength(0)
|
||||
expect(hostNode.properties.proxyWidgets).toBeUndefined()
|
||||
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
@@ -446,12 +467,8 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const cloneNode = createTestSubgraphNode(subgraph)
|
||||
cloneNode.configure(serialized)
|
||||
|
||||
const promotedNames = cloneNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((widget) => !widget.name.startsWith('$$'))
|
||||
.map((widget) => widget.sourceWidgetName)
|
||||
|
||||
expect(promotedNames).toContain('text')
|
||||
expect(cloneNode.widgets).toHaveLength(0)
|
||||
expect(promotedWidgetStateByName(cloneNode, 'text').value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -462,6 +479,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
|
||||
const originalTooltip = 'This is a test tooltip'
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const { node } = createNodeWithWidget(
|
||||
'Test Node',
|
||||
'number',
|
||||
@@ -471,9 +491,11 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
)
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
// The promoted widget should preserve the original tooltip
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].tooltip).toBe(originalTooltip)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(
|
||||
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
|
||||
).toBe(originalTooltip)
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('should handle widgets with no tooltip', () => {
|
||||
@@ -481,12 +503,17 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const { node } = createNodeWithWidget('Test Node', 'number', 42, 'number')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
|
||||
// The promoted widget should have undefined tooltip
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(
|
||||
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
|
||||
).toBeUndefined()
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('should preserve tooltips for multiple promoted widgets', () => {
|
||||
@@ -539,13 +566,18 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
multiWidgetNode
|
||||
)
|
||||
|
||||
// Create SubgraphNode
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Both widgets should preserve their tooltips
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].tooltip).toBe('Number widget tooltip')
|
||||
expect(subgraphNode.widgets[1].tooltip).toBe('String widget tooltip')
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(
|
||||
eventCapture
|
||||
.getEventsByType('widget-promoted')
|
||||
.map((event) => event.detail.widget.tooltip)
|
||||
).toEqual(['Number widget tooltip', 'String widget tooltip'])
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('should preserve original tooltip after promotion', () => {
|
||||
@@ -554,6 +586,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
|
||||
const originalTooltip = 'Original tooltip'
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-promoted'
|
||||
])
|
||||
const { node } = createNodeWithWidget(
|
||||
'Test Node',
|
||||
'number',
|
||||
@@ -562,16 +597,14 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
originalTooltip
|
||||
)
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
const state = promotedWidgetStateByName(subgraphNode, 'value')
|
||||
|
||||
const promotedWidget = subgraphNode.widgets[0]
|
||||
|
||||
// The promoted widget should preserve the original tooltip
|
||||
expect(promotedWidget.tooltip).toBe(originalTooltip)
|
||||
|
||||
// The promoted widget should still function normally
|
||||
expect(promotedWidget.name).toBe('value') // Uses subgraph input name
|
||||
expect(promotedWidget.type).toBe('number')
|
||||
expect(promotedWidget.value).toBe(42)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
expect(
|
||||
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
|
||||
).toBe(originalTooltip)
|
||||
expect(state).toMatchObject({ name: 'value', type: 'number', value: 42 })
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -586,15 +619,7 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const hostWidget = hostNode.widgets[0]
|
||||
expectPromotedWidgetView(hostWidget)
|
||||
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
|
||||
nodeId: hostNode.id,
|
||||
name: hostWidget.name,
|
||||
type: hostWidget.type,
|
||||
value: 99,
|
||||
options: {}
|
||||
})
|
||||
writePromotedWidgetValue(hostNode, 0, 99)
|
||||
hostNode.serialize()
|
||||
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
@@ -684,28 +709,11 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
return built
|
||||
}
|
||||
|
||||
function vueEdit(
|
||||
host: ReturnType<typeof createTestSubgraphNode>,
|
||||
index: number,
|
||||
value: EditValue
|
||||
) {
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData: extractVueNodeData(host),
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
|
||||
})
|
||||
widgets[index].updateHandler(value)
|
||||
}
|
||||
|
||||
function applyEdit(
|
||||
host: ReturnType<typeof createTestSubgraphNode>,
|
||||
edit: EditSpec
|
||||
) {
|
||||
if (edit.via === 'viewKey') host.widgets[edit.index].value = edit.value
|
||||
else vueEdit(host, edit.index, edit.value)
|
||||
writePromotedWidgetValue(host, edit.index, edit.value)
|
||||
}
|
||||
|
||||
function applyReorder(
|
||||
@@ -808,7 +816,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
applyReorder(host, c.reorder)
|
||||
|
||||
if (c.expectedNames) {
|
||||
expect(host.widgets.map((w) => w.name)).toEqual(c.expectedNames)
|
||||
expect(promotedInputs(host).map((input) => input.name)).toEqual(
|
||||
c.expectedNames
|
||||
)
|
||||
}
|
||||
if (c.expectedWidgetsValues !== undefined) {
|
||||
expect(host.serialize().widgets_values).toEqual(
|
||||
@@ -833,13 +843,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
controlMarker: boolean
|
||||
seedHostValue: number
|
||||
mutateSourceSeedAfterReorder?: number
|
||||
callAfterQueued?: boolean
|
||||
expect: {
|
||||
promptSeed?: number
|
||||
sourceSeed?: number
|
||||
processedSeedValue?: number
|
||||
hostSeedValue?: number
|
||||
storeSeedValue?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,25 +866,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
controlMarker: false,
|
||||
seedHostValue: 123456,
|
||||
expect: { sourceSeed: 0 }
|
||||
},
|
||||
{
|
||||
name: 'Vue + increment + afterQueued: processed widgets reflect increment',
|
||||
editVia: 'vue',
|
||||
controlMode: 'increment',
|
||||
controlMarker: true,
|
||||
seedHostValue: 123456,
|
||||
callAfterQueued: true,
|
||||
expect: { processedSeedValue: 123457 }
|
||||
},
|
||||
{
|
||||
name: 'ViewKey + increment + afterQueued: host seed increments without source value',
|
||||
editVia: 'viewKey',
|
||||
controlMode: 'increment',
|
||||
controlMarker: true,
|
||||
seedHostValue: 2,
|
||||
mutateSourceSeedAfterReorder: 8,
|
||||
callAfterQueued: true,
|
||||
expect: { hostSeedValue: 3, storeSeedValue: 3 }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -897,26 +884,18 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
host.graph?.add(host)
|
||||
}
|
||||
|
||||
if (c.editVia === 'viewKey') {
|
||||
host.widgets[0].value = 'positive prompt'
|
||||
host.widgets[1].value = 'negative prompt'
|
||||
host.widgets[2].value = c.seedHostValue
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
} else {
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
vueEdit(host, 2, c.seedHostValue)
|
||||
}
|
||||
writePromotedWidgetValue(host, 0, 'positive prompt')
|
||||
writePromotedWidgetValue(host, 1, 'negative prompt')
|
||||
writePromotedWidgetValue(host, 2, c.seedHostValue)
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
|
||||
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
|
||||
|
||||
if (c.mutateSourceSeedAfterReorder !== undefined) {
|
||||
seed.widget.value = c.mutateSourceSeedAfterReorder
|
||||
}
|
||||
if (c.callAfterQueued) host.widgets[1].afterQueued?.()
|
||||
|
||||
if (c.expect.promptSeed !== undefined) {
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
@@ -927,53 +906,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
if (c.expect.sourceSeed !== undefined) {
|
||||
expect(seed.widget.value).toBe(c.expect.sourceSeed)
|
||||
}
|
||||
if (c.expect.processedSeedValue !== undefined) {
|
||||
const updated = computeProcessedWidgets({
|
||||
nodeData: extractVueNodeData(host),
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
|
||||
})
|
||||
expect(updated[1].value).toBe(c.expect.processedSeedValue)
|
||||
}
|
||||
if (c.expect.hostSeedValue !== undefined) {
|
||||
expect(host.widgets[1].value).toBe(c.expect.hostSeedValue)
|
||||
}
|
||||
if (c.expect.storeSeedValue !== undefined) {
|
||||
expect(
|
||||
useWidgetValueStore()
|
||||
.getNodeWidgets(host.rootGraph.id, host.id)
|
||||
.find((entry) => entry.name === 'seed')?.value
|
||||
).toBe(c.expect.storeSeedValue)
|
||||
}
|
||||
})
|
||||
|
||||
it('afterQueued does not run value-control when the host input is externally linked', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const sources = buildSources(
|
||||
subgraph,
|
||||
TEXT_TEXT_SEED.map((s) =>
|
||||
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
|
||||
)
|
||||
)
|
||||
const seed = sources[2]
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget('increment', true) as never
|
||||
]
|
||||
host.widgets[2].value = 2
|
||||
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
|
||||
|
||||
const seedSlot = host.getSlotFromWidget(host.widgets[1])
|
||||
expect(seedSlot).toBeDefined()
|
||||
seedSlot!.link = -1
|
||||
|
||||
host.widgets[1].afterQueued?.()
|
||||
|
||||
expect(host.widgets[1].value).toBe(2)
|
||||
})
|
||||
|
||||
it('serializes promoted values from each host independently', () => {
|
||||
@@ -993,8 +925,8 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
subgraph.rootGraph.add(firstHost)
|
||||
subgraph.rootGraph.add(secondHost)
|
||||
|
||||
firstHost.widgets[0].value = 111
|
||||
secondHost.widgets[0].value = 222
|
||||
writePromotedWidgetValue(firstHost, 0, 111)
|
||||
writePromotedWidgetValue(secondHost, 0, 222)
|
||||
|
||||
expect(firstHost.serialize().widgets_values).toEqual([111])
|
||||
expect(secondHost.serialize().widgets_values).toEqual([222])
|
||||
@@ -1006,16 +938,17 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const { node, widget } of sources) {
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: node.id,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: `${node.title} value`,
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(
|
||||
widgetId(host.rootGraph.id, node.id, widget.name),
|
||||
{
|
||||
type: widget.type,
|
||||
value: `${node.title} value`,
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
}
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
expect(host.serialize().widgets_values).toBeUndefined()
|
||||
expect(host.serialize().widgets_values).toEqual(['', ''])
|
||||
})
|
||||
|
||||
it('does not acquire a host overlay when a source fallback is saved and reloaded', () => {
|
||||
@@ -1032,15 +965,16 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: interiorNode.id,
|
||||
name: interiorWidget.name,
|
||||
type: interiorWidget.type,
|
||||
value: 'source fallback',
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(
|
||||
widgetId(host.rootGraph.id, interiorNode.id, interiorWidget.name),
|
||||
{
|
||||
type: interiorWidget.type,
|
||||
value: 'source fallback',
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
const serialized = host.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
expect(serialized.widgets_values).toEqual([''])
|
||||
|
||||
widgetStore.clearGraph(host.rootGraph.id)
|
||||
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
@@ -1048,8 +982,8 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
).toEqual([])
|
||||
expect(reloaded.serialize().widgets_values).toBeUndefined()
|
||||
).toHaveLength(1)
|
||||
expect(reloaded.serialize().widgets_values).toEqual([''])
|
||||
})
|
||||
|
||||
it('does not hydrate missing widgets_values entries as explicit host overlays', () => {
|
||||
@@ -1057,33 +991,31 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
buildSources(subgraph, TEXT_PAIR)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
host.widgets[1].value = 'second host value'
|
||||
writePromotedWidgetValue(host, 1, 'second host value')
|
||||
const serialized = host.serialize()
|
||||
expect(serialized.widgets_values).toEqual([
|
||||
undefined,
|
||||
'second host value'
|
||||
])
|
||||
expect(serialized.widgets_values).toEqual(['', 'second host value'])
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.clearGraph(host.rootGraph.id)
|
||||
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
reloaded.configure(serialized)
|
||||
|
||||
const [first, second] = reloaded.widgets
|
||||
expectPromotedWidgetView(first)
|
||||
expectPromotedWidgetView(second)
|
||||
expect(reloaded.widgets).toHaveLength(0)
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
|
||||
).toBeUndefined()
|
||||
widgetStore.getWidget(
|
||||
widgetId(reloaded.rootGraph.id, reloaded.id, 'first')
|
||||
)?.value
|
||||
).toBe('')
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
|
||||
?.value
|
||||
widgetStore.getWidget(
|
||||
widgetId(reloaded.rootGraph.id, reloaded.id, 'second')
|
||||
)?.value
|
||||
).toBe('second host value')
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
).toHaveLength(1)
|
||||
).toHaveLength(2)
|
||||
expect(reloaded.serialize().widgets_values).toEqual([
|
||||
undefined,
|
||||
'',
|
||||
'second host value'
|
||||
])
|
||||
})
|
||||
@@ -1369,7 +1301,12 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
reloaded.configure(serialized)
|
||||
|
||||
const byName = new Map(
|
||||
reloaded.inputs.map((input) => [input.name, input._widget?.value])
|
||||
reloaded.inputs.map((input) => [
|
||||
input.name,
|
||||
input.widgetId
|
||||
? useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
: undefined
|
||||
])
|
||||
)
|
||||
expect(byName.get('unet_name')).toBe('z_image_turbo_bf16.safetensors')
|
||||
expect(byName.get('clip_name')).toBe('qwen_3_4b.safetensors')
|
||||
|
||||
2
src/lib/litegraph/src/types/widgetId.ts
Normal file
2
src/lib/litegraph/src/types/widgetId.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { WidgetId } from '@/types/widgetId'
|
||||
export { widgetId } from '@/types/widgetId'
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -399,7 +399,7 @@ export interface IBaseWidget<
|
||||
|
||||
linkedWidgets?: IBaseWidget[]
|
||||
|
||||
readonly entityId?: WidgetEntityId
|
||||
readonly widgetId?: WidgetId
|
||||
|
||||
name: string
|
||||
options: TOptions
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
function createTestWidget(
|
||||
node: LGraphNode,
|
||||
@@ -95,7 +96,7 @@ describe('BaseWidget store integration', () => {
|
||||
widget.disabled = true
|
||||
widget.advanced = true
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'writeWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'writeWidget'))
|
||||
expect(state?.label).toBe('Updated Label')
|
||||
expect(state?.disabled).toBe(true)
|
||||
|
||||
@@ -108,9 +109,11 @@ describe('BaseWidget store integration', () => {
|
||||
widget.setNodeId(1)
|
||||
|
||||
widget.value = 99
|
||||
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
|
||||
expect(store.getWidget(widgetId(graph.id, 1, 'valueWidget'))?.value).toBe(
|
||||
99
|
||||
)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'valueWidget')!
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'valueWidget'))!
|
||||
state.value = 55
|
||||
expect(widget.value).toBe(55)
|
||||
})
|
||||
@@ -128,9 +131,9 @@ describe('BaseWidget store integration', () => {
|
||||
})
|
||||
widget.setNodeId(1)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'autoRegWidget'))
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.nodeId).toBe(1)
|
||||
expect(state?.nodeId).toBe('1')
|
||||
expect(state?.name).toBe('autoRegWidget')
|
||||
expect(state?.type).toBe('number')
|
||||
expect(state?.value).toBe(100)
|
||||
@@ -146,7 +149,7 @@ describe('BaseWidget store integration', () => {
|
||||
const widget = createTestWidget(node, { name: 'defaultsWidget' })
|
||||
widget.setNodeId(1)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'defaultsWidget'))
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.disabled).toBe(false)
|
||||
expect(state?.label).toBeUndefined()
|
||||
@@ -159,7 +162,9 @@ describe('BaseWidget store integration', () => {
|
||||
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
|
||||
widget.setNodeId(1)
|
||||
|
||||
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
|
||||
expect(
|
||||
store.getWidget(widgetId(graph.id, 1, 'valuesWidget'))?.value
|
||||
).toBe(77)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,20 +182,26 @@ describe('BaseWidget store integration', () => {
|
||||
get() {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return defaultValue
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
const state = store.getWidget(
|
||||
widgetId(graphId, node.id, 'system_prompt')
|
||||
)
|
||||
return (state?.value as string) ?? defaultValue
|
||||
},
|
||||
set(v: string) {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
const state = store.getWidget(
|
||||
widgetId(graphId, node.id, 'system_prompt')
|
||||
)
|
||||
if (state) state.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.setNodeId(node.id)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'system_prompt')
|
||||
const state = store.getWidget(
|
||||
widgetId(graph.id, node.id, 'system_prompt')
|
||||
)
|
||||
expect(state?.value).toBe(defaultValue)
|
||||
})
|
||||
})
|
||||
@@ -211,7 +222,7 @@ describe('BaseWidget store integration', () => {
|
||||
|
||||
widget.disabled = undefined
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'testWidget')
|
||||
const state = store.getWidget(widgetId(graph.id, 1, 'testWidget'))
|
||||
expect(state?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
LGraphNode,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { litegraph } from '@/lib/litegraph/src/litegraphInstance'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
@@ -19,8 +19,8 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/lib/litegraph/src/types/widgetId'
|
||||
import { widgetId } from '@/lib/litegraph/src/types/widgetId'
|
||||
|
||||
export interface DrawWidgetOptions {
|
||||
/** The width of the node where this widget will be displayed. */
|
||||
@@ -132,11 +132,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
this._state.value = value
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId | undefined {
|
||||
get widgetId(): WidgetId | undefined {
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const nodeId = this._state.nodeId
|
||||
if (!graphId || nodeId === undefined) return undefined
|
||||
return widgetEntityId(graphId, nodeId, this.name)
|
||||
return widgetId(graphId, nodeId, this.name)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,14 +147,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
if (!graphId) return
|
||||
|
||||
this._state = useWidgetValueStore().registerWidget(graphId, {
|
||||
...this._state,
|
||||
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
|
||||
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
|
||||
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
|
||||
value: this.value,
|
||||
nodeId
|
||||
})
|
||||
this._state = useWidgetValueStore().registerWidget(
|
||||
widgetId(graphId, nodeId, this.name),
|
||||
{
|
||||
...this._state,
|
||||
value: this.value
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
constructor(widget: TWidget & { node: LGraphNode })
|
||||
@@ -214,8 +213,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
|
||||
getOutlineColor() {
|
||||
return this.advanced
|
||||
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
|
||||
: LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
? litegraph().WIDGET_ADVANCED_OUTLINE_COLOR
|
||||
: litegraph().WIDGET_OUTLINE_COLOR
|
||||
}
|
||||
|
||||
get outline_color() {
|
||||
@@ -223,23 +222,23 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
}
|
||||
|
||||
get background_color() {
|
||||
return LiteGraph.WIDGET_BGCOLOR
|
||||
return litegraph().WIDGET_BGCOLOR
|
||||
}
|
||||
|
||||
get height() {
|
||||
return LiteGraph.NODE_WIDGET_HEIGHT
|
||||
return litegraph().NODE_WIDGET_HEIGHT
|
||||
}
|
||||
|
||||
get text_color() {
|
||||
return LiteGraph.WIDGET_TEXT_COLOR
|
||||
return litegraph().WIDGET_TEXT_COLOR
|
||||
}
|
||||
|
||||
get secondary_text_color() {
|
||||
return LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
|
||||
return litegraph().WIDGET_SECONDARY_TEXT_COLOR
|
||||
}
|
||||
|
||||
get disabledTextColor() {
|
||||
return LiteGraph.WIDGET_DISABLED_TEXT_COLOR
|
||||
return litegraph().WIDGET_DISABLED_TEXT_COLOR
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
@@ -361,7 +360,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
if (requiredWidth <= totalWidth) {
|
||||
// Draw label & value normally
|
||||
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
|
||||
} else if (LiteGraph.truncateWidgetTextEvenly) {
|
||||
} else if (litegraph().truncateWidgetTextEvenly) {
|
||||
// Label + value will not fit - scale evenly to fit
|
||||
const scale = (totalWidth - gap) / (requiredWidth - gap)
|
||||
area.width = labelWidth * scale
|
||||
@@ -371,7 +370,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
// Move the area to the right to render the value
|
||||
area.right = x + totalWidth
|
||||
area.setWidthRightAnchored(valueWidth * scale)
|
||||
} else if (LiteGraph.truncateWidgetValuesFirst) {
|
||||
} else if (litegraph().truncateWidgetValuesFirst) {
|
||||
// Label + value will not fit - use legacy scaling of value first
|
||||
const cappedLabelWidth = Math.min(labelWidth, totalWidth)
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import { computed } from 'vue'
|
||||
|
||||
import type { LinearInput } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { entityId, name } = defineProps<{
|
||||
entityId?: WidgetEntityId
|
||||
const { widgetId, name } = defineProps<{
|
||||
widgetId?: WidgetId
|
||||
enable: boolean
|
||||
name: string
|
||||
}>()
|
||||
@@ -17,12 +17,12 @@ const appModeStore = useAppModeStore()
|
||||
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
|
||||
|
||||
function matchesThis([storedId]: LinearInput) {
|
||||
return entityId !== undefined && storedId === entityId
|
||||
return widgetId !== undefined && storedId === widgetId
|
||||
}
|
||||
function togglePromotion() {
|
||||
if (!entityId) return
|
||||
if (!widgetId) return
|
||||
if (isPromoted.value) remove(appModeStore.selectedInputs, matchesThis)
|
||||
else appModeStore.selectedInputs.push([entityId, name])
|
||||
else appModeStore.selectedInputs.push([widgetId, name])
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
|
||||
const GRAPH_ID = 'graph-test'
|
||||
|
||||
@@ -129,12 +129,12 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
|
||||
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
|
||||
const duplicateEntityId = widgetEntityId(
|
||||
const duplicateEntityId = widgetId(
|
||||
GRAPH_ID,
|
||||
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
'string_a'
|
||||
)
|
||||
const distinctEntityId = widgetEntityId(
|
||||
const distinctEntityId = widgetId(
|
||||
GRAPH_ID,
|
||||
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
'string_a'
|
||||
@@ -143,22 +143,19 @@ describe('NodeWidgets', () => {
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
entityId: duplicateEntityId,
|
||||
slotName: 'string_a'
|
||||
widgetId: duplicateEntityId
|
||||
})
|
||||
const duplicateB = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
entityId: duplicateEntityId,
|
||||
slotName: 'string_a'
|
||||
widgetId: duplicateEntityId
|
||||
})
|
||||
const distinct = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
entityId: distinctEntityId,
|
||||
slotName: 'string_a'
|
||||
widgetId: distinctEntityId
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
duplicateA,
|
||||
@@ -172,7 +169,7 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
|
||||
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
|
||||
const sharedEntityId = widgetEntityId(
|
||||
const sharedEntityId = widgetId(
|
||||
GRAPH_ID,
|
||||
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
'string_a'
|
||||
@@ -181,16 +178,14 @@ describe('NodeWidgets', () => {
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
entityId: sharedEntityId,
|
||||
slotName: 'string_a',
|
||||
widgetId: sharedEntityId,
|
||||
options: { hidden: true }
|
||||
})
|
||||
const visibleDuplicate = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
entityId: sharedEntityId,
|
||||
slotName: 'string_a',
|
||||
widgetId: sharedEntityId,
|
||||
options: { hidden: false }
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
@@ -204,7 +199,7 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
|
||||
it('does not deduplicate entries that share names but have different widget types', () => {
|
||||
const sharedEntityId = widgetEntityId(
|
||||
const sharedEntityId = widgetId(
|
||||
GRAPH_ID,
|
||||
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
'string_a'
|
||||
@@ -213,15 +208,13 @@ describe('NodeWidgets', () => {
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
entityId: sharedEntityId,
|
||||
slotName: 'string_a'
|
||||
widgetId: sharedEntityId
|
||||
})
|
||||
const comboWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'combo',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
entityId: sharedEntityId,
|
||||
slotName: 'string_a'
|
||||
widgetId: sharedEntityId
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
textWidget,
|
||||
@@ -237,14 +230,12 @@ describe('NodeWidgets', () => {
|
||||
const firstTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
name: 'string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:18'
|
||||
})
|
||||
const secondTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
name: 'string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:19'
|
||||
})
|
||||
@@ -263,15 +254,13 @@ describe('NodeWidgets', () => {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:1',
|
||||
entityId: widgetEntityId(GRAPH_ID, 'outer-subgraph:1', 'text'),
|
||||
slotName: 'text'
|
||||
widgetId: widgetId(GRAPH_ID, 'outer-subgraph:1', 'text')
|
||||
})
|
||||
const secondPromoted = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:2',
|
||||
entityId: widgetEntityId(GRAPH_ID, 'outer-subgraph:2', 'text'),
|
||||
slotName: 'text'
|
||||
widgetId: widgetId(GRAPH_ID, 'outer-subgraph:2', 'text')
|
||||
})
|
||||
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
@@ -294,37 +283,38 @@ describe('NodeWidgets', () => {
|
||||
|
||||
const { container } = renderComponent(nodeData)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget('graph-test', {
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
value: 'value',
|
||||
options: { hidden: true },
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
widgetValueStore.registerWidget(
|
||||
widgetId('graph-test', 'test_node', 'test_widget'),
|
||||
{
|
||||
type: 'combo',
|
||||
value: 'value',
|
||||
options: { hidden: true },
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('forwards canonical entityId to AppInput for selection', () => {
|
||||
const seedAEntityId = widgetEntityId(GRAPH_ID, 'test_node', 'seed_a')
|
||||
const seedBEntityId = widgetEntityId(GRAPH_ID, 'test_node', 'seed_b')
|
||||
it('forwards canonical widgetId to AppInput for selection', () => {
|
||||
const seedAEntityId = widgetId(GRAPH_ID, 'test_node', 'seed_a')
|
||||
const seedBEntityId = widgetId(GRAPH_ID, 'test_node', 'seed_b')
|
||||
const nodeData = createMockNodeData('TestNode', [
|
||||
createMockWidget({
|
||||
nodeId: 'test_node',
|
||||
name: 'seed_a',
|
||||
type: 'text',
|
||||
entityId: seedAEntityId
|
||||
widgetId: seedAEntityId
|
||||
}),
|
||||
createMockWidget({
|
||||
nodeId: 'test_node',
|
||||
name: 'seed_b',
|
||||
type: 'text',
|
||||
entityId: seedBEntityId
|
||||
widgetId: seedBEntityId
|
||||
})
|
||||
])
|
||||
|
||||
@@ -341,9 +331,9 @@ describe('NodeWidgets', () => {
|
||||
stubs: {
|
||||
InputSlot: true,
|
||||
AppInput: {
|
||||
props: ['entityId', 'name', 'enable'],
|
||||
props: ['widgetId', 'name', 'enable'],
|
||||
template:
|
||||
'<div class="app-input-stub" :data-entity-id="entityId"><slot /></div>'
|
||||
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
|
||||
}
|
||||
},
|
||||
mocks: {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<AppInput
|
||||
:entity-id="widget.entityId"
|
||||
:widget-id="widget.widgetId"
|
||||
:name="widget.name"
|
||||
:enable="canSelectInputs && !widget.simplified.options?.disabled"
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
function splitAroundFirstSpace(text: string): [string, string | undefined] {
|
||||
const index = text.indexOf(' ')
|
||||
@@ -46,7 +47,7 @@ export function trackNodePrice(node: TrackableNode) {
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
if (!graphId) continue
|
||||
void widgetStore.getWidget(graphId, node.id, name)?.value
|
||||
void widgetStore.getWidget(widgetId(graphId, node.id, name))?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
@@ -148,7 +149,8 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
if (!graphId) continue
|
||||
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
|
||||
void widgetStore.getWidget(widgetId(graphId, nodeData.id, name))
|
||||
?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
|
||||
const GRAPH_ID = 'graph-test'
|
||||
|
||||
@@ -44,11 +44,15 @@ const createMockWidget = (
|
||||
})
|
||||
|
||||
describe('getWidgetIdentity', () => {
|
||||
it('keys dedupeIdentity by entityId and widget type', () => {
|
||||
const entityId = widgetEntityId(GRAPH_ID, 'subgraph:19', 'text')
|
||||
const widget = createMockWidget({ entityId, name: 'text', type: 'text' })
|
||||
it('keys dedupeIdentity by widgetId and widget type', () => {
|
||||
const id = widgetId(GRAPH_ID, 'subgraph:19', 'text')
|
||||
const widget = createMockWidget({
|
||||
widgetId: id,
|
||||
name: 'text',
|
||||
type: 'text'
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '1', 0)
|
||||
expect(dedupeIdentity).toBe(`${entityId}:text`)
|
||||
expect(dedupeIdentity).toBe(`${id}:text`)
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
@@ -58,7 +62,7 @@ describe('getWidgetIdentity', () => {
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
|
||||
expect(dedupeIdentity).toBe('node:5:test_widget:combo')
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
@@ -73,7 +77,7 @@ describe('getWidgetIdentity', () => {
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
@@ -82,7 +86,7 @@ describe('getWidgetIdentity', () => {
|
||||
sourceExecutionId: '65:18'
|
||||
})
|
||||
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
|
||||
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
|
||||
expect(dedupeIdentity).toBe('exec:65:18:test_widget:combo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,10 +191,10 @@ describe('hasWidgetError', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses slotName for error matching when present', () => {
|
||||
it('matches errors by the slot name (widget.name) for promoted widgets', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'internal_name',
|
||||
slotName: 'display_slot'
|
||||
name: 'display_slot',
|
||||
sourceWidgetName: 'internal_name'
|
||||
})
|
||||
const nodeErrors = {
|
||||
errors: [{ extra_info: { input_name: 'display_slot' } }]
|
||||
@@ -205,6 +209,27 @@ describe('hasWidgetError', () => {
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches missing models by the interior source widget name', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'display_slot',
|
||||
sourceExecutionId: '65:18',
|
||||
sourceWidgetName: 'ckpt_name'
|
||||
})
|
||||
const spy = vi
|
||||
.spyOn(missingModelStore, 'isWidgetMissingModel')
|
||||
.mockReturnValue(true)
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
'1',
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
expect(spy).toHaveBeenCalledWith('65:18', 'ckpt_name')
|
||||
})
|
||||
})
|
||||
|
||||
const noopUi = {
|
||||
@@ -218,13 +243,18 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
})
|
||||
|
||||
it('does not apply border styling to promoted widgets', () => {
|
||||
const id = widgetId(GRAPH_ID, 'inner-subgraph:1', 'text')
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'a',
|
||||
options: {},
|
||||
label: 'Text'
|
||||
})
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
entityId: widgetEntityId(GRAPH_ID, 'inner-subgraph:1', 'text'),
|
||||
slotName: 'text',
|
||||
promotedLabel: 'Text'
|
||||
widgetId: id
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
@@ -255,8 +285,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
entityId: widgetEntityId(GRAPH_ID, 'inner-subgraph:1', 'text'),
|
||||
slotName: 'text'
|
||||
widgetId: widgetId(GRAPH_ID, 'inner-subgraph:1', 'text')
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
@@ -314,14 +343,61 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reads widget identity, value, label, and options from widgetId state', () => {
|
||||
const id = widgetId(GRAPH_ID, 'host', 'text')
|
||||
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, 'host', 'text'), {
|
||||
type: 'combo',
|
||||
value: 'state value',
|
||||
label: 'State Label',
|
||||
options: { values: ['state value'] }
|
||||
})
|
||||
const widget = createMockWidget({
|
||||
widgetId: id,
|
||||
nodeId: 'host',
|
||||
name: 'stale name',
|
||||
type: 'combo',
|
||||
options: { values: ['stale value'] }
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '3',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [widget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: GRAPH_ID,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
widgetId: id,
|
||||
renderKey: `${id}:combo`,
|
||||
value: 'state value',
|
||||
simplified: {
|
||||
name: 'text',
|
||||
value: 'state value',
|
||||
label: 'State Label',
|
||||
options: { values: ['state value'] }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('deduplication keeps visible widget over hidden duplicate', () => {
|
||||
const sharedEntityId = widgetEntityId(GRAPH_ID, '1', 'text')
|
||||
const sharedWidgetId = widgetId(GRAPH_ID, '1', 'text')
|
||||
const hiddenWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
entityId: sharedEntityId,
|
||||
slotName: 'text',
|
||||
widgetId: sharedWidgetId,
|
||||
options: { hidden: true }
|
||||
})
|
||||
|
||||
@@ -329,8 +405,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
entityId: sharedEntityId,
|
||||
slotName: 'text'
|
||||
widgetId: sharedWidgetId
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
@@ -391,7 +466,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('color')
|
||||
expect(result[0].renderKey).toBe('node:1:color:color:color')
|
||||
expect(result[0].renderKey).toBe('node:1:color:color')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,9 +507,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
callback
|
||||
})
|
||||
|
||||
useWidgetValueStore().registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
@@ -466,9 +539,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
nodeId: NODE_ID
|
||||
})
|
||||
|
||||
useWidgetValueStore().registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
@@ -477,7 +548,9 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler(99)
|
||||
|
||||
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
widgetId(GRAPH_ID, NODE_ID, 'seed')
|
||||
)
|
||||
expect(state?.value).toBe(99)
|
||||
})
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { getWidgetState } from '@/world/widgetValueIO'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LinkedUpstreamInfo,
|
||||
@@ -51,7 +51,7 @@ interface ProcessedWidget {
|
||||
hasError: boolean
|
||||
hidden: boolean
|
||||
id: string
|
||||
entityId?: WidgetEntityId
|
||||
widgetId?: WidgetId
|
||||
name: string
|
||||
renderKey: string
|
||||
simplified: SimplifiedWidget
|
||||
@@ -90,8 +90,8 @@ function createWidgetUpdateHandler(
|
||||
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
|
||||
executionErrorStore.clearWidgetRelatedErrors(
|
||||
effectiveExecId,
|
||||
widget.slotName ?? widget.name,
|
||||
widget.name,
|
||||
widget.sourceWidgetName ?? widget.name,
|
||||
newValue,
|
||||
{ min: widgetOptions?.min, max: widgetOptions?.max }
|
||||
)
|
||||
@@ -110,12 +110,11 @@ export function hasWidgetError(
|
||||
const errors = widget.sourceExecutionId
|
||||
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
|
||||
: nodeErrors?.errors
|
||||
const inputName = widget.slotName ?? widget.name
|
||||
return (
|
||||
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
|
||||
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
|
||||
missingModelStore.isWidgetMissingModel(
|
||||
widget.sourceExecutionId ?? nodeExecId,
|
||||
widget.name
|
||||
widget.sourceWidgetName ?? widget.name
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -128,11 +127,10 @@ export function getWidgetIdentity(
|
||||
dedupeIdentity?: string
|
||||
renderKey: string
|
||||
} {
|
||||
if (widget.entityId) {
|
||||
const dedupeIdentity = `${widget.entityId}:${widget.type}`
|
||||
if (widget.widgetId) {
|
||||
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
|
||||
return { dedupeIdentity, renderKey: dedupeIdentity }
|
||||
}
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const hostNodeIdRoot =
|
||||
nodeId !== undefined && nodeId !== ''
|
||||
? `node:${String(stripGraphPrefix(nodeId))}`
|
||||
@@ -144,11 +142,11 @@ export function getWidgetIdentity(
|
||||
: hostNodeIdRoot
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${widget.name}:${slotNameForIdentity}:${widget.type}`
|
||||
? `${stableIdentityRoot}:${widget.name}:${widget.type}`
|
||||
: undefined
|
||||
const renderKey =
|
||||
dedupeIdentity ??
|
||||
`transient:${String(nodeId ?? '')}:${widget.name}:${slotNameForIdentity}:${widget.type}:${index}`
|
||||
`transient:${String(nodeId ?? '')}:${widget.name}:${widget.type}:${index}`
|
||||
return { dedupeIdentity, renderKey }
|
||||
}
|
||||
|
||||
@@ -198,13 +196,15 @@ export function computeProcessedWidgets({
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const identity = getWidgetIdentity(widget, nodeId, index)
|
||||
const widgetState = widget.entityId
|
||||
? getWidgetState(widget.entityId)
|
||||
const widgetState = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: graphId
|
||||
? widgetValueStore.getWidget(
|
||||
graphId,
|
||||
String(stripGraphPrefix(widget.nodeId ?? nodeId ?? '')),
|
||||
widget.name
|
||||
widgetId(
|
||||
graphId,
|
||||
String(stripGraphPrefix(widget.nodeId ?? nodeId ?? '')),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
const mergedOptions: IWidgetOptions = {
|
||||
@@ -288,13 +288,13 @@ export function computeProcessedWidgets({
|
||||
: undefined
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
name: widgetState?.name ?? widget.name,
|
||||
type: widget.type,
|
||||
value,
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
controlWidget: widget.controlWidget,
|
||||
label: widget.promotedLabel ?? widgetState?.label,
|
||||
label: widgetState?.label,
|
||||
linkedUpstream,
|
||||
nodeLocatorId,
|
||||
options: widgetOptions,
|
||||
@@ -336,7 +336,7 @@ export function computeProcessedWidgets({
|
||||
),
|
||||
hidden: mergedOptions.hidden ?? false,
|
||||
id: String(bareWidgetId),
|
||||
entityId: widget.entityId,
|
||||
widgetId: widget.widgetId,
|
||||
name: widget.name,
|
||||
renderKey,
|
||||
type: widget.type,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
|
||||
// Consolidate with useStringWidget into shared helpers (domWidgetHelpers.ts).
|
||||
@@ -52,14 +53,18 @@ function addMarkdownWidget(
|
||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||
getValue(): string {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
|
||||
const storedValue = widgetStore.getWidget(
|
||||
widgetId(graphId, node.id, name)
|
||||
)?.value
|
||||
return typeof storedValue === 'string' ? storedValue : textarea.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
textarea.value = v
|
||||
editor.commands.setContent(v)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
const widgetState = widgetStore.getWidget(
|
||||
widgetId(graphId, node.id, name)
|
||||
)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
type TextPreviewCustomProps = Omit<
|
||||
InstanceType<typeof TextPreviewWidget>['$props'],
|
||||
@@ -39,16 +40,16 @@ export function useTextPreviewWidget(
|
||||
options: {
|
||||
getValue: () =>
|
||||
useWidgetValueStore().getWidget(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputSpec.name
|
||||
widgetId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)
|
||||
)?.value ?? '',
|
||||
setValue: (value: string | object) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
node.id,
|
||||
inputSpec.name
|
||||
widgetId(graphId, node.id, inputSpec.name)
|
||||
)
|
||||
if (widgetState)
|
||||
widgetState.value =
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const TRACKPAD_DETECTION_THRESHOLD = 50
|
||||
|
||||
@@ -30,14 +31,18 @@ function addMultilineWidget(
|
||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||
getValue(): string {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
const widgetState = widgetStore.getWidget(
|
||||
widgetId(graphId, node.id, name)
|
||||
)
|
||||
|
||||
return (widgetState?.value as string) ?? inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
const widgetState = widgetStore.getWidget(
|
||||
widgetId(graphId, node.id, name)
|
||||
)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
|
||||
|
||||
@@ -13,27 +11,6 @@ class TestNode extends LGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
class TestSubgraphNode extends TestNode {
|
||||
constructor(
|
||||
widgets: IBaseWidget[],
|
||||
innerNodesById: Record<string, LGraphNode> = {}
|
||||
) {
|
||||
super(widgets)
|
||||
this.subgraph = {
|
||||
getNodeById: (nodeId: string) => innerNodesById[nodeId]
|
||||
} as SubgraphNode['subgraph']
|
||||
}
|
||||
|
||||
override isSubgraphNode(): this is SubgraphNode {
|
||||
return true
|
||||
}
|
||||
|
||||
readonly subgraph: SubgraphNode['subgraph']
|
||||
}
|
||||
|
||||
type TestPromotedWidget = IBaseWidget &
|
||||
Pick<PromotedWidgetView, 'sourceNodeId' | 'sourceWidgetName'>
|
||||
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
@@ -43,131 +20,24 @@ function createWidget(name: string): IBaseWidget {
|
||||
}
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
): TestPromotedWidget {
|
||||
return {
|
||||
...createWidget(name),
|
||||
sourceNodeId,
|
||||
sourceWidgetName
|
||||
}
|
||||
}
|
||||
|
||||
function createHostNode(
|
||||
widgets: IBaseWidget[],
|
||||
options: {
|
||||
isSubgraphNode?: boolean
|
||||
innerNodesById?: Record<string, LGraphNode>
|
||||
} = {}
|
||||
): LGraphNode {
|
||||
const { isSubgraphNode = false, innerNodesById = {} } = options
|
||||
return isSubgraphNode
|
||||
? new TestSubgraphNode(widgets, innerNodesById)
|
||||
: new TestNode(widgets)
|
||||
}
|
||||
|
||||
describe('resolveWidgetFromHostNode', () => {
|
||||
it('returns host node widget for non-promoted widgets', () => {
|
||||
it('returns the host node widget matching the name', () => {
|
||||
const widget = createWidget('text_widget')
|
||||
const hostNode = createHostNode([widget])
|
||||
const hostNode = new TestNode([widget])
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: hostNode, widget })
|
||||
expect(resolveWidgetFromHostNode(hostNode, widget.name)).toEqual({
|
||||
node: hostNode,
|
||||
widget
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves promoted widget to the interior node widget', () => {
|
||||
const innerWidget = createWidget('inner_text')
|
||||
const innerNode = createHostNode([innerWidget])
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '42': innerNode }
|
||||
})
|
||||
it('returns undefined when no widget matches the name', () => {
|
||||
const hostNode = new TestNode([createWidget('other')])
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
|
||||
expect(resolveWidgetFromHostNode(hostNode, 'missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves nested promoted widget chain to deepest interior widget', () => {
|
||||
const innerWidget = createWidget('inner_text')
|
||||
const innerNode = createHostNode([innerWidget])
|
||||
|
||||
const middleNode = createHostNode([], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '100': innerNode }
|
||||
})
|
||||
const middlePromotedWidget = {
|
||||
...createPromotedWidget('inner_text', '100', 'inner_text'),
|
||||
node: middleNode
|
||||
} as TestPromotedWidget & { node: LGraphNode }
|
||||
middleNode.widgets = [middlePromotedWidget]
|
||||
|
||||
const outerPromotedWidget = createPromotedWidget(
|
||||
'outer_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([outerPromotedWidget], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '42': middleNode }
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(
|
||||
hostNode,
|
||||
outerPromotedWidget.name
|
||||
)
|
||||
|
||||
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
|
||||
})
|
||||
|
||||
it('returns undefined when promoted interior node is missing', () => {
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when promoted interior widget is missing', () => {
|
||||
const innerNode = createHostNode([])
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '42': innerNode }
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats promoted-shaped widgets on non-subgraph nodes as local widgets', () => {
|
||||
const widget = createPromotedWidget('promoted_text', '42', 'inner_text')
|
||||
const hostNode = createHostNode([widget], {
|
||||
isSubgraphNode: false
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: hostNode, widget })
|
||||
it('returns undefined when the host node is undefined', () => {
|
||||
expect(resolveWidgetFromHostNode(undefined, 'anything')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface ResolvedHostWidget {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export function resolveWidgetFromHostNode(
|
||||
hostNode: LGraphNode | undefined,
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
): ResolvedHostWidget | undefined {
|
||||
if (!hostNode) return undefined
|
||||
|
||||
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
const sourceWidget = resolvePromotedWidgetSource(hostNode, widget)
|
||||
if (sourceWidget) return sourceWidget
|
||||
|
||||
if (isPromotedWidgetView(widget) && hostNode.isSubgraphNode())
|
||||
return undefined
|
||||
|
||||
return {
|
||||
node: hostNode,
|
||||
widget
|
||||
}
|
||||
return { node: hostNode, widget }
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => {
|
||||
const widgetMap = new Map<string, { value: unknown }>()
|
||||
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
|
||||
widgetMap.get(name)
|
||||
const getWidget = vi.fn((id: string) =>
|
||||
widgetMap.get(id.split(':').slice(2).join(':'))
|
||||
)
|
||||
return {
|
||||
useWidgetValueStore: () => ({
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { UUID } from '@/utils/uuid'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import { curveDataToFloatLUT } from '@/components/curve/curveUtils'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
@@ -194,17 +195,14 @@ function createInnerPreview(
|
||||
if (isGLSLNode.value) {
|
||||
const nId = nodeId.value
|
||||
if (nId == null) return undefined
|
||||
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
|
||||
| string
|
||||
| undefined
|
||||
return widgetValueStore.getWidget(widgetId(gId, nId, 'fragment_shader'))
|
||||
?.value as string | undefined
|
||||
}
|
||||
|
||||
const inner = innerGLSLNode
|
||||
if (inner) {
|
||||
return widgetValueStore.getWidget(
|
||||
gId,
|
||||
inner.id as NodeId,
|
||||
'fragment_shader'
|
||||
widgetId(gId, inner.id as NodeId, 'fragment_shader')
|
||||
)?.value as string | undefined
|
||||
}
|
||||
|
||||
@@ -292,21 +290,15 @@ function createInnerPreview(
|
||||
if (sizeModeNodeId == null) return null
|
||||
|
||||
const sizeMode = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode'
|
||||
widgetId(gId, sizeModeNodeId, 'size_mode')
|
||||
)
|
||||
if (sizeMode?.value !== 'custom') return null
|
||||
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.width'
|
||||
widgetId(gId, sizeModeNodeId, 'size_mode.width')
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.height'
|
||||
widgetId(gId, sizeModeNodeId, 'size_mode.height')
|
||||
)
|
||||
if (!widthWidget || !heightWidget) return null
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { isCurveData } from '@/components/curve/curveUtils'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import { hexToInt } from '@/utils/colorUtil'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
interface AutogrowGroup {
|
||||
max: number
|
||||
@@ -132,7 +133,9 @@ export function useGLSLUniforms(
|
||||
|
||||
if (subgraphSources) {
|
||||
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
const widget = widgetValueStore.getWidget(
|
||||
widgetId(gId, nId, widgetName)
|
||||
)
|
||||
return coerce(widget?.value ?? directValue() ?? defaultValue)
|
||||
})
|
||||
}
|
||||
@@ -144,7 +147,7 @@ export function useGLSLUniforms(
|
||||
const values: T[] = []
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
const inputName = `${groupName}.${uniformPrefix}${i}`
|
||||
const widget = widgetValueStore.getWidget(gId, nId, inputName)
|
||||
const widget = widgetValueStore.getWidget(widgetId(gId, nId, inputName))
|
||||
if (widget !== undefined) {
|
||||
values.push(coerce(widget.value))
|
||||
continue
|
||||
@@ -214,7 +217,9 @@ export function useGLSLUniforms(
|
||||
if (sources && sources.length > 0) {
|
||||
return sources
|
||||
.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
const widget = widgetValueStore.getWidget(
|
||||
widgetId(gId, nId, widgetName)
|
||||
)
|
||||
const value = widget?.value ?? directValue()
|
||||
return isCurveData(value) ? (value as CurveData) : null
|
||||
})
|
||||
@@ -230,7 +235,7 @@ export function useGLSLUniforms(
|
||||
for (let i = 0; i < max; i++) {
|
||||
const inputName = `curves.u_curve${i}`
|
||||
|
||||
const widget = widgetValueStore.getWidget(gId, nId, inputName)
|
||||
const widget = widgetValueStore.getWidget(widgetId(gId, nId, inputName))
|
||||
if (widget && isCurveData(widget.value)) {
|
||||
values.push(widget.value as CurveData)
|
||||
continue
|
||||
|
||||
@@ -5,8 +5,17 @@ import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode as LGraphNodeClass,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } 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 { widgetId } from '@/world/entityIds'
|
||||
import type {
|
||||
InputWidgetConfig,
|
||||
LinearInput,
|
||||
@@ -17,7 +26,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
|
||||
@@ -127,16 +136,16 @@ function createWorkflowWithLinearData(
|
||||
}
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entityPrompt = `${rootGraphId}:1:prompt` as WidgetEntityId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
const entitySteps = `${rootGraphId}:1:steps` as WidgetEntityId
|
||||
const entityPrompt = `${rootGraphId}:1:prompt` as WidgetId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
|
||||
const entitySteps = `${rootGraphId}:1:steps` as WidgetId
|
||||
|
||||
function nodeWithWidgets(id: number, widgetNames: string[]) {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -660,10 +669,10 @@ describe('appModeStore', () => {
|
||||
})
|
||||
|
||||
describe('updateInputConfig', () => {
|
||||
const entity = 'g:1:prompt' as WidgetEntityId
|
||||
const otherEntity = 'g:99:prompt' as WidgetEntityId
|
||||
const widget = fromAny<IBaseWidget, unknown>({ entityId: entity })
|
||||
const otherWidget = fromAny<IBaseWidget, unknown>({ entityId: otherEntity })
|
||||
const entity = 'g:1:prompt' as WidgetId
|
||||
const otherEntity = 'g:99:prompt' as WidgetId
|
||||
const widget = fromAny<IBaseWidget, unknown>({ widgetId: entity })
|
||||
const otherWidget = fromAny<IBaseWidget, unknown>({ widgetId: otherEntity })
|
||||
|
||||
it('sets config on an existing input', () => {
|
||||
store.selectedInputs.push([entity, 'prompt'])
|
||||
@@ -681,11 +690,11 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedInputs[0][2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op when the widget has no entityId', () => {
|
||||
it('is a no-op when the widget has no widgetId', () => {
|
||||
store.selectedInputs.push([entity, 'prompt'])
|
||||
|
||||
store.updateInputConfig(
|
||||
fromAny<IBaseWidget, unknown>({ entityId: undefined }),
|
||||
fromAny<IBaseWidget, unknown>({ widgetId: undefined }),
|
||||
{ height: 200 }
|
||||
)
|
||||
|
||||
@@ -721,10 +730,10 @@ describe('appModeStore', () => {
|
||||
|
||||
describe('removeSelectedInput', () => {
|
||||
it('removes the matching input entry only', () => {
|
||||
const promptEntity = 'g:1:prompt' as WidgetEntityId
|
||||
const stepsEntity = 'g:2:steps' as WidgetEntityId
|
||||
const promptEntity = 'g:1:prompt' as WidgetId
|
||||
const stepsEntity = 'g:2:steps' as WidgetId
|
||||
const stepsWidget = fromAny<IBaseWidget, unknown>({
|
||||
entityId: stepsEntity,
|
||||
widgetId: stepsEntity,
|
||||
name: 'steps'
|
||||
})
|
||||
store.selectedInputs.push([promptEntity, 'prompt'])
|
||||
@@ -804,33 +813,38 @@ describe('appModeStore', () => {
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
it('migrates legacy `(sourceNodeId, sourceWidgetName)` to the host promoted widget entity id', () => {
|
||||
const hostId = 5
|
||||
const sourceNodeId = 42
|
||||
const subgraphInputName = 'Prompt'
|
||||
const sourceWidgetName = 'text'
|
||||
const promotedEntityId =
|
||||
`${rootGraphId}:${hostId}:${subgraphInputName}` as WidgetEntityId
|
||||
const hostWidget = {
|
||||
name: subgraphInputName,
|
||||
sourceNodeId: String(sourceNodeId),
|
||||
sourceWidgetName,
|
||||
entityId: promotedEntityId
|
||||
}
|
||||
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
|
||||
id: hostId,
|
||||
inputs: [{ name: subgraphInputName, _widget: hostWidget }],
|
||||
widgets: [hostWidget],
|
||||
isSubgraphNode: () => true
|
||||
}) as SubgraphNode
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
vi.mocked(app.rootGraph).nodes = [hostNode]
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: subgraphInputName, type: 'STRING' }]
|
||||
})
|
||||
const interior = new LGraphNodeClass('Interior')
|
||||
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
|
||||
interior.addWidget('string', sourceWidgetName, '', () => undefined)
|
||||
interiorInput.widget = { name: sourceWidgetName }
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interior)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 5 })
|
||||
const rootGraph = host.graph as LGraph
|
||||
rootGraph.add(host)
|
||||
host._internalConfigureAfterSlots()
|
||||
|
||||
const promotedEntityId = widgetId(
|
||||
rootGraph.id,
|
||||
host.id,
|
||||
subgraphInputName
|
||||
)
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraph.id
|
||||
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id: NodeId | null | undefined) => (id == hostId ? hostNode : null)
|
||||
(id: NodeId | null | undefined) => rootGraph.getNodeById(id as NodeId)
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
|
||||
inputs: [[interior.id, sourceWidgetName, { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
@@ -844,16 +858,16 @@ describe('appModeStore', () => {
|
||||
const sourceNodeId = 42
|
||||
const sourceWidgetName = 'text'
|
||||
const rootEntityId =
|
||||
`${rootGraphId}:${sourceNodeId}:${sourceWidgetName}` as WidgetEntityId
|
||||
`${rootGraphId}:${sourceNodeId}:${sourceWidgetName}` as WidgetId
|
||||
const rootNode = fromAny<LGraphNode, unknown>({
|
||||
id: sourceNodeId,
|
||||
widgets: [{ name: sourceWidgetName, entityId: rootEntityId }]
|
||||
widgets: [{ name: sourceWidgetName, widgetId: rootEntityId }]
|
||||
})
|
||||
const hostWidget = {
|
||||
name: 'Prompt',
|
||||
sourceNodeId: String(sourceNodeId),
|
||||
sourceWidgetName,
|
||||
entityId: `${rootGraphId}:${hostId}:Prompt` as WidgetEntityId
|
||||
widgetId: `${rootGraphId}:${hostId}:Prompt` as WidgetId
|
||||
}
|
||||
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
|
||||
id: hostId,
|
||||
@@ -907,11 +921,11 @@ describe('appModeStore', () => {
|
||||
const hostId = 5
|
||||
const hostLocator = `${rootGraphId}:${hostId}`
|
||||
const promotedEntityId =
|
||||
`${rootGraphId}:${hostId}:subgraph_input_name` as WidgetEntityId
|
||||
`${rootGraphId}:${hostId}:subgraph_input_name` as WidgetId
|
||||
const hostNode = fromAny<LGraphNode, unknown>({
|
||||
id: hostId,
|
||||
isSubgraphNode: () => true,
|
||||
widgets: [{ name: 'subgraph_input_name', entityId: promotedEntityId }]
|
||||
widgets: [{ name: 'subgraph_input_name', widgetId: promotedEntityId }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
vi.mocked(app.rootGraph).nodes = [hostNode]
|
||||
|
||||
@@ -17,24 +17,24 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
getWidgetEntityIdForNode,
|
||||
getWidgetIdForNode,
|
||||
resolveNode,
|
||||
resolveNodeWidget
|
||||
} from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { isWidgetId, parseWidgetId } from '@/world/entityIds'
|
||||
|
||||
function findWidgetByEntityId(
|
||||
rootGraph: LGraph,
|
||||
entityId: WidgetEntityId
|
||||
widgetId: WidgetId
|
||||
): IBaseWidget | undefined {
|
||||
for (const node of rootGraph.nodes) {
|
||||
const widget = node.widgets?.find(
|
||||
(w) => getWidgetEntityIdForNode(node, w) === entityId
|
||||
(w) => getWidgetIdForNode(node, w) === widgetId
|
||||
)
|
||||
if (widget) return widget
|
||||
}
|
||||
@@ -82,11 +82,11 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
}
|
||||
|
||||
function buildEntry(
|
||||
entityId: WidgetEntityId,
|
||||
widgetId: WidgetId,
|
||||
name: string,
|
||||
config: InputWidgetConfig | undefined
|
||||
): LinearInput {
|
||||
return config === undefined ? [entityId, name] : [entityId, name, config]
|
||||
return config === undefined ? [widgetId, name] : [widgetId, name, config]
|
||||
}
|
||||
|
||||
function upgradeAndValidateInput(
|
||||
@@ -95,10 +95,10 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
): LinearInput | null {
|
||||
const [storedId, widgetName, config] = input
|
||||
|
||||
if (typeof storedId === 'string' && isWidgetEntityId(storedId)) {
|
||||
if (typeof storedId === 'string' && isWidgetId(storedId)) {
|
||||
const widget = findWidgetByEntityId(rootGraph, storedId)
|
||||
if (widget) return buildEntry(storedId, widgetName, config)
|
||||
const { nodeId } = parseWidgetEntityId(storedId)
|
||||
const { nodeId } = parseWidgetId(storedId)
|
||||
if (rootGraph.getNodeById?.(nodeId)) {
|
||||
return buildEntry(storedId, widgetName, config)
|
||||
}
|
||||
@@ -107,31 +107,29 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
|
||||
if (typeof storedId === 'string' && storedId.includes(':')) {
|
||||
const [, widget] = resolveNodeWidget(storedId, widgetName)
|
||||
if (!widget?.entityId) return null
|
||||
return buildEntry(widget.entityId, widgetName, config)
|
||||
if (!widget?.widgetId) return null
|
||||
return buildEntry(widget.widgetId, widgetName, config)
|
||||
}
|
||||
|
||||
const directNode = rootGraph.getNodeById?.(storedId)
|
||||
const directWidget = directNode?.widgets?.find((w) => w.name === widgetName)
|
||||
if (directNode && directWidget) {
|
||||
const derivedId = getWidgetEntityIdForNode(directNode, directWidget)
|
||||
const derivedId = getWidgetIdForNode(directNode, directWidget)
|
||||
if (derivedId) return buildEntry(derivedId, widgetName, config)
|
||||
}
|
||||
|
||||
const matches: LinearInput[] = rootGraph.nodes.flatMap((node) => {
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
return node.inputs.flatMap((inputSlot): LinearInput[] => {
|
||||
const widget = inputSlot._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return []
|
||||
if (!inputSlot.widgetId) return []
|
||||
const target = resolveSubgraphInputTarget(node, inputSlot.name)
|
||||
if (
|
||||
widget.sourceNodeId !== String(storedId) ||
|
||||
widget.sourceWidgetName !== widgetName
|
||||
target?.nodeId !== String(storedId) ||
|
||||
target.widgetName !== widgetName
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return widget.entityId
|
||||
? [buildEntry(widget.entityId, inputSlot.name, config)]
|
||||
: []
|
||||
return [buildEntry(inputSlot.widgetId, inputSlot.name, config)]
|
||||
})
|
||||
})
|
||||
if (matches.length === 1) return matches[0]
|
||||
@@ -246,7 +244,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
}
|
||||
|
||||
function removeSelectedInput(widget: IBaseWidget) {
|
||||
const targetEntityId = widget.entityId
|
||||
const targetEntityId = widget.widgetId
|
||||
if (!targetEntityId) return
|
||||
const index = selectedInputs.value.findIndex(
|
||||
([id]) => id === targetEntityId
|
||||
@@ -255,7 +253,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
}
|
||||
|
||||
function updateInputConfig(widget: IBaseWidget, config: InputWidgetConfig) {
|
||||
const targetEntityId = widget.entityId
|
||||
const targetEntityId = widget.widgetId
|
||||
if (!targetEntityId) return
|
||||
const index = selectedInputs.value.findIndex(
|
||||
([id]) => id === targetEntityId
|
||||
|
||||
@@ -2,6 +2,13 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { NodeDefFilter } from '@/stores/nodeDefStore'
|
||||
@@ -331,6 +338,59 @@ describe('useNodeDefStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subgraph widget input specs', () => {
|
||||
function setupPromotedPrompt(nodeDef: ComfyNodeDef): SubgraphNode {
|
||||
store.updateNodeDefs([nodeDef])
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const graph = host.graph as LGraph
|
||||
graph.add(host)
|
||||
|
||||
const interior = new LGraphNode('PromptNode', 'PromptNode')
|
||||
const input = interior.addInput('prompt', 'STRING')
|
||||
const widget = interior.addWidget(
|
||||
'text',
|
||||
'prompt',
|
||||
'current edited value',
|
||||
() => undefined
|
||||
)
|
||||
input.widget = { name: widget.name }
|
||||
subgraph.add(interior)
|
||||
promoteValueWidgetViaSubgraphInput(host, interior, widget)
|
||||
return host
|
||||
}
|
||||
|
||||
it('resolves the interior node definition spec, not the current value', () => {
|
||||
const host = setupPromotedPrompt(
|
||||
createMockNodeDef({
|
||||
name: 'PromptNode',
|
||||
input: {
|
||||
required: { prompt: ['STRING', { default: 'real default' }] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getInputSpecForWidget(host, 'prompt')).toMatchObject({
|
||||
name: 'prompt',
|
||||
type: 'STRING',
|
||||
default: 'real default'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fabricate a default from the current promoted value', () => {
|
||||
const host = setupPromotedPrompt(
|
||||
createMockNodeDef({
|
||||
name: 'PromptNode',
|
||||
input: { required: { prompt: ['STRING', {}] } }
|
||||
})
|
||||
)
|
||||
|
||||
const spec = store.getInputSpecForWidget(host, 'prompt')
|
||||
expect(spec?.type).toBe('STRING')
|
||||
expect(spec?.default).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance', () => {
|
||||
it('should perform single traversal for multiple filters', () => {
|
||||
let filterCallCount = 0
|
||||
|
||||
@@ -4,8 +4,8 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { promotedInputSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveInputType } from '@/core/graph/widgets/dynamicTypes'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -427,13 +427,22 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
|
||||
return nodeDef.inputs[widgetName]
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget || !isPromotedWidgetView(widget)) return undefined
|
||||
|
||||
const sourceWidget = resolvePromotedWidgetSource(node, widget)
|
||||
if (!sourceWidget) return undefined
|
||||
|
||||
return getInputSpecForWidget(sourceWidget.node, sourceWidget.widget.name)
|
||||
// A subgraph node's widget is a promoted input named after its slot; resolve
|
||||
// the interior source and read its real spec instead of fabricating one.
|
||||
const input = node.inputs.find((i) => i.name === widgetName)
|
||||
if (!input) return undefined
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
if (resolution.status !== 'resolved') return undefined
|
||||
return getInputSpecForWidget(
|
||||
resolution.resolved.node,
|
||||
resolution.resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,24 +3,25 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import type { WidgetState } from './widgetValueStore'
|
||||
import { useWidgetValueStore } from './widgetValueStore'
|
||||
|
||||
function widget<T>(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
function state<T>(
|
||||
type: string,
|
||||
value: T,
|
||||
extra: Partial<
|
||||
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
|
||||
> = {}
|
||||
): WidgetState<T> {
|
||||
return { nodeId, name, type, value, options: {}, ...extra }
|
||||
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
|
||||
): Omit<WidgetState<T>, 'nodeId' | 'name'> {
|
||||
return { type, value, options: {}, ...extra }
|
||||
}
|
||||
|
||||
describe('useWidgetValueStore', () => {
|
||||
const graphA = 'graph-a' as UUID
|
||||
const graphB = 'graph-b' as UUID
|
||||
const seedA = widgetId(graphA, 'node-1', 'seed')
|
||||
const seedB = widgetId(graphB, 'node-1', 'seed')
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
@@ -28,62 +29,81 @@ describe('useWidgetValueStore', () => {
|
||||
describe('widgetState.value access', () => {
|
||||
it('getWidget returns undefined for unregistered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
expect(store.getWidget(seedA)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('widgetState.value can be read and written directly', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
expect(state.value).toBe(100)
|
||||
const registered = store.registerWidget(seedA, state('number', 100))
|
||||
expect(registered.value).toBe(100)
|
||||
|
||||
state.value = 200
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
|
||||
registered.value = 200
|
||||
expect(store.getWidget(seedA)?.value).toBe(200)
|
||||
})
|
||||
|
||||
it('stores different value types', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
|
||||
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
|
||||
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'array', 'combo', [1, 2, 3])
|
||||
widgetId(graphA, 'node-1', 'text'),
|
||||
state('string', 'hello')
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'number'),
|
||||
state('number', 42)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'boolean'),
|
||||
state('toggle', true)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'array'),
|
||||
state('combo', [1, 2, 3])
|
||||
)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
|
||||
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
|
||||
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
|
||||
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
expect(store.getWidget(widgetId(graphA, 'node-1', 'text'))?.value).toBe(
|
||||
'hello'
|
||||
)
|
||||
expect(store.getWidget(widgetId(graphA, 'node-1', 'number'))?.value).toBe(
|
||||
42
|
||||
)
|
||||
expect(
|
||||
store.getWidget(widgetId(graphA, 'node-1', 'boolean'))?.value
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.getWidget(widgetId(graphA, 'node-1', 'array'))?.value
|
||||
).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget registration', () => {
|
||||
it('registers a widget with minimal properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 12345)
|
||||
)
|
||||
const registered = store.registerWidget(seedA, state('number', 12345))
|
||||
|
||||
expect(state.nodeId).toBe('node-1')
|
||||
expect(state.name).toBe('seed')
|
||||
expect(state.type).toBe('number')
|
||||
expect(state.value).toBe(12345)
|
||||
expect(state.disabled).toBeUndefined()
|
||||
expect(state.serialize).toBeUndefined()
|
||||
expect(state.options).toEqual({})
|
||||
expect(registered.nodeId).toBe('node-1')
|
||||
expect(registered.name).toBe('seed')
|
||||
expect(registered.type).toBe('number')
|
||||
expect(registered.value).toBe(12345)
|
||||
expect(registered.disabled).toBeUndefined()
|
||||
expect(registered.serialize).toBeUndefined()
|
||||
expect(registered.options).toEqual({})
|
||||
})
|
||||
|
||||
it('registerWidget is idempotent and does not overwrite existing state', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const first = store.registerWidget(seedA, state('number', 11))
|
||||
first.value = 99
|
||||
|
||||
const second = store.registerWidget(seedA, state('number', 11))
|
||||
expect(second).toBe(first)
|
||||
expect(second.value).toBe(99)
|
||||
})
|
||||
|
||||
it('registers a widget with all properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'string', 'test', {
|
||||
const registered = store.registerWidget(
|
||||
seedA,
|
||||
state('string', 'test', {
|
||||
label: 'Prompt Text',
|
||||
disabled: true,
|
||||
serialize: false,
|
||||
@@ -91,34 +111,38 @@ describe('useWidgetValueStore', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(state.label).toBe('Prompt Text')
|
||||
expect(state.disabled).toBe(true)
|
||||
expect(state.serialize).toBe(false)
|
||||
expect(state.options).toEqual({ multiline: true })
|
||||
expect(registered.label).toBe('Prompt Text')
|
||||
expect(registered.disabled).toBe(true)
|
||||
expect(registered.serialize).toBe(false)
|
||||
expect(registered.options).toEqual({ multiline: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget getters', () => {
|
||||
it('getWidget returns widget state', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
|
||||
store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
const state = store.getWidget(graphA, 'node-1', 'seed')
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.name).toBe('seed')
|
||||
expect(state?.value).toBe(100)
|
||||
})
|
||||
|
||||
it('getWidget returns undefined for missing widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
const registered = store.getWidget(seedA)
|
||||
expect(registered).toBeDefined()
|
||||
expect(registered?.name).toBe('seed')
|
||||
expect(registered?.value).toBe(100)
|
||||
})
|
||||
|
||||
it('getNodeWidgets returns all widgets for a node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
|
||||
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'seed'),
|
||||
state('number', 1)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-1', 'steps'),
|
||||
state('number', 20)
|
||||
)
|
||||
store.registerWidget(
|
||||
widgetId(graphA, 'node-2', 'cfg'),
|
||||
state('number', 7)
|
||||
)
|
||||
|
||||
const widgets = store.getNodeWidgets(graphA, 'node-1')
|
||||
expect(widgets).toHaveLength(2)
|
||||
@@ -126,54 +150,66 @@ describe('useWidgetValueStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('value mutation', () => {
|
||||
it('setValue updates registered widgets and reports missing widgets', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
expect(store.setValue(seedA, 200)).toBe(true)
|
||||
expect(store.getWidget(seedA)?.value).toBe(200)
|
||||
expect(store.setValue(widgetId(graphA, 'missing', 'seed'), 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('deleteWidget removes registered widgets', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
expect(store.deleteWidget(seedA)).toBe(true)
|
||||
expect(store.getWidget(seedA)).toBeUndefined()
|
||||
expect(store.deleteWidget(seedA)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct property mutation', () => {
|
||||
it('disabled can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
const registered = store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
state.disabled = true
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
|
||||
registered.disabled = true
|
||||
expect(store.getWidget(seedA)?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('label can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
const registered = store.registerWidget(seedA, state('number', 100))
|
||||
|
||||
state.label = 'Random Seed'
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
|
||||
'Random Seed'
|
||||
)
|
||||
registered.label = 'Random Seed'
|
||||
expect(store.getWidget(seedA)?.label).toBe('Random Seed')
|
||||
|
||||
state.label = undefined
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
|
||||
registered.label = undefined
|
||||
expect(store.getWidget(seedA)?.label).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates widget states by graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidget(seedB, state('number', 2))
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(store.getWidget(seedA)?.value).toBe(1)
|
||||
expect(store.getWidget(seedB)?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
store.registerWidget(seedA, state('number', 1))
|
||||
store.registerWidget(seedB, state('number', 2))
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(store.getWidget(seedA)).toBeUndefined()
|
||||
expect(store.getWidget(seedB)?.value).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,93 +3,69 @@ import { reactive, ref } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { parseWidgetId } from '@/types/widgetId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import type { WidgetState, WidgetStateInit } from '@/types/widgetState'
|
||||
|
||||
type WidgetKey = `${NodeId}:${string}`
|
||||
export type { WidgetState, WidgetStateInit } from '@/types/widgetState'
|
||||
|
||||
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
|
||||
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
|
||||
}
|
||||
|
||||
export interface WidgetState<
|
||||
TValue = unknown,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
> extends Pick<
|
||||
IBaseWidget<TValue, TType, TOptions>,
|
||||
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
|
||||
> {
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
|
||||
const graphWidgetStates = ref(new Map<UUID, Map<WidgetId, WidgetState>>())
|
||||
|
||||
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
|
||||
function getGraphWidgetStates(graphId: UUID): Map<WidgetId, WidgetState> {
|
||||
const widgetStates = graphWidgetStates.value.get(graphId)
|
||||
if (widgetStates) return widgetStates
|
||||
|
||||
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
|
||||
const nextWidgetStates = reactive(new Map<WidgetId, WidgetState>())
|
||||
graphWidgetStates.value.set(graphId, nextWidgetStates)
|
||||
return nextWidgetStates
|
||||
}
|
||||
|
||||
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
|
||||
return `${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `ensureWidgetState(widget.entityId, init)` from
|
||||
* `src/world/widgetValueIO.ts` — the branded `WidgetEntityId` prevents
|
||||
* producer/consumer drift that loose triples allow.
|
||||
*/
|
||||
function registerWidget<TValue = unknown>(
|
||||
graphId: UUID,
|
||||
state: WidgetState<TValue>
|
||||
widgetId: WidgetId,
|
||||
init: WidgetStateInit<TValue>
|
||||
): WidgetState<TValue> {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
widgetStates.set(key, state)
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
const existing = getWidget(widgetId)
|
||||
if (existing) return existing as WidgetState<TValue>
|
||||
|
||||
const { graphId, nodeId, name } = parseWidgetId(widgetId)
|
||||
const state: WidgetState<TValue> = {
|
||||
...init,
|
||||
nodeId,
|
||||
name
|
||||
}
|
||||
const widgetStates = getGraphWidgetStates(graphId)
|
||||
widgetStates.set(widgetId, state)
|
||||
return widgetStates.get(widgetId) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${nodeId}:`
|
||||
return [...widgetStates]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, state]) => state)
|
||||
function getWidget(widgetId: WidgetId): WidgetState | undefined {
|
||||
const { graphId } = parseWidgetId(widgetId)
|
||||
return getGraphWidgetStates(graphId).get(widgetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `getWidgetState(widget.entityId)` or
|
||||
* `readWidgetValue(widget.entityId)` from `src/world/widgetValueIO.ts` —
|
||||
* the branded `WidgetEntityId` prevents producer/consumer drift that loose
|
||||
* triples allow.
|
||||
*/
|
||||
function getWidget(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string
|
||||
): WidgetState | undefined {
|
||||
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
}
|
||||
|
||||
function setValue(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
value: WidgetState['value']
|
||||
): boolean {
|
||||
const state = getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
function setValue(widgetId: WidgetId, value: WidgetState['value']): boolean {
|
||||
const state = getWidget(widgetId)
|
||||
if (!state) return false
|
||||
state.value = value
|
||||
return true
|
||||
}
|
||||
|
||||
function deleteWidget(widgetId: WidgetId): boolean {
|
||||
const { graphId } = parseWidgetId(widgetId)
|
||||
return getGraphWidgetStates(graphId).delete(widgetId)
|
||||
}
|
||||
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
return [...getGraphWidgetStates(graphId).values()].filter(
|
||||
(state) => String(state.nodeId) === String(nodeId)
|
||||
)
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphWidgetStates.value.delete(graphId)
|
||||
}
|
||||
@@ -98,6 +74,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
registerWidget,
|
||||
getWidget,
|
||||
setValue,
|
||||
deleteWidget,
|
||||
getNodeWidgets,
|
||||
clearGraph
|
||||
}
|
||||
|
||||
51
src/types/widgetId.ts
Normal file
51
src/types/widgetId.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
type NodeId = number | string
|
||||
type UUID = string
|
||||
|
||||
export type WidgetId = string & { readonly __brand: 'WidgetId' }
|
||||
|
||||
const SEPARATOR = ':'
|
||||
|
||||
export function widgetId(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
name: string
|
||||
): WidgetId {
|
||||
return [
|
||||
graphId,
|
||||
encodeURIComponent(String(nodeId)),
|
||||
encodeURIComponent(name)
|
||||
].join(SEPARATOR) as WidgetId
|
||||
}
|
||||
|
||||
function decodeWidgetIdSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch (error) {
|
||||
if (error instanceof URIError) return segment
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWidgetId(id: WidgetId): {
|
||||
graphId: UUID
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
} {
|
||||
const firstColon = id.indexOf(SEPARATOR)
|
||||
const secondColon = id.indexOf(SEPARATOR, firstColon + 1)
|
||||
const nodeId = id.slice(firstColon + 1, secondColon)
|
||||
const name = id.slice(secondColon + 1)
|
||||
return {
|
||||
graphId: id.slice(0, firstColon),
|
||||
nodeId: decodeWidgetIdSegment(nodeId),
|
||||
name: decodeWidgetIdSegment(name)
|
||||
}
|
||||
}
|
||||
|
||||
export function isWidgetId(value: unknown): value is WidgetId {
|
||||
if (typeof value !== 'string') return false
|
||||
const firstColon = value.indexOf(SEPARATOR)
|
||||
if (firstColon <= 0) return false
|
||||
const secondColon = value.indexOf(SEPARATOR, firstColon + 1)
|
||||
return secondColon > firstColon + 1
|
||||
}
|
||||
22
src/types/widgetState.ts
Normal file
22
src/types/widgetState.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface WidgetState<
|
||||
TValue = unknown,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
> extends Pick<
|
||||
IBaseWidget<TValue, TType, TOptions>,
|
||||
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
|
||||
> {
|
||||
isDOMWidget?: boolean
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
export type WidgetStateInit<TValue = unknown> = Omit<
|
||||
WidgetState<TValue>,
|
||||
'nodeId' | 'name'
|
||||
>
|
||||
@@ -3,14 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
|
||||
import {
|
||||
createNode,
|
||||
getWidgetEntityIdForNode,
|
||||
resolveNode
|
||||
} from './litegraphUtil'
|
||||
import { createNode, getWidgetIdForNode, resolveNode } from './litegraphUtil'
|
||||
|
||||
const mockBringNodeToFront = vi.fn()
|
||||
|
||||
@@ -149,7 +145,7 @@ describe('createNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWidgetEntityIdForNode', () => {
|
||||
describe('getWidgetIdForNode', () => {
|
||||
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
function fakeNode(id: number, opts: { detached?: boolean } = {}): LGraphNode {
|
||||
@@ -159,29 +155,29 @@ describe('getWidgetEntityIdForNode', () => {
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
it('returns widget.entityId when present', () => {
|
||||
it('returns widget.widgetId when present', () => {
|
||||
const node = fakeNode(7)
|
||||
const widget = {
|
||||
name: 'seed',
|
||||
entityId: 'precomputed:7:seed' as WidgetEntityId
|
||||
widgetId: 'precomputed:7:seed' as WidgetId
|
||||
}
|
||||
expect(getWidgetEntityIdForNode(node, widget)).toBe('precomputed:7:seed')
|
||||
expect(getWidgetIdForNode(node, widget)).toBe('precomputed:7:seed')
|
||||
})
|
||||
|
||||
it('derives an entityId for plain POJO widgets bound to a node', () => {
|
||||
it('derives an widgetId for plain POJO widgets bound to a node', () => {
|
||||
const node = fakeNode(42)
|
||||
expect(getWidgetEntityIdForNode(node, { name: 'legacy_widget' })).toBe(
|
||||
widgetEntityId(graphId, 42, 'legacy_widget')
|
||||
expect(getWidgetIdForNode(node, { name: 'legacy_widget' })).toBe(
|
||||
widgetId(graphId, 42, 'legacy_widget')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined when the node has no graph', () => {
|
||||
const node = fakeNode(1, { detached: true })
|
||||
expect(getWidgetEntityIdForNode(node, { name: 'x' })).toBeUndefined()
|
||||
expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for placeholder node id (-1)', () => {
|
||||
const node = fakeNode(-1)
|
||||
expect(getWidgetEntityIdForNode(node, { name: 'x' })).toBeUndefined()
|
||||
expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import {
|
||||
@@ -28,8 +27,8 @@ import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNod
|
||||
import { app } from '@/scripts/app'
|
||||
import { t } from '@/i18n'
|
||||
import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/world/entityIds'
|
||||
|
||||
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
|
||||
type VideoNode = LGraphNode & {
|
||||
@@ -358,28 +357,17 @@ export function resolveNodeWidget(
|
||||
return widget ? [node, widget] : []
|
||||
}
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (!node.isSubgraphNode()) continue
|
||||
const widget = node.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
)
|
||||
if (widget) return [node, widget]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function getWidgetEntityIdForNode(
|
||||
export function getWidgetIdForNode(
|
||||
node: LGraphNode,
|
||||
widget: Pick<IBaseWidget, 'name' | 'entityId'>
|
||||
): WidgetEntityId | undefined {
|
||||
if (widget.entityId) return widget.entityId
|
||||
widget: Pick<IBaseWidget, 'name' | 'widgetId'>
|
||||
): WidgetId | undefined {
|
||||
if (widget.widgetId) return widget.widgetId
|
||||
const graphId = node.graph?.rootGraph.id
|
||||
if (!graphId || node.id === -1) return undefined
|
||||
return widgetEntityId(graphId, node.id, widget.name)
|
||||
return widgetId(graphId, node.id, widget.name)
|
||||
}
|
||||
|
||||
export function isLoad3dNode(node: LGraphNode) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -6,14 +8,8 @@ import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { getWidgetDefaultValue, renameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn()
|
||||
}))
|
||||
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
|
||||
const mockedResolve = vi.mocked(resolvePromotedWidgetSource)
|
||||
import type { WidgetId } from '@/world/entityIds'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
describe('getWidgetDefaultValue', () => {
|
||||
it('returns undefined for undefined spec', () => {
|
||||
@@ -76,12 +72,18 @@ function makeNode({
|
||||
|
||||
describe('renameWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renames a regular widget and its matching input', () => {
|
||||
const widget = makeWidget({ name: 'seed' })
|
||||
const input = { name: 'seed', widget: { name: 'seed' } } as INodeInputSlot
|
||||
it('writes the label to the widget and the input matched by widgetId', () => {
|
||||
const id = 'graph-id:1:seed' as WidgetId
|
||||
const widget = makeWidget({ name: 'seed', widgetId: id })
|
||||
const input = {
|
||||
name: 'seed',
|
||||
widgetId: id,
|
||||
widget: { name: 'seed' }
|
||||
} as INodeInputSlot
|
||||
const node = makeNode({ inputs: [input] })
|
||||
|
||||
const result = renameWidget(widget, node, 'My Seed')
|
||||
@@ -100,46 +102,45 @@ describe('renameWidget', () => {
|
||||
expect(widget.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renames promoted widget source when node is a subgraph without explicit parents', () => {
|
||||
const sourceWidget = makeWidget({ name: 'innerSeed' })
|
||||
const interiorInput = {
|
||||
name: 'innerSeed',
|
||||
widget: { name: 'innerSeed' }
|
||||
} as INodeInputSlot
|
||||
const interiorNode = makeNode({ inputs: [interiorInput] })
|
||||
|
||||
mockedResolve.mockReturnValue({
|
||||
widget: sourceWidget,
|
||||
node: interiorNode
|
||||
it('writes the label to host widget state for a promoted subgraph input', () => {
|
||||
const hostWidgetId = 'graph-id:7:seed' as WidgetId
|
||||
useWidgetValueStore().registerWidget(hostWidgetId, {
|
||||
type: 'number',
|
||||
value: 12,
|
||||
options: {},
|
||||
label: undefined
|
||||
})
|
||||
|
||||
const promotedWidget = makeWidget({
|
||||
const widget = makeWidget({ name: 'seed', widgetId: hostWidgetId })
|
||||
const input = {
|
||||
name: 'seed',
|
||||
sourceNodeId: '5',
|
||||
sourceWidgetName: 'innerSeed'
|
||||
})
|
||||
const subgraphNode = makeNode({ isSubgraph: true })
|
||||
widgetId: hostWidgetId,
|
||||
widget: { name: 'seed' }
|
||||
} as INodeInputSlot
|
||||
const subgraphNode = makeNode({ isSubgraph: true, inputs: [input] })
|
||||
|
||||
const result = renameWidget(promotedWidget, subgraphNode, 'Renamed')
|
||||
const result = renameWidget(widget, subgraphNode, 'Renamed')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(sourceWidget.label).toBe('Renamed')
|
||||
expect(interiorInput.label).toBe('Renamed')
|
||||
expect(promotedWidget.label).toBe('Renamed')
|
||||
expect(useWidgetValueStore().getWidget(hostWidgetId)?.label).toBe('Renamed')
|
||||
expect(input.label).toBe('Renamed')
|
||||
expect(widget.label).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
|
||||
const promotedWidget = makeWidget({
|
||||
it('never mutates the widget or input name/widgetId (label-only)', () => {
|
||||
const id = 'graph-id:7:seed' as WidgetId
|
||||
const widget = makeWidget({ name: 'seed', widgetId: id })
|
||||
const input = {
|
||||
name: 'seed',
|
||||
sourceNodeId: '5',
|
||||
sourceWidgetName: 'innerSeed'
|
||||
})
|
||||
const node = makeNode({ isSubgraph: false })
|
||||
widgetId: id,
|
||||
widget: { name: 'seed' }
|
||||
} as INodeInputSlot
|
||||
const subgraphNode = makeNode({ isSubgraph: true, inputs: [input] })
|
||||
|
||||
const result = renameWidget(promotedWidget, node, 'Renamed')
|
||||
renameWidget(widget, subgraphNode, 'Display Only')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockedResolve).not.toHaveBeenCalled()
|
||||
expect(promotedWidget.label).toBe('Renamed')
|
||||
expect(widget.name).toBe('seed')
|
||||
expect(widget.widgetId).toBe(id)
|
||||
expect(input.name).toBe('seed')
|
||||
expect(input.widgetId).toBe(id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -7,6 +5,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
export type WidgetValue = boolean | number | string | object | undefined
|
||||
|
||||
@@ -34,13 +33,13 @@ export function getWidgetDefaultValue(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a widget and its corresponding input.
|
||||
* Handles both regular widgets and promoted widget views in subgraphs.
|
||||
* Renames a widget: a label-only write across the widget, its store state, and
|
||||
* its backing input slot (matched by widgetId). Never touches the widget/input
|
||||
* name or widgetId — those are the stable identity a promoted input derives from.
|
||||
*
|
||||
* @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 promoted widgets)
|
||||
* @returns true if the rename was successful, false otherwise
|
||||
*/
|
||||
export function renameWidget(
|
||||
@@ -49,35 +48,18 @@ export function renameWidget(
|
||||
newLabel: string,
|
||||
parents?: SubgraphNode[]
|
||||
): boolean {
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
(parents?.length || node.isSubgraphNode())
|
||||
) {
|
||||
const sourceWidget = resolvePromotedWidgetSource(node, widget)
|
||||
if (!sourceWidget) {
|
||||
console.error('Could not resolve source widget for promoted widget')
|
||||
return false
|
||||
}
|
||||
void parents
|
||||
const label = newLabel || undefined
|
||||
const input = widget.widgetId
|
||||
? node.inputs?.find((inp) => inp.widgetId === widget.widgetId)
|
||||
: undefined
|
||||
const widgetState = widget.widgetId
|
||||
? useWidgetValueStore().getWidget(widget.widgetId)
|
||||
: undefined
|
||||
|
||||
const originalWidget = sourceWidget.widget
|
||||
const interiorNode = sourceWidget.node
|
||||
|
||||
originalWidget.label = newLabel || undefined
|
||||
|
||||
const interiorInput = interiorNode.inputs?.find(
|
||||
(inp) => inp.widget?.name === originalWidget.name
|
||||
)
|
||||
if (interiorInput) {
|
||||
interiorInput.label = newLabel || undefined
|
||||
}
|
||||
}
|
||||
|
||||
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
|
||||
|
||||
widget.label = newLabel || undefined
|
||||
if (input) {
|
||||
input.label = newLabel || undefined
|
||||
}
|
||||
widget.label = label
|
||||
if (widgetState) widgetState.label = label
|
||||
if (input) input.label = label
|
||||
|
||||
// Fires for all node types; listeners guard against non-subgraph nodes.
|
||||
node.graph?.trigger('node:slot-label:changed', {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @example
|
||||
* ```ts
|
||||
* type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
* const bad: WidgetEntityId = 'arbitrary string' // ❌ type error
|
||||
* ```
|
||||
*/
|
||||
export type Brand<T, Tag extends string> = T & { readonly __brand: Tag }
|
||||
@@ -1,46 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WidgetEntityId } from './entityIds'
|
||||
import {
|
||||
isWidgetEntityId,
|
||||
parseWidgetEntityId,
|
||||
widgetEntityId
|
||||
} from './entityIds'
|
||||
import type { WidgetId } from './entityIds'
|
||||
import { isWidgetId, parseWidgetId, widgetId } from './entityIds'
|
||||
|
||||
describe('widgetEntityId', () => {
|
||||
describe('widgetId', () => {
|
||||
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
it('builds a deterministic id from its components', () => {
|
||||
const id = widgetEntityId(graphId, 42, 'seed')
|
||||
const id = widgetId(graphId, 42, 'seed')
|
||||
expect(id).toBe(`${graphId}:42:seed`)
|
||||
})
|
||||
|
||||
it('produces equal ids for equal inputs', () => {
|
||||
expect(widgetEntityId(graphId, 42, 'seed')).toBe(
|
||||
widgetEntityId(graphId, 42, 'seed')
|
||||
)
|
||||
expect(widgetId(graphId, 42, 'seed')).toBe(widgetId(graphId, 42, 'seed'))
|
||||
})
|
||||
|
||||
it('produces distinct ids when any component differs', () => {
|
||||
const baseline = widgetEntityId(graphId, 42, 'seed')
|
||||
expect(widgetEntityId(graphId, 43, 'seed')).not.toBe(baseline)
|
||||
expect(widgetEntityId(graphId, 42, 'steps')).not.toBe(baseline)
|
||||
const baseline = widgetId(graphId, 42, 'seed')
|
||||
expect(widgetId(graphId, 43, 'seed')).not.toBe(baseline)
|
||||
expect(widgetId(graphId, 42, 'steps')).not.toBe(baseline)
|
||||
const otherGraph = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
expect(widgetEntityId(otherGraph, 42, 'seed')).not.toBe(baseline)
|
||||
expect(widgetId(otherGraph, 42, 'seed')).not.toBe(baseline)
|
||||
})
|
||||
|
||||
it('accepts string node ids', () => {
|
||||
const id = widgetEntityId(graphId, 'node-7', 'value')
|
||||
const id = widgetId(graphId, 'node-7', 'value')
|
||||
expect(id).toBe(`${graphId}:node-7:value`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWidgetEntityId', () => {
|
||||
describe('parseWidgetId', () => {
|
||||
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
it('round-trips a constructed id', () => {
|
||||
const id = widgetEntityId(graphId, 42, 'seed')
|
||||
expect(parseWidgetEntityId(id)).toEqual({
|
||||
const id = widgetId(graphId, 42, 'seed')
|
||||
expect(parseWidgetId(id)).toEqual({
|
||||
graphId,
|
||||
nodeId: '42',
|
||||
name: 'seed'
|
||||
@@ -49,8 +43,8 @@ describe('parseWidgetEntityId', () => {
|
||||
|
||||
it('preserves colons inside the name segment', () => {
|
||||
const rawName = 'nested:label:with:colons'
|
||||
const rawId = `${graphId}:42:${rawName}` as WidgetEntityId
|
||||
expect(parseWidgetEntityId(rawId)).toEqual({
|
||||
const rawId = `${graphId}:42:${rawName}` as WidgetId
|
||||
expect(parseWidgetId(rawId)).toEqual({
|
||||
graphId,
|
||||
nodeId: '42',
|
||||
name: rawName
|
||||
@@ -58,24 +52,24 @@ describe('parseWidgetEntityId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetEntityId', () => {
|
||||
describe('isWidgetId', () => {
|
||||
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
it('accepts ids built by the constructor', () => {
|
||||
expect(isWidgetEntityId(widgetEntityId(graphId, 1, 'x'))).toBe(true)
|
||||
expect(isWidgetId(widgetId(graphId, 1, 'x'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects strings without two colon-separated segments', () => {
|
||||
expect(isWidgetEntityId('only-one-colon:42')).toBe(false)
|
||||
expect(isWidgetEntityId('no-colons')).toBe(false)
|
||||
expect(isWidgetEntityId(':leading-colon:name')).toBe(false)
|
||||
expect(isWidgetEntityId('graph::name')).toBe(false)
|
||||
expect(isWidgetId('only-one-colon:42')).toBe(false)
|
||||
expect(isWidgetId('no-colons')).toBe(false)
|
||||
expect(isWidgetId(':leading-colon:name')).toBe(false)
|
||||
expect(isWidgetId('graph::name')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-strings', () => {
|
||||
expect(isWidgetEntityId(42)).toBe(false)
|
||||
expect(isWidgetEntityId(null)).toBe(false)
|
||||
expect(isWidgetEntityId(undefined)).toBe(false)
|
||||
expect(isWidgetEntityId({})).toBe(false)
|
||||
expect(isWidgetId(42)).toBe(false)
|
||||
expect(isWidgetId(null)).toBe(false)
|
||||
expect(isWidgetId(undefined)).toBe(false)
|
||||
expect(isWidgetId({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,39 +1,4 @@
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import type { Brand } from './brand'
|
||||
|
||||
export type NodeId = number | string
|
||||
|
||||
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
|
||||
const SEPARATOR = ':'
|
||||
|
||||
export function widgetEntityId(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
name: string
|
||||
): WidgetEntityId {
|
||||
return `${graphId}${SEPARATOR}${nodeId}${SEPARATOR}${name}` as WidgetEntityId
|
||||
}
|
||||
|
||||
export function parseWidgetEntityId(id: WidgetEntityId): {
|
||||
graphId: UUID
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
} {
|
||||
const firstColon = id.indexOf(SEPARATOR)
|
||||
const secondColon = id.indexOf(SEPARATOR, firstColon + 1)
|
||||
return {
|
||||
graphId: id.slice(0, firstColon),
|
||||
nodeId: id.slice(firstColon + 1, secondColon),
|
||||
name: id.slice(secondColon + 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function isWidgetEntityId(value: unknown): value is WidgetEntityId {
|
||||
if (typeof value !== 'string') return false
|
||||
const firstColon = value.indexOf(SEPARATOR)
|
||||
if (firstColon <= 0) return false
|
||||
const secondColon = value.indexOf(SEPARATOR, firstColon + 1)
|
||||
return secondColon > firstColon + 1
|
||||
}
|
||||
export type { WidgetId } from '@/types/widgetId'
|
||||
export { isWidgetId, parseWidgetId, widgetId } from '@/types/widgetId'
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { widgetEntityId } from './entityIds'
|
||||
import {
|
||||
ensureWidgetState,
|
||||
getWidgetState,
|
||||
readWidgetValue,
|
||||
writeWidgetValue
|
||||
} from './widgetValueIO'
|
||||
|
||||
describe('widgetValueIO', () => {
|
||||
const graphA = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const graphB = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('ensureWidgetState', () => {
|
||||
it('registers a new state when none exists', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const state = ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
expect(state.value).toBe(11)
|
||||
expect(state.nodeId).toBe('1')
|
||||
expect(state.name).toBe('seed')
|
||||
})
|
||||
|
||||
it('is idempotent — returns the same state on repeated calls', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
const first = ensureWidgetState(id, init)
|
||||
const second = ensureWidgetState(id, init)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('does not overwrite an existing state with init values', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const first = ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
first.value = 99
|
||||
const second = ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
expect(second.value).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWidgetValue / writeWidgetValue', () => {
|
||||
it('round-trips a value through the entity-id surface', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
expect(readWidgetValue(id)).toBe(11)
|
||||
|
||||
expect(writeWidgetValue(id, 22)).toBe(true)
|
||||
expect(readWidgetValue(id)).toBe(22)
|
||||
})
|
||||
|
||||
it('returns false when writing to an unregistered id', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
expect(writeWidgetValue(id, 22)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns undefined when reading an unregistered id', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
expect(readWidgetValue(id)).toBeUndefined()
|
||||
expect(getWidgetState(id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation', () => {
|
||||
it('keeps independent values across distinct entity ids', () => {
|
||||
const id1 = widgetEntityId(graphA, 1, 'seed')
|
||||
const id2 = widgetEntityId(graphA, 2, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 0,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
ensureWidgetState(id1, init)
|
||||
ensureWidgetState(id2, init)
|
||||
|
||||
writeWidgetValue(id1, 11)
|
||||
writeWidgetValue(id2, 22)
|
||||
|
||||
expect(readWidgetValue(id1)).toBe(11)
|
||||
expect(readWidgetValue(id2)).toBe(22)
|
||||
})
|
||||
|
||||
it('isolates values across graph ids', () => {
|
||||
const idA = widgetEntityId(graphA, 1, 'seed')
|
||||
const idB = widgetEntityId(graphB, 1, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 0,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
ensureWidgetState(idA, init)
|
||||
ensureWidgetState(idB, init)
|
||||
|
||||
writeWidgetValue(idA, 11)
|
||||
writeWidgetValue(idB, 22)
|
||||
|
||||
expect(readWidgetValue(idA)).toBe(11)
|
||||
expect(readWidgetValue(idB)).toBe(22)
|
||||
})
|
||||
|
||||
it('matches the legacy triple-keyed API for the same widget', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
ensureWidgetState(id, init)
|
||||
|
||||
const viaLegacy = useWidgetValueStore().getWidget(graphA, '1', 'seed')
|
||||
expect(viaLegacy).toBe(getWidgetState(id))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
|
||||
import { parseWidgetEntityId } from './entityIds'
|
||||
import type { WidgetEntityId } from './entityIds'
|
||||
|
||||
export function getWidgetState(
|
||||
entityId: WidgetEntityId
|
||||
): WidgetState | undefined {
|
||||
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
|
||||
return useWidgetValueStore().getWidget(graphId, nodeId, name)
|
||||
}
|
||||
|
||||
export function readWidgetValue(
|
||||
entityId: WidgetEntityId
|
||||
): WidgetState['value'] | undefined {
|
||||
return getWidgetState(entityId)?.value
|
||||
}
|
||||
|
||||
export function writeWidgetValue(
|
||||
entityId: WidgetEntityId,
|
||||
value: WidgetState['value']
|
||||
): boolean {
|
||||
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
|
||||
return useWidgetValueStore().setValue(graphId, nodeId, name, value)
|
||||
}
|
||||
|
||||
type WidgetStateInit = Omit<WidgetState, 'nodeId' | 'name'>
|
||||
|
||||
export function ensureWidgetState(
|
||||
entityId: WidgetEntityId,
|
||||
init: WidgetStateInit
|
||||
): WidgetState {
|
||||
const existing = getWidgetState(entityId)
|
||||
if (existing) return existing
|
||||
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
|
||||
return useWidgetValueStore().registerWidget(graphId, {
|
||||
...init,
|
||||
nodeId,
|
||||
name
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user