[backport core/1.41] fix: address PR review feedback for upstream value composable (#9908) (#10096)

Backport of #9908 to core/1.41

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2026-03-16 06:20:32 -07:00
committed by GitHub
parent 85f48a592f
commit fec7589c07
5 changed files with 242 additions and 0 deletions

View File

@@ -13,4 +13,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
: modelValue.value
)
</script>

View File

@@ -1,5 +1,19 @@
import type { CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
return (
Array.isArray(value) &&
value.length >= 2 &&
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

@@ -116,6 +116,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
: modelValue.value,
set: (v) => {
if (!isDisabled.value) modelValue.value = v
}
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
}
const isNumber = (v: unknown): v is number => typeof v === 'number'
describe('singleValueExtractor', () => {
const extract = singleValueExtractor(isNumber)
it('matches widget by outputName', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, 'b')).toBe(42)
})
it('returns undefined when outputName widget has invalid value', () => {
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
expect(extract(widgets, 'b')).toBeUndefined()
})
it('falls back to unique valid widget when outputName has no match', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, 'missing')).toBe(42)
})
it('falls back to unique valid widget when no outputName provided', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, undefined)).toBe(42)
})
it('returns undefined when multiple widgets have valid values', () => {
const widgets = [widget('a', 1), widget('b', 2)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when no widgets have valid values', () => {
const widgets = [widget('a', 'text')]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
})
})
describe('boundsExtractor', () => {
const extract = boundsExtractor()
it('extracts a single bounds object widget', () => {
const bounds = { x: 10, y: 20, width: 100, height: 200 }
const widgets = [widget('crop', bounds)]
expect(extract(widgets, undefined)).toEqual(bounds)
})
it('matches bounds widget by outputName', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [widget('other', 'text'), widget('crop', bounds)]
expect(extract(widgets, 'crop')).toEqual(bounds)
})
it('assembles bounds from individual x/y/width/height widgets', () => {
const widgets = [
widget('x', 10),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
expect(extract(widgets, undefined)).toEqual({
x: 10,
y: 20,
width: 100,
height: 200
})
})
it('returns undefined when some bound components are missing', () => {
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when bound components have wrong types', () => {
const widgets = [
widget('x', '10'),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
})
it('rejects partial bounds objects', () => {
const partial = { x: 10, y: 20 }
const widgets = [widget('crop', partial)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('prefers single bounds object over individual widgets', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [
widget('crop', bounds),
widget('x', 99),
widget('y', 99),
widget('width', 99),
widget('height', 99)
]
expect(extract(widgets, undefined)).toEqual(bounds)
})
})

View File

@@ -0,0 +1,80 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
outputName: string | undefined
) => T | undefined
export function useUpstreamValue<T>(
getLinkedUpstream: () => LinkedUpstreamInfo | undefined,
extractValue: ValueExtractor<T>
) {
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<T>(
isValid: (value: unknown) => value is T
): ValueExtractor<T> {
return (widgets, outputName) => {
if (outputName) {
const matched = widgets.find((w) => w.name === outputName)
if (matched && isValid(matched.value)) return matched.value
}
const validValues = widgets.map((w) => w.value).filter(isValid)
return validValues.length === 1 ? validValues[0] : undefined
}
}
function isBoundsObject(value: unknown): value is Bounds {
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<Bounds> {
const single = singleValueExtractor(isBoundsObject)
return (widgets, outputName) => {
const singleResult = single(widgets, outputName)
if (singleResult) return singleResult
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
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
}
}