From ccd4cee65a3be6563378bb369e634b8cb57de3b0 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 27 Feb 2025 15:38:50 +0800 Subject: [PATCH] Add TriggerWord Toggle node --- __init__.py | 4 +- py/lora_manager.py | 1 - py/nodes/trigger_word_toggle.py | 61 +++++ py/nodes/utils.py | 32 +++ web/comfyui/domWidget.ts | 399 +++++++++++++++++++++++++++++ web/comfyui/lm_widgets.js | 199 ++++++++++++++ web/comfyui/trigger-word-toggle.js | 72 ++++++ web/comfyui/utils.js | 24 ++ 8 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 py/nodes/trigger_word_toggle.py create mode 100644 py/nodes/utils.py create mode 100644 web/comfyui/domWidget.ts create mode 100644 web/comfyui/lm_widgets.js create mode 100644 web/comfyui/trigger-word-toggle.js create mode 100644 web/comfyui/utils.js diff --git a/__init__.py b/__init__.py index 945bc570..db97c0e1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,8 +1,10 @@ from .py.lora_manager import LoraManager from .py.nodes.lora_loader import LoraManagerLoader +from .py.nodes.trigger_word_toggle import TriggerWordToggle NODE_CLASS_MAPPINGS = { - LoraManagerLoader.NAME: LoraManagerLoader + LoraManagerLoader.NAME: LoraManagerLoader, + TriggerWordToggle.NAME: TriggerWordToggle } WEB_DIRECTORY = "./web/comfyui" diff --git a/py/lora_manager.py b/py/lora_manager.py index 8e0208f4..51655b45 100644 --- a/py/lora_manager.py +++ b/py/lora_manager.py @@ -97,7 +97,6 @@ class LoraManager: # 分阶段加载缓存 await scanner.get_cached_data(force_refresh=True) - print("LoRA Manager: Cache initialization completed") except Exception as e: print(f"LoRA Manager: Error initializing cache: {e}") diff --git a/py/nodes/trigger_word_toggle.py b/py/nodes/trigger_word_toggle.py new file mode 100644 index 00000000..1d28ea3c --- /dev/null +++ b/py/nodes/trigger_word_toggle.py @@ -0,0 +1,61 @@ +from server import PromptServer # type: ignore +from .utils import FlexibleOptionalInputType, any_type +import json + +class TriggerWordToggle: + NAME = "TriggerWord Toggle (LoraManager)" + CATEGORY = "lora manager" + DESCRIPTION = "Toggle trigger words on/off" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "trigger_words": ("STRING", {"defaultInput": True, "forceInput": True}), + }, + "optional": FlexibleOptionalInputType(any_type), + "hidden": { + "id": "UNIQUE_ID", # 会被 ComfyUI 自动替换为唯一ID + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("filtered_trigger_words",) + FUNCTION = "process_trigger_words" + + def process_trigger_words(self, trigger_words, id, **kwargs): + # Send trigger words to frontend + PromptServer.instance.send_sync("trigger_word_update", { + "id": id, + "message": trigger_words + }) + + filtered_triggers = trigger_words + + if 'hidden_trigger_words' in kwargs: + try: + # Parse the hidden trigger words JSON + trigger_data = json.loads(kwargs['hidden_trigger_words']) if isinstance(kwargs['hidden_trigger_words'], str) else kwargs['hidden_trigger_words'] + + # Create dictionaries to track active state of words + active_state = {item['text']: item.get('active', False) for item in trigger_data} + + # Split original trigger words + original_words = [word.strip() for word in trigger_words.split(',')] + + # Filter words: keep those not in hidden_trigger_words or those that are active + filtered_words = [word for word in original_words if word not in active_state or active_state[word]] + + # Join them in the same format as input + if filtered_words: + filtered_triggers = ', '.join(filtered_words) + else: + filtered_triggers = "" + + except Exception as e: + print(f"Error processing trigger words: {e}") + + for key, value in kwargs.items(): + print(f"{key}: {value}") + + return (filtered_triggers,) \ No newline at end of file diff --git a/py/nodes/utils.py b/py/nodes/utils.py new file mode 100644 index 00000000..4c1884b1 --- /dev/null +++ b/py/nodes/utils.py @@ -0,0 +1,32 @@ +class AnyType(str): + """A special class that is always equal in not equal comparisons. Credit to pythongosssss""" + + def __ne__(self, __value: object) -> bool: + return False + +class FlexibleOptionalInputType(dict): + """A special class to make flexible nodes that pass data to our python handlers. + + Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs + (like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc). + + Note, for ComfyUI, all that's needed is the `__contains__` override below, which tells ComfyUI + that our node will handle the input, regardless of what it is. + + However, with https://github.com/comfyanonymous/ComfyUI/pull/2666 a large change would occur + requiring more details on the input itself. There, we need to return a list/tuple where the first + item is the type. This can be a real type, or use the AnyType for additional flexibility. + + This should be forwards compatible unless more changes occur in the PR. + """ + def __init__(self, type): + self.type = type + + def __getitem__(self, key): + return (self.type, ) + + def __contains__(self, key): + return True + + +any_type = AnyType("*") \ No newline at end of file diff --git a/web/comfyui/domWidget.ts b/web/comfyui/domWidget.ts new file mode 100644 index 00000000..be59be18 --- /dev/null +++ b/web/comfyui/domWidget.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/lm_widgets.js b/web/comfyui/lm_widgets.js new file mode 100644 index 00000000..1f79241a --- /dev/null +++ b/web/comfyui/lm_widgets.js @@ -0,0 +1,199 @@ +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: "8px", + 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 + const defaultValue = opts?.defaultVal || "[]"; + + // Parse trigger words and states from string + const parseTagsValue = (value) => { + if (!value) return []; + + try { + return JSON.parse(value); + } catch (e) { + // If it's not valid JSON, try legacy format or return empty array + console.warn("Invalid tags data format", e); + return []; + } + }; + + // Format tags data back to string + const formatTagsValue = (tagsData) => { + return JSON.stringify(tagsData); + }; + + // Function to render tags from data + const renderTags = (value, widget) => { + // Clear existing tags + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + // Parse the tags data + const tagsData = parseTagsValue(value); + + tagsData.forEach((tagData) => { + 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 tag + const tagsData = parseTagsValue(widget.value); + const tagIndex = tagsData.findIndex((t) => t.text === text); + + if (tagIndex >= 0) { + tagsData[tagIndex].active = !tagsData[tagIndex].active; + updateTagStyle(tagEl, tagsData[tagIndex].active); + + // Update value and trigger widget callback + const newValue = formatTagsValue(tagsData); + widget.value = newValue; + widget.callback?.(newValue); + } + }); + + container.appendChild(tagEl); + }); + }; + + // Helper function to update tag style based on active state + function updateTagStyle(tagEl, active) { + const baseStyles = { + padding: "6px 12px", // 水平内边距从16px减小到12px + 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: "4px", // 从6px减小到4px + }; + + 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 in a variable to avoid recursion + let widgetValue = defaultValue; + + // Create widget with initial properties + const widget = node.addDOMWidget(name, "tags", container, { + getValue: function() { + return widgetValue; + }, + setValue: function(v) { + // Format the incoming value if it's not in the expected JSON format + let parsedValue = v; + + try { + // Try to parse as JSON first + if (typeof v === "string" && (v.startsWith("[") || v.startsWith("{"))) { + JSON.parse(v); + // If no error, it's already valid JSON + parsedValue = v; + } else if (typeof v === "string") { + // If it's a comma-separated string of trigger words, convert to tag format + const triggerWords = v + .split(",") + .map((word) => word.trim()) + .filter((word) => word); + + // Get existing tags to merge with new ones + const existingTags = parseTagsValue(widgetValue || "[]"); + const existingTagsMap = {}; + existingTags.forEach((tag) => { + existingTagsMap[tag.text] = tag.active; + }); + + // Create new tags with merging logic + const newTags = triggerWords.map((word) => ({ + text: word, + active: word in existingTagsMap ? existingTagsMap[word] : true, + })); + + parsedValue = JSON.stringify(newTags); + } + } catch (e) { + console.warn("Error formatting tags value:", e); + // Keep the original value if there's an error + } + + widgetValue = parsedValue || ""; // Store in our local variable instead + renderTags(widgetValue, widget); + }, + getHeight: function() { + // Calculate height based on content + return Math.max( + 150, + Math.ceil(container.scrollHeight / 5) * 5 // Round up to nearest 5px + ); + }, + onDraw: function() { + // Empty function + } + }); + + // Initialize widget value using options methods + widget.options.setValue(defaultValue); + + widget.callback = callback; + + // Render initial state + renderTags(widgetValue, widget); + + widget.onRemove = () => { + container.remove(); + }; + + return { minWidth: 300, minHeight: 30, widget }; +} diff --git a/web/comfyui/trigger-word-toggle.js b/web/comfyui/trigger-word-toggle.js new file mode 100644 index 00000000..97b24994 --- /dev/null +++ b/web/comfyui/trigger-word-toggle.js @@ -0,0 +1,72 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; +import { addTagsWidget } from "./lm_widgets.js"; +import { hideWidgetForGood } from "./utils.js"; + +// TriggerWordToggle extension for ComfyUI +app.registerExtension({ + name: "LoraManager.TriggerWordToggle", + + setup() { + // Add message handler to listen for messages from Python + api.addEventListener("trigger_word_update", (event) => { + const { id, message } = event.detail; + this.handleTriggerWordUpdate(id, message); + }); + }, + + async nodeCreated(node) { + if (node.comfyClass === "TriggerWord Toggle (LoraManager)") { + // Enable widget serialization + node.serialize_widgets = true; + + // Wait for node to be properly initialized + requestAnimationFrame(() => { + // add a hidden widget for excluded trigger words to send to Python + node.hiddenWidget = node.addWidget("text", "hidden_trigger_words", "", (value) => { + // empty callback + }); + hideWidgetForGood(node, node.hiddenWidget); + + // Get the widget object directly from the returned object + const result = addTagsWidget(node, "trigger_words", { + defaultVal: "[]" + }, (value) => { + // update value of hidden widget + node.hiddenWidget.value = value; + }); + + node.tagWidget = result.widget; + + // Restore saved value if exists + if (node.widgets_values && node.widgets_values.length > 0) { + // 0 is input, 1 is hidden widget, 2 is tag widget + const savedValue = node.widgets_values[2]; + if (savedValue) { + result.widget.value = savedValue; + } + } + }); + } + }, + + async nodeRemoved(node) { + if (node.comfyClass === "TriggerWord Toggle (LoraManager)") { + // TODO: Remove widget from node + } + }, + + // Handle trigger word updates from Python + handleTriggerWordUpdate(id, message) { + const node = app.graph.getNodeById(+id); + if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") { + console.warn("Node not found or not a TriggerWordToggle:", id); + return; + } + + if (node.tagWidget) { + // Use widget.value setter instead of setValue + node.tagWidget.value = message; + } + }, +}); diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js new file mode 100644 index 00000000..d1cbbbd9 --- /dev/null +++ b/web/comfyui/utils.js @@ -0,0 +1,24 @@ +export const CONVERTED_TYPE = 'converted-widget'; + +export function hideWidgetForGood(node, widget, suffix = "") { + widget.origType = widget.type; + widget.origComputeSize = widget.computeSize; + widget.origSerializeValue = widget.serializeValue; + widget.computeSize = () => [0, -4]; // -4 is due to the gap litegraph adds between widgets automatically + widget.type = CONVERTED_TYPE + suffix; + // widget.serializeValue = () => { + // // Prevent serializing the widget if we have no input linked + // const w = node.inputs?.find((i) => i.widget?.name === widget.name); + // if (w?.link == null) { + // return undefined; + // } + // return widget.origSerializeValue ? widget.origSerializeValue() : widget.value; + // }; + + // Hide any linked widgets, e.g. seed+seedControl + if (widget.linkedWidgets) { + for (const w of widget.linkedWidgets) { + hideWidgetForGood(node, w, `:${widget.name}`); + } + } +} \ No newline at end of file