feat: add gradient-slider widget for FLOAT inputs (#8992)

## Summary
Add a new 'gradient-slider' display mode for FLOAT widget inputs. Nodes
can specify gradient_stops (color stop arrays) to render a colored
gradient track behind the slider thumb, useful for color adjustment
parameters like hue, saturation, brightness, etc.

- GradientSlider.vue: reusable Reka UI-based gradient slider component
- GradientSliderWidget.ts: litegraph canvas-mode fallback rendering
- WidgetInputNumberGradientSlider.vue: Vue node widget integration
- Schema, registry, and type updates for gradient-slider support

this is prerequisite for color correct and balance

BE changes https://github.com/Comfy-Org/ComfyUI/pull/12536

## Screenshots (if applicable)

<img width="610" height="237" alt="image"
src="https://github.com/user-attachments/assets/b0577ca8-8576-4062-8f14-0a3612e56242"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8992-feat-add-gradient-slider-widget-for-FLOAT-inputs-30d6d73d36508199b3e8db6a0c213ab4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Terry Jia
2026-02-20 20:51:10 -05:00
committed by GitHub
parent 4103379901
commit f7a83f6dfa
14 changed files with 590 additions and 24 deletions

View File

@@ -0,0 +1,80 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import GradientSlider from './GradientSlider.vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import { interpolateStops, stopsToGradient } from './gradients'
const TEST_STOPS: ColorStop[] = [
{ offset: 0, color: [0, 0, 0] },
{ offset: 1, color: [255, 255, 255] }
]
function mountSlider(props: {
stops?: ColorStop[]
modelValue: number
min?: number
max?: number
step?: number
}) {
return mount(GradientSlider, {
props: { stops: TEST_STOPS, ...props }
})
}
describe('GradientSlider', () => {
it('passes min, max, step to SliderRoot', () => {
const wrapper = mountSlider({
modelValue: 50,
min: -100,
max: 100,
step: 5
})
const thumb = wrapper.find('[role="slider"]')
expect(thumb.attributes('aria-valuemin')).toBe('-100')
expect(thumb.attributes('aria-valuemax')).toBe('100')
})
it('renders slider root with track and thumb', () => {
const wrapper = mountSlider({ modelValue: 0 })
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
})
it('does not render SliderRange', () => {
const wrapper = mountSlider({ modelValue: 50 })
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
})
})
describe('stopsToGradient', () => {
it('returns transparent for empty stops', () => {
expect(stopsToGradient([])).toBe('transparent')
})
})
describe('interpolateStops', () => {
it('returns transparent for empty stops', () => {
expect(interpolateStops([], 0.5)).toBe('transparent')
})
it('returns start color at t=0', () => {
expect(interpolateStops(TEST_STOPS, 0)).toBe('rgb(0,0,0)')
})
it('returns end color at t=1', () => {
expect(interpolateStops(TEST_STOPS, 1)).toBe('rgb(255,255,255)')
})
it('returns midpoint color at t=0.5', () => {
expect(interpolateStops(TEST_STOPS, 0.5)).toBe('rgb(128,128,128)')
})
it('clamps values below 0', () => {
expect(interpolateStops(TEST_STOPS, -1)).toBe('rgb(0,0,0)')
})
it('clamps values above 1', () => {
expect(interpolateStops(TEST_STOPS, 2)).toBe('rgb(255,255,255)')
})
})

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { SliderRoot, SliderThumb, SliderTrack } from 'reka-ui'
import { computed, ref } from 'vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import {
interpolateStops,
stopsToGradient
} from '@/components/gradientslider/gradients'
import { cn } from '@/utils/tailwindUtil'
const {
stops,
min = 0,
max = 100,
step = 1,
disabled = false,
ariaLabel
} = defineProps<{
stops: ColorStop[]
min?: number
max?: number
step?: number
disabled?: boolean
ariaLabel?: string
}>()
const modelValue = defineModel<number>({ required: true })
const sliderValue = computed({
get: () => [modelValue.value],
set: (v: number[]) => {
if (v.length) modelValue.value = v[0]
}
})
const gradient = computed(() => stopsToGradient(stops))
const thumbColor = computed(() => {
const t = max === min ? 0 : (modelValue.value - min) / (max - min)
return interpolateStops(stops, t)
})
const pressed = ref(false)
</script>
<template>
<SliderRoot
v-model="sliderValue"
:min
:max
:step
:disabled
:class="
cn(
'relative flex w-full touch-none items-center select-none',
'data-[disabled]:opacity-50'
)
"
:style="{ '--reka-slider-thumb-transform': 'translate(-50%, -50%)' }"
@slide-start="pressed = true"
@slide-move="pressed = true"
@slide-end="pressed = false"
>
<SliderTrack
:class="
cn(
'relative h-2.5 w-full grow cursor-pointer overflow-visible rounded-full',
'before:absolute before:-inset-2 before:block before:bg-transparent'
)
"
:style="{ background: gradient }"
>
<SliderThumb
:class="
cn(
'block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25 top-1/2',
'transition-[color,box-shadow,background-color]',
'before:absolute before:-inset-1.5 before:block before:rounded-full before:bg-transparent',
'hover:ring-2 hover:ring-black/40 focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-hidden',
'disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
)
"
:style="{ backgroundColor: thumbColor }"
:aria-label
/>
</SliderTrack>
</SliderRoot>
</template>

View File

@@ -0,0 +1,40 @@
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
export function stopsToGradient(stops: ColorStop[]): string {
if (!stops.length) return 'transparent'
const colors = stops.map(
({ offset, color: [r, g, b] }) => `rgb(${r},${g},${b}) ${offset * 100}%`
)
return `linear-gradient(to right, ${colors.join(', ')})`
}
export function interpolateStops(stops: ColorStop[], t: number): string {
if (!stops.length) return 'transparent'
const clamped = Math.max(0, Math.min(1, t))
if (clamped <= stops[0].offset) {
const [r, g, b] = stops[0].color
return `rgb(${r},${g},${b})`
}
for (let i = 0; i < stops.length - 1; i++) {
const {
offset: o1,
color: [r1, g1, b1]
} = stops[i]
const {
offset: o2,
color: [r2, g2, b2]
} = stops[i + 1]
if (clamped >= o1 && clamped <= o2) {
const f = o2 === o1 ? 0 : (clamped - o1) / (o2 - o1)
const r = Math.round(r1 + (r2 - r1) * f)
const g = Math.round(g1 + (g2 - g1) * f)
const b = Math.round(b1 + (b2 - b1) * f)
return `rgb(${r},${g},${b})`
}
}
const [r, g, b] = stops[stops.length - 1].color
return `rgb(${r},${g},${b})`
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
describe('PrimitiveFloat widget type bridging', () => {
function createMockNodeAndWidget() {
const properties: Record<string, unknown> = {}
const options: Record<string, unknown> = {
min: -Infinity,
max: Infinity
}
const widget = { type: 'number', options, value: 0, callback: undefined }
return { properties, widget }
}
function applyFloatPropertyBridges(
properties: Record<string, unknown>,
widget: { type: string; options: Record<string, unknown> }
) {
const DISPLAY_WIDGET_TYPES = new Set(['gradientslider', 'slider', 'knob'])
let baseType = widget.type
Object.defineProperty(widget, 'type', {
get: () => {
const display = properties.display as string | undefined
if (display && DISPLAY_WIDGET_TYPES.has(display)) return display
return baseType
},
set: (v: string) => {
baseType = v
}
})
Object.defineProperty(widget.options, 'gradient_stops', {
get: () => properties.gradient_stops,
set: (v) => {
properties.gradient_stops = v
}
})
}
it('returns base type when display property is not set', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
expect(widget.type).toBe('number')
})
it('returns gradientslider when display property is set', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
properties.display = 'gradientslider'
expect(widget.type).toBe('gradientslider')
})
it('returns slider when display property is slider', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
properties.display = 'slider'
expect(widget.type).toBe('slider')
})
it('returns base type for unknown display values', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
properties.display = 'unknown'
expect(widget.type).toBe('number')
})
it('bridges gradient_stops from properties to options', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
expect(widget.options.gradient_stops).toBeUndefined()
const stops = [
{ offset: 0, color: [255, 0, 0] },
{ offset: 1, color: [0, 0, 255] }
]
properties.gradient_stops = stops
expect(widget.options.gradient_stops).toBe(stops)
})
it('writes gradient_stops back to properties', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
const stops = [
{ offset: 0, color: [0, 0, 0] },
{ offset: 1, color: [255, 255, 255] }
]
widget.options.gradient_stops = stops
expect(properties.gradient_stops).toBe(stops)
})
it('allows type to be set and overridden', () => {
const { properties, widget } = createMockNodeAndWidget()
applyFloatPropertyBridges(properties, widget)
widget.type = 'slider'
expect(widget.type).toBe('slider')
properties.display = 'gradientslider'
expect(widget.type).toBe('gradientslider')
})
})

View File

@@ -141,10 +141,30 @@ function onCustomIntCreated(this: LGraphNode) {
}
})
}
const DISPLAY_WIDGET_TYPES = new Set(['gradientslider', 'slider', 'knob'])
function onCustomFloatCreated(this: LGraphNode) {
const valueWidget = this.widgets?.[0]
if (!valueWidget) return
let baseType = valueWidget.type
Object.defineProperty(valueWidget, 'type', {
get: () => {
const display = this.properties.display as string | undefined
if (display && DISPLAY_WIDGET_TYPES.has(display)) return display
return baseType
},
set: (v: string) => {
baseType = v
}
})
Object.defineProperty(valueWidget.options, 'gradient_stops', {
get: () => this.properties.gradient_stops,
set: (v) => {
this.properties.gradient_stops = v
}
})
Object.defineProperty(valueWidget.options, 'min', {
get: () => this.properties.min ?? -Infinity,
set: (v) => {

View File

@@ -48,6 +48,11 @@ export type SharedIntersection<T1, T2> = {
export type CanvasColour = string | CanvasGradient | CanvasPattern
export interface ColorStop {
readonly offset: number
readonly color: readonly [r: number, g: number, b: number]
}
/**
* Any object that has a {@link boundingRect}.
*/

View File

@@ -1,6 +1,12 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
import type {
CanvasColour,
ColorStop,
Point,
RequiredProps,
Size
} from '../interfaces'
import type {
CanvasPointer,
LGraphCanvas,
@@ -64,6 +70,13 @@ interface IWidgetSliderOptions extends IWidgetOptions<number[]> {
marker_color?: CanvasColour
}
export interface IWidgetGradientSliderOptions extends IWidgetOptions<number[]> {
min: number
max: number
step2: number
gradient_stops?: ColorStop[]
}
interface IWidgetKnobOptions extends IWidgetOptions<number[]> {
min: number
max: number
@@ -93,6 +106,7 @@ export type IWidget =
| IStringComboWidget
| ICustomWidget
| ISliderWidget
| IGradientSliderWidget
| IButtonWidget
| IKnobWidget
| IFileUploadWidget
@@ -131,6 +145,15 @@ export interface ISliderWidget extends IBaseWidget<
marker?: number
}
export interface IGradientSliderWidget extends IBaseWidget<
number,
'gradientslider',
IWidgetGradientSliderOptions
> {
type: 'gradientslider'
value: number
}
export interface IKnobWidget extends IBaseWidget<
number,
'knob',

View File

@@ -0,0 +1,85 @@
import { clamp } from 'es-toolkit/compat'
import type { IGradientSliderWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class GradientSliderWidget
extends BaseWidget<IGradientSliderWidget>
implements IGradientSliderWidget
{
override type = 'gradientslider' as const
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
) {
ctx.save()
const { height, y } = this
const { margin } = BaseWidget
ctx.fillStyle = this.background_color
ctx.fillRect(margin, y, width - margin * 2, height)
const range = this.options.max - this.options.min
let nvalue = (this.value - this.options.min) / range
nvalue = clamp(nvalue, 0, 1)
ctx.fillStyle = '#678'
ctx.fillRect(margin, y, nvalue * (width - margin * 2), height)
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.outline_color
ctx.strokeRect(margin, y, width - margin * 2, height)
}
if (showText) {
ctx.textAlign = 'center'
ctx.fillStyle = this.text_color
const fixedValue = Number(this.value).toFixed(this.options.precision ?? 3)
ctx.fillText(
`${this.label || this.name} ${fixedValue}`,
width * 0.5,
y + height * 0.7
)
}
ctx.restore()
}
override onClick(options: WidgetEventOptions) {
if (this.options.read_only) return
const { e, node } = options
const width = this.width || node.size[0]
const x = e.canvasX - node.pos[0]
const { margin } = BaseWidget
const slideFactor = clamp((x - margin) / (width - margin * 2), 0, 1)
const newValue =
this.options.min + (this.options.max - this.options.min) * slideFactor
if (newValue !== this.value) {
this.setValue(newValue, options)
}
}
override onDrag(options: WidgetEventOptions) {
if (this.options.read_only) return false
const { e, node } = options
const width = this.width || node.size[0]
const x = e.canvasX - node.pos[0]
const { margin } = BaseWidget
const slideFactor = clamp((x - margin) / (width - margin * 2), 0, 1)
const newValue =
this.options.min + (this.options.max - this.options.min) * slideFactor
if (newValue !== this.value) {
this.setValue(newValue, options)
}
}
}

View File

@@ -18,6 +18,7 @@ import { ColorWidget } from './ColorWidget'
import { ComboWidget } from './ComboWidget'
import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
@@ -35,6 +36,7 @@ export type WidgetTypeMap = {
button: ButtonWidget
toggle: BooleanWidget
slider: SliderWidget
gradientslider: GradientSliderWidget
knob: KnobWidget
combo: ComboWidget
number: NumberWidget
@@ -92,6 +94,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(BooleanWidget, narrowedWidget, node)
case 'slider':
return toClass(SliderWidget, narrowedWidget, node)
case 'gradientslider':
return toClass(GradientSliderWidget, narrowedWidget, node)
case 'knob':
return toClass(KnobWidget, narrowedWidget, node)
case 'combo':

View File

@@ -6,6 +6,7 @@ import type {
SimplifiedWidget
} from '@/types/simplifiedWidget'
import WidgetInputNumberGradientSlider from './WidgetInputNumberGradientSlider.vue'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetWithControl from './WidgetWithControl.vue'
@@ -16,28 +17,33 @@ const props = defineProps<{
const modelValue = defineModel<number>({ default: 0 })
const hasControlAfterGenerate = computed(() => {
return !!props.widget.controlWidget
const controlWidget = computed<SimplifiedControlWidget<number> | null>(() =>
props.widget.controlWidget
? (props.widget as SimplifiedControlWidget<number>)
: null
)
const widgetComponent = computed(() => {
switch (props.widget.type) {
case 'gradientslider':
return WidgetInputNumberGradientSlider
case 'slider':
return WidgetInputNumberSlider
default:
return WidgetInputNumberInput
}
})
</script>
<template>
<WidgetWithControl
v-if="hasControlAfterGenerate"
v-if="controlWidget"
v-model="modelValue"
:widget="widget as SimplifiedControlWidget<number>"
:component="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
:widget="controlWidget"
:component="widgetComponent"
/>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
:is="widgetComponent"
v-else
v-model="modelValue"
:widget="widget"

View File

@@ -0,0 +1,93 @@
<template>
<WidgetLayoutField :widget="widget">
<div :class="cn(WidgetInputBaseClass, 'flex items-center gap-2 pl-3 pr-2')">
<GradientSlider
v-model="modelValue"
:stops="gradientStops"
:min="widget.options?.min ?? 0"
:max="widget.options?.max ?? 100"
:step="stepValue"
:disabled="widget.options?.disabled"
:aria-label="widget.name"
class="flex-1 min-w-0"
/>
<InputNumber
:key="timesEmptied"
:model-value="modelValue"
v-bind="filteredProps"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
:aria-label="widget.name"
size="small"
pt:pc-input-text:root="min-w-[4ch] bg-transparent border-none text-center truncate"
class="w-16 shrink-0"
:pt="numberPt"
@update:model-value="handleNumberInputUpdate"
/>
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import { computed, ref } from 'vue'
import GradientSlider from '@/components/gradientslider/GradientSlider.vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import type { IWidgetGradientSliderOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const DEFAULT_GRADIENT_STOPS: ColorStop[] = [
{ offset: 0, color: [0, 0, 0] },
{ offset: 1, color: [255, 255, 255] }
]
const { widget } = defineProps<{
widget: SimplifiedWidget<number, IWidgetGradientSliderOptions>
}>()
const modelValue = defineModel<number>({ default: 0 })
const timesEmptied = ref(0)
const handleNumberInputUpdate = (newValue: number | undefined) => {
if (newValue !== undefined) {
modelValue.value = newValue
return
}
timesEmptied.value += 1
}
const gradientStops = computed<ColorStop[]>(() => {
const stops = widget.options?.gradient_stops
if (stops && stops.length >= 2) return stops
return DEFAULT_GRADIENT_STOPS
})
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
const precision = computed(() => {
const p = widget.options?.precision
return typeof p === 'number' && p >= 0 ? p : undefined
})
const stepValue = useNumberStepCalculation(widget.options, precision, true)
const numberPt = useNumberWidgetButtonPt({
roundedLeft: true,
roundedRight: true
})
</script>

View File

@@ -43,11 +43,13 @@ export const useFloatWidget = () => {
const display_type = inputSpec.display
const widgetType =
sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
display_type == 'gradientslider'
? 'gradientslider'
: sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
const step = inputSpec.step ?? 0.5
const precision =
@@ -72,7 +74,10 @@ export const useFloatWidget = () => {
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,
step2: step,
precision
precision,
...(inputSpec.gradient_stops
? { gradient_stops: inputSpec.gradient_stops }
: {})
}
)

View File

@@ -93,7 +93,7 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
'float',
{
component: WidgetInputNumber,
aliases: ['FLOAT', 'number', 'slider'],
aliases: ['FLOAT', 'number', 'slider', 'gradientslider'],
essential: true
}
],

View File

@@ -43,7 +43,7 @@ const zNumericInputOptions = zBaseInputOptions.extend({
step: z.number().optional(),
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
default: z.union([z.number(), z.array(z.number())]).optional(),
display: z.enum(['slider', 'number', 'knob']).optional()
display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional()
})
export const zIntInputOptions = zNumericInputOptions.extend({
@@ -57,7 +57,15 @@ export const zIntInputOptions = zNumericInputOptions.extend({
})
export const zFloatInputOptions = zNumericInputOptions.extend({
round: z.union([z.number(), z.literal(false)]).optional()
round: z.union([z.number(), z.literal(false)]).optional(),
gradient_stops: z
.array(
z.object({
offset: z.number(),
color: z.tuple([z.number(), z.number(), z.number()])
})
)
.optional()
})
export const zBooleanInputOptions = zBaseInputOptions.extend({