Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue
Comfy Org PR Bot 7c69c5f4c6 [backport core/1.41] Mobile input tweaks (#9710)
Backport of #9686 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9710-backport-core-1-41-Mobile-input-tweaks-31f6d73d365081048373f3616a84cd51)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 23:35:03 -07:00

183 lines
5.4 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
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 { locale } = useI18n()
const props = defineProps<{
widget: SimplifiedWidget<number>
rootClass?: string
}>()
function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
return new Intl.NumberFormat(locale.value, options).format(value)
}
const decimalSeparator = computed(() =>
formatNumber(1.1).replace(/\p{Number}/gu, '')
)
const groupSeparator = computed(() =>
formatNumber(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 value = modelValue.value
if ((value as unknown) === '' || !isFinite(value)) return `${value}`
const options: Intl.NumberFormatOptions = {
useGrouping: useGrouping.value
}
if (precision.value !== undefined) {
options.minimumFractionDigits = precision.value
options.maximumFractionDigits = precision.value
}
return formatNumber(value, options)
})
function parseWidgetValue(raw: string): number | undefined {
return evaluateInput(unformatValue(raw))
}
interface NumericWidgetOptions {
min: number
max: number
step?: number
step2?: number
precision?: number
disabled?: boolean
useGrouping?: boolean
}
const filteredProps = computed(() => {
const filtered = filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
return filtered as Partial<NumericWidgetOptions>
})
const isDisabled = computed(() => props.widget.options?.disabled ?? false)
// 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 as number | undefined
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
)
})
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 ratio = (modelValue.value - min) / (max - min)
return (ratio * 100).toFixed(0)
})
const inputAriaAttrs = computed(() => ({
'aria-valuenow': modelValue.value,
'aria-valuemin': filteredProps.value.min,
'aria-valuemax': filteredProps.value.max,
role: 'spinbutton',
tabindex: 0
}))
</script>
<template>
<WidgetLayoutField :widget :root-class="props.rootClass">
<ScrubableNumberInput
v-model="modelValue"
v-tooltip="buttonTooltip"
:aria-label="widget.name"
:min="filteredProps.min"
:max="filteredProps.max"
:step="stepValue"
:display-value="formattedValue"
:disabled="isDisabled"
:hide-buttons="buttonsDisabled"
:parse-value="parseWidgetValue"
:input-attrs="inputAriaAttrs"
:class="cn(WidgetInputBaseClass, 'relative flex h-7 grow text-xs')"
>
<template #background>
<div
class="pointer-events-none absolute size-full overflow-clip rounded-lg"
>
<div
class="size-full bg-primary-background/15"
:style="{ width: `${sliderWidth}%` }"
/>
</div>
</template>
<slot />
</ScrubableNumberInput>
</WidgetLayoutField>
</template>