mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 22:34:15 +00:00
feat: reuse WidgetInputNumberInput for BoundingBox numeric inputs (#8895)
## Summary Make WidgetInputNumberInput usable without the widget system by making the widget prop optional and adding simple min/max/step/disabled props. BoundingBox now uses this component instead of a separate ScrubableNumberInput ## Screenshots (if applicable) <img width="828" height="1393" alt="image" src="https://github.com/user-attachments/assets/68e012cf-baae-4a53-b4f8-70917cf05554" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8895-feat-add-scrub-drag-to-adjust-to-BoundingBox-numeric-inputs-3086d73d36508194b4b5e9bc823b34d1) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -3,49 +3,26 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="x"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="y"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
|
||||
175
src/components/common/ScrubableNumberInput.vue
Normal file
175
src/components/common/ScrubableNumberInput.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
|
||||
<input
|
||||
ref="inputField"
|
||||
v-bind="inputAttrs"
|
||||
:value="displayValue ?? modelValue"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
displayValue,
|
||||
parseValue
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
hideButtons?: boolean
|
||||
displayValue?: string
|
||||
parseValue?: (raw: string) => number | undefined
|
||||
inputAttrs?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
if (textEdit.value) textEdit.value = false
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const raw = target.value.trim()
|
||||
const parsed = parseValue
|
||||
? parseValue(raw)
|
||||
: raw === ''
|
||||
? undefined
|
||||
: Number(raw)
|
||||
if (parsed != null && !isNaN(parsed)) {
|
||||
modelValue.value = clamp(parsed)
|
||||
} else {
|
||||
target.value = displayValue ?? String(modelValue.value)
|
||||
}
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
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'
|
||||
@@ -21,15 +20,6 @@ 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
|
||||
}
|
||||
})
|
||||
|
||||
function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
|
||||
return new Intl.NumberFormat(locale.value, options).format(value)
|
||||
}
|
||||
@@ -49,9 +39,8 @@ function unformatValue(value: string) {
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
const unformattedValue = dragValue.value ?? modelValue.value
|
||||
if ((unformattedValue as unknown) === '' || !isFinite(unformattedValue))
|
||||
return `${unformattedValue}`
|
||||
const value = modelValue.value
|
||||
if ((value as unknown) === '' || !isFinite(value)) return `${value}`
|
||||
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: useGrouping.value
|
||||
@@ -60,20 +49,11 @@ const formattedValue = computed(() => {
|
||||
options.minimumFractionDigits = precision.value
|
||||
options.maximumFractionDigits = precision.value
|
||||
}
|
||||
return formatNumber(unformattedValue, options)
|
||||
return formatNumber(value, options)
|
||||
})
|
||||
|
||||
function updateValue(e: UIEvent) {
|
||||
const { target } = e
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const parsed = evaluateInput(unformatValue(target.value))
|
||||
if (parsed !== undefined) {
|
||||
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
||||
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
||||
modelValue.value = Math.min(max, Math.max(min, parsed))
|
||||
} else target.value = formattedValue.value
|
||||
|
||||
textEdit.value = false
|
||||
function parseWidgetValue(raw: string): number | undefined {
|
||||
return evaluateInput(unformatValue(raw))
|
||||
}
|
||||
|
||||
interface NumericWidgetOptions {
|
||||
@@ -93,15 +73,6 @@ const filteredProps = computed(() => {
|
||||
|
||||
const isDisabled = computed(() => props.widget.options?.disabled ?? false)
|
||||
|
||||
const canDecrement = computed(() => {
|
||||
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
||||
return modelValue.value > min && !isDisabled.value
|
||||
})
|
||||
const canIncrement = computed(() => {
|
||||
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
||||
return modelValue.value < max && !isDisabled.value
|
||||
})
|
||||
|
||||
// Get the precision value for proper number formatting
|
||||
const precision = computed(() => {
|
||||
const p = props.widget.options?.precision
|
||||
@@ -155,42 +126,6 @@ function updateValueBy(delta: number) {
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
|
||||
const dragValue = ref<number>()
|
||||
const dragDelta = ref(0)
|
||||
function handleMouseDown(e: PointerEvent) {
|
||||
if (e.button > 0) return
|
||||
if (isDisabled.value) 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
|
||||
const max = filteredProps.value.max ?? Number.MAX_VALUE
|
||||
const min = filteredProps.value.min ?? -Number.MAX_VALUE
|
||||
dragValue.value = Math.min(max, Math.max(min, unclippedValue))
|
||||
}
|
||||
function handleMouseUp() {
|
||||
const newValue = dragValue.value
|
||||
if (newValue === undefined) return
|
||||
|
||||
if (newValue === modelValue.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.setSelectionRange(0, -1)
|
||||
}
|
||||
|
||||
modelValue.value = newValue
|
||||
dragValue.value = undefined
|
||||
dragDelta.value = 0
|
||||
}
|
||||
|
||||
const buttonTooltip = computed(() => {
|
||||
if (buttonsDisabled.value) {
|
||||
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
|
||||
@@ -207,98 +142,50 @@ const sliderWidth = computed(() => {
|
||||
(max - min) / step >= 100
|
||||
)
|
||||
return 0
|
||||
const value = dragValue.value ?? modelValue.value
|
||||
const ratio = (value - min) / (max - min)
|
||||
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>
|
||||
<div
|
||||
ref="widgetContainer"
|
||||
<ScrubableNumberInput
|
||||
v-model="modelValue"
|
||||
v-tooltip="buttonTooltip"
|
||||
v-bind="filteredProps"
|
||||
: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, 'grow text-xs flex h-7 relative')"
|
||||
@keydown.up.prevent="updateValueBy(stepValue)"
|
||||
@keydown.down.prevent="updateValueBy(-stepValue)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
|
||||
>
|
||||
<div
|
||||
class="absolute size-full rounded-lg pointer-events-none overflow-clip"
|
||||
>
|
||||
<template #background>
|
||||
<div
|
||||
class="bg-primary-background/15 size-full"
|
||||
:style="{ width: `${sliderWidth}%` }"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!buttonsDisabled"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue -= stepValue"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<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="isDisabled"
|
||||
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>
|
||||
|
||||
class="absolute size-full rounded-lg pointer-events-none overflow-clip"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-background/15 size-full"
|
||||
:style="{ width: `${sliderWidth}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!buttonsDisabled"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue += stepValue"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</ScrubableNumberInput>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user