Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue
AustinMroz 284bdce61b Add a slider indicator for number widgets in vue mode. (#8122)
Sometimes it's difficult to gauge the valid range of values for a
widget. Litegraph includes a "slider" widget which displays the distance
from the min and max values as a colored bar. However, this
implementation is rather strongly disliked because it prevents entering
an exact number. Vue mode makes it simple to add just the indicator onto
our existing widget.

In addition to requiring both min and max be set, not every widget would
want this functionality. It's not useful information for seed, but also
has potential to cause confusion on widgets like CFG, that allow
inputting numbers up to 100 even though values beyond ~15 are rarely
desirable.

As a proposed heuristic, the ratio of "step" to distance between min and
max is currently used, but this could fairly easily be changed to an
opt-in only system.

<img width="617" height="487" alt="image"
src="https://github.com/user-attachments/assets/9c5f2119-0a03-4b56-bcf5-e4a0d0250784"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8122-Add-a-slider-indicator-for-number-widgets-in-vue-mode-2eb6d73d365081218fc8e86f37001958)
by [Unito](https://www.unito.io)
2026-01-17 21:28:48 -07:00

278 lines
8.2 KiB
Vue

<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { evaluateInput } from '@/lib/litegraph/src/utils/widget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { n } = useI18n()
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const widgetContainer = useTemplateRef<HTMLDivElement>('widgetContainer')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(widgetContainer, () => {
if (textEdit.value) {
textEdit.value = false
}
})
const decimalSeparator = computed(() => n(1.1).replace(/\p{Number}/gu, ''))
const groupSeparator = computed(() => n(11111).replace(/\p{Number}/gu, ''))
function unformatValue(value: string) {
return value
.replaceAll(groupSeparator.value, '')
.replaceAll(decimalSeparator.value, '.')
}
const modelValue = defineModel<number>({ default: 0 })
const formattedValue = computed(() => {
const unformattedValue = dragValue.value ?? modelValue.value
if ((unformattedValue as unknown) === '' || !isFinite(unformattedValue))
return `${unformattedValue}`
return n(unformattedValue, {
useGrouping: useGrouping.value,
minimumFractionDigits: precision.value,
maximumFractionDigits: precision.value
})
})
function updateValue(e: UIEvent) {
const { target } = e
if (!(target instanceof HTMLInputElement)) return
const parsed = evaluateInput(unformatValue(target.value))
if (parsed !== undefined)
modelValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, parsed)
)
else target.value = formattedValue.value
textEdit.value = false
}
const sharedButtonClass = 'w-8 bg-transparent border-0 text-sm text-smoke-700'
const canDecrement = computed(
() =>
modelValue.value > filteredProps.value.min &&
!props.widget.options?.disabled
)
const canIncrement = computed(
() =>
modelValue.value < filteredProps.value.max &&
!props.widget.options?.disabled
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) if available
if (props.widget.options?.step2 !== undefined) {
return Number(props.widget.options.step2)
}
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
// We skip default step values (1, 10) to avoid affecting normal widgets
const step = props.widget.options?.step
if (step !== undefined && step > 10) {
return Number(step) / 10
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
}
// Default to 'any' for unrestricted stepping
return 0
})
// Disable grouping separators by default unless explicitly enabled by the node author
const useGrouping = computed(() => {
return props.widget.options?.useGrouping === true
})
// Check if increment/decrement buttons should be disabled due to precision limits
const buttonsDisabled = computed(() => {
const currentValue = modelValue.value ?? 0
return (
!Number.isFinite(currentValue) ||
Math.abs(currentValue) > Number.MAX_SAFE_INTEGER
)
})
function updateValueBy(delta: number) {
modelValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, modelValue.value + delta)
)
}
const dragValue = ref<number>()
const dragDelta = ref(0)
function handleMouseDown(e: PointerEvent) {
if (props.widget.options?.disabled) return
const { target } = e
if (!(target instanceof HTMLElement)) return
target.setPointerCapture(e.pointerId)
dragValue.value = modelValue.value
dragDelta.value = 0
}
function handleMouseMove(e: PointerEvent) {
if (dragValue.value === undefined) return
dragDelta.value += e.movementX
const unclippedValue =
dragValue.value + ((dragDelta.value / 10) | 0) * stepValue.value
dragDelta.value %= 10
dragValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, unclippedValue)
)
}
function handleMouseUp() {
const newValue = dragValue.value
if (newValue === undefined) return
modelValue.value = newValue
dragValue.value = undefined
if (dragDelta.value === 0) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.setSelectionRange(0, -1)
}
dragDelta.value = 0
}
const buttonTooltip = computed(() => {
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
}
return null
})
const sliderWidth = computed(() => {
const { max, min, step } = filteredProps.value
if (
min === undefined ||
max === undefined ||
step === undefined ||
(max - min) / step >= 100
)
return 0
const value = dragValue.value ?? modelValue.value
const ratio = (value - min) / (max - min)
return (ratio * 100).toFixed(0)
})
</script>
<template>
<WidgetLayoutField :widget>
<div
ref="widgetContainer"
v-tooltip="buttonTooltip"
v-bind="filteredProps"
:aria-label="widget.name"
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7 relative')"
>
<div
class="bg-primary-background/15 absolute left-0 bottom-0 h-full rounded-lg pointer-events-none"
:style="{ width: `${sliderWidth}%` }"
/>
<button
v-if="!buttonsDisabled"
data-testid="decrement"
:class="
cn(sharedButtonClass, 'pi pi-minus', !canDecrement && 'opacity-60')
"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue -= stepValue"
/>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
:aria-valuenow="dragValue ?? modelValue"
:aria-valuemin="filteredProps.min"
:aria-valuemax="filteredProps.max"
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
:value="formattedValue"
role="spinbutton"
tabindex="0"
:disabled="widget.options?.disabled"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="updateValue"
@keyup.enter="updateValue"
@keydown.up.prevent="updateValueBy(stepValue)"
@keydown.down.prevent="updateValueBy(-stepValue)"
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'hidden pointer-events-none'
)
"
@pointerdown="handleMouseDown"
@pointermove="handleMouseMove"
@pointerup="handleMouseUp"
@pointercancel="
() => {
dragValue = undefined
dragDelta = 0
}
"
/>
</div>
<slot />
<button
v-if="!buttonsDisabled"
data-testid="increment"
:class="
cn(sharedButtonClass, 'pi pi-plus', !canIncrement && 'opacity-60')
"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue += stepValue"
/>
</div>
</WidgetLayoutField>
</template>