Implement vue math (#7759)

Adds support for entering math inside number widgets in vue mode

![vue-math_00001](https://github.com/user-attachments/assets/e8ed7e2a-511f-4550-bfdd-1c467972d30d)

Migrates components to simple html elements (div and button) by
borrowing styling from the (reverted) reka-ui migration in #6985. The
existing (evil) litegraph eval code is extracted as a utility function
and reused.

This PR means we're entirely writing our own NumberField.

Also adds support for scrubbing widgets like in litegraph

![scrubbing_00001](https://github.com/user-attachments/assets/890bcd28-34f4-4be0-908f-657e43f671b0)

### Known Issue
- Scrubbing causes text to be highlighted, ~~starting a scrub from
highlighted text will instead drag the text~~.
- It seems this can only be prevented with `pointerdown.prevent`, but
this requires a manual `input.focus()` which does not place the cursor
at location of mouse click.

(Obligatory: _It won't do you a bit of good to review math_)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7759-Implement-vue-math-2d46d73d365081b9acd4d6422669016e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
This commit is contained in:
AustinMroz
2026-01-13 15:11:33 -08:00
committed by GitHub
parent 4b2e4c59af
commit 97a78f4a35
28 changed files with 217 additions and 191 deletions

View File

@@ -159,8 +159,8 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').nth(1)
decrementButton: widget.getByTestId('decrement'),
incrementButton: widget.getByTestId('increment')
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -8,3 +8,18 @@ import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
export function getWidgetStep(options: IWidgetOptions<unknown>): number {
return options.step2 || (options.step || 10) * 0.1
}
export function evaluateInput(input: string): number | undefined {
// Check if v is a valid equation or a number
if (/^[\d\s.()*+/-]+$/.test(input)) {
// Solve the equation if possible
try {
input = eval(input)
} catch {
// Ignore eval errors
}
}
const newValue = Number(input)
if (isNaN(newValue)) return undefined
return newValue
}

View File

@@ -1,5 +1,5 @@
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { getWidgetStep } from '@/lib/litegraph/src/utils/widget'
import { evaluateInput, getWidgetStep } from '@/lib/litegraph/src/utils/widget'
import { BaseSteppedWidget } from './BaseSteppedWidget'
import type { WidgetEventOptions } from './BaseWidget'
@@ -68,19 +68,8 @@ export class NumberWidget
'Value',
this.value,
(v: string) => {
// Check if v is a valid equation or a number
if (/^[\d\s()*+/-]+|\d+\.\d+$/.test(v)) {
// Solve the equation if possible
try {
v = eval(v)
} catch {
// Ignore eval errors
}
}
const newValue = Number(v)
if (!isNaN(newValue)) {
this.setValue(newValue, { e, node, canvas })
}
const parsed = evaluateInput(v)
if (parsed !== undefined) this.setValue(parsed, { e, node, canvas })
},
e
)

View File

@@ -1,12 +1,16 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
const i18n = createI18n({
legacy: false,
locale: 'en'
})
function createMockWidget(
value: number = 0,
type: 'int' | 'float' = 'int',
@@ -24,10 +28,7 @@ function createMockWidget(
function mountComponent(widget: SimplifiedWidget<number>, modelValue: number) {
return mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
components: { InputNumber }
},
global: { plugins: [i18n] },
props: {
widget,
modelValue
@@ -36,7 +37,7 @@ function mountComponent(widget: SimplifiedWidget<number>, modelValue: number) {
}
function getNumberInput(wrapper: ReturnType<typeof mount>) {
const input = wrapper.get<HTMLInputElement>('input[inputmode="numeric"]')
const input = wrapper.get<HTMLInputElement>('input[inputmode="decimal"]')
return input.element
}
@@ -53,7 +54,7 @@ describe('WidgetInputNumberInput Value Binding', () => {
const widget = createMockWidget(10, 'int')
const wrapper = mountComponent(widget, 10)
const inputNumber = wrapper.findComponent(InputNumber)
const inputNumber = wrapper
await inputNumber.vm.$emit('update:modelValue', 20)
const emitted = wrapper.emitted('update:modelValue')
@@ -78,75 +79,6 @@ describe('WidgetInputNumberInput Value Binding', () => {
})
})
describe('WidgetInputNumberInput Component Rendering', () => {
it('renders InputNumber component with show-buttons', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.exists()).toBe(true)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('sets button layout to horizontal', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('buttonLayout')).toBe('horizontal')
})
it('sets size to small', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('size')).toBe('small')
})
})
describe('WidgetInputNumberInput Step Value', () => {
it('defaults to 0 for unrestricted stepping', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0)
})
it('uses step2 value when provided', () => {
const widget = createMockWidget(5, 'int', { step2: 0.5 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.5)
})
it('calculates step from precision for precision 0', () => {
const widget = createMockWidget(5, 'int', { precision: 0 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(1)
})
it('calculates step from precision for precision 1', () => {
const widget = createMockWidget(5, 'float', { precision: 1 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.1)
})
it('calculates step from precision for precision 2', () => {
const widget = createMockWidget(5, 'float', { precision: 2 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.01)
})
})
describe('WidgetInputNumberInput Grouping Behavior', () => {
it('displays numbers without commas by default for int widgets', () => {
const widget = createMockWidget(1000, 'int')
@@ -202,24 +134,21 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
const widget = createMockWidget(1000, 'int')
const wrapper = mountComponent(widget, 1000)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
expect(wrapper.findAll('button').length).toBe(2)
})
it('shows buttons for values at safe integer limit', () => {
const widget = createMockWidget(SAFE_INTEGER_MAX, 'int')
const wrapper = mountComponent(widget, SAFE_INTEGER_MAX)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
expect(wrapper.findAll('button').length).toBe(2)
})
it('hides buttons for unsafe large integer values', () => {
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('hides buttons for unsafe negative integer values', () => {
@@ -227,8 +156,7 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
const widget = createMockWidget(unsafeNegative, 'int')
const wrapper = mountComponent(widget, unsafeNegative)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('shows tooltip for disabled buttons due to precision limits', (context) => {
@@ -250,43 +178,19 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(tooltipDiv.attributes('v-tooltip')).toBeUndefined()
})
it('handles edge case of zero value', () => {
const widget = createMockWidget(0, 'int')
const wrapper = mountComponent(widget, 0)
it('handles floating point values correctly', () => {
const widget = createMockWidget(1000.5, 'float')
const wrapper = mountComponent(widget, 1000.5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
expect(wrapper.findAll('button').length).toBe(2)
})
it('correctly identifies safe vs unsafe integers using Number.isSafeInteger', () => {
// Test the JavaScript behavior our component relies on
expect(Number.isSafeInteger(SAFE_INTEGER_MAX)).toBe(true)
expect(Number.isSafeInteger(SAFE_INTEGER_MAX + 1)).toBe(false)
expect(Number.isSafeInteger(UNSAFE_LARGE_INTEGER)).toBe(false)
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX)).toBe(true)
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX - 1)).toBe(false)
})
it('handles floating point values correctly', (context) => {
context.skip('needs diagnosis')
const safeFloat = 1000.5
const widget = createMockWidget(safeFloat, 'float')
const wrapper = mountComponent(widget, safeFloat)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('hides buttons for unsafe floating point values', (context) => {
context.skip('needs diagnosis')
it('hides buttons for unsafe floating point values', () => {
const unsafeFloat = UNSAFE_LARGE_INTEGER + 0.5
const widget = createMockWidget(unsafeFloat, 'float')
const wrapper = mountComponent(widget, unsafeFloat)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
})
@@ -295,18 +199,14 @@ describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
const widget = createMockWidget(0, 'int')
// Mount with undefined as modelValue
const wrapper = mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
components: { InputNumber }
},
global: { plugins: [i18n] },
props: {
widget,
modelValue: undefined as any
}
})
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true) // Should default to safe behavior
expect(wrapper.findAll('button').length).toBe(2)
})
it('handles NaN values gracefully', (context) => {
@@ -314,24 +214,20 @@ describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
const widget = createMockWidget(NaN, 'int')
const wrapper = mountComponent(widget, NaN)
const inputNumber = wrapper.findComponent(InputNumber)
// NaN is not a safe integer, so buttons should be hidden
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('handles Infinity values', () => {
const widget = createMockWidget(Infinity, 'int')
const wrapper = mountComponent(widget, Infinity)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('handles negative Infinity values', () => {
const widget = createMockWidget(-Infinity, 'int')
const wrapper = mountComponent(widget, -Infinity)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
})

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
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 {
@@ -12,12 +14,68 @@ import {
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 (!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)
)
@@ -69,6 +127,48 @@ const buttonsDisabled = computed(() => {
)
})
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)'
@@ -79,54 +179,80 @@ const buttonTooltip = computed(() => {
<template>
<WidgetLayoutField :widget>
<InputNumber
v-model="modelValue"
<div
ref="widgetContainer"
v-tooltip="buttonTooltip"
v-bind="filteredProps"
fluid
button-layout="horizontal"
size="small"
variant="outlined"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'grow text-xs')"
:aria-label="widget.name"
:show-buttons="!buttonsDisabled"
:pt="{
root: {
class: cn(
'[&>input]:bg-transparent [&>input]:border-0',
'[&>input]:truncate [&>input]:min-w-[4ch]',
$slots.default && '[&>input]:pr-7'
)
},
decrementButton: {
class: 'w-8 border-0'
},
incrementButton: {
class: 'w-8 border-0'
}
}"
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7')"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />
</template>
<template #decrementicon>
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<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>
<style scoped>
:deep(.p-inputnumber-input) {
height: 1.625rem;
margin: 1px 0;
box-shadow: none;
}
</style>

View File

@@ -50,7 +50,7 @@ const togglePopover = (event: Event) => {
variant="textonly"
size="sm"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@pointerdown.stop.prevent="togglePopover"
@click.stop.prevent="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
</Button>