feat: reactive upstream value display for disabled curve and imagecrop widgets (#9851)

Replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/9364

## Summary
Add a generic mechanism for widgets to reactively display upstream
linked values when disabled, with concrete implementations for curve and
imagecrop widgets.

## Changes

- What: When a widget input is linked to an upstream node, the widget
enters a disabled state and displays the upstream node's current value
reactively. This is built as a two-layer system:
- Infrastructure layer: Resolve link origin info (originNodeId,
originOutputName) in buildSlotMetadata, pass it through
SimplifiedWidget.linkedUpstream, and provide a generic useUpstreamValue
composable that reads upstream values from widgetValueStore.
- Widget layer: Each widget type provides its own ValueExtractor to
interpret upstream data. singleValueExtractor handles simple
type-matched values (e.g. CurvePoint[]); boundsExtractor composes a
Bounds object from either a single upstream widget or four individual
x/y/width/height number widgets.
- Curve widget: shows upstream curve points in read-only mode
- ImageCrop widget: shows upstream bounding box with disabled crop
handles and number inputs
- CurveEditor and WidgetBoundingBox: gain disabled prop support

## Adapting future widgets
The system is designed so that any widget needing upstream value display
only needs to:
1. Accept widget: SimplifiedWidget as a prop (provides linkedUpstream
automatically)
2. Call useUpstreamValue(() => widget.linkedUpstream, extractor) with a
suitable extractor
3. Use singleValueExtractor(typeGuard) for single-value types, or write
a custom ValueExtractor for composite cases like boundsExtractor
4. Compute an effectiveValue that switches between upstream and local
based on disabled state

No infrastructure changes are needed — linkedUpstream is already
populated for all widget types that have a corresponding input slot.

## Review Focus
- The buildSlotMetadata helper is shared between extractVueNodeData and
refreshNodeSlots — verify the graph ref is reliably available in both
paths
- boundsExtractor composing from 4 individual number widgets
(x/y/width/height) — this handles the BBox→ImageCrop case where upstream
exposes separate widgets rather than a single Bounds object

## Screenshots

https://github.com/user-attachments/assets/dbc57a44-c5df-44f0-acce-d347797ee8fb

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9851-feat-reactive-upstream-value-display-for-disabled-curve-and-imagecrop-widgets-3226d73d36508134b386ddc9b9f1266b)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2026-03-13 16:59:53 -04:00
committed by GitHub
parent 7131c274f3
commit 9f9fa60137
9 changed files with 281 additions and 57 deletions

View File

@@ -3,19 +3,19 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
</div>
</template>
@@ -25,6 +25,10 @@ import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const { disabled = false } = defineProps<{
disabled?: boolean
}>()
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})

View File

@@ -3,8 +3,13 @@
ref="svgRef"
viewBox="-0.04 -0.04 1.08 1.08"
preserveAspectRatio="xMidYMid meet"
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
@pointerdown.stop="handleSvgPointerDown"
:class="
cn(
'aspect-square w-full rounded-[5px] bg-node-component-surface',
disabled ? 'cursor-default' : 'cursor-crosshair'
)
"
@pointerdown.stop="onSvgPointerDown"
@contextmenu.prevent.stop
>
<line
@@ -56,20 +61,23 @@
:stroke="curveColor"
stroke-width="0.008"
stroke-linecap="round"
:opacity="disabled ? 0.5 : 1"
/>
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
<template v-if="!disabled">
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</template>
</svg>
</template>
@@ -77,14 +85,20 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import { cn } from '@/utils/tailwindUtil'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const { curveColor = 'white', histogram } = defineProps<{
const {
curveColor = 'white',
histogram,
disabled = false
} = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
disabled?: boolean
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -98,6 +112,10 @@ const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
modelValue
})
function onSvgPointerDown(e: PointerEvent) {
if (!disabled) handleSvgPointerDown(e)
}
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)

View File

@@ -1,11 +1,27 @@
<template>
<CurveEditor v-model="modelValue" />
<CurveEditor
:model-value="effectivePoints"
:disabled="isDisabled"
@update:model-value="modelValue = $event"
/>
</template>
<script setup lang="ts">
import type { CurvePoint } from './types'
import { computed } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import CurveEditor from './CurveEditor.vue'
import { isCurvePointArray } from './curveUtils'
import type { CurvePoint } from './types'
const { widget } = defineProps<{
widget: SimplifiedWidget
}>()
const modelValue = defineModel<CurvePoint[]>({
default: () => [
@@ -13,4 +29,17 @@ const modelValue = defineModel<CurvePoint[]>({
[1, 1]
]
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurvePointArray)
)
const effectivePoints = computed(() =>
isDisabled.value && upstreamValue.value
? (upstreamValue.value as CurvePoint[])
: modelValue.value
)
</script>

View File

@@ -1,5 +1,18 @@
import type { CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
return (
Array.isArray(value) &&
value.every(
(p) =>
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
)
}
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points

View File

@@ -36,26 +36,37 @@
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
:class="
cn(
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
/>
<div
v-for="handle in resizeHandles"
v-show="imageUrl && !isLoading"
:key="handle.direction"
:class="['absolute', handle.class]"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
<template v-for="handle in resizeHandles" :key="handle.direction">
<div
v-show="imageUrl && !isLoading"
:class="
cn(
'absolute',
handle.class,
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</template>
</div>
<div class="flex shrink-0 items-center gap-2">
<div v-if="!isDisabled" class="flex shrink-0 items-center gap-2">
<label class="text-xs text-muted-foreground">
{{ $t('imageCrop.ratio') }}
</label>
@@ -90,12 +101,16 @@
</Button>
</div>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
<WidgetBoundingBox
v-model="effectiveBounds"
:disabled="isDisabled"
class="shrink-0"
/>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -105,10 +120,17 @@ import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
import {
boundsExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const { widget, nodeId } = defineProps<{
widget: SimplifiedWidget
nodeId: NodeId
}>()
@@ -116,6 +138,23 @@ const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
boundsExtractor()
)
const effectiveBounds = computed({
get: () =>
isDisabled.value && upstreamValue.value
? (upstreamValue.value as Bounds)
: modelValue.value,
set: (v) => {
modelValue.value = v
}
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
@@ -139,5 +178,5 @@ const {
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue: effectiveBounds })
</script>

View File

@@ -41,6 +41,8 @@ import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: string
originOutputName?: string
}
/**
@@ -355,6 +357,36 @@ function safeWidgetMapper(
}
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName
}
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
@@ -427,15 +459,11 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
node.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
@@ -488,17 +516,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (!nodeRef || !currentData) return
// Only extract slot-related data instead of full node re-extraction
const slotMetadata = new Map<string, WidgetSlotMetadata>()
nodeRef.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {

View File

@@ -0,0 +1,83 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
interface UpstreamWidget {
name: string
type: string
value?: unknown
}
type ValueExtractor = (
widgets: UpstreamWidget[],
outputName: string | undefined
) => unknown | undefined
export function useUpstreamValue(
getLinkedUpstream: () => LinkedUpstreamInfo | undefined,
extractValue: ValueExtractor
) {
const canvasStore = useCanvasStore()
const widgetValueStore = useWidgetValueStore()
return computed(() => {
const upstream = getLinkedUpstream()
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
return extractValue(widgets, upstream.outputName)
})
}
export function singleValueExtractor(
isValid: (value: unknown) => boolean
): ValueExtractor {
return (widgets, outputName) => {
if (outputName) {
const matched = widgets.find((w) => w.name === outputName)
if (matched && isValid(matched.value)) return matched.value
}
const valid = widgets.filter((w) => isValid(w.value))
return valid.length === 1 ? valid[0].value : undefined
}
}
function isBoundsObject(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
return (
typeof v.x === 'number' &&
typeof v.y === 'number' &&
typeof v.width === 'number' &&
typeof v.height === 'number'
)
}
export function boundsExtractor(): ValueExtractor {
const single = singleValueExtractor(isBoundsObject)
return (widgets, outputName) => {
const singleResult = single(widgets, outputName)
if (singleResult) return singleResult
const getNum = (name: string): number | undefined => {
const w = widgets.find((w) => w.name === name)
return typeof w?.value === 'number' ? w.value : undefined
}
const x = getNum('x')
const y = getNum('y')
const width = getNum('width')
const height = getNum('height')
if (
x !== undefined &&
y !== undefined &&
width !== undefined &&
height !== undefined
) {
return { x, y, width, height }
}
return undefined
}
}

View File

@@ -120,7 +120,11 @@ import {
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
@@ -297,6 +301,14 @@ const processedWidgets = computed((): ProcessedWidget[] => {
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
@@ -305,6 +317,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
options: widgetOptions,
spec: widget.spec
}

View File

@@ -38,6 +38,11 @@ export type SafeControlWidget = {
update: (value: WidgetValue) => void
}
export interface LinkedUpstreamInfo {
nodeId: string
outputName?: string
}
export interface SimplifiedWidget<
T extends WidgetValue = WidgetValue,
O extends IWidgetOptions = IWidgetOptions
@@ -77,6 +82,8 @@ export interface SimplifiedWidget<
tooltip?: string
controlWidget?: SafeControlWidget
linkedUpstream?: LinkedUpstreamInfo
}
export interface SimplifiedControlWidget<