mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
9 Commits
feat/websi
...
feat/4768-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c749a077fc | ||
|
|
1b879a5ac2 | ||
|
|
1f84d6409b | ||
|
|
7b8d6869d2 | ||
|
|
f9db1ce8da | ||
|
|
cd257f8aa0 | ||
|
|
098150acc3 | ||
|
|
543fa23151 | ||
|
|
ec544e21f0 |
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
180
src/lib/litegraph/src/widgets/NumberWidget.test.ts
Normal file
180
src/lib/litegraph/src/widgets/NumberWidget.test.ts
Normal 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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -2535,6 +2535,7 @@
|
||||
"true": "true",
|
||||
"false": "false"
|
||||
},
|
||||
"editExpression": "Edit Expression",
|
||||
"node2only": "Node 2.0 only",
|
||||
"selectModel": "Select model",
|
||||
"uploadSelect": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user