Compare commits

...

37 Commits

Author SHA1 Message Date
DrJKL
2bc7e64ed9 refactor: fold widget render state into a single store action
Combine widget value + render-state registration into one
registerWidget call so callers can't drift; register render state for
nested promoted inputs. Derive the live widget map once in
computeProcessedWidgets and drop the redundant slot-links listener in
WidgetItem.
2026-07-02 16:47:51 -07:00
GitHub Action
c5b03ad3ee [automated] Apply ESLint and Oxfmt fixes 2026-07-02 22:32:42 +00:00
Alexander Brown
15ecd0efa8 Apply suggestions from code review
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
2026-07-02 15:28:45 -07:00
DrJKL
3eabfed14a refactor: share widget grid types and slim processed widgets
Extract WidgetGridItem and WidgetSlotMetadata into a shared types module and
make ProcessedWidget extend it, dropping the dead flat fields it duplicated.
Decompose computeProcessedWidgets into resolveLiveWidgetContext + processWidget,
dedupe ids via a Set, and stop exporting isWidgetVisible/hasWidgetError (tested
through the public compute path instead).

WidgetGrid drives widgets through :model-value so updateHandler is the sole
writer; preview items stay static. removeWidget now drops a widget from the
render order without purging its stored value, so remove-then-re-add restores
what the user set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:05:43 -07:00
DrJKL
47a90aca7b refactor: inline widget render key derivation
getWidgetIdentity took two params it never read and returned two
identical strings under different names. Inline it to a single renderKey
used for both dedupe and the render key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:57:53 -07:00
DrJKL
7cce118564 perf: resolve live widgets by id once per node
getLiveWidget rescanned every node.widgets entry for each widget id
(O(n^2) per node, per reactive recompute), and the same duplicate-index
scan was hand-copied into LGraphNode.vue. Extract mapLiveWidgetsById so
both build the WidgetId->widget map once. Also resolve the promoted
widget source a single time per widget instead of once for sourceWidget
and again for the error target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:46:52 -07:00
DrJKL
9021f68b38 refactor: derive widget render state through one helper
isDOMBackedWidget and the render-state object were copy-pasted into
BaseWidget, SubgraphNode, and AppModeWidgetList. Collapse both into
deriveWidgetRenderState in the canonical litegraph widget util so all
three register through a single derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:45:54 -07:00
DrJKL
4663692fcd refactor: drop dead widget spec store and replaceNodeWidgetOrder
The graphWidgetSpecs map, WidgetSpec type, registerWidgetSpec/getWidgetSpec,
and replaceNodeWidgetOrder had no production callers: specs always fell
through to nodeDefStore.getInputSpecForWidget, and no path pruned via
replaceNodeWidgetOrder. Also drop the unused getNodeWidgetIds litegraph
util and an inert File-array branch in normalizeWidgetValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:37:42 -07:00
DrJKL
66a0c1da8e docs: tighten WidgetGridItem comment 2026-07-02 11:49:15 -07:00
DrJKL
9ffbcb921e refactor: drop verbose comment from getNodeWidgetIds 2026-07-02 11:37:37 -07:00
DrJKL
6066f1ebea refactor: drop getRegisteredNodeWidgetIds duplicate of getNodeWidgetIds
Once both just return `[...order]` they were identical; use
getNodeWidgetIds at the two internal call sites.
2026-07-02 10:59:04 -07:00
DrJKL
609eec113c perf: read widget order from the incremental index, not a full scan
`order` (graphNodeWidgetOrders) is already maintained per node on
register/delete, so the reconciliation-by-full-scan in the read helpers
was redundant. getRegisteredNodeWidgetIds and getNodeWidgetIds now return
`[...order]` directly, and replaceNodeWidgetOrder prunes by iterating the
node's registered ids instead of the whole graph.

This removes an O(graph-widgets) scan from hot paths (per-node on load,
per-rendered-node in computeProcessedWidgets) and stops getNodeWidgetIds
from splicing reactive state during a Vue computed read.
2026-07-02 00:56:04 -07:00
DrJKL
1136b3d062 fix: lead combo preview options with an explicitly empty value
The preview's `value` kept an explicit empty-string widget value (nullish
coalescing) while `values` dropped it (truthy check), so a combo whose
provided value is "" no longer listed that value among its options. Match
the presence check so the selected value is always led in.
2026-07-02 00:26:16 -07:00
DrJKL
678f96d00d refactor: drop duplicated name/type/value from WidgetGridItem
WidgetGrid now reads name/type/value from the widget's `simplified`
descriptor instead of duplicating them as top-level fields. A preview
item collapses to `{ simplified, vueComponent, visible, renderKey }`.
2026-07-02 00:02:02 -07:00
DrJKL
b38256ffa2 refactor: drop usePreviewWidgets composable; simplify WidgetGrid
Build the preview widget list as a plain inline computed in
LGraphNodePreview (like the pre-refactor path) instead of a dedicated
composable. WidgetGrid now takes a narrow WidgetGridItem (a structural
subset of ProcessedWidget, so NodeWidgets is unchanged), defaults the
interactive-only fields, and derives gridTemplateRows itself — removing
that prop from both callers and the gridTemplateRows/visibleWidgets
computeds from useProcessedWidgets. A preview widget is now just its
name, type, value, simplified spec, component, and visibility.
2026-07-01 23:48:08 -07:00
DrJKL
bfbbcd9a42 fix: drop unused WidgetSlotMetadata export
Only ProcessedWidget needs to be public; it references WidgetSlotMetadata
internally, so exporting the latter tripped knip.
2026-07-01 23:30:41 -07:00
DrJKL
7f50e2a6b8 refactor: render node preview from plain data, not the widget store
Extract the presentational grid from NodeWidgets into WidgetGrid, driven by
a plain ProcessedWidget[] prop. NodeWidgets keeps the store-backed
useProcessedWidgets path (and its interactivity, forwarded to the grid via
attribute fallthrough); LGraphNodePreview builds ProcessedWidget[] straight
from the node schema via a new pure usePreviewWidgets composable.

The preview no longer fabricates widgetValueStore entries: gone are the
synthetic graph-id counter, the register widget/render-state/spec writes,
the manual state patching, and the onUnmounted cleanup. A static preview
has no live graph, so it now touches no store-backed widget state at all.
2026-07-01 23:30:41 -07:00
DrJKL
783a7cefbc refactor: move preview widget store writes out of computed
LGraphNodePreview derived previewWidgetIds in a computed whose body wrote
to widgetValueStore (register widget/render-state/spec), so a "pure"
derivation was the write path. Split it into a pure previewWidgets
computed plus a watchEffect that performs the store registration, and drop
the redundant state.type reassignment. Behaviour is unchanged; the writes
now live where side effects belong.
2026-07-01 22:07:26 -07:00
DrJKL
079b620555 fix: use uuid subgraph ids in nested promoted model asset
The nested promoted-missing-model test asset used readable slug ids for
its subgraph definitions. createNodeLocatorId only accepts UUID subgraph
ids, so getLiveWidget could not resolve the interior nodes, and the stale
promoted combobox never rendered disabled. Real subgraphs always get uuid
ids; the asset now matches, which is the actual fix for the interior
disabled-state assertion.

Revert the useProcessedWidgets disabled-derivation change: with valid
asset ids the original logic is correct, so the refactor was unnecessary.
2026-07-01 21:45:13 -07:00
DrJKL
624963c37a fix: derive vue widget disabled state from live input link
Query the live node's widget input slot (getSlotFromWidget) as the single
source of truth for whether a widget is linked/disabled, matching
LGraphNode.updateComputedDisabled. This replaces the buildSlotMetadata
snapshot derivation, which missed promoted interior subgraph widgets whose
input carries a link to the subgraph input node, leaving them enabled.

Also drop the runtime import('/src/...') setup from the legacy app-mode
spec (only resolvable against the dev server, not the CI dist build) in
favor of the native devtools widget plus enterAppModeWithInputs.
2026-07-01 21:01:16 -07:00
DrJKL
0c759912e3 test: finish pinia setup for vue node unit tests 2026-07-01 19:57:17 -07:00
DrJKL
f210590bdf test: set up pinia for graph replacement tests 2026-07-01 19:37:36 -07:00
Alexander Brown
bc918fef11 Merge branch 'main' into drjkl/safe-widget-cleanup 2026-07-01 16:21:40 -07:00
DrJKL
dfdf78f0d3 fix: resolve vue widget rendering regressions 2026-07-01 16:19:14 -07:00
DrJKL
7cbdb94b3f fix: separate widget spec state from rendering 2026-07-01 11:59:20 -07:00
Alexander Brown
7907985db8 Merge branch 'main' into drjkl/safe-widget-cleanup 2026-07-01 01:08:42 -07:00
DrJKL
24258bf1a8 fix: preserve promoted widget render order 2026-07-01 00:52:07 -07:00
DrJKL
61b87a467d fix: unify widget update handler paths 2026-06-30 20:49:22 -07:00
DrJKL
6be63bd50c fix: sync widget order through store 2026-06-30 20:13:26 -07:00
GitHub Action
4f9077dd98 [automated] Apply ESLint and Oxfmt fixes 2026-07-01 00:47:58 +00:00
DrJKL
61658f604b refactor: render widgets from widget ids 2026-06-30 17:42:14 -07:00
DrJKL
54e688f912 fix: restore Load3D widget rendering 2026-06-30 14:41:40 -07:00
DrJKL
638f0332b4 fix: stop renderer resolving promoted widgets 2026-06-30 13:17:20 -07:00
DrJKL
26a53d7d2c cleanup: minor cleanups 2026-06-30 11:58:38 -07:00
DrJKL
b45320ab3d knip unused exports 2026-06-30 11:55:49 -07:00
DrJKL
34c11a07db refactor: store widget render state separately 2026-06-30 11:48:50 -07:00
DrJKL
c64b8678ec refactor: store widget render state separately 2026-06-30 11:48:41 -07:00
45 changed files with 1856 additions and 1902 deletions

View File

@@ -6,7 +6,7 @@
"nodes": [
{
"id": 3,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
@@ -20,7 +20,7 @@
},
{
"id": 4,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
@@ -38,7 +38,7 @@
"definitions": {
"subgraphs": [
{
"id": "outer-subgraph-with-promoted-missing-model",
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"version": 1,
"state": {
"lastGroupId": 0,
@@ -71,7 +71,7 @@
"nodes": [
{
"id": 2,
"type": "inner-subgraph-with-promoted-missing-model",
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
@@ -105,7 +105,7 @@
]
},
{
"id": "inner-subgraph-with-promoted-missing-model",
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"version": 1,
"state": {
"lastGroupId": 0,

View File

@@ -14,12 +14,12 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
test('Fix link input slots', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
const linkId = toLinkId(1)
await expect
.poll(() =>
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
)
comfyPage.page.evaluate((linkId) => {
return window.app!.graph!.links.get(linkId)?.target_slot
}, linkId)
)
.toBe(1)
})

View File

@@ -8,25 +8,32 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
let legacyNodeId = toNodeId(10)
await test.step('setup', async () => {
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
const legacyNode = await comfyPage.nodeOps.addNode(
'DevToolsNodeWithLegacyWidget',
undefined,
{
x: 0,
y: 0
}
)
legacyNodeId = legacyNode.id
await comfyPage.appMode.enterAppModeWithInputs([
[String(legacyNodeId), 'legacy_widget']
])
})
const getWidth = () =>
comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
const getWidth = async () =>
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
?.width ?? 0
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
@@ -36,8 +43,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
})
await test.step('Resize to update width', async () => {
await expect.poll(getWidth).toBeGreaterThan(0)
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')

View File

@@ -3,31 +3,43 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
import { toNodeId } from '@/types/nodeId'
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
test('Should display added widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
const nodeId = toNodeId(
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => (node.widgets?.length ?? 0) === 1
)
if (!node) throw new Error('Node with one widget not found')
return String(node.id)
})
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
const widgets = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-widget')
await expect(widgets).toHaveCount(1)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_a', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(2)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_b', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(3)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_c', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(4)
}, nodeId)
await expect(widgets).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {

View File

@@ -11,6 +11,8 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import type { WidgetId } from '@/types/widgetId'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -19,6 +21,7 @@ import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.v
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -29,9 +32,8 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
nodeData: ReturnType<typeof nodeToNodeData>
widgetIds: readonly WidgetId[]
action: { widget: IBaseWidget; node: LGraphNode }
}
@@ -43,6 +45,7 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const widgetValueStore = useWidgetValueStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
@@ -53,49 +56,60 @@ provide(HideLayoutFieldKey, true)
const resolvedInputs = useResolvedSelectedInputs()
const mappedSelections = computed((): WidgetEntry[] => {
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
function ensureSelectedWidgetState(
widgetId: WidgetId,
widget: IBaseWidget
): void {
if (widgetValueStore.getWidget(widgetId)) return
widgetValueStore.registerWidget(
widgetId,
{
type: widget.type,
value: widget.value,
options: widget.options,
label: widget.label,
serialize: widget.serialize,
disabled: widget.disabled
},
deriveWidgetRenderState(widget)
)
}
const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
ensureSelectedWidgetState(widgetId, widget)
const fullNodeData = nodeToNodeData(node, widgetId)
if (
node.inputs?.some(
(input) => input.widget?.name === widget.name && input.link != null
)
) {
return []
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
return [
{
key: widgetId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
nodeData: fullNodeData,
widgetIds: [widgetId],
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
function getDropIndicator(node: LGraphNode, id: WidgetId) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const stringValue = extractWidgetStringValue(
widgetValueStore.getWidget(id)?.value
)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
@@ -119,8 +133,8 @@ function getDropIndicator(node: LGraphNode) {
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
function nodeToNodeData(node: LGraphNode, id: WidgetId) {
const dropIndicator = getDropIndicator(node, id)
const nodeData = extractVueNodeData(node)
return {
@@ -147,7 +161,13 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
v-for="{
key,
persistedHeight,
nodeData,
widgetIds,
action
} in mappedSelections"
:key
:class="
cn(
@@ -234,6 +254,7 @@ defineExpose({ handleDragDrop })
>
<NodeWidgets
:node-data
:widget-ids
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',

View File

@@ -13,8 +13,8 @@ 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 { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
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'
@@ -255,7 +255,10 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
options
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
)
}

View File

@@ -2,14 +2,17 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick, shallowReactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
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 { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -204,5 +207,49 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
it('passes null from widget state to the widget component', () => {
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: null,
options: {}
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.value).toBe('null')
})
it('updates disabled options when the widget input is linked', async () => {
// Mirrors production: node.inputs is a shallowReactive array (installed by
// useGraphNodeManager), so linking an input re-triggers the isLinked
// computed with no event listener wiring.
const inputs = shallowReactive(
fromAny<INodeInputSlot[], unknown>([
{
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: null,
boundingRect: [0, 0, 0, 0]
}
])
)
const node = createMockNode(
fromAny<Partial<LGraphNode>, unknown>({ inputs })
)
const widget = createMockWidget({ name: 'seed', options: {} })
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()
inputs[0] = { ...inputs[0], link: toLinkId(1) }
await nextTick()
expect(getStubWidget(container).options.disabled).toBe(true)
})
})
})

View File

@@ -3,8 +3,6 @@ import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -21,7 +19,11 @@ import {
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -68,14 +70,13 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
const isLinked = computed(() => {
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
})
const isLinked = computed(() =>
Boolean(
node.inputs?.some(
(input) => input.widget?.name === widget.name && input.link != null
)
)
)
const simplifiedWidget = computed((): SimplifiedWidget => {
const graphId = node.graph?.rootGraph?.id
@@ -93,7 +94,9 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),

View File

@@ -87,7 +87,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget slotMetadata reactivity on link disconnect', () => {
describe('Widget input link reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -96,10 +96,8 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -112,31 +110,26 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
it('exposes linked widget input slots through Vue node inputs', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
it('updates input link state after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
@@ -147,32 +140,19 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
expect(nodeData?.inputs?.[0]?.link).toBeNull()
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
it('keeps widget input link state current after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
expect(
nodeData.inputs?.find((slot) => slot.widget?.name === 'prompt')?.link
).not.toBeNull()
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
@@ -184,9 +164,9 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
expect(
nodeData.inputs?.find((slot) => slot.widget?.name === 'prompt')?.link
).toBeNull()
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -205,15 +185,11 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
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.
it('registers promoted widget render state separately from value state', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
@@ -229,23 +205,34 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
useGraphNodeManager(graph)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
it('reflects input/widget renames after link refresh', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
@@ -261,7 +248,11 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(false)
})
})
@@ -368,15 +359,13 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
useGraphNodeManager(graph)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -405,27 +394,23 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
useGraphNodeManager(graph)
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
])
expect(ids[0]).not.toBe(ids[1])
})
})
describe('Promoted widget sourceExecutionId', () => {
describe('Promoted widget render state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
it('registers plain render metadata for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -451,22 +436,21 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, '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(
`${subgraphNode.id}:${interiorNode.id}`
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
it('does not set sourceExecutionId for non-promoted widgets', () => {
it('registers plain render metadata for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -474,12 +458,14 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
useGraphNodeManager(graph)
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
})

View File

@@ -1,14 +1,6 @@
/**
* Vue node lifecycle management for LiteGraph integration
* 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 { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -19,16 +11,6 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
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 { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -36,70 +18,13 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam,
SubgraphNode
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
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
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
}
export interface VueNodeData {
executing: boolean
id: NodeId
@@ -124,260 +49,24 @@ export interface VueNodeData {
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
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?: NodeExecutionId
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 source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
return {
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
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
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
function makeReactiveNodeArrays(node: LGraphNode): {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
} {
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
'widgets'
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
@@ -406,6 +95,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -417,6 +107,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -429,19 +120,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return { inputs: reactiveInputs, outputs: reactiveOutputs }
}
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
export function extractVueNodeData(node: LGraphNode): VueNodeData {
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const { inputs, outputs } = makeReactiveNodeArrays(node)
const nodeType =
node.type ||
node.constructor?.comfyClass ||
@@ -449,9 +137,6 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
@@ -459,14 +144,13 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
executing: false,
subgraphId,
apiNode,
badges,
apiNode: node.constructor?.nodeData?.api_node ?? false,
badges: node.badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
inputs,
outputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -477,39 +161,26 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeSlots = (nodeId: NodeId) => {
const refreshNodeInputs = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef?.inputs || !currentData) return
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
}
nodeRef.inputs = [...nodeRef.inputs]
vueNodeData.set(nodeId, { ...currentData, inputs: nodeRef.inputs })
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
@@ -517,76 +188,49 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
@@ -603,16 +247,13 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
dropNodeReferences(id)
for (const nodeId of nodeRefs.keys()) refreshNodeInputs(nodeId)
originalCallback?.(node)
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
@@ -620,7 +261,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
@@ -630,19 +270,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
@@ -761,11 +398,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
refreshNodeInputs(toNodeId(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
refreshNodeInputs(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
@@ -773,16 +410,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
@@ -802,11 +435,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
return createCleanupFunction(
@@ -817,10 +448,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {

View File

@@ -145,6 +145,13 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -281,22 +288,26 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
const store = useWidgetValueStore()
const sourceState = store.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 ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
store.registerWidget(
id,
{
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled
},
store.getWidgetRenderState(sourceSlot.widgetId) ?? {}
)
}
function promotePreviewViaExposure(

View File

@@ -11,7 +11,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -48,6 +51,16 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -105,7 +118,10 @@ function dynamicComboWidget(
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) return
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -140,6 +156,7 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -541,8 +558,11 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -61,6 +63,8 @@ async function createNodeWithFilenamePrefix(
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },

View File

@@ -59,6 +59,7 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -1006,9 +1007,15 @@ export class LGraph
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)

View File

@@ -93,7 +93,7 @@ describe('drawConnections widget-input slot positioning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -9,6 +9,7 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { adjustColor } from '@/utils/colorUtil'
@@ -96,6 +97,7 @@ import type {
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -2055,6 +2057,20 @@ export class LGraphNode
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
const graphId = this.graph?.rootGraph.id
if (graphId) {
const widgetValueStore = useWidgetValueStore()
// Drop the widget from the render order but keep its stored value, so a
// remove-then-re-add of the same widget id preserves what the user set.
if (widget.widgetId)
widgetValueStore.removeNodeWidgetOrder(widget.widgetId)
widgetValueStore.setNodeWidgetOrder(
graphId,
this.id,
getWidgetIds(this.widgets)
)
}
}
ensureWidgetRemoved(widget: IBaseWidget): void {

View File

@@ -32,6 +32,7 @@ import type {
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
@@ -643,20 +644,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
const store = useWidgetValueStore()
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
})
store.registerWidget(
id,
{
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled
},
deriveWidgetRenderState(interiorWidget)
)
input._widget =
this.createPromotedHostWidget(input, id, interiorWidget) ??
this._projectPromotedWidget(input)

View File

@@ -1,5 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { WidgetRenderState } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import type { UUID } from '@/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
@@ -24,6 +29,35 @@ export function evaluateInput(input: string): number | undefined {
return newValue
}
export function getWidgetIds(
widgets: readonly { readonly widgetId?: WidgetId }[]
): WidgetId[] {
return widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
}
function isDOMBackedWidget(widget: Readonly<IBaseWidget>): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export function deriveWidgetRenderState(
widget: Readonly<IBaseWidget>
): WidgetRenderState {
return {
advanced: widget.options?.advanced ?? widget.advanced,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(widget),
tooltip: widget.tooltip
}
}
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>
): UUID | undefined

View File

@@ -4,7 +4,15 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import type {
DrawWidgetOptions,
WidgetEventOptions
} from '@/lib/litegraph/src/widgets/BaseWidget'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
@@ -27,6 +35,28 @@ function createTestWidget(
)
}
class MutableTypeWidget extends BaseWidget<IBaseWidget<number, string>> {
drawWidget(
_ctx: CanvasRenderingContext2D,
_options: DrawWidgetOptions
): void {}
onClick(_options: WidgetEventOptions): void {}
}
function createMutableTypeWidget(node: LGraphNode): MutableTypeWidget {
return new MutableTypeWidget(
{
type: 'number',
name: 'typeChangedWidget',
value: 42,
options: { min: 0, max: 100 },
y: 0
},
node
)
}
describe('BaseWidget store integration', () => {
let graph: LGraph
let node: LGraphNode
@@ -175,6 +205,31 @@ describe('BaseWidget store integration', () => {
store.getWidget(widgetId(graph.id, toNodeId(1), 'valuesWidget'))?.value
).toBe(77)
})
it('registers the live widget type', () => {
const widget = createMutableTypeWidget(node)
widget.type = 'number-custom'
widget.setNodeId(toNodeId(1))
expect(
store.getWidget(widgetId(graph.id, toNodeId(1), 'typeChangedWidget'))
?.type
).toBe('number-custom')
})
it('stores explicit isDOMWidget false over component presence', () => {
const widget = createTestWidget(node, { name: 'flaggedDomWidget' })
Object.assign(widget, { component: {}, isDOMWidget: false })
widget.setNodeId(toNodeId(1))
expect(
store.getWidgetRenderState(
widgetId(graph.id, toNodeId(1), 'flaggedDomWidget')
)?.isDOMWidget
).toBe(false)
})
})
describe('DOM widget value registration', () => {

View File

@@ -17,6 +17,7 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
@@ -147,12 +148,16 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._state = useWidgetValueStore().registerWidget(
widgetId(graphId, nodeId, this.name),
const store = useWidgetValueStore()
const id = widgetId(graphId, nodeId, this.name)
this._state = store.registerWidget(
id,
{
...this._state,
type: this.type,
value: this.value
}
},
deriveWidgetRenderState(this)
)
}

View File

@@ -85,7 +85,6 @@ describe('Vue Node - Subgraph Functionality', () => {
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,

View File

@@ -60,7 +60,7 @@ vi.mock(
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { getNodeById: vi.fn() },
rootGraph: { id: 'graph-test', getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
@@ -161,7 +161,6 @@ const mockNodeData: VueNodeData = {
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
@@ -178,6 +177,7 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockExecuting = false
mockData.mockLgraphNode = null
setActivePinia(pinia)
const canvasStore = useCanvasStore()
@@ -274,17 +274,16 @@ describe('LGraphNode', () => {
})
it('should hide advanced footer button while the node is collapsed', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
flags: { collapsed: true }
}
})
@@ -294,18 +293,17 @@ describe('LGraphNode', () => {
})
it('should show error-only footer for collapsed nodes with advanced widgets', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
hasErrors: true,
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
hasErrors: true
}
})

View File

@@ -57,7 +57,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
hasAnyError ? '-inset-1.75' : '-inset-0.75',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
@@ -107,10 +107,10 @@
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
<NodeSlots :node-data unified />
</template>
<NodeHeader
:node-data="nodeData"
:node-data
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@@ -130,7 +130,7 @@
/>
<template v-if="!isCollapsed && isRerouteNode">
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
</template>
<template v-else-if="!isCollapsed">
@@ -157,20 +157,16 @@
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<NodeWidgets v-if="hasRenderableWidgets" :node-data />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent v-if="nodeMedia" :node-data :media="nodeMedia" />
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data="nodeData"
:node-data
:media="preview"
/>
</div>
@@ -302,6 +298,10 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
@@ -736,18 +736,30 @@ const { promotedPreviews } = usePromotedPreviews(lgraphNode)
useGLSLPreview(lgraphNode)
const widgetValueStore = useWidgetValueStore()
const widgetIds = computed(() => {
const graphId = app.rootGraph?.id
const bareNodeId = stripGraphPrefix(nodeData.id)
if (!graphId || !bareNodeId) return []
return widgetValueStore.getNodeWidgetIds(graphId, bareNodeId) ?? []
})
const hasRenderableWidgets = computed(() => widgetIds.value.length > 0)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
if (isCollapsed.value) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
return hasUnpromotedWidgets(node)
}
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
const hasAdvancedWidgets = widgetIds.value.some((id) => {
const renderState = widgetValueStore.getWidgetRenderState(id)
const widgetState = widgetValueStore.getWidget(id)
return renderState?.advanced ?? widgetState?.options?.advanced
})
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)

View File

@@ -1,6 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
@@ -9,12 +12,20 @@ vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
const WidgetGridProbe = {
props: ['processedWidgets'],
setup(props: { processedWidgets?: WidgetGridItem[] }) {
const widgets = computed(() =>
(props.processedWidgets ?? []).map((widget) => ({
name: widget.simplified.name,
value: widget.simplified.value,
options: { values: widget.simplified.options?.values }
}))
)
return { widgets }
},
template:
'<div data-testid="node-data">{{ JSON.stringify({ widgets }) }}</div>'
}
interface ProbedWidget {
@@ -39,10 +50,11 @@ function renderedWidgets(
render(LGraphNodePreview, {
props: { nodeDef: def, ...props },
global: {
plugins: [createTestingPinia({ stubActions: false })],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: NodeWidgetsProbe
WidgetGrid: WidgetGridProbe
}
}
})
@@ -77,6 +89,17 @@ describe('LGraphNodePreview', () => {
expect(widget?.options?.values).toEqual(['a.safetensors', 'b.safetensors'])
})
it('leads with an explicitly empty provided value', () => {
const widget = renderedComboWidget({ widgetValues: { ckpt_name: '' } })
expect(widget?.value).toBe('')
expect(widget?.options?.values).toEqual([
'',
'a.safetensors',
'b.safetensors'
])
})
it('uses the input default when defined and empty string otherwise', () => {
const widgets = renderedWidgets(
fromPartial<ComfyNodeDefV2>({

View File

@@ -19,9 +19,11 @@
>
<NodeSlots :node-data="nodeData" />
<NodeWidgets
v-if="nodeData.widgets?.length"
:node-data="nodeData"
<WidgetGrid
v-if="previewWidgets.length"
:processed-widgets="previewWidgets"
:node-type="nodeData.type"
:node-id="nodeData.id"
class="pointer-events-none"
/>
</div>
@@ -36,14 +38,17 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { toNodeId } from '@/types/nodeId'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
const {
@@ -58,39 +63,9 @@ const {
const widgetStore = useWidgetStore()
// Convert nodeDef into VueNodeData
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: toNodeId('-1'),
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
.filter(([, input]) => !widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.type,
@@ -119,16 +94,41 @@ const nodeData = computed<VueNodeData>(() => {
id: toNodeId(`preview-${nodeDef.name}`),
title: nodeDef.display_name || nodeDef.name,
type: nodeDef.name,
mode: 0, // Normal mode
mode: 0,
selected: false,
executing: false,
widgets,
inputs,
outputs,
flags: {
collapsed: false
}
}
})
const previewWidgets = computed<WidgetGridItem[]>(() =>
Object.entries(nodeDef.inputs || {})
.filter(([, input]) => widgetStore.inputIsWidget(input) && !input.hidden)
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
const leadValue = widgetValues?.[name]
const value = (leadValue ??
input.default ??
comboValues?.[0] ??
'') as WidgetValue
const type = input.widgetType || input.type
const values =
leadValue !== undefined && comboValues
? [leadValue, ...comboValues.filter((option) => option !== leadValue)]
: comboValues
return {
visible: true,
renderKey: `preview:${nodeDef.name}:${name}`,
vueComponent: getComponent(type) ?? WidgetLegacy,
simplified: { name, type, value, options: { values }, spec: input }
}
})
)
</script>

View File

@@ -26,7 +26,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
mode: 0,
selected: false,
executing: false,
widgets: [],
inputs: [],
outputs: [],
flags: { collapsed: false },

View File

@@ -38,7 +38,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})

View File

@@ -3,20 +3,17 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -36,7 +33,13 @@ const WidgetStub = {
name: 'WidgetStub',
props: ['widget', 'nodeId', 'nodeType', 'modelValue'],
template:
'<div class="widget-stub" :data-node-type="nodeType">{{ nodeType }}</div>'
'<div class="widget-stub" :data-node-type="nodeType" :data-name="widget.name">{{ nodeType }}</div>'
}
const AppInputStub = {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
vi.mock(
@@ -50,63 +53,78 @@ vi.mock(
}
)
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = [],
id: NodeId = toNodeId(1)
): VueNodeData => ({
function createMockNodeData(
nodeType = 'TestNode',
id: NodeId = toNodeId(1)
): VueNodeData {
return {
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
})
function renderComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
})
}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
options?: Record<string, unknown>
} = {}
) {
useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: init.value ?? 'value',
options: init.options ?? {}
})
}
function renderComponent({
nodeData,
widgetIds,
setupStores
}: {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
setupStores?: () => void
}) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData,
widgetIds
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true,
AppInput: AppInputStub
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('NodeWidgets', () => {
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('CheckpointLoaderSimple')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -116,15 +134,31 @@ describe('NodeWidgets', () => {
})
it('renders no widgets when nodeData is undefined', () => {
const { container } = renderComponent(undefined)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const { container } = renderComponent({
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('renders no widgets when no widget ids are registered or passed', () => {
const { container } = renderComponent({
nodeData: createMockNodeData('CheckpointLoaderSimple')
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('passes empty string when nodeData.type is empty', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -132,7 +166,18 @@ describe('NodeWidgets', () => {
})
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
it('derives widget ids from the store when ids are not passed', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'test_widget')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('deduplicates repeated widget ids while keeping distinct widget ids', () => {
const duplicateEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
@@ -143,163 +188,34 @@ describe('NodeWidgets', () => {
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
'string_a'
)
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
widgetId: distinctEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
duplicateB,
distinct
])
const nodeData = createMockNodeData('SubgraphNode')
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
hiddenDuplicate,
visibleDuplicate
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('does not deduplicate entries that share names but have different widget types', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
comboWidget
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(19)])
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
secondTransientEntry
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:2'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'test_widget',
options: { hidden: false }
})
])
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(
widgetId('graph-test', toNodeId('test_node'), 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
const { container } = renderComponent({
nodeData,
widgetIds: [duplicateEntityId, duplicateEntityId, distinctEntityId],
setupStores: () => {
registerWidgetState(duplicateEntityId, { type: 'text' })
registerWidgetState(distinctEntityId, { type: 'text' })
}
)
})
await nextTick()
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when store options mark them hidden', () => {
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const id = widgetId(GRAPH_ID, toNodeId('test_node'), 'test_widget')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => {
registerWidgetState(id, {
type: 'combo',
options: { hidden: true }
})
}
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
})
@@ -307,44 +223,17 @@ describe('NodeWidgets', () => {
it('forwards canonical widgetId to AppInput for selection', () => {
const seedAEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_a')
const seedBEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_b')
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_a',
type: 'text',
widgetId: seedAEntityId
}),
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_b',
type: 'text',
widgetId: seedBEntityId
})
])
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const { container } = render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [
(() => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
return pinia
})()
],
stubs: {
InputSlot: true,
AppInput: {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
},
mocks: {
$t: (key: string) => key
}
const { container } = renderComponent({
nodeData,
widgetIds: [seedAEntityId, seedBEntityId],
setupStores: () => {
registerWidgetState(seedAEntityId, { type: 'text' })
registerWidgetState(seedBEntityId, { type: 'text' })
}
})
const appInputElements = container.querySelectorAll('.app-input-stub')
const ids = Array.from(appInputElements).map((el) =>
el.getAttribute('data-entity-id')
@@ -352,4 +241,35 @@ describe('NodeWidgets', () => {
expect(ids).toStrictEqual([seedAEntityId, seedBEntityId])
})
it('marks widgets with host execution errors', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'seed')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
widgetIds: [id],
setupStores: () => {
useExecutionErrorStore().lastNodeErrors = {
[createNodeExecutionId([nodeId])]: {
errors: [
{
type: 'value_not_in_list',
message: 'seed is invalid',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
registerWidgetState(id, { type: 'text' })
}
})
expect(container.querySelector('.widget-stub')?.className).toContain(
'text-node-stroke-error'
)
})
})

View File

@@ -2,104 +2,43 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
</div>
<div
<WidgetGrid
v-else
data-testid="node-widgets"
:processed-widgets
:node-type
:can-select-inputs
:node-id="nodeData?.id"
:class="
cn(
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
/>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { WidgetId } from '@/types/widgetId'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
}
const { nodeData } = defineProps<NodeWidgetsProps>()
const { nodeData, widgetIds } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -128,8 +67,10 @@ onErrorCaptured((error) => {
return false
})
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(() => nodeData)
const { canSelectInputs, nodeType, processedWidgets } = useProcessedWidgets(
() => nodeData,
() => widgetIds
)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

View File

@@ -0,0 +1,104 @@
<template>
<div
data-testid="node-widgets"
class="lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.simplified.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.simplified.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.simplified.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-tooltip.left="widget.tooltipConfig ?? EMPTY_TOOLTIP"
:model-value="widget.simplified.value"
:widget="widget.simplified"
:node-id
:node-type
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import { shouldExpand } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { NodeId } from '@/types/nodeId'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
const EMPTY_TOOLTIP: TooltipOptions = {}
const {
processedWidgets,
nodeType,
canSelectInputs = false,
nodeId
} = defineProps<{
processedWidgets: WidgetGridItem[]
nodeType: string
canSelectInputs?: boolean
nodeId?: NodeId
}>()
const gridTemplateRows = computed(() =>
processedWidgets
.filter((widget) => widget.visible)
.map((widget) =>
shouldExpand(widget.simplified.type) || widget.hasLayoutSize
? 'auto'
: 'min-content'
)
.join(' ')
)
</script>

View File

@@ -2,7 +2,6 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { i18n, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
@@ -19,9 +18,8 @@ const positiveCoordsTooltipKey =
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const positiveCoordsWidget: SafeWidgetData = {
name: 'positive_coords',
type: 'STRING'
const positiveCoordsWidget: { name: string; tooltip?: string } = {
name: 'positive_coords'
}
function mergeOutputTooltipMessage(tooltip: string | null) {

View File

@@ -5,7 +5,6 @@ import type {
import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st, stRaw } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -136,11 +135,11 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: SafeWidgetData) => {
const getWidgetTooltip = (widget: { name: string; tooltip?: string }) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
const widgetTooltip = (widget as { tooltip?: string }).tooltip
const widgetTooltip = widget.tooltip
if (widgetTooltip) return widgetTooltip
// Then try input-based tooltip lookup

View File

@@ -1,51 +1,60 @@
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import type {
WidgetGridItem,
WidgetSlotMetadata
} from '@/renderer/extensions/vueNodes/types/widgetGrid'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { app } from '@/scripts/app'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
LinkedUpstreamInfo,
SafeControlWidget,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import type { WidgetId } from '@/types/widgetId'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { mapLiveWidgetsById } from '@/utils/litegraphUtil'
const TOOLTIP_VALUE_TYPES = ['asset', 'combo', 'number', 'text'] as const
type TooltipValueType = (typeof TOOLTIP_VALUE_TYPES)[number]
@@ -53,33 +62,36 @@ function isTooltipValueType(val: unknown): val is TooltipValueType {
return TOOLTIP_VALUE_TYPES.includes(val as TooltipValueType)
}
interface ProcessedWidget {
advanced: boolean
interface WidgetTooltipSource {
name: string
tooltip?: string
}
interface WidgetErrorTarget {
executionId: NodeExecutionId
widgetName: string
}
export interface ProcessedWidget extends WidgetGridItem {
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id?: string
widgetId?: WidgetId
name: string
renderKey: string
simplified: SimplifiedWidget
widgetId: WidgetId
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
visible: boolean
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
interface WidgetUiCallbacks {
getTooltipConfig: (widget: SafeWidgetData, fullVal?: string) => TooltipOptions
getTooltipConfig: (
widget: WidgetTooltipSource,
fullVal?: string
) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: NodeId) => void
}
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
widgetIds?: readonly WidgetId[]
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
@@ -87,86 +99,53 @@ interface ComputeProcessedWidgetsOptions {
ui: WidgetUiCallbacks
}
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
widgetOptions: IWidgetOptions | Record<string, never>,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (widget.sourceExecutionId) {
const sourceWidgetName = widget.sourceWidgetName ?? widget.name
executionErrorStore.clearWidgetRelatedErrors(
widget.sourceExecutionId,
sourceWidgetName,
sourceWidgetName,
newValue,
options
)
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') return value
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
let linked = input.link != null
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
linked = Boolean(link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widget.name,
widget.name,
newValue,
options
)
}
}
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
return (
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}
export function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: NodeId | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
if (widget.widgetId) {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
const hostNodeIdRoot = nodeId ? stripGraphPrefix(nodeId) : null
const widgetNodeIdRoot = widget.nodeId
? stripGraphPrefix(widget.nodeId)
: null
const stableIdentityRoot = widgetNodeIdRoot
? `node:${widgetNodeIdRoot}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
? `node:${hostNodeIdRoot}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${widget.name}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${widget.name}:${widget.type}:${index}`
return { dedupeIdentity, renderKey }
const slotInfo: WidgetSlotMetadata = {
index,
linked,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
function getProcessedNodeExecutionId(
@@ -190,7 +169,16 @@ function getWidgetNodeLocatorId(
)
}
export function isWidgetVisible(
function getHostNode(
rootGraph: LGraph | null,
nodeData: VueNodeData
): LGraphNode | null {
if (!rootGraph) return null
const locatorId = getLocatorIdFromNodeData(nodeData)
return locatorId ? getNodeByLocatorId(rootGraph, locatorId) : null
}
function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean,
linked = false
@@ -200,19 +188,266 @@ export function isWidgetVisible(
return !hidden && (!advanced || showAdvanced || linked)
}
function hasWidgetError(
widget: { name: string; errorTarget?: WidgetErrorTarget },
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const hasHostError =
!!nodeErrors?.errors.some(
(e) => e.extra_info?.input_name === widget.name
) || missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
const target = widget.errorTarget
if (!target) return hasHostError
const sourceErrors = executionErrorStore.lastNodeErrors?.[target.executionId]
return (
hasHostError ||
!!sourceErrors?.errors.some(
(e) => e.extra_info?.input_name === target.widgetName
) ||
missingModelStore.isWidgetMissingModel(
target.executionId,
target.widgetName
)
)
}
function createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId,
widgetName,
widgetOptions,
executionErrorStore,
widgetValueStore
}: {
id: WidgetId
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
nodeExecId: NodeExecutionId
widgetName: string
widgetOptions: IWidgetOptions
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
widgetValueStore: ReturnType<typeof useWidgetValueStore>
}): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
widgetValueStore.setValue(id, newValue)
if (live) {
const normalized = normalizeWidgetValue(newValue)
live.widget.value = normalized ?? undefined
live.widget.callback?.(normalized, app.canvas, live.node)
live.node.widgets?.forEach((w) => w.triggerDraw?.())
}
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (errorTarget) {
executionErrorStore.clearWidgetRelatedErrors(
errorTarget.executionId,
errorTarget.widgetName,
errorTarget.widgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widgetName,
widgetName,
newValue,
options
)
}
}
function resolveWidgetIds(
graphId: string | undefined,
nodeId: NodeId,
explicitWidgetIds: readonly WidgetId[] | undefined,
widgetValueStore: ReturnType<typeof useWidgetValueStore>
): readonly WidgetId[] {
if (explicitWidgetIds) return explicitWidgetIds
const bareNodeId = stripGraphPrefix(nodeId)
return graphId && bareNodeId
? widgetValueStore.getNodeWidgetIds(graphId, bareNodeId)
: []
}
interface LiveWidgetContext {
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
controlWidget?: SafeControlWidget
}
/**
* Resolves the live litegraph widget (and, for promoted subgraph inputs, its
* interior source) into the control widget and error target the render path
* needs. Empty when the widget has no live counterpart (e.g. static previews).
*/
function resolveLiveWidgetContext(
rootGraph: LGraph | null,
hostNode: LGraphNode | null,
liveWidget: IBaseWidget | undefined
): LiveWidgetContext {
if (!hostNode || !liveWidget) return {}
const promotedSource = resolvePromotedWidgetSource(
rootGraph,
hostNode,
liveWidget
)
const errorTarget: WidgetErrorTarget | undefined =
promotedSource?.sourceExecutionId
? {
executionId: promotedSource.sourceExecutionId,
widgetName: promotedSource.sourceWidgetName
}
: undefined
const controlWidget =
getControlWidget(liveWidget) ??
(promotedSource?.sourceWidget
? getControlWidget(promotedSource.sourceWidget)
: undefined)
return {
live: { node: hostNode, widget: liveWidget },
errorTarget,
controlWidget
}
}
interface WidgetProcessingContext {
nodeData: VueNodeData
showAdvanced: boolean
rootGraph: LGraph | null
hostNode: LGraphNode | null
liveWidgets: Map<WidgetId, IBaseWidget>
slotMetadata: Map<string, WidgetSlotMetadata>
nodeExecId: NodeExecutionId
nodeErrors: Parameters<typeof hasWidgetError>[2]
widgetValueStore: ReturnType<typeof useWidgetValueStore>
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
missingModelStore: ReturnType<typeof useMissingModelStore>
nodeDefStore: ReturnType<typeof useNodeDefStore>
ui: WidgetUiCallbacks
}
function processWidget(
id: WidgetId,
ctx: WidgetProcessingContext
): ProcessedWidget | null {
const widgetState = ctx.widgetValueStore.getWidget(id)
if (!widgetState) return null
const renderState = ctx.widgetValueStore.getWidgetRenderState(id)
const options: IWidgetOptions = { ...(widgetState.options ?? {}) }
if (options.advanced === undefined) options.advanced = renderState?.advanced
if (!shouldRenderAsVue({ type: widgetState.type, options })) return null
const { live, errorTarget, controlWidget } = resolveLiveWidgetContext(
ctx.rootGraph,
ctx.hostNode,
ctx.liveWidgets.get(id)
)
const slotInfo = ctx.slotMetadata.get(widgetState.name)
const visible = isWidgetVisible(options, ctx.showAdvanced, slotInfo?.linked)
const isDisabled = slotInfo?.linked || widgetState.disabled
const widgetOptions = isDisabled ? { ...options, disabled: true } : options
const value = widgetState.value as WidgetValue
const bareWidgetId = stripGraphPrefix(widgetState.nodeId)
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotInfo?.linked && slotInfo.originNodeId
? { nodeId: slotInfo.originNodeId, outputName: slotInfo.originOutputName }
: undefined
const updateHandler = createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId: ctx.nodeExecId,
widgetName: widgetState.name,
widgetOptions,
executionErrorStore: ctx.executionErrorStore,
widgetValueStore: ctx.widgetValueStore
})
const simplified: SimplifiedWidget = {
name: widgetState.name,
type: widgetState.type,
value,
borderStyle: widgetOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
callback: updateHandler,
controlWidget,
label: widgetState.label,
linkedUpstream,
nodeLocatorId: getWidgetNodeLocatorId(ctx.nodeData, bareWidgetId),
options: widgetOptions,
spec: live
? ctx.nodeDefStore.getInputSpecForWidget(live.node, live.widget.name)
: undefined
}
const valueTooltip =
isTooltipValueType(widgetState.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ctx.ui.getTooltipConfig(
{ name: widgetState.name, tooltip: renderState?.tooltip },
valueTooltip
)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
ctx.ui.handleNodeRightClick(e, ctx.nodeData.id)
showNodeOptions(e, widgetState.name)
}
return {
handleContextMenu,
hasLayoutSize: renderState?.hasLayoutSize ?? false,
hasError: hasWidgetError(
{ name: widgetState.name, errorTarget },
ctx.nodeExecId,
ctx.nodeErrors,
ctx.executionErrorStore,
ctx.missingModelStore
),
widgetId: id,
renderKey: `${id}:${widgetState.type}`,
vueComponent:
getComponent(widgetState.type) ||
(renderState?.isDOMWidget ? WidgetDOM : WidgetLegacy),
simplified,
visible,
updateHandler,
tooltipConfig,
slotMetadata: slotInfo
}
}
export function computeProcessedWidgets({
nodeData,
widgetIds,
graphId,
showAdvanced,
isGraphReady,
rootGraph,
ui
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
if (!nodeData) return []
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
const nodeDefStore = useNodeDefStore()
const nodeExecId = getProcessedNodeExecutionId(
isGraphReady,
@@ -221,185 +456,49 @@ export function computeProcessedWidgets({
)
if (!nodeExecId) return []
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetNodeId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: graphId && widgetNodeId
? widgetValueStore.getWidget(
widgetId(graphId, widgetNodeId, widget.name)
)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(
mergedOptions,
showAdvanced,
widget.slotMetadata?.linked
)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
const hostNode = getHostNode(rootGraph, nodeData)
const liveWidgets = hostNode
? mapLiveWidgetsById(hostNode)
: new Map<WidgetId, IBaseWidget>()
const orderedIds = resolveWidgetIds(
graphId,
nodeData.id,
widgetIds,
widgetValueStore
)
// Drop ids whose live widget is gone (e.g. removed directly on node.widgets);
// when the host node isn't resolvable yet, fall back to the stored order.
const ids = hostNode
? orderedIds.filter((id) => liveWidgets.has(id))
: orderedIds
const slotMetadata = buildSlotMetadata(
nodeData.inputs ?? hostNode?.inputs,
hostNode?.graph ?? rootGraph
)
const ctx: WidgetProcessingContext = {
nodeData,
showAdvanced,
rootGraph,
hostNode,
liveWidgets,
slotMetadata,
nodeExecId,
nodeErrors: executionErrorStore.lastNodeErrors?.[nodeExecId],
widgetValueStore,
executionErrorStore,
missingModelStore,
nodeDefStore,
ui
}
for (const {
widget,
mergedOptions,
widgetState,
isVisible: visible,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle = mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = getWidgetNodeLocatorId(nodeData, bareWidgetId)
const simplified: SimplifiedWidget = {
name: widgetState?.name ?? widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions,
executionErrorStore
)
const valueTooltip =
isTooltipValueType(widget.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? (stripGraphPrefix(widget.nodeId) ?? undefined)
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(
widget,
nodeExecId,
nodeErrors,
executionErrorStore,
missingModelStore
),
hidden: mergedOptions.hidden ?? false,
widgetId: widget.widgetId,
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
visible,
updateHandler,
tooltipConfig,
slotMetadata,
...(bareWidgetId === null ? {} : { id: bareWidgetId })
})
}
return result
return Array.from(new Set(ids))
.map((id) => processWidget(id, ctx))
.filter((widget): widget is ProcessedWidget => widget !== null)
}
export function useProcessedWidgets(
nodeDataGetter: () => VueNodeData | undefined
nodeDataGetter: () => VueNodeData | undefined,
widgetIdsGetter: () => readonly WidgetId[] | undefined = () => undefined
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
@@ -436,6 +535,7 @@ export function useProcessedWidgets(
const processedWidgets = computed((): ProcessedWidget[] =>
computeProcessedWidgets({
nodeData: nodeDataGetter(),
widgetIds: widgetIdsGetter(),
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,
@@ -444,23 +544,9 @@ export function useProcessedWidgets(
})
)
const visibleWidgets = computed(() =>
processedWidgets.value.filter((w) => w.visible)
)
const gridTemplateRows = computed((): string =>
visibleWidgets.value
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
)
return {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
visibleWidgets
processedWidgets
}
}

View File

@@ -0,0 +1,33 @@
import type { TooltipOptions } from 'primevue'
import type { Component } from 'vue'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { WidgetId } from '@/types/widgetId'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
/**
* Data a widget row needs to render in {@link WidgetGrid}. Required fields cover
* the static preview path; the optional interactive fields are supplied only by
* the store-backed {@link ProcessedWidget} superset.
*/
export interface WidgetGridItem {
simplified: SimplifiedWidget
vueComponent: Component
visible: boolean
renderKey: string
hasLayoutSize?: boolean
hasError?: boolean
widgetId?: WidgetId
slotMetadata?: WidgetSlotMetadata
tooltipConfig?: TooltipOptions
updateHandler?: (value: WidgetValue) => void
handleContextMenu?: (e: PointerEvent) => void
}

View File

@@ -78,7 +78,9 @@ watch(() => canvasStore.currentGraph, bindWidget)
function draw() {
if (!widgetInstance || !node) return
const width = canvasEl.value.parentElement.clientWidth
const width =
canvasEl.value.parentElement.clientWidth ||
canvasEl.value.getBoundingClientRect().width
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
let height = 20
if (widgetInstance.computedHeight) {
@@ -126,7 +128,7 @@ function handleMove(e: PointerEvent) {
</script>
<template>
<div
class="relative mx-[-12px] min-w-0 basis-0"
class="relative mx-[-12px] w-full min-w-0"
:style="{ minHeight: `${containerHeight}px` }"
>
<canvas

View File

@@ -8,7 +8,6 @@ import {
shouldRenderAsVue,
FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const {
WidgetButton,
@@ -134,7 +133,7 @@ describe('widgetRegistry', () => {
})
it('should respect options while checking type', () => {
const widget: Partial<SafeWidgetData> = {
const widget: { type: string; options: { canvasOnly: boolean } } = {
type: 'text',
options: { canvasOnly: false }
}

View File

@@ -4,7 +4,7 @@
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
const WidgetButton = defineAsyncComponent(
() => import('../components/WidgetButton.vue')
@@ -268,7 +268,10 @@ export const isEssential = (type: string): boolean => {
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
export const shouldRenderAsVue = (widget: {
options?: Pick<IWidgetOptions, 'canvasOnly'>
type?: string
}): boolean => {
return !widget.options?.canvasOnly && !!widget.type
}

View File

@@ -141,7 +141,7 @@ describe('useWidgetValueStore', () => {
expect(registered?.value).toBe(100)
})
it('getNodeWidgets returns all widgets for a node', () => {
it('getNodeWidgets returns widgets in registration order', () => {
const store = useWidgetValueStore()
store.registerWidget(
widgetId(graphA, toNodeId('node-1'), 'seed'),
@@ -157,8 +157,40 @@ describe('useWidgetValueStore', () => {
)
const widgets = store.getNodeWidgets(graphA, toNodeId('node-1'))
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
expect(widgets.map((w) => w.name)).toEqual(['seed', 'steps'])
})
it('getNodeWidgetIds returns the explicit node widget order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
const cfg = widgetId(graphA, toNodeId('node-1'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(steps, state('number', 20))
store.registerWidget(cfg, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [cfg, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
cfg,
seed,
steps
])
expect(
store.getNodeWidgets(graphA, toNodeId('node-1')).map((w) => w.name)
).toEqual(['cfg', 'seed', 'steps'])
})
it('ignores widget IDs from other nodes when setting order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const other = widgetId(graphA, toNodeId('node-2'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(other, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [other, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([seed])
})
})
@@ -174,14 +206,33 @@ describe('useWidgetValueStore', () => {
).toBe(false)
})
it('deleteWidget removes registered widgets', () => {
it('deleteWidget removes registered widgets from node order', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.deleteWidget(seedA)).toBe(false)
})
it('removeNodeWidgetOrder drops the id from order but keeps its value', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
store.removeNodeWidgetOrder(seedA)
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.getWidget(seedA)?.value).toBe(100)
})
})
describe('direct property mutation', () => {

View File

@@ -8,12 +8,23 @@ import { parseWidgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetState, WidgetStateInit } from '@/types/widgetState'
export interface WidgetRenderState {
advanced?: boolean
hasLayoutSize?: boolean
isDOMWidget?: boolean
tooltip?: string
}
export function stripGraphPrefix(scopedId: SerializedNodeId): NodeId | null {
return parseNodeId(String(scopedId).replace(/^(.*:)+/, ''))
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetId, WidgetState>>())
const graphWidgetRenderStates = ref(
new Map<UUID, Map<WidgetId, WidgetRenderState>>()
)
const graphNodeWidgetOrders = ref(new Map<UUID, Map<NodeId, WidgetId[]>>())
function getGraphWidgetStates(graphId: UUID): Map<WidgetId, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
@@ -24,12 +35,67 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return nextWidgetStates
}
function getGraphWidgetRenderStates(
graphId: UUID
): Map<WidgetId, WidgetRenderState> {
const widgetRenderStates = graphWidgetRenderStates.value.get(graphId)
if (widgetRenderStates) return widgetRenderStates
const nextWidgetRenderStates = reactive(
new Map<WidgetId, WidgetRenderState>()
)
graphWidgetRenderStates.value.set(graphId, nextWidgetRenderStates)
return nextWidgetRenderStates
}
function getGraphNodeWidgetOrders(graphId: UUID): Map<NodeId, WidgetId[]> {
const widgetOrders = graphNodeWidgetOrders.value.get(graphId)
if (widgetOrders) return widgetOrders
const nextWidgetOrders = reactive(new Map<NodeId, WidgetId[]>())
graphNodeWidgetOrders.value.set(graphId, nextWidgetOrders)
return nextWidgetOrders
}
function getNodeWidgetOrder(graphId: UUID, nodeId: NodeId): WidgetId[] {
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (order) return order
const nextOrder = reactive<WidgetId[]>([])
graphOrders.set(nodeId, nextOrder)
return nextOrder
}
function appendNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const order = getNodeWidgetOrder(graphId, nodeId)
if (!order.includes(widgetId)) order.push(widgetId)
}
function removeNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (!order) return
const index = order.indexOf(widgetId)
if (index !== -1) order.splice(index, 1)
if (order.length === 0) graphOrders.delete(nodeId)
}
function registerWidget<TValue = unknown>(
widgetId: WidgetId,
init: WidgetStateInit<TValue>
init: WidgetStateInit<TValue>,
renderState: WidgetRenderState = {}
): WidgetState<TValue> {
registerWidgetRenderState(widgetId, renderState)
const existing = getWidget(widgetId)
if (existing) return existing as WidgetState<TValue>
if (existing) {
appendNodeWidgetOrder(widgetId)
return existing as WidgetState<TValue>
}
const { graphId, nodeId, name } = parseWidgetId(widgetId)
const state: WidgetState<TValue> = {
@@ -40,14 +106,39 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
}
const widgetStates = getGraphWidgetStates(graphId)
widgetStates.set(widgetId, state)
appendNodeWidgetOrder(widgetId)
return widgetStates.get(widgetId) as WidgetState<TValue>
}
function registerWidgetRenderState(
widgetId: WidgetId,
init: WidgetRenderState
): WidgetRenderState {
const { graphId } = parseWidgetId(widgetId)
const widgetRenderStates = getGraphWidgetRenderStates(graphId)
const existing = widgetRenderStates.get(widgetId)
if (existing) {
Object.assign(existing, init)
return existing
}
const state: WidgetRenderState = { ...init }
widgetRenderStates.set(widgetId, state)
return widgetRenderStates.get(widgetId) as WidgetRenderState
}
function getWidget(widgetId: WidgetId): WidgetState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetStates(graphId).get(widgetId)
}
function getWidgetRenderState(
widgetId: WidgetId
): WidgetRenderState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetRenderStates(graphId).get(widgetId)
}
function setValue(widgetId: WidgetId, value: WidgetState['value']): boolean {
const state = getWidget(widgetId)
if (!state) return false
@@ -57,25 +148,70 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
function deleteWidget(widgetId: WidgetId): boolean {
const { graphId } = parseWidgetId(widgetId)
getGraphWidgetRenderStates(graphId).delete(widgetId)
removeNodeWidgetOrder(widgetId)
return getGraphWidgetStates(graphId).delete(widgetId)
}
function getNodeWidgets(graphId: UUID, localNodeId: NodeId): WidgetState[] {
return [...getGraphWidgetStates(graphId).values()].filter(
(state) => state.nodeId === localNodeId
return getNodeWidgetIds(graphId, localNodeId).flatMap((id) => {
const state = getWidget(id)
return state ? [state] : []
})
}
/**
* Merges a requested widget order against the ids already tracked for the
* node: the request is filtered to tracked ids, then any tracked id the
* request omitted is appended. Tracked ids are never dropped here — only
* {@link removeNodeWidgetOrder} removes an id from the order.
*/
function reconcileNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): WidgetId[] {
const currentOrder = getNodeWidgetIds(graphId, localNodeId)
const currentIds = new Set(currentOrder)
const nextOrder = orderedWidgetIds.filter((id) => currentIds.has(id))
const nextIds = new Set(nextOrder)
return [...nextOrder, ...currentOrder.filter((id) => !nextIds.has(id))]
}
function getNodeWidgetIds(graphId: UUID, localNodeId: NodeId): WidgetId[] {
return [...getNodeWidgetOrder(graphId, localNodeId)]
}
function setNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): void {
const nextOrder = reconcileNodeWidgetOrder(
graphId,
localNodeId,
orderedWidgetIds
)
const order = getNodeWidgetOrder(graphId, localNodeId)
order.splice(0, order.length, ...nextOrder)
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
graphWidgetRenderStates.value.delete(graphId)
graphNodeWidgetOrders.value.delete(graphId)
}
return {
registerWidget,
getWidget,
getWidgetRenderState,
setValue,
deleteWidget,
getNodeWidgets,
getNodeWidgetIds,
setNodeWidgetOrder,
removeNodeWidgetOrder,
clearGraph
}
})

View File

@@ -3,7 +3,11 @@
* Removes all DOM manipulation and positioning concerns
*/
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import type { NodeId } from '@/types/nodeId'
import type { NodeLocatorId } from '@/types/nodeIdentification'
@@ -30,7 +34,7 @@ function isControlOption(val: WidgetValue): val is ControlOptions {
return CONTROL_OPTIONS.includes(val as ControlOptions)
}
export function normalizeControlOption(val: WidgetValue): ControlOptions {
function normalizeControlOption(val: WidgetValue): ControlOptions {
if (isControlOption(val)) return val
return 'randomize'
}
@@ -40,6 +44,17 @@ export type SafeControlWidget = {
update: (value: WidgetValue) => void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const controlWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!controlWidget) return
return {
value: normalizeControlOption(controlWidget.value),
update: (value) => (controlWidget.value = normalizeControlOption(value))
}
}
export interface LinkedUpstreamInfo {
nodeId: NodeId
outputName?: string

View File

@@ -19,7 +19,6 @@ export interface WidgetState<
| 'disabled'
| 'y'
> {
isDOMWidget?: boolean
nodeId: NodeId
}

View File

@@ -379,6 +379,26 @@ export function getWidgetIdForNode(
return widgetId(graphId, nodeId, name)
}
/**
* Maps a node's live widgets to their {@link WidgetId}, replicating the
* duplicate-name disambiguation used when the ids were minted. Building the map
* once lets callers resolve widgets by id in O(1) instead of rescanning.
*/
export function mapLiveWidgetsById(
node: LGraphNode
): Map<WidgetId, IBaseWidget> {
const byId = new Map<WidgetId, IBaseWidget>()
const duplicateIndexByKey = new Map<string, number>()
for (const widget of node.widgets ?? []) {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const id = getWidgetIdForNode(node, widget, duplicateIndex)
if (id) byId.set(id, widget)
}
return byId
}
export function isLoad3dNode(node: LGraphNode) {
return (
node &&

View File

@@ -1,10 +1,16 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { applyTextReplacements } from '@/utils/searchAndReplace'
describe('applyTextReplacements', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
// Test specifically the filename sanitization part
describe('filename sanitization', () => {
it('should replace invalid filename characters with underscores', () => {

View File

@@ -4,7 +4,7 @@ import { app } from '../../scripts/app.js'
function legacyWidget(node, inputName, inputData) {
if (!node.widgets) node.widgets = []
node.widgets.push({
const widget = {
draw: function (ctx, node, widget_width, y, H) {
ctx.save()
ctx.fillStyle = '#7F7'
@@ -24,7 +24,9 @@ function legacyWidget(node, inputName, inputData) {
type: 'DEVTOOLS.LEGACYWIDGET',
value: 0,
y: 0
})
}
node.widgets.push(widget)
return { widget }
}
app.registerExtension({