feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)

## Summary

Replace the Proxy-based proxy widget system with a store-driven
architecture where `promotionStore` and `widgetValueStore` are the
single sources of truth for subgraph widget promotion and widget values,
and `SubgraphNode.widgets` is a synthetic getter composing lightweight
`PromotedWidgetView` objects from store state.

## Motivation

The subgraph widget promotion system previously scattered state across
multiple unsynchronized layers:

- **Persistence**: `node.properties.proxyWidgets` (tuples on the
LiteGraph node)
- **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects,
`DisconnectedWidget` singleton, and `isProxyWidget` type guards
- **UI**: Each Vue component independently calling `parseProxyWidgets()`
via `customRef` hacks
- **Mutation flags**: Imperative `widget.promoted = true/false` set on
`subgraph-opened` events

This led to 4+ independent parsings of the same data, complex cache
invalidation, and no reactive contract between the promotion state and
the rendering layer. Widget values were similarly owned by LiteGraph
with no Vue-reactive backing.

The core principle driving these changes: **Vue owns truth**. Pinia
stores are the canonical source; LiteGraph objects delegate to stores
via getters/setters; Vue components react to store state directly.

## Changes

### New stores (single sources of truth)

- **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>`
tracking which interior widgets are promoted on which SubgraphNode
instances. Graph-scoped by root graph ID to prevent cross-workflow state
collision. Replaces `properties.proxyWidgets` parsing, `customRef`
hacks, `widget.promoted` mutation, and the `subgraph-opened` event
listener.
- **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>`
that is the canonical owner of widget values. `BaseWidget.value`
delegates to this store via getter/setter when a node ID is assigned.
Eliminates the need for Proxy-based value forwarding.

### Synthetic widgets getter (SubgraphNode)

`SubgraphNode.widgets` is now a getter that reads
`promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached
`PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets
persisted in the array. The setter is a no-op — mutations go through
`promotionStore`.

### PromotedWidgetView

A class behind a `createPromotedWidgetView` factory, implementing the
`PromotedWidgetView` interface. Delegates value/type/options/drawing to
the resolved interior widget and stores. Owns positional state (`y`,
`computedHeight`) for canvas layout. Cached by
`PromotedWidgetViewManager` for object-identity stability across frames.

### DOM widget promotion

Promoted DOM widgets (textarea, image upload, etc.) render on the
SubgraphNode surface via `positionOverride` in `domWidgetStore`.
`DomWidgets.vue` checks for overrides and uses the SubgraphNode's
coordinates instead of the interior node's.

### Promoted previews

New `usePromotedPreviews` composable resolves image/audio/video preview
widgets from promoted entries, enabling SubgraphNodes to display
previews of interior preview nodes.

### Deleted

- `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`,
`newProxyWidget`, `isProxyWidget`
- `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target
- `useValueTransform.ts` (32 lines) — Replaced by store delegation

### Key architectural changes

- `BaseWidget.value` getter/setter delegates to `widgetValueStore` when
node ID is set
- `LGraph.add()` reordered: `node.graph` assigned before widget
`setNodeId` (enables store registration)
- `LGraph.clear()` cleans up graph-scoped stores to prevent stale
entries across workflow switches
- `promotionStore` and `widgetValueStore` state nested under root graph
UUID for multi-workflow isolation
- `SubgraphNode.serialize()` writes promotions back to
`properties.proxyWidgets` for persistence compatibility
- Legacy `-1` promotion entries resolved and migrated on first load with
dev warning

## Test coverage

- **3,700+ lines of new/updated tests** across 36 test files
- **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`,
`promotedWidgetView.test.ts` (921 lines),
`subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`,
`DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`,
`usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`,
`subgraphPseudoWidgetCache.test.ts`
- **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote,
manual/auto promotion, paste preservation, seed control augmentation,
image preview promotion; `imagePreview.spec.ts` extended with
multi-promoted-preview coverage
- **Fixtures**: 2 new subgraph workflow fixtures for preview promotion
scenarios

## Review focus

- Graph-scoped store keying (`rootGraphId`) — verify isolation across
workflows/tabs and cleanup on `LGraph.clear()`
- `PromotedWidgetView` positional stability — `_arrangeWidgets` writes
to `y`/`computedHeight` on cached objects; getter returns fresh array
but stable object references
- DOM widget position override lifecycle — overrides set on promote,
cleared on demote/removal/subgraph navigation
- Legacy `-1` entry migration — resolved and written back on first load;
unresolvable entries dropped with dev warning
- Serialization round-trip — `promotionStore` state →
`properties.proxyWidgets` on serialize, hydrated back on configure

## Diff breakdown (excluding lockfile)

- 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding
pnpm-lock.yaml churn)
- ~3,700 lines are tests
- ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts,
useValueTransform.ts)

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-23 13:33:41 -08:00
committed by GitHub
parent d7546e68ef
commit c25f9a0e93
128 changed files with 7295 additions and 1931 deletions

View File

@@ -10,8 +10,10 @@ import type {
LGraph,
LGraphCanvas,
LGraphGroup,
LGraphNode
LGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { app } from '@/scripts/app'
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
@@ -135,6 +137,13 @@ export const useCanvasStore = defineStore('canvas', () => {
useEventListener(newCanvas.canvas, 'subgraph-opened', () => {
isInSubgraph.value = true
})
useEventListener(
newCanvas.canvas,
'subgraph-converted',
(e: CustomEvent<{ subgraphNode: SubgraphNode }>) =>
promoteRecommendedWidgets(e.detail.subgraphNode)
)
},
{ immediate: true }
)

View File

@@ -7,7 +7,7 @@
<!-- Video Wrapper -->
<div
ref="videoWrapperEl"
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
class="relative flex flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@@ -21,7 +21,7 @@
<div
v-if="videoError"
role="alert"
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
class="flex flex-auto flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
>
<i
class="mb-2 icon-[lucide--video-off] h-12 w-12 text-base-foreground"

View File

@@ -0,0 +1,19 @@
<template>
<div v-if="audioUrls.length > 0" class="flex flex-col gap-2 px-2">
<audio
v-for="(url, index) in audioUrls"
:key="index"
:src="url"
controls
class="w-full"
/>
</div>
</template>
<script setup lang="ts">
interface AudioPreviewProps {
readonly audioUrls: readonly string[]
}
defineProps<AudioPreviewProps>()
</script>

View File

@@ -7,7 +7,7 @@
<!-- Image Wrapper -->
<div
ref="imageWrapperEl"
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
class="min-h-0 flex-1 flex w-full overflow-hidden rounded-[5px] bg-muted-background relative"
tabindex="0"
role="img"
:aria-label="$t('g.imagePreview')"
@@ -21,7 +21,7 @@
<div
v-if="imageError"
role="alert"
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
class="flex flex-1 self-center size-full flex-col items-center justify-around bg-muted-background text-center text-base-foreground py-8"
>
<i
class="mb-2 icon-[lucide--image-off] h-12 w-12 text-base-foreground"
@@ -42,7 +42,7 @@
v-if="!imageError"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain pointer-events-none contain-size"
class="absolute inset-0 block size-full object-contain pointer-events-none"
@load="handleImageLoad"
@error="handleImageError"
/>

View File

@@ -111,8 +111,18 @@
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
<NodeContent :node-data="nodeData" :media="nodeMedia" />
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:node-data="nodeData"
:media="preview"
/>
</div>
<!-- Live mid-execution preview images -->
<LivePreview
@@ -256,6 +266,7 @@ import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/unpromotedWidgetUtils'
import { st } from '@/i18n'
import {
LGraphCanvas,
@@ -270,6 +281,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import { LayoutSource } from '@/renderer/core/layout/types'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
@@ -525,7 +537,7 @@ watch(isCollapsed, (collapsed) => {
// Check if node has custom content (like image/video outputs)
const hasCustomContent = computed(() => {
// Show custom content if node has media outputs
if (promotedPreviews.value.length > 0) return true
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
})
@@ -639,15 +651,17 @@ const lgraphNode = computed(() => {
return getNodeByLocatorId(app.rootGraph, locatorId)
})
// TODO: Surface subgraph info more cleanly in VueNodeData instead of
// reaching through lgraphNode for promoted preview resolution.
const { promotedPreviews } = usePromotedPreviews(lgraphNode)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
const interiorNodes = node.subgraph.nodes
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
return hasUnpromotedWidgets(node)
}
// For regular nodes: show button if there are advanced widgets and they're currently hidden

View File

@@ -2,20 +2,25 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.content', 'Node Content Error') }}
</div>
<div v-else class="lg-node-content flex grow flex-col">
<div v-else class="lg-node-content flex grow flex-col flex-auto">
<!-- Default slot for custom content -->
<slot>
<VideoPreview
v-if="hasMedia && media?.type === 'video'"
:image-urls="media.urls"
:node-id="nodeId"
class="mt-2"
class="mt-2 flex-auto"
/>
<AudioPreview
v-else-if="hasMedia && media?.type === 'audio'"
:audio-urls="media.urls"
class="mt-2 flex-auto"
/>
<ImagePreview
v-else-if="hasMedia && media?.type === 'image'"
:image-urls="media.urls"
:node-id="nodeId"
class="mt-2"
class="mt-2 flex-auto"
/>
</slot>
</div>
@@ -29,12 +34,13 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import VideoPreview from '../VideoPreview.vue'
import AudioPreview from './AudioPreview.vue'
import ImagePreview from './ImagePreview.vue'
interface NodeContentProps {
nodeData?: VueNodeData
media?: {
type: 'image' | 'video'
type: 'image' | 'video' | 'audio'
urls: string[]
}
}

View File

@@ -87,6 +87,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
@@ -101,6 +102,7 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -115,7 +117,9 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
function handleWidgetPointerEvent(event: PointerEvent) {
@@ -171,6 +175,7 @@ interface ProcessedWidget {
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeData.id ?? '']
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
const { widgets } = nodeData
@@ -187,7 +192,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widgetValueStore.getWidget(bareWidgetId, widget.name)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, widget.name)
: undefined
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
@@ -198,9 +205,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
? { ...storeOptions, disabled: true }
: storeOptions
// Derive border style from store metadata
const isPromotedView = !!widget.nodeId
const borderStyle =
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
graphId &&
!isPromotedView &&
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
? 'ring ring-component-node-widget-promoted'
: widget.options?.advanced
? 'ring ring-component-node-widget-advanced'

View File

@@ -6,6 +6,7 @@ import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeBadgeMode } from '@/types/nodeSource'
@@ -54,10 +55,12 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
if (!graphId) continue
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
}
}
// Access input connections for regular inputs

View File

@@ -3,7 +3,9 @@ import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { DOMWidget } from '@/scripts/domWidget'
import { isDOMWidget } from '@/scripts/domWidget'
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
// Button widgets don't have a v-model value, they trigger actions
@@ -17,12 +19,18 @@ const domEl = ref<HTMLElement>()
const canvasStore = useCanvasStore()
const { canvas } = canvasStore
function findDOMWidget(): DOMWidget<HTMLElement, object | string> | undefined {
const hostNode = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
const resolved = resolveWidgetFromHostNode(hostNode, props.widget.name)
if (resolved && isDOMWidget(resolved.widget)) return resolved.widget
return undefined
}
function mountWidgetElement() {
if (!domEl.value) return
const node = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
if (!node) return
const widget = node.widgets?.find((w) => w.name === props.widget.name)
if (!widget || !isDOMWidget(widget)) return
const widget = findDOMWidget()
if (!widget) return
if (domEl.value.contains(widget.element)) return
domEl.value.replaceChildren(widget.element)
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { useResizeObserver, whenever } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
@@ -9,31 +9,46 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
widget: SimplifiedWidget<void>
nodeId: string
}>()
const canvasEl = ref()
const containerHeight = ref(20)
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
const canvasStore = useCanvasStore()
const canvas: LGraphCanvas = canvasStore.canvas as LGraphCanvas
let node: LGraphNode | undefined
let widgetInstance: IBaseWidget | undefined
let pointer: CanvasPointer | undefined
const scaleFactor = 2
onMounted(() => {
node =
canvas?.graph?.getNodeById(
canvasEl.value.parentElement.attributes['node-id'].value
) ?? undefined
if (!node) return
widgetInstance = node.widgets?.find((w) => w.name === props.widget.name)
if (!widgetInstance) return
canvasEl.value.width *= scaleFactor
function findLegacyWidget():
| {
node: LGraphNode
widget: IBaseWidget
}
| undefined {
const hostNode = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
return resolveWidgetFromHostNode(hostNode, props.widget.name)
}
function bindWidget() {
if (widgetInstance) widgetInstance.triggerDraw = () => {}
const resolved = findLegacyWidget()
if (!resolved) {
widgetInstance = undefined
node = undefined
return
}
node = resolved.node
widgetInstance = resolved.widget
if (!widgetInstance.triggerDraw)
widgetInstance.callback = useChainCallback(
widgetInstance.callback,
@@ -42,6 +57,13 @@ onMounted(() => {
}
)
widgetInstance.triggerDraw = draw
draw()
}
onMounted(() => {
canvasEl.value.width *= scaleFactor
bindWidget()
if (!widgetInstance) return
useResizeObserver(canvasEl.value.parentElement, draw)
watch(() => useColorPaletteStore().activePaletteId, draw)
pointer = new CanvasPointer(canvasEl.value)
@@ -50,6 +72,9 @@ onBeforeUnmount(() => {
if (widgetInstance) widgetInstance.triggerDraw = () => {}
})
whenever(() => !canvasStore.linearMode, bindWidget)
watch(() => canvasStore.currentGraph, bindWidget)
function draw() {
if (!widgetInstance || !node) return
const width = canvasEl.value.parentElement.clientWidth

View File

@@ -49,6 +49,8 @@ const renderPreview = (
shiftY: number,
computedHeight: number | undefined
) => {
if (!node.size) return
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse

View File

@@ -1,10 +1,9 @@
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -15,17 +14,14 @@ import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
type ExposedValue = string | string[]
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
value: ExposedValue
}
const findFileComboWidget = (
node: LGraphNode,
inputName: string
): IComboWidget | undefined =>
node.widgets?.find((w): w is IComboWidget => w.name === inputName)
export const useImageUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
@@ -51,24 +47,11 @@ export const useImageUploadWidget = () => {
const fileFilter = isVideo ? isVideoFile : isImageFile
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch
? internalValue.map(formatPath)
: formatPath(internalValue[0])
return formatPath(internalValue)
if (!fileComboWidget) {
throw new Error(`Widget "${imageInputName}" not found on node`)
}
Object.defineProperty(
fileComboWidget,
'value',
useValueTransform(transform, initialFile)
)
const formatPath = (value: string) =>
createAnnotatedPath(value, { rootFolder: image_folder })
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
@@ -77,10 +60,12 @@ export const useImageUploadWidget = () => {
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
const annotated = output.map(formatPath)
annotated.forEach((path) => {
addToComboValues(fileComboWidget, path)
})
// Create a NEW array to ensure Vue reactivity detects the change
const newValue = allow_batch ? [...output] : output[0]
const newValue = allow_batch ? annotated : annotated[0]
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = newValue
@@ -103,7 +88,7 @@ export const useImageUploadWidget = () => {
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
isAnimated
})
node.graph?.setDirtyCanvas(true)
@@ -113,7 +98,7 @@ export const useImageUploadWidget = () => {
// The value isn't set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
isAnimated
})
showPreview({ block: false })

View File

@@ -7,6 +7,7 @@ import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
@@ -48,15 +49,15 @@ function addMarkdownWidget(
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
return (
(widgetStore.getWidget(node.id, name)?.value as string) ??
textarea.value
)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
return typeof storedValue === 'string' ? storedValue : textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
const widgetState = widgetStore.getWidget(node.id, name)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
if (widgetState) widgetState.value = v
}
})

View File

@@ -1,8 +1,10 @@
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -36,9 +38,15 @@ export function useTextPreviewWidget(
},
options: {
getValue: () =>
useWidgetValueStore().getWidget(node.id, inputSpec.name)?.value ?? '',
useWidgetValueStore().getWidget(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputSpec.name
)?.value ?? '',
setValue: (value: string | object) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
node.id,
inputSpec.name
)

View File

@@ -1,4 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -18,19 +19,22 @@ function addMultilineWidget(
const widgetStore = useWidgetValueStore()
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.dataset.testid = 'dom-widget-textarea'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
const widgetState = widgetStore.getWidget(node.id, name)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
return (widgetState?.value as string) ?? inputEl.value
},
setValue(v: string) {
inputEl.value = v
const widgetState = widgetStore.getWidget(node.id, name)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
if (widgetState) widgetState.value = v
}
})

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
class TestNode extends LGraphNode {
constructor(widgets: IBaseWidget[]) {
super('TestNode')
this.widgets = widgets
}
}
class TestSubgraphNode extends TestNode {
constructor(
widgets: IBaseWidget[],
innerNodesById: Record<string, LGraphNode> = {}
) {
super(widgets)
this.subgraph = {
getNodeById: (nodeId: string) => innerNodesById[nodeId]
} as SubgraphNode['subgraph']
}
override isSubgraphNode(): this is SubgraphNode {
return true
}
readonly subgraph: SubgraphNode['subgraph']
}
type TestPromotedWidget = IBaseWidget &
Pick<PromotedWidgetView, 'sourceNodeId' | 'sourceWidgetName'>
function createWidget(name: string): IBaseWidget {
return {
name,
type: 'text',
y: 0,
options: {}
}
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string
): TestPromotedWidget {
return {
...createWidget(name),
sourceNodeId,
sourceWidgetName
}
}
function createHostNode(
widgets: IBaseWidget[],
options: {
isSubgraphNode?: boolean
innerNodesById?: Record<string, LGraphNode>
} = {}
): LGraphNode {
const { isSubgraphNode = false, innerNodesById = {} } = options
return isSubgraphNode
? new TestSubgraphNode(widgets, innerNodesById)
: new TestNode(widgets)
}
describe('resolveWidgetFromHostNode', () => {
it('returns host node widget for non-promoted widgets', () => {
const widget = createWidget('text_widget')
const hostNode = createHostNode([widget])
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
expect(resolved).toEqual({ node: hostNode, widget })
})
it('resolves promoted widget to the interior node widget', () => {
const innerWidget = createWidget('inner_text')
const innerNode = createHostNode([innerWidget])
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': innerNode }
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
})
it('returns undefined when promoted interior node is missing', () => {
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toBeUndefined()
})
it('returns undefined when promoted interior widget is missing', () => {
const innerNode = createHostNode([])
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': innerNode }
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toBeUndefined()
})
it('treats promoted-shaped widgets on non-subgraph nodes as local widgets', () => {
const widget = createPromotedWidget('promoted_text', '42', 'inner_text')
const hostNode = createHostNode([widget], {
isSubgraphNode: false
})
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
expect(resolved).toEqual({ node: hostNode, widget })
})
})

View File

@@ -0,0 +1,30 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type ResolvedWidget = {
node: LGraphNode
widget: IBaseWidget
}
export function resolveWidgetFromHostNode(
hostNode: LGraphNode | undefined,
widgetName: string
): ResolvedWidget | undefined {
if (!hostNode) return undefined
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
const sourceWidget = resolvePromotedWidgetSource(hostNode, widget)
if (sourceWidget) return sourceWidget
if (isPromotedWidgetView(widget) && hostNode.isSubgraphNode())
return undefined
return {
node: hostNode,
widget
}
}