mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Add TriggerWord Toggle node
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
from .py.lora_manager import LoraManager
|
from .py.lora_manager import LoraManager
|
||||||
from .py.nodes.lora_loader import LoraManagerLoader
|
from .py.nodes.lora_loader import LoraManagerLoader
|
||||||
|
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
LoraManagerLoader.NAME: LoraManagerLoader
|
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||||
|
TriggerWordToggle.NAME: TriggerWordToggle
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ class LoraManager:
|
|||||||
|
|
||||||
# 分阶段加载缓存
|
# 分阶段加载缓存
|
||||||
await scanner.get_cached_data(force_refresh=True)
|
await scanner.get_cached_data(force_refresh=True)
|
||||||
print("LoRA Manager: Cache initialization completed")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LoRA Manager: Error initializing cache: {e}")
|
print(f"LoRA Manager: Error initializing cache: {e}")
|
||||||
|
|
||||||
|
|||||||
61
py/nodes/trigger_word_toggle.py
Normal file
61
py/nodes/trigger_word_toggle.py
Normal file
@@ -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,)
|
||||||
32
py/nodes/utils.py
Normal file
32
py/nodes/utils.py
Normal file
@@ -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("*")
|
||||||
399
web/comfyui/domWidget.ts
Normal file
399
web/comfyui/domWidget.ts
Normal file
@@ -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<T extends HTMLElement, V extends object | string>
|
||||||
|
extends ICustomWidget<T> {
|
||||||
|
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
|
||||||
|
type: 'custom'
|
||||||
|
name: string
|
||||||
|
element: T
|
||||||
|
options: DOMWidgetOptions<T, V>
|
||||||
|
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<T, V>) => void
|
||||||
|
getValue?: () => V
|
||||||
|
setValue?: (value: V) => void
|
||||||
|
getMinHeight?: () => number
|
||||||
|
getMaxHeight?: () => number
|
||||||
|
getHeight?: () => string | number
|
||||||
|
onDraw?: (widget: DOMWidget<T, V>) => void
|
||||||
|
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
||||||
|
afterResize?: (this: DOMWidget<T, V>, 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<LGraphNode>()
|
||||||
|
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<HTMLElement, object>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||||
|
implements DOMWidget<T, V>
|
||||||
|
{
|
||||||
|
type: 'custom'
|
||||||
|
name: string
|
||||||
|
element: T
|
||||||
|
options: DOMWidgetOptions<T, V>
|
||||||
|
computedHeight?: number
|
||||||
|
callback?: (value: V) => void
|
||||||
|
private mouseDownHandler?: (event: MouseEvent) => void
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
element: T,
|
||||||
|
options: DOMWidgetOptions<T, V> = {}
|
||||||
|
) {
|
||||||
|
// @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<T, V> = {}
|
||||||
|
): DOMWidget<T, V> {
|
||||||
|
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<T, V>): V {
|
||||||
|
return this.options.getValue?.() ?? ('' as V)
|
||||||
|
},
|
||||||
|
set(this: DOMWidgetImpl<T, V>, 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
|
||||||
|
}
|
||||||
199
web/comfyui/lm_widgets.js
Normal file
199
web/comfyui/lm_widgets.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
72
web/comfyui/trigger-word-toggle.js
Normal file
72
web/comfyui/trigger-word-toggle.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
24
web/comfyui/utils.js
Normal file
24
web/comfyui/utils.js
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user