mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-21 14:59:39 +00:00
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:
80
src/components/gradientslider/GradientSlider.test.ts
Normal file
80
src/components/gradientslider/GradientSlider.test.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
90
src/components/gradientslider/GradientSlider.vue
Normal file
90
src/components/gradientslider/GradientSlider.vue
Normal 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>
|
||||
40
src/components/gradientslider/gradients.ts
Normal file
40
src/components/gradientslider/gradients.ts
Normal 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})`
|
||||
}
|
||||
Reference in New Issue
Block a user