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

@@ -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)
})
})