mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 07:44:11 +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})`
|
||||
}
|
||||
107
src/extensions/core/customWidgets.test.ts
Normal file
107
src/extensions/core/customWidgets.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
85
src/lib/litegraph/src/widgets/GradientSliderWidget.ts
Normal file
85
src/lib/litegraph/src/widgets/GradientSliderWidget.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
'float',
|
||||
{
|
||||
component: WidgetInputNumber,
|
||||
aliases: ['FLOAT', 'number', 'slider'],
|
||||
aliases: ['FLOAT', 'number', 'slider', 'gradientslider'],
|
||||
essential: true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user