diff --git a/web/comfyui/DomWidget.vue b/web/comfyui/DomWidget.vue
new file mode 100644
index 00000000..245030fd
--- /dev/null
+++ b/web/comfyui/DomWidget.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/comfyui/domWidget.ts b/web/comfyui/domWidget.ts
index be59be18..e2429e06 100644
--- a/web/comfyui/domWidget.ts
+++ b/web/comfyui/domWidget.ts
@@ -1,193 +1,121 @@
-import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
-import type { Size, Vector4 } from '@comfyorg/litegraph'
-import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
+import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
ICustomWidget,
+ IWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
+import _ from 'lodash'
+import { type Component, toRaw } from 'vue'
-import { useSettingStore } from '@/stores/settingStore'
+import { useChainCallback } from '@/composables/functional/useChainCallback'
+import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
+import { useDomWidgetStore } from '@/stores/domWidgetStore'
+import { generateUUID } from '@/utils/formatUtil'
-import { app } from './app'
+export interface BaseDOMWidget
+ extends ICustomWidget {
+ // ICustomWidget properties
+ type: 'custom'
+ options: DOMWidgetOptions
+ value: V
+ callback?: (value: V) => void
-const SIZE = Symbol()
-
-interface Rect {
- height: number
- width: number
- x: number
- y: number
+ // BaseDOMWidget properties
+ /** The unique ID of the widget. */
+ readonly id: string
+ /** The node that the widget belongs to. */
+ readonly node: LGraphNode
+ /** Whether the widget is visible. */
+ isVisible(): boolean
+ /** The margin of the widget. */
+ margin: number
}
+/**
+ * A DOM widget that wraps a custom HTML element as a litegraph widget.
+ */
export interface DOMWidget
- extends ICustomWidget {
- // All unrecognized types will be treated the same way as 'custom' in litegraph internally.
- type: 'custom'
- name: string
+ extends BaseDOMWidget {
element: T
- options: DOMWidgetOptions
- value: V
- y?: number
/**
* @deprecated Legacy property used by some extensions for customtext
- * (textarea) widgets. Use `element` instead as it provides the same
+ * (textarea) widgets. Use {@link element} instead as it provides the same
* functionality and works for all DOMWidget types.
*/
inputEl?: T
- callback?: (value: V) => void
- /**
- * Draw the widget on the canvas.
- */
- draw?: (
- ctx: CanvasRenderingContext2D,
- node: LGraphNode,
- widgetWidth: number,
- y: number,
- widgetHeight: number
- ) => void
- /**
- * TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
- * on litegraph's IBaseWidget definition, but not called in litegraph.
- * Currently only called in widgetInputs.ts.
- */
- onRemove?: () => void
}
-export interface DOMWidgetOptions<
- T extends HTMLElement,
- V extends object | string
-> extends IWidgetOptions {
+/**
+ * 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 {
+ /**
+ * Whether to render a placeholder rectangle when zoomed out.
+ */
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
- beforeResize?: (this: DOMWidget, node: LGraphNode) => void
- afterResize?: (this: DOMWidget, node: LGraphNode) => void
+ onDraw?: (widget: BaseDOMWidget) => void
+ margin?: number
+ /**
+ * @deprecated Use `afterResize` instead. This callback is a legacy API
+ * that fires before resize happens, but it is no longer supported. Now it
+ * fires after resize happens.
+ * The resize logic has been upstreamed to litegraph in
+ * https://github.com/Comfy-Org/ComfyUI_frontend/pull/2557
+ */
+ beforeResize?: (this: BaseDOMWidget, node: LGraphNode) => void
+ afterResize?: (this: BaseDOMWidget, node: LGraphNode) => void
}
-function intersect(a: Rect, b: Rect): Vector4 | null {
- const x = Math.max(a.x, b.x)
- const num1 = Math.min(a.x + a.width, b.x + b.width)
- const y = Math.max(a.y, b.y)
- const num2 = Math.min(a.y + a.height, b.y + b.height)
- if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
- else return null
-}
+export const isDOMWidget = (
+ widget: IWidget
+): widget is DOMWidget => 'element' in widget && !!widget.element
-function getClipPath(
- node: LGraphNode,
- element: HTMLElement,
- canvasRect: DOMRect
-): string {
- const selectedNode: LGraphNode = Object.values(
- app.canvas.selected_nodes ?? {}
- )[0] as LGraphNode
- if (selectedNode && selectedNode !== node) {
- const elRect = element.getBoundingClientRect()
- const MARGIN = 4
- const { offset, scale } = app.canvas.ds
- const { renderArea } = selectedNode
+export const isComponentWidget = (
+ widget: IWidget
+): widget is ComponentWidget => 'component' in widget && !!widget.component
- // Get intersection in browser space
- const intersection = intersect(
- {
- x: elRect.left - canvasRect.left,
- y: elRect.top - canvasRect.top,
- width: elRect.width,
- height: elRect.height
- },
- {
- x: (renderArea[0] + offset[0] - MARGIN) * scale,
- y: (renderArea[1] + offset[1] - MARGIN) * scale,
- width: (renderArea[2] + 2 * MARGIN) * scale,
- height: (renderArea[3] + 2 * MARGIN) * scale
- }
- )
-
- if (!intersection) {
- return ''
- }
-
- // Convert intersection to canvas scale (element has scale transform)
- const clipX =
- (intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
- const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
- const clipWidth = intersection[2] / scale + 'px'
- const clipHeight = intersection[3] / scale + 'px'
- const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
- return path
- }
- return ''
-}
-
-// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
-const elementWidgets = new Set()
-const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
-LGraphCanvas.prototype.computeVisibleNodes = function (
- nodes?: LGraphNode[],
- out?: LGraphNode[]
-): LGraphNode[] {
- const visibleNodes = computeVisibleNodes.call(this, nodes, out)
-
- for (const node of app.graph.nodes) {
- if (elementWidgets.has(node)) {
- const hidden = visibleNodes.indexOf(node) === -1
- for (const w of node.widgets ?? []) {
- if (w.element) {
- w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
- const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
- const isCollapsed = w.element.dataset.collapsed === 'true'
- const wasHidden = w.element.hidden
- const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
- w.element.hidden = actualHidden
- w.element.style.display = actualHidden ? 'none' : ''
- if (actualHidden && !wasHidden) {
- w.options.onHide?.(w as DOMWidget)
- }
- }
- }
- }
- }
-
- return visibleNodes
-}
-
-export class DOMWidgetImpl
- implements DOMWidget
+abstract class BaseDOMWidgetImpl
+ implements BaseDOMWidget
{
- type: 'custom'
- name: string
- element: T
- options: DOMWidgetOptions
+ static readonly DEFAULT_MARGIN = 10
+ readonly type: 'custom'
+ readonly name: string
+ readonly options: DOMWidgetOptions
computedHeight?: number
+ y: number = 0
callback?: (value: V) => void
- private mouseDownHandler?: (event: MouseEvent) => void
- constructor(
- name: string,
- type: string,
- element: T,
- options: DOMWidgetOptions = {}
- ) {
+ readonly id: string
+ readonly node: LGraphNode
+
+ constructor(obj: {
+ id: string
+ node: LGraphNode
+ name: string
+ type: string
+ options: DOMWidgetOptions
+ }) {
// @ts-expect-error custom widget type
- this.type = type
- this.name = name
- this.element = element
- this.options = options
+ this.type = obj.type
+ this.name = obj.name
+ this.options = obj.options
- if (element.blur) {
- this.mouseDownHandler = (event) => {
- if (!element.contains(event.target as HTMLElement)) {
- element.blur()
- }
- }
- document.addEventListener('mousedown', this.mouseDownHandler)
- }
+ this.id = obj.id
+ this.node = obj.node
}
get value(): V {
@@ -199,6 +127,67 @@ export class DOMWidgetImpl
this.callback?.(this.value)
}
+ get margin(): number {
+ return this.options.margin ?? BaseDOMWidgetImpl.DEFAULT_MARGIN
+ }
+
+ isVisible(): boolean {
+ return (
+ !_.isNil(this.computedHeight) &&
+ this.computedHeight > 0 &&
+ !['converted-widget', 'hidden'].includes(this.type) &&
+ !this.node.collapsed
+ )
+ }
+
+ draw(
+ ctx: CanvasRenderingContext2D,
+ _node: LGraphNode,
+ widget_width: number,
+ y: number,
+ widget_height: number,
+ lowQuality?: boolean
+ ): void {
+ if (this.options.hideOnZoom && lowQuality && this.isVisible()) {
+ // Draw a placeholder rectangle
+ const originalFillStyle = ctx.fillStyle
+ ctx.beginPath()
+ ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
+ ctx.rect(
+ this.margin,
+ y + this.margin,
+ widget_width - this.margin * 2,
+ (this.computedHeight ?? widget_height) - 2 * this.margin
+ )
+ ctx.fill()
+ ctx.fillStyle = originalFillStyle
+ }
+ 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
@@ -241,69 +230,61 @@ export class DOMWidgetImpl
minWidth: 0
}
}
+}
- draw(
- ctx: CanvasRenderingContext2D,
- node: LGraphNode,
- widgetWidth: number,
- y: number
- ): void {
- const { offset, scale } = app.canvas.ds
- const hidden =
- (!!this.options.hideOnZoom && app.canvas.low_quality) ||
- (this.computedHeight ?? 0) <= 0 ||
- // @ts-expect-error custom widget type
- this.type === 'converted-widget' ||
- // @ts-expect-error custom widget type
- this.type === 'hidden'
+export class ComponentWidgetImpl
+ extends BaseDOMWidgetImpl
+ implements ComponentWidget
+{
+ readonly component: Component
+ readonly inputSpec: InputSpec
- this.element.dataset.shouldHide = hidden ? 'true' : 'false'
- const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
- const isCollapsed = this.element.dataset.collapsed === 'true'
- const actualHidden = hidden || !isInVisibleNodes || isCollapsed
- const wasHidden = this.element.hidden
- this.element.hidden = actualHidden
- this.element.style.display = actualHidden ? 'none' : ''
-
- if (actualHidden && !wasHidden) {
- this.options.onHide?.(this)
- }
- if (actualHidden) {
- return
- }
-
- const elRect = ctx.canvas.getBoundingClientRect()
- const margin = 10
- const top = node.pos[0] + offset[0] + margin
- const left = node.pos[1] + offset[1] + margin + y
-
- Object.assign(this.element.style, {
- transformOrigin: '0 0',
- transform: `scale(${scale})`,
- left: `${top * scale}px`,
- top: `${left * scale}px`,
- width: `${widgetWidth - margin * 2}px`,
- height: `${(this.computedHeight ?? 50) - margin * 2}px`,
- position: 'absolute',
- zIndex: app.graph.nodes.indexOf(node),
- pointerEvents: app.canvas.read_only ? 'none' : 'auto'
+ constructor(obj: {
+ id: string
+ node: LGraphNode
+ name: string
+ component: Component
+ inputSpec: InputSpec
+ options: DOMWidgetOptions
+ }) {
+ super({
+ ...obj,
+ type: 'custom'
})
-
- if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
- const clipPath = getClipPath(node, this.element, elRect)
- this.element.style.clipPath = clipPath ?? 'none'
- this.element.style.willChange = 'clip-path'
- }
-
- this.options.onDraw?.(this)
+ this.component = obj.component
+ this.inputSpec = obj.inputSpec
}
- onRemove(): void {
- if (this.mouseDownHandler) {
- document.removeEventListener('mousedown', this.mouseDownHandler)
+ computeLayoutSize() {
+ const minHeight = this.options.getMinHeight?.() ?? 50
+ const maxHeight = this.options.getMaxHeight?.()
+ return {
+ minHeight,
+ maxHeight,
+ minWidth: 0
}
- this.element.remove()
}
+
+ 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 <
@@ -314,24 +295,19 @@ LGraphNode.prototype.addDOMWidget = function <
name: string,
type: string,
element: T,
- options: DOMWidgetOptions = {}
+ options: DOMWidgetOptions = {}
): DOMWidget {
- options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
+ 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`.
+ addWidget(this, widget as unknown as BaseDOMWidget