Compare commits

...

9 Commits

Author SHA1 Message Date
dante01yoon
c749a077fc feat: add Edit Expression context menu and / key toggle for number widgets
Add getContextMenuOptions to NumberWidget for right-click "Edit Expression"
that opens the prompt in text mode, allowing math expressions like 2+3.
Add / key handler in prompt dialog to switch from number to text mode.
Wire up widget context menu items in LGraphCanvas processContextMenu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:07:29 +09:00
dante01yoon
1b879a5ac2 fix: omit unsafe min/max from number input prompt to prevent step grid lock
The browser's stepUp/stepDown algorithm uses min as the step base.
When min exceeds Number.MAX_SAFE_INTEGER (e.g. Python sys.maxsize),
the subtraction silently loses precision, snapping the value back to 0.
safeMinMax() omits these attributes so the input works correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:06:11 +09:00
dante01yoon
1f84d6409b Merge remote-tracking branch 'origin/main' into feat/4768-number-input-type 2026-03-03 11:45:01 +09:00
dante01yoon
7b8d6869d2 fix(test): use type-agnostic selector for prompt dialog input
E2E test used input[type="text"] selector which fails now that
number widgets use type="number". Use class-based selector instead.
2026-02-22 21:30:52 +09:00
dante01yoon
f9db1ce8da test: align NumberWidget suite title with class name 2026-02-22 20:15:13 +09:00
dante01yoon
cd257f8aa0 fix: restrict prompt input type to number or text 2026-02-22 20:14:45 +09:00
dante01yoon
098150acc3 fix(litegraph): forward prompt options through changeTracker override 2026-02-19 19:25:33 +09:00
dante01yoon
543fa23151 feat(litegraph): pass number input options from NumberWidget to prompt
Pass inputType, min, max, and step options when NumberWidget opens the
canvas prompt dialog so the browser renders a native number input with
proper constraints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:53:48 +09:00
dante01yoon
ec544e21f0 feat(litegraph): support number input type in canvas prompt dialog
Add PromptOptions interface to allow specifying input type, min, max,
and step attributes for the prompt dialog input element. Refactor the
prompt method to accept PromptOptions instead of a plain boolean,
while maintaining backward compatibility.

Fixes #4768

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:53:26 +09:00
6 changed files with 291 additions and 11 deletions

View File

@@ -177,7 +177,7 @@ export class NodeOperationsHelper {
await this.page.locator('#graph-canvas').click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
const dialogInput = this.page.locator('.graphdialog input[type="text"]')
const dialogInput = this.page.locator('.graphdialog input.value')
await dialogInput.click()
await dialogInput.fill('128')
await dialogInput.press('Enter')

View File

@@ -183,6 +183,14 @@ interface IDialogOptions {
onclose?(): void
}
export interface PromptOptions {
multiline?: boolean
inputType?: 'text' | 'number'
min?: number
max?: number
step?: number
}
/** @inheritdoc {@link LGraphCanvas.state} */
interface LGraphCanvasState {
/** {@link Positionable} items are being dragged on the canvas. */
@@ -6867,17 +6875,23 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
value: string | number,
callback: (value: string) => void,
event: CanvasPointerEvent,
multiline?: boolean
multilineOrOptions?: boolean | PromptOptions
): HTMLDivElement {
const that = this
title = title || ''
const options: PromptOptions =
typeof multilineOrOptions === 'boolean'
? { multiline: multilineOrOptions }
: (multilineOrOptions ?? {})
const inputType = options.inputType === 'number' ? 'number' : 'text'
const customProperties = {
is_modified: false,
className: 'graphdialog rounded',
innerHTML: multiline
innerHTML: options.multiline
? "<span class='name'></span> <textarea autofocus class='value'></textarea><button class='rounded'>OK</button>"
: "<span class='name'></span> <input autofocus type='text' class='value'/><button class='rounded'>OK</button>",
: `<span class='name'></span> <input autofocus type='${inputType}' class='value'/><button class='rounded'>OK</button>`,
close() {
that.prompt_box = null
if (dialog.parentNode) {
@@ -6943,6 +6957,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!value_element) throw new TypeError('value_element was null')
value_element.value = String(value)
if (options.inputType === 'number') {
if (options.min != null)
value_element.setAttribute('min', String(options.min))
if (options.max != null)
value_element.setAttribute('max', String(options.max))
if (options.step != null)
value_element.setAttribute('step', String(options.step))
}
value_element.select()
const input = value_element
@@ -6951,6 +6973,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (e.key == 'Escape') {
// ESC
dialog.close()
} else if (e.key === '/' && this.type === 'number') {
// Switch to text mode for expression editing
this.type = 'text'
this.removeAttribute('min')
this.removeAttribute('max')
this.removeAttribute('step')
} else if (
e.key == 'Enter' &&
(e.target as Element).localName != 'textarea'
@@ -8489,6 +8517,26 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} else {
// on node
menu_info = this.getNodeMenuOptions(node)
const widget = node.getWidgetOnPos(event.canvasX, event.canvasY)
if (
widget &&
'getContextMenuOptions' in widget &&
typeof widget.getContextMenuOptions === 'function'
) {
const widgetMenuItems = (
widget as {
getContextMenuOptions: (opts: {
e: CanvasPointerEvent
node: LGraphNode
canvas: LGraphCanvas
}) => IContextMenuValue[]
}
).getContextMenuOptions({ e: event, node, canvas: this })
if (widgetMenuItems.length) {
menu_info.unshift(...widgetMenuItems, null)
}
}
}
} else {
menu_info = this.getCanvasMenuOptions()

View File

@@ -0,0 +1,180 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from './NumberWidget'
function createMockCanvas() {
return { prompt: vi.fn() } as unknown as LGraphCanvas
}
function createMockEvent(canvasX: number): CanvasPointerEvent {
return { canvasX } as unknown as CanvasPointerEvent
}
function createNumberWidget(
node: LGraphNode,
overrides: Partial<INumericWidget> = {}
): NumberWidget {
return new NumberWidget(
{
type: 'number',
name: 'test',
value: 50,
options: { min: 0, max: 100 },
y: 0,
...overrides
},
node
)
}
describe(NumberWidget, () => {
let node: LGraphNode
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
node = new LGraphNode('TestNode')
node.id = 1
node.size = [200, 100]
})
it('passes number input options to canvas.prompt for center click', () => {
const canvas = createMockCanvas()
const e = createMockEvent(100)
const widget = createNumberWidget(node)
widget.onClick({ e, node, canvas })
expect(canvas.prompt).toHaveBeenCalledWith(
'Value',
50,
expect.any(Function),
e,
{ inputType: 'number', min: 0, max: 100, step: 1 }
)
})
it('uses widget step2 option for step value', () => {
const canvas = createMockCanvas()
const e = createMockEvent(100)
const widget = createNumberWidget(node, {
options: { min: 0, max: 1, step2: 0.05 }
})
widget.onClick({ e, node, canvas })
expect(canvas.prompt).toHaveBeenCalledWith(
'Value',
50,
expect.any(Function),
e,
{ inputType: 'number', min: 0, max: 1, step: 0.05 }
)
})
it('does not call prompt when clicking left arrow area', () => {
const canvas = createMockCanvas()
const e = createMockEvent(20)
const widget = createNumberWidget(node)
widget.onClick({ e, node, canvas })
expect(canvas.prompt).not.toHaveBeenCalled()
})
it('does not call prompt when clicking right arrow area', () => {
const canvas = createMockCanvas()
const e = createMockEvent(180)
const widget = createNumberWidget(node)
widget.onClick({ e, node, canvas })
expect(canvas.prompt).not.toHaveBeenCalled()
})
it('passes undefined min/max when options have no bounds', () => {
const canvas = createMockCanvas()
const e = createMockEvent(100)
const widget = createNumberWidget(node, { options: {} })
widget.onClick({ e, node, canvas })
expect(canvas.prompt).toHaveBeenCalledWith(
'Value',
50,
expect.any(Function),
e,
{ inputType: 'number', min: undefined, max: undefined, step: 1 }
)
})
it('omits min/max exceeding MAX_SAFE_INTEGER from prompt options', () => {
const canvas = createMockCanvas()
const e = createMockEvent(100)
const widget = createNumberWidget(node, {
options: { min: -9223372036854776000, max: 9223372036854776000 }
})
widget.onClick({ e, node, canvas })
expect(canvas.prompt).toHaveBeenCalledWith(
'Value',
50,
expect.any(Function),
e,
{ inputType: 'number', min: undefined, max: undefined, step: 1 }
)
})
it('keeps safe min/max in prompt options', () => {
const canvas = createMockCanvas()
const e = createMockEvent(100)
const widget = createNumberWidget(node, {
options: { min: -1000, max: 1000 }
})
widget.onClick({ e, node, canvas })
expect(canvas.prompt).toHaveBeenCalledWith(
'Value',
50,
expect.any(Function),
e,
{ inputType: 'number', min: -1000, max: 1000, step: 1 }
)
})
it('getContextMenuOptions returns Edit Expression with text inputType', () => {
const canvas = createMockCanvas()
const e = createMockEvent(100)
const widget = createNumberWidget(node)
const options = widget.getContextMenuOptions({ e, node, canvas })
expect(options).toHaveLength(1)
expect(options[0].content).toBe('Edit Expression')
})
it('getContextMenuOptions callback opens prompt in text mode', () => {
const canvas = createMockCanvas()
const e = createMockEvent(100)
const widget = createNumberWidget(node)
const options = widget.getContextMenuOptions({ e, node, canvas })
void options[0].callback!.call({} as never)
expect(canvas.prompt).toHaveBeenCalledWith(
'Value',
50,
expect.any(Function),
e,
{ inputType: 'text' }
)
})
})

View File

@@ -1,9 +1,25 @@
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { evaluateInput, getWidgetStep } from '@/lib/litegraph/src/utils/widget'
import { BaseSteppedWidget } from './BaseSteppedWidget'
import type { WidgetEventOptions } from './BaseWidget'
/**
* Returns the value if it's within the safe integer range for
* `<input type="number">` step arithmetic, otherwise `undefined`.
*
* The browser's stepUp/stepDown algorithm computes `value - min` internally.
* When `min` exceeds `Number.MAX_SAFE_INTEGER`, that subtraction silently
* loses the small delta (e.g. ±1), causing the browser to snap back to the
* previous value. Omitting the attribute avoids the broken constraint.
*/
function safeMinMax(v: number | undefined): number | undefined {
if (v == null) return undefined
return Math.abs(v) > Number.MAX_SAFE_INTEGER ? undefined : v
}
export class NumberWidget
extends BaseSteppedWidget<INumericWidget>
implements INumericWidget
@@ -71,14 +87,41 @@ export class NumberWidget
const parsed = evaluateInput(v)
if (parsed !== undefined) this.setValue(parsed, { e, node, canvas })
},
e
e,
{
inputType: 'number',
min: safeMinMax(this.options.min),
max: safeMinMax(this.options.max),
step: getWidgetStep(this.options)
}
)
}
/**
* Handles drag events for the number widget
* @param options The options for handling the drag event
*/
getContextMenuOptions({
e,
node,
canvas
}: WidgetEventOptions): IContextMenuValue[] {
return [
{
content: t('widgets.editExpression'),
callback: () => {
canvas.prompt(
'Value',
this.value,
(v: string) => {
const parsed = evaluateInput(v)
if (parsed !== undefined)
this.setValue(parsed, { e, node, canvas })
},
e,
{ inputType: 'text' }
)
}
}
]
}
override onDrag({ e, node, canvas }: WidgetEventOptions) {
const width = this.width || node.width
const x = e.canvasX - node.pos[0]

View File

@@ -2535,6 +2535,7 @@
"true": "true",
"false": "false"
},
"editExpression": "Edit Expression",
"node2only": "Node 2.0 only",
"selectModel": "Select model",
"uploadSelect": {

View File

@@ -3,6 +3,7 @@ import * as jsondiffpatch from 'jsondiffpatch'
import log from 'loglevel'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import type { PromptOptions } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
ComfyWorkflow,
@@ -306,14 +307,21 @@ export class ChangeTracker {
title: string,
value: string | number,
callback: (v: string) => void,
event: CanvasPointerEvent
event: CanvasPointerEvent,
multilineOrOptions?: boolean | PromptOptions
) {
const extendedCallback = (v: string) => {
callback(v)
checkState()
}
logger.debug('checkState on prompt')
return prompt.apply(this, [title, value, extendedCallback, event])
return prompt.apply(this, [
title,
value,
extendedCallback,
event,
multilineOrOptions
])
}
// Handle litegraph context menu for COMBO widgets