diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index 59f4c1338..da019521d 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -6,6 +6,7 @@ :key="widget.id" :widget="widget" :widget-state="domWidgetStore.widgetStates.get(widget.id)" + @update:widget-value="widget.value = $event" /> @@ -16,7 +17,7 @@ import { computed, watch } from 'vue' import DomWidget from '@/components/graph/widgets/DomWidget.vue' import { useChainCallback } from '@/composables/functional/useChainCallback' -import { DOMWidget } from '@/scripts/domWidget' +import { BaseDOMWidget } from '@/scripts/domWidget' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useCanvasStore } from '@/stores/graphStore' @@ -24,7 +25,7 @@ const domWidgetStore = useDomWidgetStore() const widgets = computed(() => Array.from( domWidgetStore.widgetInstances.values() as Iterable< - DOMWidget + BaseDOMWidget > ) ) diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index c54d5fcf5..864bc9c95 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -5,7 +5,15 @@ ref="widgetElement" :style="style" v-show="widgetState.visible" - /> + > + + diff --git a/src/components/graph/widgets/MultiSelectWidget.vue b/src/components/graph/widgets/MultiSelectWidget.vue new file mode 100644 index 000000000..caad10393 --- /dev/null +++ b/src/components/graph/widgets/MultiSelectWidget.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/composables/widgets/useComboWidget.ts b/src/composables/widgets/useComboWidget.ts index 9fa194f0d..c0cfd7c8a 100644 --- a/src/composables/widgets/useComboWidget.ts +++ b/src/composables/widgets/useComboWidget.ts @@ -1,16 +1,24 @@ import type { LGraphNode } from '@comfyorg/litegraph' import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets' +import { ref } from 'vue' +import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue' import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration' import { ComboInputSpec, type InputSpec, isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { + type BaseDOMWidget, + ComponentWidgetImpl, + addWidget +} from '@/scripts/domWidget' import { type ComfyWidgetConstructorV2, addValueControlWidgets } from '@/scripts/widgets' +import { generateUUID } from '@/utils/formatUtil' import { useRemoteWidget } from './useRemoteWidget' @@ -21,6 +29,71 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => { return undefined } +const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => { + const widgetValue = ref([]) + const widget = new ComponentWidgetImpl({ + id: generateUUID(), + node, + name: inputSpec.name, + component: MultiSelectWidget, + inputSpec, + options: { + getValue: () => widgetValue.value, + setValue: (value: string[]) => { + widgetValue.value = value + } + } + }) + addWidget(node, widget as BaseDOMWidget) + // TODO: Add remote support to multi-select widget + // https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003 + return widget +} + +const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => { + const defaultValue = getDefaultValue(inputSpec) + const comboOptions = inputSpec.options ?? [] + const widget = node.addWidget( + 'combo', + inputSpec.name, + defaultValue, + () => {}, + { + values: comboOptions + } + ) as IComboWidget + + if (inputSpec.remote) { + const remoteWidget = useRemoteWidget({ + remoteConfig: inputSpec.remote, + defaultValue, + node, + widget + }) + if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton() + + const origOptions = widget.options + widget.options = new Proxy(origOptions as Record, { + get(target, prop: string | symbol) { + if (prop !== 'values') return target[prop] + return remoteWidget.getValue() + } + }) + } + + if (inputSpec.control_after_generate) { + widget.linkedWidgets = addValueControlWidgets( + node, + widget, + undefined, + undefined, + transformInputSpecV2ToV1(inputSpec) + ) + } + + return widget +} + export const useComboWidget = () => { const widgetConstructor: ComfyWidgetConstructorV2 = ( node: LGraphNode, @@ -29,48 +102,9 @@ export const useComboWidget = () => { if (!isComboInputSpec(inputSpec)) { throw new Error(`Invalid input data: ${inputSpec}`) } - - const comboOptions = inputSpec.options ?? [] - const defaultValue = getDefaultValue(inputSpec) - - const widget = node.addWidget( - 'combo', - inputSpec.name, - defaultValue, - () => {}, - { - values: comboOptions - } - ) as IComboWidget - - if (inputSpec.remote) { - const remoteWidget = useRemoteWidget({ - remoteConfig: inputSpec.remote, - defaultValue, - node, - widget - }) - if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton() - - const origOptions = widget.options - widget.options = new Proxy(origOptions as Record, { - get(target, prop: string | symbol) { - if (prop !== 'values') return target[prop] - return remoteWidget.getValue() - } - }) - } - - if (inputSpec.control_after_generate) { - widget.linkedWidgets = addValueControlWidgets( - node, - widget, - undefined, - undefined, - transformInputSpecV2ToV1(inputSpec) - ) - } - return widget + return inputSpec.multi_select + ? addMultiSelectWidget(node, inputSpec) + : addComboWidget(node, inputSpec) } return widgetConstructor diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index 7c9dd39b6..afe94a99a 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -12,6 +12,10 @@ const zRemoteWidgetConfig = z.object({ timeout: z.number().gte(0).optional(), max_retries: z.number().gte(0).optional() }) +const zMultiSelectOption = z.object({ + placeholder: z.string().optional(), + chip: z.boolean().optional() +}) export const zBaseInputOptions = z .object({ @@ -72,7 +76,9 @@ export const zComboInputOptions = zBaseInputOptions.extend({ allow_batch: z.boolean().optional(), video_upload: z.boolean().optional(), options: z.array(zComboOption).optional(), - remote: zRemoteWidgetConfig.optional() + remote: zRemoteWidgetConfig.optional(), + /** Whether the widget is a multi-select widget. */ + multi_select: zMultiSelectOption.optional() }) const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()]) diff --git a/src/scripts/domWidget.ts b/src/scripts/domWidget.ts index becf2026b..411d5161c 100644 --- a/src/scripts/domWidget.ts +++ b/src/scripts/domWidget.ts @@ -5,47 +5,64 @@ import type { IWidgetOptions } from '@comfyorg/litegraph/dist/types/widgets' import _ from 'lodash' +import { type Component, toRaw } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { generateUUID } from '@/utils/formatUtil' -export interface DOMWidget +export interface BaseDOMWidget extends ICustomWidget { // ICustomWidget properties type: 'custom' - element: T - options: DOMWidgetOptions + options: DOMWidgetOptions value: V - /** - * @deprecated Legacy property used by some extensions for customtext - * (textarea) widgets. Use `element` instead as it provides the same - * functionality and works for all DOMWidget types. - */ - inputEl?: T callback?: (value: V) => void - // DOMWidget properties + + // BaseDOMWidget properties /** The unique ID of the widget. */ - id: string + readonly id: string /** The node that the widget belongs to. */ - node: LGraphNode + readonly node: LGraphNode /** Whether the widget is visible. */ isVisible(): boolean } -export interface DOMWidgetOptions< - T extends HTMLElement, - V extends object | string -> extends IWidgetOptions { +/** + * A DOM widget that wraps a custom HTML element as a litegraph widget. + */ +export interface DOMWidget + extends BaseDOMWidget { + element: T + /** + * @deprecated Legacy property used by some extensions for customtext + * (textarea) widgets. Use {@link element} instead as it provides the same + * functionality and works for all DOMWidget types. + */ + inputEl?: T +} + +/** + * A DOM widget that wraps a Vue component as a litegraph widget. + */ +export interface ComponentWidget + extends BaseDOMWidget { + readonly component: Component + readonly inputSpec: InputSpec +} + +export interface DOMWidgetOptions + extends IWidgetOptions { hideOnZoom?: boolean selectOn?: string[] - onHide?: (widget: DOMWidget) => void + onHide?: (widget: BaseDOMWidget) => void getValue?: () => V setValue?: (value: V) => void getMinHeight?: () => number getMaxHeight?: () => number getHeight?: () => string | number - onDraw?: (widget: DOMWidget) => void + onDraw?: (widget: BaseDOMWidget) => void /** * @deprecated Use `afterResize` instead. This callback is a legacy API * that fires before resize happens, but it is no longer supported. Now it @@ -53,21 +70,24 @@ export interface DOMWidgetOptions< * The resize logic has been upstreamed to litegraph in * https://github.com/Comfy-Org/ComfyUI_frontend/pull/2557 */ - beforeResize?: (this: DOMWidget, node: LGraphNode) => void - afterResize?: (this: DOMWidget, node: LGraphNode) => void + beforeResize?: (this: BaseDOMWidget, node: LGraphNode) => void + afterResize?: (this: BaseDOMWidget, node: LGraphNode) => void } export const isDOMWidget = ( widget: IWidget ): widget is DOMWidget => 'element' in widget && !!widget.element -export class DOMWidgetImpl - implements DOMWidget +export const isComponentWidget = ( + widget: IWidget +): widget is ComponentWidget => 'component' in widget && !!widget.component + +abstract class BaseDOMWidgetImpl + implements BaseDOMWidget { readonly type: 'custom' readonly name: string - readonly element: T - readonly options: DOMWidgetOptions + readonly options: DOMWidgetOptions computedHeight?: number y: number = 0 callback?: (value: V) => void @@ -80,13 +100,11 @@ export class DOMWidgetImpl node: LGraphNode name: string type: string - element: T - options: DOMWidgetOptions + options: DOMWidgetOptions }) { // @ts-expect-error custom widget type this.type = obj.type this.name = obj.name - this.element = obj.element this.options = obj.options this.id = obj.id @@ -102,6 +120,42 @@ export class DOMWidgetImpl this.callback?.(this.value) } + isVisible(): boolean { + return ( + !_.isNil(this.computedHeight) && + this.computedHeight > 0 && + !['converted-widget', 'hidden'].includes(this.type) && + !this.node.collapsed + ) + } + + draw(): void { + this.options.onDraw?.(this) + } + + onRemove(): void { + useDomWidgetStore().unregisterWidget(this.id) + } +} + +export class DOMWidgetImpl + extends BaseDOMWidgetImpl + implements DOMWidget +{ + readonly element: T + + constructor(obj: { + id: string + node: LGraphNode + name: string + type: string + element: T + options: DOMWidgetOptions + }) { + super(obj) + this.element = obj.element + } + /** Extract DOM widget size info */ computeLayoutSize(node: LGraphNode) { // @ts-expect-error custom widget type @@ -144,25 +198,63 @@ export class DOMWidgetImpl minWidth: 0 } } +} - isVisible(): boolean { - return ( - !_.isNil(this.computedHeight) && - this.computedHeight > 0 && - !['converted-widget', 'hidden'].includes(this.type) && - !this.node.collapsed - ) +export class ComponentWidgetImpl + extends BaseDOMWidgetImpl + implements ComponentWidget +{ + readonly component: Component + readonly inputSpec: InputSpec + + constructor(obj: { + id: string + node: LGraphNode + name: string + component: Component + inputSpec: InputSpec + options: DOMWidgetOptions + }) { + super({ + ...obj, + type: 'custom' + }) + this.component = obj.component + this.inputSpec = obj.inputSpec } - draw(): void { - this.options.onDraw?.(this) + computeLayoutSize() { + const minHeight = this.options.getMinHeight?.() ?? 50 + const maxHeight = this.options.getMaxHeight?.() + return { + minHeight, + maxHeight, + minWidth: 0 + } } - onRemove(): void { - useDomWidgetStore().unregisterWidget(this.id) + serializeValue(): V { + return toRaw(this.value) } } +export const addWidget = >( + node: LGraphNode, + widget: W +) => { + node.addCustomWidget(widget) + node.onRemoved = useChainCallback(node.onRemoved, () => { + widget.onRemove?.() + }) + + node.onResize = useChainCallback(node.onResize, () => { + widget.options.beforeResize?.call(widget, node) + widget.options.afterResize?.call(widget, node) + }) + + useDomWidgetStore().registerWidget(widget) +} + LGraphNode.prototype.addDOMWidget = function < T extends HTMLElement, V extends object | string @@ -171,22 +263,18 @@ LGraphNode.prototype.addDOMWidget = function < name: string, type: string, element: T, - options: DOMWidgetOptions = {} + options: DOMWidgetOptions = {} ): DOMWidget { + const widget = new DOMWidgetImpl({ + id: generateUUID(), + node: this, + name, + type, + element, + options: { hideOnZoom: true, ...options } + }) // Note: Before `LGraphNode.configure` is called, `this.id` is always `-1`. - const widget = this.addCustomWidget( - new DOMWidgetImpl({ - id: generateUUID(), - node: this, - name, - type, - element, - options: { - hideOnZoom: true, - ...options - } - }) - ) + addWidget(this, widget as unknown as BaseDOMWidget) // Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493 // Some custom nodes are explicitly expecting getter and setter of `value` @@ -201,16 +289,5 @@ LGraphNode.prototype.addDOMWidget = function < } }) - this.onRemoved = useChainCallback(this.onRemoved, () => { - widget.onRemove() - }) - - this.onResize = useChainCallback(this.onResize, () => { - options.beforeResize?.call(widget, this) - options.afterResize?.call(widget, this) - }) - - useDomWidgetStore().registerWidget(widget) - return widget } diff --git a/src/stores/domWidgetStore.ts b/src/stores/domWidgetStore.ts index b8975163f..3e5d1e15f 100644 --- a/src/stores/domWidgetStore.ts +++ b/src/stores/domWidgetStore.ts @@ -5,7 +5,7 @@ import { defineStore } from 'pinia' import { markRaw, ref } from 'vue' import type { PositionConfig } from '@/composables/element/useAbsolutePosition' -import type { DOMWidget } from '@/scripts/domWidget' +import type { BaseDOMWidget } from '@/scripts/domWidget' export interface DomWidgetState extends PositionConfig { visible: boolean @@ -18,17 +18,15 @@ export const useDomWidgetStore = defineStore('domWidget', () => { // Map to reference actual widget instances // Widgets are stored as raw values to avoid reactivity issues - const widgetInstances = ref( - new Map>() - ) + const widgetInstances = ref(new Map>()) // Register a widget with the store - const registerWidget = ( - widget: DOMWidget + const registerWidget = ( + widget: BaseDOMWidget ) => { widgetInstances.value.set( widget.id, - markRaw(widget as unknown as DOMWidget) + markRaw(widget) as unknown as BaseDOMWidget ) widgetStates.value.set(widget.id, { visible: true, diff --git a/src/types/litegraph-augmentation.d.ts b/src/types/litegraph-augmentation.d.ts index c139e5b4e..7854c95a8 100644 --- a/src/types/litegraph-augmentation.d.ts +++ b/src/types/litegraph-augmentation.d.ts @@ -134,7 +134,7 @@ declare module '@comfyorg/litegraph' { name: string, type: string, element: T, - options?: DOMWidgetOptions + options?: DOMWidgetOptions ): DOMWidget animatedImages?: boolean