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) - if (!element.parentElement) { - app.canvasContainer.append(element) - } - element.hidden = true - element.style.display = 'none' - - const { nodeData } = this.constructor - const tooltip = (nodeData?.input.required?.[name] ?? - nodeData?.input.optional?.[name])?.[1]?.tooltip - if (tooltip && !element.title) { - element.title = tooltip - } - - const widget = new DOMWidgetImpl(name, type, element, options) // Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493 // Some custom nodes are explicitly expecting getter and setter of `value` // property to be on instance instead of prototype. @@ -345,55 +321,5 @@ LGraphNode.prototype.addDOMWidget = function < } }) - // Ensure selectOn exists before iteration - const selectEvents = options.selectOn ?? ['focus', 'click'] - for (const evt of selectEvents) { - element.addEventListener(evt, () => { - app.canvas.selectNode(this) - app.canvas.bringToFront(this) - }) - } - - this.addCustomWidget(widget) - elementWidgets.add(this) - - const collapse = this.collapse - this.collapse = function (this: LGraphNode, force?: boolean) { - collapse.call(this, force) - if (this.collapsed) { - element.hidden = true - element.style.display = 'none' - } - element.dataset.collapsed = this.collapsed ? 'true' : 'false' - } - - const { onConfigure } = this - this.onConfigure = function ( - this: LGraphNode, - serializedNode: ISerialisedNode - ) { - onConfigure?.call(this, serializedNode) - element.dataset.collapsed = this.collapsed ? 'true' : 'false' - } - - const onRemoved = this.onRemoved - this.onRemoved = function (this: LGraphNode) { - element.remove() - elementWidgets.delete(this) - onRemoved?.call(this) - } - - // @ts-ignore index with symbol - if (!this[SIZE]) { - // @ts-ignore index with symbol - this[SIZE] = true - const onResize = this.onResize - this.onResize = function (this: LGraphNode, size: Size) { - options.beforeResize?.call(widget, this) - onResize?.call(this, size) - options.afterResize?.call(widget, this) - } - } - return widget } diff --git a/web/comfyui/legacyDomWidget.ts b/web/comfyui/legacyDomWidget.ts new file mode 100644 index 00000000..be59be18 --- /dev/null +++ b/web/comfyui/legacyDomWidget.ts @@ -0,0 +1,399 @@ +import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph' +import type { Size, Vector4 } from '@comfyorg/litegraph' +import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation' +import type { + ICustomWidget, + IWidgetOptions +} from '@comfyorg/litegraph/dist/types/widgets' + +import { useSettingStore } from '@/stores/settingStore' + +import { app } from './app' + +const SIZE = Symbol() + +interface Rect { + height: number + width: number + x: number + y: number +} + +export interface DOMWidget + extends ICustomWidget { + // All unrecognized types will be treated the same way as 'custom' in litegraph internally. + type: 'custom' + name: string + 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 + * 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 { + hideOnZoom?: boolean + selectOn?: string[] + onHide?: (widget: DOMWidget) => 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 +} + +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 +} + +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 + + // 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 +{ + type: 'custom' + name: string + element: T + options: DOMWidgetOptions + computedHeight?: number + callback?: (value: V) => void + private mouseDownHandler?: (event: MouseEvent) => void + + constructor( + name: string, + type: string, + element: T, + options: DOMWidgetOptions = {} + ) { + // @ts-expect-error custom widget type + this.type = type + this.name = name + this.element = element + this.options = options + + if (element.blur) { + this.mouseDownHandler = (event) => { + if (!element.contains(event.target as HTMLElement)) { + element.blur() + } + } + document.addEventListener('mousedown', this.mouseDownHandler) + } + } + + get value(): V { + return this.options.getValue?.() ?? ('' as V) + } + + set value(v: V) { + this.options.setValue?.(v) + this.callback?.(this.value) + } + + /** Extract DOM widget size info */ + computeLayoutSize(node: LGraphNode) { + // @ts-expect-error custom widget type + if (this.type === 'hidden') { + return { + minHeight: 0, + maxHeight: 0, + minWidth: 0 + } + } + + const styles = getComputedStyle(this.element) + let minHeight = + this.options.getMinHeight?.() ?? + parseInt(styles.getPropertyValue('--comfy-widget-min-height')) + let maxHeight = + this.options.getMaxHeight?.() ?? + parseInt(styles.getPropertyValue('--comfy-widget-max-height')) + + let prefHeight: string | number = + this.options.getHeight?.() ?? + styles.getPropertyValue('--comfy-widget-height') + + if (typeof prefHeight === 'string' && prefHeight.endsWith?.('%')) { + prefHeight = + node.size[1] * + (parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100) + } else { + prefHeight = + typeof prefHeight === 'number' ? prefHeight : parseInt(prefHeight) + + if (isNaN(minHeight)) { + minHeight = prefHeight + } + } + + return { + minHeight: isNaN(minHeight) ? 50 : minHeight, + maxHeight: isNaN(maxHeight) ? undefined : maxHeight, + 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' + + 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' + }) + + 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) + } + + onRemove(): void { + if (this.mouseDownHandler) { + document.removeEventListener('mousedown', this.mouseDownHandler) + } + this.element.remove() + } +} + +LGraphNode.prototype.addDOMWidget = function < + T extends HTMLElement, + V extends object | string +>( + this: LGraphNode, + name: string, + type: string, + element: T, + options: DOMWidgetOptions = {} +): DOMWidget { + options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options } + + if (!element.parentElement) { + app.canvasContainer.append(element) + } + element.hidden = true + element.style.display = 'none' + + const { nodeData } = this.constructor + const tooltip = (nodeData?.input.required?.[name] ?? + nodeData?.input.optional?.[name])?.[1]?.tooltip + if (tooltip && !element.title) { + element.title = tooltip + } + + const widget = new DOMWidgetImpl(name, type, element, options) + // Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493 + // Some custom nodes are explicitly expecting getter and setter of `value` + // property to be on instance instead of prototype. + Object.defineProperty(widget, 'value', { + get(this: DOMWidgetImpl): V { + return this.options.getValue?.() ?? ('' as V) + }, + set(this: DOMWidgetImpl, v: V) { + this.options.setValue?.(v) + this.callback?.(this.value) + } + }) + + // Ensure selectOn exists before iteration + const selectEvents = options.selectOn ?? ['focus', 'click'] + for (const evt of selectEvents) { + element.addEventListener(evt, () => { + app.canvas.selectNode(this) + app.canvas.bringToFront(this) + }) + } + + this.addCustomWidget(widget) + elementWidgets.add(this) + + const collapse = this.collapse + this.collapse = function (this: LGraphNode, force?: boolean) { + collapse.call(this, force) + if (this.collapsed) { + element.hidden = true + element.style.display = 'none' + } + element.dataset.collapsed = this.collapsed ? 'true' : 'false' + } + + const { onConfigure } = this + this.onConfigure = function ( + this: LGraphNode, + serializedNode: ISerialisedNode + ) { + onConfigure?.call(this, serializedNode) + element.dataset.collapsed = this.collapsed ? 'true' : 'false' + } + + const onRemoved = this.onRemoved + this.onRemoved = function (this: LGraphNode) { + element.remove() + elementWidgets.delete(this) + onRemoved?.call(this) + } + + // @ts-ignore index with symbol + if (!this[SIZE]) { + // @ts-ignore index with symbol + this[SIZE] = true + const onResize = this.onResize + this.onResize = function (this: LGraphNode, size: Size) { + options.beforeResize?.call(widget, this) + onResize?.call(this, size) + options.afterResize?.call(widget, this) + } + } + + return widget +} diff --git a/web/comfyui/legacy_tags_widget.js b/web/comfyui/legacy_tags_widget.js new file mode 100644 index 00000000..d43cb016 --- /dev/null +++ b/web/comfyui/legacy_tags_widget.js @@ -0,0 +1,193 @@ +export function addTagsWidget(node, name, opts, callback) { + // Create container for tags + const container = document.createElement("div"); + container.className = "comfy-tags-container"; + Object.assign(container.style, { + display: "flex", + flexWrap: "wrap", + gap: "4px", // 从8px减小到4px + padding: "6px", + minHeight: "30px", + backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background + borderRadius: "6px", // Slightly larger radius + width: "100%", + }); + + // Initialize default value as array + const initialTagsData = opts?.defaultVal || []; + + // Function to render tags from array data + const renderTags = (tagsData, widget) => { + // Clear existing tags + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + const normalizedTags = tagsData; + + if (normalizedTags.length === 0) { + // Show message when no tags are present + const emptyMessage = document.createElement("div"); + emptyMessage.textContent = "No trigger words detected"; + Object.assign(emptyMessage.style, { + textAlign: "center", + padding: "20px 0", + color: "rgba(226, 232, 240, 0.8)", + fontStyle: "italic", + userSelect: "none", + WebkitUserSelect: "none", + MozUserSelect: "none", + msUserSelect: "none", + }); + container.appendChild(emptyMessage); + return; + } + + normalizedTags.forEach((tagData, index) => { + const { text, active } = tagData; + const tagEl = document.createElement("div"); + tagEl.className = "comfy-tag"; + + updateTagStyle(tagEl, active); + + tagEl.textContent = text; + tagEl.title = text; // Set tooltip for full content + + // Add click handler to toggle state + tagEl.addEventListener("click", (e) => { + e.stopPropagation(); + + // Toggle active state for this specific tag using its index + const updatedTags = [...widget.value]; + updatedTags[index].active = !updatedTags[index].active; + updateTagStyle(tagEl, updatedTags[index].active); + + widget.value = updatedTags; + }); + + container.appendChild(tagEl); + }); + }; + + // Helper function to update tag style based on active state + function updateTagStyle(tagEl, active) { + const baseStyles = { + padding: "4px 12px", // 垂直内边距从6px减小到4px + borderRadius: "6px", // Matching container radius + maxWidth: "200px", // Increased max width + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontSize: "13px", // Slightly larger font + cursor: "pointer", + transition: "all 0.2s ease", // Smoother transition + border: "1px solid transparent", + display: "inline-block", + boxShadow: "0 1px 2px rgba(0,0,0,0.1)", + margin: "2px", // 从4px减小到2px + userSelect: "none", // Add this line to prevent text selection + WebkitUserSelect: "none", // For Safari support + MozUserSelect: "none", // For Firefox support + msUserSelect: "none", // For IE/Edge support + }; + + if (active) { + Object.assign(tagEl.style, { + ...baseStyles, + backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue + color: "white", + borderColor: "rgba(66, 153, 225, 0.9)", + }); + } else { + Object.assign(tagEl.style, { + ...baseStyles, + backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state + color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast + borderColor: "rgba(226, 232, 240, 0.2)", + }); + } + + // Add hover effect + tagEl.onmouseenter = () => { + tagEl.style.transform = "translateY(-1px)"; + tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)"; + }; + + tagEl.onmouseleave = () => { + tagEl.style.transform = "translateY(0)"; + tagEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)"; + }; + } + + // Store the value as array + let widgetValue = initialTagsData; + + // Create widget with initial properties + const widget = node.addDOMWidget(name, "tags", container, { + getValue: function() { + return widgetValue; + }, + setValue: function(v) { + widgetValue = v; + renderTags(widgetValue, widget); + + // Update container height after rendering + requestAnimationFrame(() => { + const minHeight = this.getMinHeight(); + container.style.height = `${minHeight}px`; + + // Force node to update size + node.setSize([node.size[0], node.computeSize()[1]]); + node.setDirtyCanvas(true, true); + }); + }, + getMinHeight: function() { + const minHeight = 150; + // If no tags or only showing the empty message, return a minimum height + if (widgetValue.length === 0) { + return minHeight; // Height for empty state with message + } + + // Get all tag elements + const tagElements = container.querySelectorAll('.comfy-tag'); + + if (tagElements.length === 0) { + return minHeight; // Fallback if elements aren't rendered yet + } + + // Calculate the actual height based on tag positions + let maxBottom = 0; + + tagElements.forEach(tag => { + const rect = tag.getBoundingClientRect(); + const tagBottom = rect.bottom - container.getBoundingClientRect().top; + maxBottom = Math.max(maxBottom, tagBottom); + }); + + // Add padding (top and bottom padding of container) + const computedStyle = window.getComputedStyle(container); + const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0; + const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0; + + // Add extra buffer for potential wrapping issues and to ensure no clipping + const extraBuffer = 20; + + // Round up to nearest 5px for clean sizing and ensure minimum height + return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5); + }, + }); + + widget.value = initialTagsData; + + widget.callback = callback; + + widget.serializeValue = () => { + // Add dummy items to avoid the 2-element serialization issue, a bug in comfyui + return [...widgetValue, + { text: "__dummy_item__", active: false, _isDummy: true }, + { text: "__dummy_item__", active: false, _isDummy: true } + ]; + }; + + return { minWidth: 300, minHeight: 150, widget }; +} diff --git a/web/comfyui/tags_widget.js b/web/comfyui/tags_widget.js index d43cb016..cb16c1a6 100644 --- a/web/comfyui/tags_widget.js +++ b/web/comfyui/tags_widget.js @@ -2,20 +2,36 @@ export function addTagsWidget(node, name, opts, callback) { // Create container for tags const container = document.createElement("div"); container.className = "comfy-tags-container"; + + // Set initial height + const defaultHeight = 150; + container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`); + container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`); + container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`); + Object.assign(container.style, { display: "flex", flexWrap: "wrap", - gap: "4px", // 从8px减小到4px + gap: "4px", padding: "6px", - minHeight: "30px", - backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background - borderRadius: "6px", // Slightly larger radius + backgroundColor: "rgba(40, 44, 52, 0.6)", + borderRadius: "6px", width: "100%", + boxSizing: "border-box", + overflow: "auto", + alignItems: "flex-start" // Ensure tags align at the top of each row }); // Initialize default value as array const initialTagsData = opts?.defaultVal || []; + // Fixed sizes for tag elements to avoid zoom-related calculation issues + const TAG_HEIGHT = 26; // Adjusted height of a single tag including margins + const TAGS_PER_ROW = 3; // Approximate number of tags per row + const ROW_GAP = 2; // Reduced gap between rows + const CONTAINER_PADDING = 12; // Top and bottom padding + const EMPTY_CONTAINER_HEIGHT = 60; // Height when no tags are present + // Function to render tags from array data const renderTags = (tagsData, widget) => { // Clear existing tags @@ -38,11 +54,28 @@ export function addTagsWidget(node, name, opts, callback) { WebkitUserSelect: "none", MozUserSelect: "none", msUserSelect: "none", + width: "100%" }); container.appendChild(emptyMessage); + + // Set fixed height for empty state + updateWidgetHeight(EMPTY_CONTAINER_HEIGHT); return; } + // Create a row container approach for better layout control + let rowContainer = document.createElement("div"); + rowContainer.className = "comfy-tags-row"; + Object.assign(rowContainer.style, { + display: "flex", + flexWrap: "wrap", + gap: "4px", + width: "100%", + marginBottom: "2px" // Small gap between rows + }); + container.appendChild(rowContainer); + + let tagCount = 0; normalizedTags.forEach((tagData, index) => { const { text, active } = tagData; const tagEl = document.createElement("div"); @@ -65,44 +98,75 @@ export function addTagsWidget(node, name, opts, callback) { widget.value = updatedTags; }); - container.appendChild(tagEl); + rowContainer.appendChild(tagEl); + tagCount++; }); + + // Calculate height based on number of tags and fixed sizes + const tagsCount = normalizedTags.length; + const rows = Math.ceil(tagsCount / TAGS_PER_ROW); + const calculatedHeight = CONTAINER_PADDING + (rows * TAG_HEIGHT) + ((rows - 1) * ROW_GAP); + + // Update widget height with calculated value + updateWidgetHeight(calculatedHeight); + }; + + // Function to update widget height consistently + const updateWidgetHeight = (height) => { + // Ensure minimum height + const finalHeight = Math.max(defaultHeight, height); + + // Update CSS variables + container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`); + container.style.setProperty('--comfy-widget-height', `${finalHeight}px`); + + // Force node to update size after a short delay to ensure DOM is updated + if (node) { + setTimeout(() => { + node.setDirtyCanvas(true, true); + }, 10); + } }; // Helper function to update tag style based on active state function updateTagStyle(tagEl, active) { const baseStyles = { - padding: "4px 12px", // 垂直内边距从6px减小到4px - borderRadius: "6px", // Matching container radius - maxWidth: "200px", // Increased max width + padding: "4px 10px", // Slightly reduced horizontal padding + borderRadius: "6px", + maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", - fontSize: "13px", // Slightly larger font + fontSize: "13px", cursor: "pointer", - transition: "all 0.2s ease", // Smoother transition + transition: "all 0.2s ease", border: "1px solid transparent", - display: "inline-block", + display: "inline-flex", // Changed to inline-flex for better text alignment + alignItems: "center", // Center text vertically + justifyContent: "center", // Center text horizontally boxShadow: "0 1px 2px rgba(0,0,0,0.1)", - margin: "2px", // 从4px减小到2px - userSelect: "none", // Add this line to prevent text selection - WebkitUserSelect: "none", // For Safari support - MozUserSelect: "none", // For Firefox support - msUserSelect: "none", // For IE/Edge support + margin: "1px", // Reduced margin + userSelect: "none", + WebkitUserSelect: "none", + MozUserSelect: "none", + msUserSelect: "none", + height: "20px", // Slightly increased height to prevent text cutoff + minHeight: "20px", // Ensure consistent height + boxSizing: "border-box" // Ensure padding doesn't affect the overall size }; if (active) { Object.assign(tagEl.style, { ...baseStyles, - backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue + backgroundColor: "rgba(66, 153, 225, 0.9)", color: "white", borderColor: "rgba(66, 153, 225, 0.9)", }); } else { Object.assign(tagEl.style, { ...baseStyles, - backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state - color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast + backgroundColor: "rgba(45, 55, 72, 0.7)", + color: "rgba(226, 232, 240, 0.8)", borderColor: "rgba(226, 232, 240, 0.2)", }); } @@ -122,72 +186,48 @@ export function addTagsWidget(node, name, opts, callback) { // Store the value as array let widgetValue = initialTagsData; - // Create widget with initial properties - const widget = node.addDOMWidget(name, "tags", container, { + // Create widget with new DOM Widget API + const widget = node.addDOMWidget(name, "custom", container, { getValue: function() { return widgetValue; }, setValue: function(v) { widgetValue = v; renderTags(widgetValue, widget); - - // Update container height after rendering - requestAnimationFrame(() => { - const minHeight = this.getMinHeight(); - container.style.height = `${minHeight}px`; - - // Force node to update size - node.setSize([node.size[0], node.computeSize()[1]]); - node.setDirtyCanvas(true, true); - }); }, getMinHeight: function() { - const minHeight = 150; - // If no tags or only showing the empty message, return a minimum height - if (widgetValue.length === 0) { - return minHeight; // Height for empty state with message - } - - // Get all tag elements - const tagElements = container.querySelectorAll('.comfy-tag'); - - if (tagElements.length === 0) { - return minHeight; // Fallback if elements aren't rendered yet - } - - // Calculate the actual height based on tag positions - let maxBottom = 0; - - tagElements.forEach(tag => { - const rect = tag.getBoundingClientRect(); - const tagBottom = rect.bottom - container.getBoundingClientRect().top; - maxBottom = Math.max(maxBottom, tagBottom); - }); - - // Add padding (top and bottom padding of container) - const computedStyle = window.getComputedStyle(container); - const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0; - const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0; - - // Add extra buffer for potential wrapping issues and to ensure no clipping - const extraBuffer = 20; - - // Round up to nearest 5px for clean sizing and ensure minimum height - return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5); + return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight; }, + getMaxHeight: function() { + return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2; + }, + getHeight: function() { + return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight; + }, + hideOnZoom: true, + selectOn: ['click', 'focus'], + afterResize: function(node) { + // Re-render tags after node resize + if (this.value && this.value.length > 0) { + renderTags(this.value, this); + } + } }); + // Set initial value widget.value = initialTagsData; + // Set callback widget.callback = callback; + // Add serialization method to avoid ComfyUI serialization issues widget.serializeValue = () => { - // Add dummy items to avoid the 2-element serialization issue, a bug in comfyui + // Add dummy items to avoid the 2-element serialization issue return [...widgetValue, { text: "__dummy_item__", active: false, _isDummy: true }, { text: "__dummy_item__", active: false, _isDummy: true } ]; }; - return { minWidth: 300, minHeight: 150, widget }; -} + return { minWidth: 300, minHeight: defaultHeight, widget }; +} \ No newline at end of file diff --git a/web/comfyui/trigger_word_toggle.js b/web/comfyui/trigger_word_toggle.js index 13dee1da..3b61da84 100644 --- a/web/comfyui/trigger_word_toggle.js +++ b/web/comfyui/trigger_word_toggle.js @@ -1,9 +1,36 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -import { addTagsWidget } from "./tags_widget.js"; const CONVERTED_TYPE = 'converted-widget' +function getComfyUIFrontendVersion() { + // 直接访问全局变量 + return window['__COMFYUI_FRONTEND_VERSION__']; +} + +// Dynamically import the appropriate tags widget based on app version +function getTagsWidgetModule() { + // Parse app version and compare with 1.12.6 + const currentVersion = getComfyUIFrontendVersion() || "0.0.0"; + console.log("currentVersion", currentVersion); + const versionParts = currentVersion.split('.').map(part => parseInt(part, 10)); + const requiredVersion = [1, 12, 6]; + + // Compare version numbers + for (let i = 0; i < 3; i++) { + if (versionParts[i] > requiredVersion[i]) { + console.log("Using tags_widget.js"); + return import("./tags_widget.js"); + } else if (versionParts[i] < requiredVersion[i]) { + console.log("Using legacy_tags_widget.js"); + return import("./legacy_tags_widget.js"); + } + } + + // If we get here, versions are equal, use the new module + return import("./tags_widget.js"); +} + // TriggerWordToggle extension for ComfyUI app.registerExtension({ name: "LoraManager.TriggerWordToggle", @@ -26,7 +53,11 @@ app.registerExtension({ }); // Wait for node to be properly initialized - requestAnimationFrame(() => { + requestAnimationFrame(async () => { + // Dynamically import the appropriate tags widget module + const tagsWidgetModule = await getTagsWidgetModule(); + const { addTagsWidget } = tagsWidgetModule; + // Get the widget object directly from the returned object const result = addTagsWidget(node, "toggle_trigger_words", { defaultVal: []