From 79dd9a1b29fa17bbfe5e9e7f8bbe5bc317fde293 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 21 Apr 2026 10:29:49 +0800 Subject: [PATCH] fix(trigger-word-toggle): compact group editing for #907 --- py/nodes/trigger_word_toggle.py | 44 +- tests/nodes/test_trigger_word_toggle.py | 93 +++ web/comfyui/lm_styles.css | 32 + web/comfyui/tags_widget.js | 986 ++++++++++++++++++------ web/comfyui/trigger_word_toggle.js | 717 ++++++++++------- 5 files changed, 1366 insertions(+), 506 deletions(-) diff --git a/py/nodes/trigger_word_toggle.py b/py/nodes/trigger_word_toggle.py index 58a7062a..3a7d9539 100644 --- a/py/nodes/trigger_word_toggle.py +++ b/py/nodes/trigger_word_toggle.py @@ -76,6 +76,9 @@ class TriggerWordToggleLM: # Filter out empty strings and return as set return set(word for word in words if word) + def _group_has_child_items(self, item): + return isinstance(item, dict) and isinstance(item.get("items"), list) + def process_trigger_words( self, id, @@ -112,7 +115,11 @@ class TriggerWordToggleLM: if isinstance(trigger_data, list): if group_mode: - if allow_strength_adjustment: + if any(self._group_has_child_items(item) for item in trigger_data): + filtered_groups = self._process_group_items( + trigger_data, allow_strength_adjustment + ) + elif allow_strength_adjustment: parsed_items = [ self._parse_trigger_item( item, allow_strength_adjustment @@ -174,6 +181,41 @@ class TriggerWordToggleLM: return (filtered_triggers,) + def _process_group_items(self, trigger_data, allow_strength_adjustment): + filtered_groups = [] + + for item in trigger_data: + group = self._parse_trigger_item(item, allow_strength_adjustment) + if not group["text"] or not group["active"]: + continue + + raw_items = item.get("items") if isinstance(item, dict) else None + if isinstance(raw_items, list): + active_items = [] + for raw_item in raw_items: + child = self._parse_trigger_item( + raw_item, allow_strength_adjustment=False + ) + if child["text"] and child["active"]: + active_items.append(child["text"]) + + if not active_items: + continue + + group_text = ", ".join(active_items) + else: + group_text = group["text"] + + filtered_groups.append( + self._format_word_output( + group_text, + group["strength"], + allow_strength_adjustment, + ) + ) + + return filtered_groups + def _parse_trigger_item(self, item, allow_strength_adjustment): text = (item.get("text") or "").strip() active = bool(item.get("active", False)) diff --git a/tests/nodes/test_trigger_word_toggle.py b/tests/nodes/test_trigger_word_toggle.py index d088f650..1360a941 100644 --- a/tests/nodes/test_trigger_word_toggle.py +++ b/tests/nodes/test_trigger_word_toggle.py @@ -99,6 +99,99 @@ def test_duplicate_groups_respect_active_state(): assert filtered == "A, B" +def test_group_mode_can_exclude_individual_tags_within_active_group(): + node = TriggerWordToggleLM() + trigger_data = [ + { + "text": "outfit red, outfit blue, smiling", + "active": True, + "strength": None, + "highlighted": False, + "items": [ + {"text": "outfit red", "active": True, "highlighted": False}, + {"text": "outfit blue", "active": False, "highlighted": False}, + {"text": "smiling", "active": True, "highlighted": False}, + ], + } + ] + + (filtered,) = node.process_trigger_words( + id="node", + group_mode=True, + default_active=True, + allow_strength_adjustment=False, + orinalMessage="outfit red, outfit blue, smiling", + toggle_trigger_words=trigger_data, + ) + + assert filtered == "outfit red, smiling" + + +def test_group_mode_keeps_group_strength_when_individual_tags_are_excluded(): + node = TriggerWordToggleLM() + trigger_data = [ + { + "text": "(outfit red, outfit blue, smiling:1.15)", + "active": True, + "strength": 1.15, + "highlighted": False, + "items": [ + {"text": "outfit red", "active": True, "highlighted": False}, + {"text": "outfit blue", "active": False, "highlighted": False}, + {"text": "smiling", "active": True, "highlighted": False}, + ], + } + ] + + (filtered,) = node.process_trigger_words( + id="node", + group_mode=True, + default_active=True, + allow_strength_adjustment=True, + orinalMessage="outfit red, outfit blue, smiling", + toggle_trigger_words=trigger_data, + ) + + assert filtered == "(outfit red, smiling:1.15)" + + +def test_group_mode_omits_group_when_all_children_are_disabled(): + node = TriggerWordToggleLM() + trigger_data = [ + { + "text": "A, B", + "active": True, + "strength": None, + "highlighted": False, + "items": [ + {"text": "A", "active": False, "highlighted": False}, + {"text": "B", "active": False, "highlighted": False}, + ], + }, + { + "text": "C, D", + "active": True, + "strength": None, + "highlighted": False, + "items": [ + {"text": "C", "active": True, "highlighted": False}, + {"text": "D", "active": True, "highlighted": False}, + ], + }, + ] + + (filtered,) = node.process_trigger_words( + id="node", + group_mode=True, + default_active=True, + allow_strength_adjustment=False, + orinalMessage="A, B,, C, D", + toggle_trigger_words=trigger_data, + ) + + assert filtered == "C, D" + + def test_trigger_words_override_different_from_original(): node = TriggerWordToggleLM() trigger_data = [ diff --git a/web/comfyui/lm_styles.css b/web/comfyui/lm_styles.css index 3d936e7b..b440d6d4 100644 --- a/web/comfyui/lm_styles.css +++ b/web/comfyui/lm_styles.css @@ -580,6 +580,38 @@ body.lm-lora-reordering * { margin: 6px 0; } +.lm-trigger-count-badge { + font-variant-numeric: tabular-nums; +} + +.lm-trigger-count-badge--edited { + color: rgba(226, 232, 240, 0.92); +} + +.lm-trigger-group-edit-button { + transition: opacity 0.18s ease, color 0.18s ease; +} + +.lm-trigger-group-edit-button:hover { + color: rgba(255, 255, 255, 0.98); +} + +.lm-trigger-group-editor { + background: rgba(26, 32, 44, 0.95); + border: 1px solid rgba(66, 153, 225, 0.24); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(6px); +} + +.lm-trigger-group-editor__title { + color: rgba(226, 232, 240, 0.96); +} + +.lm-trigger-group-editor__subtitle, +.lm-trigger-group-editor__empty { + color: rgba(226, 232, 240, 0.72); +} + /* Autocomplete styling */ .lm-autocomplete-name { flex: 1; diff --git a/web/comfyui/tags_widget.js b/web/comfyui/tags_widget.js index fe2dc445..453312ad 100644 --- a/web/comfyui/tags_widget.js +++ b/web/comfyui/tags_widget.js @@ -1,9 +1,574 @@ +import { app } from "../../scripts/app.js"; import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js"; const MIN_HEIGHT = 150; +const GROUP_EDITOR_ID = "lm-trigger-group-editor"; + +function isGroupTag(tagData) { + return Array.isArray(tagData?.items); +} + +function cloneTagData(tagData) { + if (!isGroupTag(tagData)) { + return { ...tagData }; + } + + return { + ...tagData, + items: tagData.items.map((item) => ({ ...item })), + }; +} + +function getGroupState(tagData) { + const items = Array.isArray(tagData?.items) ? tagData.items : []; + const hasChildren = items.length > 0; + const activeChildren = items.filter((item) => item.active).length; + const highlightedChildren = items.some((item) => item.highlighted); + + return { + hasChildren, + activeChildren, + totalChildren: items.length, + hasInactiveChildren: hasChildren && activeChildren < items.length, + highlighted: Boolean(tagData?.highlighted) || highlightedChildren, + }; +} + +function splitTopLevelCommas(text) { + if (typeof text !== "string" || !text.trim()) { + return []; + } + + const parts = []; + let current = ""; + let depth = 0; + + for (const char of text) { + if (char === "(") { + depth += 1; + current += char; + continue; + } + if (char === ")") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + if (char === "," && depth === 0) { + const trimmed = current.trim(); + if (trimmed) { + parts.push(trimmed); + } + current = ""; + continue; + } + current += char; + } + + const trimmed = current.trim(); + if (trimmed) { + parts.push(trimmed); + } + + return parts; +} + +function createStrengthBadge() { + const badge = document.createElement("span"); + badge.className = "comfy-tag-strength"; + Object.assign(badge.style, { + fontSize: "11px", + fontWeight: "600", + padding: "1px 6px", + borderRadius: "999px", + letterSpacing: "0.2px", + backgroundColor: "rgba(255,255,255,0.08)", + color: "rgba(255,255,255,0.95)", + border: "1px solid rgba(255,255,255,0.25)", + lineHeight: "normal", + minWidth: "34px", + textAlign: "center", + pointerEvents: "none", + opacity: "0", + visibility: "hidden", + transition: "opacity 0.2s ease", + }); + return badge; +} + +function formatStrengthValue(value) { + if (value === undefined || value === null) { + return null; + } + const num = Number(value); + if (!Number.isFinite(num)) { + return null; + } + return num.toFixed(2); +} + +function updateStrengthDisplay(tagEl, strength, baseText, showStrengthInfo) { + if (!showStrengthInfo) { + tagEl.title = baseText; + return; + } + const badge = tagEl.querySelector(".comfy-tag-strength"); + if (!badge) { + tagEl.title = baseText; + return; + } + const displayValue = strength === undefined || strength === null ? 1 : strength; + const formatted = formatStrengthValue(displayValue); + if (formatted !== null) { + badge.textContent = formatted; + badge.style.opacity = "1"; + badge.style.visibility = "visible"; + tagEl.title = `${baseText} (${formatted})`; + } else { + badge.textContent = ""; + badge.style.opacity = "0"; + badge.style.visibility = "hidden"; + tagEl.title = baseText; + } +} + +function ensureGroupEditorState(widget) { + if (!widget._groupEditorState) { + widget._groupEditorState = { + openIndex: null, + anchorEl: null, + panelEl: null, + closeHandlersAttached: false, + outsideClickHandler: null, + keydownHandler: null, + trackingFrame: null, + lastUiScale: null, + lastAnchorRect: null, + }; + } + return widget._groupEditorState; +} + +function getCanvasUiScale() { + const rawScale = Number(app?.canvas?.ds?.scale); + if (!Number.isFinite(rawScale) || rawScale <= 0) { + return 1; + } + return Math.min(1.35, Math.max(0.85, rawScale)); +} + +function roundScaled(value, scale) { + return Math.round(value * scale * 100) / 100; +} + +function getEditorMetrics(scale) { + return { + panelMinWidth: roundScaled(220, scale), + panelMaxWidth: roundScaled(360, scale), + panelMaxHeight: roundScaled(240, scale), + panelPadding: roundScaled(10, scale), + panelRadius: roundScaled(10, scale), + panelGap: roundScaled(8, scale), + headerGap: roundScaled(2, scale), + titleFontSize: roundScaled(12, scale), + subtitleFontSize: roundScaled(11, scale), + itemsGap: roundScaled(6, scale), + childTagFontSize: roundScaled(13, scale), + childTagPaddingY: roundScaled(3, scale), + childTagPaddingX: roundScaled(10, scale), + childTagMinHeight: roundScaled(22, scale), + childTagMaxWidth: roundScaled(200, scale), + childTagRadius: roundScaled(6, scale), + }; +} + +function getRectSnapshot(element) { + if (!element || !document.body.contains(element)) { + return null; + } + + const rect = element.getBoundingClientRect(); + return { + left: Math.round(rect.left * 100) / 100, + top: Math.round(rect.top * 100) / 100, + width: Math.round(rect.width * 100) / 100, + height: Math.round(rect.height * 100) / 100, + }; +} + +function closeGroupEditor(widget) { + const state = ensureGroupEditorState(widget); + state.openIndex = null; + state.anchorEl = null; + state.lastUiScale = null; + state.lastAnchorRect = null; + if (state.panelEl) { + state.panelEl.remove(); + state.panelEl = null; + } + if (state.trackingFrame) { + cancelAnimationFrame(state.trackingFrame); + state.trackingFrame = null; + } + if (state.closeHandlersAttached) { + document.removeEventListener("mousedown", state.outsideClickHandler, true); + document.removeEventListener("keydown", state.keydownHandler, true); + state.closeHandlersAttached = false; + } +} + +function attachGroupEditorCloseHandlers(widget) { + const state = ensureGroupEditorState(widget); + if (state.closeHandlersAttached) { + return; + } + + state.outsideClickHandler = (event) => { + const panel = state.panelEl; + const anchor = state.anchorEl; + if (panel?.contains(event.target) || anchor?.contains(event.target)) { + return; + } + closeGroupEditor(widget); + }; + + state.keydownHandler = (event) => { + if (event.key === "Escape") { + closeGroupEditor(widget); + } + }; + + document.addEventListener("mousedown", state.outsideClickHandler, true); + document.addEventListener("keydown", state.keydownHandler, true); + state.closeHandlersAttached = true; +} + +function updateWidgetValue(widget, updater) { + const nextValue = updater((widget.value || []).map(cloneTagData)); + widget.value = nextValue; +} + +function createTagElement({ + text, + active, + highlighted, + group = false, + mixed = false, + styleScale = 1, + textMaxWidth = null, +}) { + const tagEl = document.createElement("div"); + tagEl.className = "comfy-tag"; + tagEl.dataset.captureWheel = "true"; + + const baseStyles = { + padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`, + borderRadius: `${roundScaled(6, styleScale)}px`, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontSize: `${roundScaled(group ? 12 : 13, styleScale)}px`, + cursor: "pointer", + transition: "all 0.2s ease", + border: "1px solid transparent", + display: "inline-flex", + alignItems: "center", + gap: `${roundScaled(6, styleScale)}px`, + boxShadow: "0 1px 2px rgba(0,0,0,0.1)", + margin: "1px", + userSelect: "none", + WebkitUserSelect: "none", + MozUserSelect: "none", + msUserSelect: "none", + minHeight: `${roundScaled(group ? 26 : 22, styleScale)}px`, + boxSizing: "border-box", + width: "fit-content", + maxWidth: `${roundScaled(group ? 260 : 200, styleScale)}px`, + lineHeight: `${roundScaled(16, styleScale)}px`, + verticalAlign: "middle", + textAlign: "center", + }; + + const highlightStyles = highlighted + ? { + boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.35), 0 1px 2px rgba(0,0,0,0.15)", + borderColor: "rgba(246, 224, 94, 0.8)", + backgroundImage: "linear-gradient(120deg, rgba(255,255,255,0.08), rgba(255,255,255,0))", + } + : { + boxShadow: "0 1px 2px rgba(0,0,0,0.1)", + backgroundImage: "none", + }; + + Object.assign(tagEl.style, { + ...baseStyles, + ...(active + ? { + backgroundColor: "rgba(66, 153, 225, 0.9)", + color: "white", + borderColor: "rgba(66, 153, 225, 0.9)", + } + : { + backgroundColor: "rgba(45, 55, 72, 0.7)", + color: "rgba(226, 232, 240, 0.8)", + borderColor: "rgba(226, 232, 240, 0.2)", + }), + ...highlightStyles, + }); + + tagEl.onmouseenter = () => { + tagEl.style.transform = "translateY(-1px)"; + tagEl.dataset.prevBoxShadow = tagEl.style.boxShadow || ""; + tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)"; + }; + + tagEl.onmouseleave = () => { + tagEl.style.transform = "translateY(0)"; + tagEl.style.boxShadow = tagEl.dataset.prevBoxShadow || "0 1px 2px rgba(0,0,0,0.1)"; + }; + + tagEl.dataset.active = active ? "true" : "false"; + tagEl.dataset.highlighted = highlighted ? "true" : "false"; + tagEl.dataset.mixed = mixed ? "true" : "false"; + tagEl.dataset.group = group ? "true" : "false"; + + if (text) { + const textSpan = document.createElement("span"); + textSpan.className = "comfy-tag-text"; + textSpan.textContent = text; + Object.assign(textSpan.style, { + display: "inline-block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + minWidth: "0", + flexGrow: "1", + maxWidth: textMaxWidth !== null + ? `${roundScaled(textMaxWidth, styleScale)}px` + : group + ? `${roundScaled(170, styleScale)}px` + : "inherit", + }); + tagEl.appendChild(textSpan); + } + + return tagEl; +} + +function positionGroupEditor(widget) { + const state = ensureGroupEditorState(widget); + if (!state.panelEl || !state.anchorEl || !document.body.contains(state.anchorEl)) { + closeGroupEditor(widget); + return; + } + + const anchorRect = state.anchorEl.getBoundingClientRect(); + const panel = state.panelEl; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const panelRect = panel.getBoundingClientRect(); + + let left = anchorRect.left; + let top = anchorRect.bottom + roundScaled(8, getCanvasUiScale()); + + if (left + panelRect.width > viewportWidth - 8) { + left = Math.max(8, viewportWidth - panelRect.width - 8); + } + + if (top + panelRect.height > viewportHeight - 8) { + top = Math.max(8, anchorRect.top - panelRect.height - 8); + } + + panel.style.left = `${left}px`; + panel.style.top = `${top}px`; + state.lastAnchorRect = getRectSnapshot(state.anchorEl); +} + +function startGroupEditorTracking(widget) { + const state = ensureGroupEditorState(widget); + if (state.trackingFrame) { + return; + } + + const tick = () => { + state.trackingFrame = null; + + if (state.openIndex === null || !state.anchorEl || !state.panelEl) { + return; + } + + const nextScale = getCanvasUiScale(); + const nextRect = getRectSnapshot(state.anchorEl); + if (!nextRect) { + closeGroupEditor(widget); + return; + } + + const scaleChanged = state.lastUiScale === null || Math.abs(nextScale - state.lastUiScale) > 0.001; + const rectChanged = + !state.lastAnchorRect || + nextRect.left !== state.lastAnchorRect.left || + nextRect.top !== state.lastAnchorRect.top || + nextRect.width !== state.lastAnchorRect.width || + nextRect.height !== state.lastAnchorRect.height; + + if (scaleChanged) { + const currentTag = (widget.value || [])[state.openIndex]; + if (!currentTag || !isGroupTag(currentTag)) { + closeGroupEditor(widget); + return; + } + renderGroupEditor(widget, currentTag, state.openIndex); + } else if (rectChanged) { + positionGroupEditor(widget); + } + + if (state.openIndex !== null && state.anchorEl && state.panelEl) { + state.trackingFrame = requestAnimationFrame(tick); + } + }; + + state.trackingFrame = requestAnimationFrame(tick); +} + +function renderGroupEditor(widget, tagData, index) { + const state = ensureGroupEditorState(widget); + if (state.openIndex !== index || !state.anchorEl) { + closeGroupEditor(widget); + return; + } + + if (state.panelEl) { + state.panelEl.remove(); + } + + const uiScale = getCanvasUiScale(); + const metrics = getEditorMetrics(uiScale); + + const panel = document.createElement("div"); + panel.id = GROUP_EDITOR_ID; + panel.className = "lm-trigger-group-editor"; + Object.assign(panel.style, { + position: "fixed", + zIndex: "10001", + minWidth: `${metrics.panelMinWidth}px`, + maxWidth: `${metrics.panelMaxWidth}px`, + maxHeight: `${metrics.panelMaxHeight}px`, + overflowY: "auto", + padding: `${metrics.panelPadding}px`, + borderRadius: `${metrics.panelRadius}px`, + border: "1px solid rgba(255,255,255,0.15)", + background: "rgba(22, 26, 32, 0.96)", + boxShadow: "0 10px 30px rgba(0,0,0,0.35)", + backdropFilter: "blur(8px)", + display: "flex", + flexDirection: "column", + gap: `${metrics.panelGap}px`, + }); + + const header = document.createElement("div"); + header.className = "lm-trigger-group-editor__header"; + Object.assign(header.style, { + display: "flex", + flexDirection: "column", + gap: `${metrics.headerGap}px`, + minWidth: "0", + }); + + const title = document.createElement("div"); + title.className = "lm-trigger-group-editor__title"; + title.textContent = tagData.text; + Object.assign(title.style, { + color: "rgba(255,255,255,0.96)", + fontSize: `${metrics.titleFontSize}px`, + fontWeight: "600", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + + const groupState = getGroupState(tagData); + const subtitle = document.createElement("div"); + subtitle.className = "lm-trigger-group-editor__subtitle"; + subtitle.textContent = `${groupState.activeChildren}/${groupState.totalChildren} active`; + Object.assign(subtitle.style, { + color: "rgba(226,232,240,0.72)", + fontSize: `${metrics.subtitleFontSize}px`, + }); + header.appendChild(title); + header.appendChild(subtitle); + panel.appendChild(header); + + const itemsWrap = document.createElement("div"); + itemsWrap.className = "lm-trigger-group-editor__items"; + Object.assign(itemsWrap.style, { + display: "flex", + flexWrap: "wrap", + gap: `${metrics.itemsGap}px`, + }); + + tagData.items.forEach((item, itemIndex) => { + const childEl = createTagElement({ + text: item.text, + active: item.active, + highlighted: item.highlighted, + group: false, + mixed: false, + styleScale: uiScale, + textMaxWidth: metrics.childTagMaxWidth, + }); + childEl.title = item.text; + childEl.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + updateWidgetValue(widget, (updatedTags) => { + updatedTags[index].items[itemIndex].active = !updatedTags[index].items[itemIndex].active; + return updatedTags; + }); + }); + itemsWrap.appendChild(childEl); + }); + + if (!tagData.items.length) { + const emptyHint = document.createElement("div"); + emptyHint.className = "lm-trigger-group-editor__empty"; + emptyHint.textContent = "No tags in this group"; + Object.assign(emptyHint.style, { + fontSize: `${metrics.titleFontSize}px`, + opacity: "0.7", + fontStyle: "italic", + color: "rgba(226,232,240,0.75)", + }); + itemsWrap.appendChild(emptyHint); + } + + panel.appendChild(itemsWrap); + document.body.appendChild(panel); + + state.panelEl = panel; + state.lastUiScale = uiScale; + state.lastAnchorRect = getRectSnapshot(state.anchorEl); + attachGroupEditorCloseHandlers(widget); + positionGroupEditor(widget); + startGroupEditorTracking(widget); +} + +function openGroupEditor(widget, index, anchorEl) { + const state = ensureGroupEditorState(widget); + state.openIndex = index; + state.anchorEl = anchorEl; +} + +function toggleGroupEditor(widget, index, anchorEl) { + const state = ensureGroupEditorState(widget); + if (state.openIndex === index && state.panelEl) { + closeGroupEditor(widget); + return; + } + openGroupEditor(widget, index, anchorEl); +} export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) { - // Create container for tags const container = document.createElement("div"); container.className = "comfy-tags-container"; @@ -15,7 +580,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0 Object.assign(container.style, { display: "flex", flexWrap: "wrap", - gap: "4px", + gap: "6px", padding: "6px", backgroundColor: "rgba(40, 44, 52, 0.6)", borderRadius: "6px", @@ -24,24 +589,190 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0 boxSizing: "border-box", overflow: "auto", alignItems: "flex-start", - alignContent: "flex-start" + alignContent: "flex-start", }); - // Initialize default value as array const initialTagsData = opts?.defaultVal || []; - // Function to render tags from array data + function renderSimpleTag(tagData, index, widget, showStrengthInfo) { + const { text, active, highlighted, strength } = tagData; + const tagEl = createTagElement({ + text, + active, + highlighted, + group: false, + mixed: false, + }); + + const strengthBadge = showStrengthInfo ? createStrengthBadge() : null; + if (strengthBadge) { + tagEl.appendChild(strengthBadge); + } + updateStrengthDisplay(tagEl, strength, text, showStrengthInfo); + + tagEl.addEventListener("click", (e) => { + e.stopPropagation(); + updateWidgetValue(widget, (updatedTags) => { + updatedTags[index].active = !updatedTags[index].active; + return updatedTags; + }); + }); + + if (showStrengthInfo) { + tagEl.addEventListener("wheel", (e) => { + e.preventDefault(); + e.stopPropagation(); + + updateWidgetValue(widget, (updatedTags) => { + let currentStrength = updatedTags[index].strength; + if (currentStrength === undefined || currentStrength === null) { + currentStrength = 1.0; + } + + currentStrength += e.deltaY < 0 ? wheelSensitivity : -wheelSensitivity; + updatedTags[index].strength = Math.max(0, currentStrength); + return updatedTags; + }); + }); + } + + return tagEl; + } + + function renderGroupTag(tagData, index, widget, showStrengthInfo) { + const groupState = getGroupState(tagData); + const groupChip = createTagElement({ + text: tagData.text, + active: tagData.active, + highlighted: groupState.highlighted, + group: true, + mixed: groupState.hasInactiveChildren, + }); + Object.assign(groupChip.style, { + maxWidth: "280px", + paddingRight: "6px", + }); + + const textEl = groupChip.querySelector(".comfy-tag-text"); + if (textEl) { + textEl.style.maxWidth = "140px"; + } + + const countBadge = document.createElement("span"); + countBadge.className = "lm-trigger-count-badge"; + countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`; + Object.assign(countBadge.style, { + fontSize: "11px", + padding: "1px 6px", + borderRadius: "999px", + backgroundColor: "rgba(255,255,255,0.12)", + color: "inherit", + flexShrink: "0", + boxSizing: "border-box", + minWidth: "42px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + lineHeight: "1", + fontVariantNumeric: "tabular-nums", + }); + if (groupState.hasInactiveChildren) { + countBadge.classList.add("lm-trigger-count-badge--edited"); + Object.assign(countBadge.style, { + backgroundColor: "rgba(255,255,255,0.08)", + boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)", + }); + } + groupChip.appendChild(countBadge); + + if (showStrengthInfo) { + const strengthBadge = createStrengthBadge(); + groupChip.appendChild(strengthBadge); + updateStrengthDisplay(groupChip, tagData.strength, tagData.text, showStrengthInfo); + } else { + const activePreview = tagData.items + .filter((item) => item.active) + .map((item) => item.text) + .join(", "); + groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text; + } + + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.className = "lm-trigger-group-edit-button"; + editButton.textContent = "⋯"; + Object.assign(editButton.style, { + border: "none", + background: "transparent", + color: "inherit", + cursor: "pointer", + fontSize: "14px", + lineHeight: "1", + padding: "0 2px", + marginLeft: "2px", + opacity: groupState.hasInactiveChildren ? "0.9" : "0.72", + flexShrink: "0", + }); + editButton.title = "Edit group tags"; + + const openEditor = (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleGroupEditor(widget, index, groupChip); + renderGroupEditor(widget, tagData, index); + }; + + editButton.addEventListener("click", openEditor); + groupChip.addEventListener("contextmenu", openEditor); + + groupChip.appendChild(editButton); + + groupChip.addEventListener("click", (e) => { + e.stopPropagation(); + if (e.target === editButton) { + return; + } + updateWidgetValue(widget, (updatedTags) => { + updatedTags[index].active = !updatedTags[index].active; + return updatedTags; + }); + }); + + if (showStrengthInfo) { + groupChip.addEventListener("wheel", (e) => { + if (e.target === editButton) { + return; + } + e.preventDefault(); + e.stopPropagation(); + + updateWidgetValue(widget, (updatedTags) => { + let currentStrength = updatedTags[index].strength; + if (currentStrength === undefined || currentStrength === null) { + currentStrength = 1.0; + } + + currentStrength += e.deltaY < 0 ? wheelSensitivity : -wheelSensitivity; + updatedTags[index].strength = Math.max(0, currentStrength); + return updatedTags; + }); + }); + } + + return groupChip; + } + const renderTags = (tagsData, widget) => { - // Clear existing tags while (container.firstChild) { container.removeChild(container.firstChild); } - const normalizedTags = tagsData; + const normalizedTags = Array.isArray(tagsData) ? tagsData : []; const showStrengthInfo = widget.allowStrengthAdjustment ?? allowStrengthAdjustment; + const groupAnchors = new Map(); if (normalizedTags.length === 0) { - // Show message when no tags are present + closeGroupEditor(widget); const emptyMessage = document.createElement("div"); emptyMessage.textContent = "No trigger words detected"; Object.assign(emptyMessage.style, { @@ -53,223 +784,37 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0 WebkitUserSelect: "none", MozUserSelect: "none", msUserSelect: "none", - width: "100%" + width: "100%", }); container.appendChild(emptyMessage); return; } normalizedTags.forEach((tagData, index) => { - const { text, active, highlighted, strength } = tagData; - const tagEl = document.createElement("div"); - tagEl.className = "comfy-tag"; - tagEl.dataset.captureWheel = "true"; - - const textSpan = document.createElement("span"); - textSpan.className = "comfy-tag-text"; - textSpan.textContent = text; - Object.assign(textSpan.style, { - display: "inline-block", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - minWidth: "0", - flexGrow: "1", - }); - tagEl.appendChild(textSpan); - - const strengthBadge = showStrengthInfo ? document.createElement("span") : null; - if (strengthBadge) { - strengthBadge.className = "comfy-tag-strength"; - Object.assign(strengthBadge.style, { - fontSize: "11px", - fontWeight: "600", - padding: "1px 6px", - borderRadius: "999px", - letterSpacing: "0.2px", - backgroundColor: "rgba(255,255,255,0.08)", - color: "rgba(255,255,255,0.95)", - border: "1px solid rgba(255,255,255,0.25)", - lineHeight: "normal", - minWidth: "34px", - textAlign: "center", - pointerEvents: "none", - opacity: "0", - visibility: "hidden", - transition: "opacity 0.2s ease", - }); - tagEl.appendChild(strengthBadge); - } - - updateTagStyle(tagEl, active, highlighted, strength); - updateStrengthDisplay(tagEl, strength, text, showStrengthInfo); - - // Add click handler to toggle state - tagEl.addEventListener("click", (e) => { - e.stopPropagation(); - - const updatedTags = [...widget.value]; - updatedTags[index].active = !updatedTags[index].active; - textSpan.textContent = updatedTags[index].text; - updateTagStyle( - tagEl, - updatedTags[index].active, - updatedTags[index].highlighted, - updatedTags[index].strength - ); - updateStrengthDisplay(tagEl, updatedTags[index].strength, updatedTags[index].text); - - tagEl.dataset.active = updatedTags[index].active ? "true" : "false"; - tagEl.dataset.highlighted = updatedTags[index].highlighted ? "true" : "false"; - - widget.value = updatedTags; - }); - - // Add mouse wheel handler to adjust strength - if (showStrengthInfo) { - tagEl.addEventListener("wheel", (e) => { - e.preventDefault(); - e.stopPropagation(); - - const updatedTags = [...widget.value]; - let currentStrength = updatedTags[index].strength; - - if (currentStrength === undefined || currentStrength === null) { - currentStrength = 1.0; - } - - if (e.deltaY < 0) { - currentStrength += wheelSensitivity; - } else { - currentStrength -= wheelSensitivity; - } - - currentStrength = Math.max(0, currentStrength); - updatedTags[index].strength = currentStrength; - textSpan.textContent = updatedTags[index].text; - - updateStrengthDisplay(tagEl, currentStrength, updatedTags[index].text, showStrengthInfo); - - widget.value = updatedTags; - }); - } - + const tagEl = isGroupTag(tagData) + ? renderGroupTag(tagData, index, widget, showStrengthInfo) + : renderSimpleTag(tagData, index, widget, showStrengthInfo); container.appendChild(tagEl); + if (isGroupTag(tagData)) { + groupAnchors.set(index, tagEl); + } }); + + const state = ensureGroupEditorState(widget); + if (state.openIndex !== null) { + const activeGroup = normalizedTags[state.openIndex]; + const anchorEl = groupAnchors.get(state.openIndex); + if (!activeGroup || !anchorEl || !isGroupTag(activeGroup)) { + closeGroupEditor(widget); + } else { + state.anchorEl = anchorEl; + renderGroupEditor(widget, activeGroup, state.openIndex); + } + } else if (state.panelEl) { + closeGroupEditor(widget); + } }; - // Helper function to update tag style based on active state - function updateTagStyle(tagEl, active, highlighted = false, strength = null) { - const baseStyles = { - padding: "3px 10px", - borderRadius: "6px", - maxWidth: "200px", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - fontSize: "13px", - cursor: "pointer", - transition: "all 0.2s ease", - border: "1px solid transparent", - display: "inline-flex", - alignItems: "center", - gap: "6px", - boxShadow: "0 1px 2px rgba(0,0,0,0.1)", - margin: "1px", - userSelect: "none", - WebkitUserSelect: "none", - MozUserSelect: "none", - msUserSelect: "none", - height: "22px", - minHeight: "22px", - boxSizing: "border-box", - width: "fit-content", - maxWidth: "200px", - lineHeight: "16px", - verticalAlign: "middle", - textAlign: "center", - }; - - const highlightStyles = highlighted - ? { - boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.35), 0 1px 2px rgba(0,0,0,0.15)", - borderColor: "rgba(246, 224, 94, 0.8)", - backgroundImage: "linear-gradient(120deg, rgba(255,255,255,0.08), rgba(255,255,255,0))", - } - : { - boxShadow: "0 1px 2px rgba(0,0,0,0.1)", - backgroundImage: "none", - }; - - if (active) { - Object.assign(tagEl.style, { - ...baseStyles, - backgroundColor: "rgba(66, 153, 225, 0.9)", - color: "white", - borderColor: "rgba(66, 153, 225, 0.9)", - ...highlightStyles, - }); - } else { - Object.assign(tagEl.style, { - ...baseStyles, - backgroundColor: "rgba(45, 55, 72, 0.7)", - color: "rgba(226, 232, 240, 0.8)", - borderColor: "rgba(226, 232, 240, 0.2)", - ...highlightStyles, - }); - } - - tagEl.onmouseenter = () => { - tagEl.style.transform = "translateY(-1px)"; - tagEl.dataset.prevBoxShadow = tagEl.style.boxShadow || ""; - tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)"; - }; - - tagEl.onmouseleave = () => { - tagEl.style.transform = "translateY(0)"; - tagEl.style.boxShadow = tagEl.dataset.prevBoxShadow || "0 1px 2px rgba(0,0,0,0.1)"; - }; - - tagEl.dataset.active = active ? "true" : "false"; - tagEl.dataset.highlighted = highlighted ? "true" : "false"; - } - - function formatStrengthValue(value) { - if (value === undefined || value === null) { - return null; - } - const num = Number(value); - if (!Number.isFinite(num)) { - return null; - } - return num.toFixed(2); - } - - function updateStrengthDisplay(tagEl, strength, baseText, showStrengthInfo) { - if (!showStrengthInfo) { - tagEl.title = baseText; - return; - } - const badge = tagEl.querySelector(".comfy-tag-strength"); - if (!badge) { - tagEl.title = baseText; - return; - } - const displayValue = strength === undefined || strength === null ? 1 : strength; - const formatted = formatStrengthValue(displayValue); - if (formatted !== null) { - badge.textContent = formatted; - badge.style.opacity = "1"; - badge.style.visibility = "visible"; - tagEl.title = `${baseText} (${formatted})`; - } else { - badge.textContent = ""; - badge.style.opacity = "0"; - badge.style.visibility = "hidden"; - tagEl.title = baseText; - } - } - let widgetValue = initialTagsData; const widget = node.addDOMWidget(name, "custom", container, { @@ -277,20 +822,19 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0 return widgetValue; }, setValue: function(v) { - widgetValue = v; + widgetValue = Array.isArray(v) ? v : []; renderTags(widgetValue, widget); }, getMinHeight: () => MIN_HEIGHT, hideOnZoom: true, - selectOn: ['click', 'focus'] + selectOn: ["click", "focus"], }); widget.value = initialTagsData; widget.callback = callback; - - widget.serializeValue = () => { - return widgetValue - }; + widget.serializeValue = () => widgetValue; + widget.splitTopLevelCommas = splitTopLevelCommas; + widget.closeGroupEditor = () => closeGroupEditor(widget); return { minWidth: 300, minHeight: MIN_HEIGHT, widget }; } diff --git a/web/comfyui/trigger_word_toggle.js b/web/comfyui/trigger_word_toggle.js index 40c90231..5e9533bd 100644 --- a/web/comfyui/trigger_word_toggle.js +++ b/web/comfyui/trigger_word_toggle.js @@ -4,294 +4,443 @@ import { CONVERTED_TYPE, getNodeFromGraph } from "./utils.js"; import { addTagsWidget } from "./tags_widget.js"; import { getWheelSensitivity } from "./settings.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, graph_id: graphId, message } = event.detail; - this.handleTriggerWordUpdate(id, graphId, message); - }); - }, - - async nodeCreated(node) { - if (node.comfyClass === "TriggerWord Toggle (LoraManager)") { - // Enable widget serialization - node.serialize_widgets = true; - - node.addInput("trigger_words", 'string', { - "shape": 7 // 7 is the shape of the optional input - }); +function normalizeTagText(text) { + return typeof text === "string" ? text.trim().toLowerCase() : ""; +} - // Wait for node to be properly initialized - requestAnimationFrame(async () => { - // Get the wheel sensitivity setting - const wheelSensitivity = getWheelSensitivity(); - const groupModeWidget = node.widgets[0]; - const defaultActiveWidget = node.widgets[1]; - const strengthAdjustmentWidget = node.widgets[2]; - const initialStrengthAdjustment = Boolean(strengthAdjustmentWidget?.value); - - // Get the widget object directly from the returned object - const result = addTagsWidget(node, "toggle_trigger_words", { - defaultVal: [] - }, null, wheelSensitivity, { - allowStrengthAdjustment: initialStrengthAdjustment - }); - - node.tagWidget = result.widget; - node.tagWidget.allowStrengthAdjustment = initialStrengthAdjustment; +function splitTopLevelCommas(text) { + if (typeof text !== "string" || !text.trim()) { + return []; + } - const normalizeTagText = (text) => - (typeof text === 'string' ? text.trim().toLowerCase() : ''); + const parts = []; + let current = ""; + let depth = 0; - const collectHighlightTokens = (wordsArray) => { - const tokens = new Set(); - - const addToken = (text) => { - const normalized = normalizeTagText(text); - if (normalized) { - tokens.add(normalized); - } - }; - - wordsArray.forEach((rawWord) => { - if (typeof rawWord !== 'string') { - return; - } - - addToken(rawWord); - - const groupParts = rawWord.split(/,{2,}/); - groupParts.forEach((groupPart) => { - addToken(groupPart); - groupPart.split(',').forEach(addToken); - }); - - rawWord.split(',').forEach(addToken); - }); - - return tokens; - }; - - const applyHighlightState = () => { - if (!node.tagWidget) return; - const highlightSet = node._highlightedTriggerWords || new Set(); - const updatedTags = (node.tagWidget.value || []).map(tag => ({ - ...tag, - highlighted: highlightSet.size > 0 && highlightSet.has(normalizeTagText(tag.text)) - })); - node.tagWidget.value = updatedTags; - }; - - node.highlightTriggerWords = (triggerWords) => { - const wordsArray = Array.isArray(triggerWords) - ? triggerWords - : triggerWords - ? [triggerWords] - : []; - node._highlightedTriggerWords = collectHighlightTokens(wordsArray); - applyHighlightState(); - }; - - if (node.__pendingHighlightWords !== undefined) { - const pending = node.__pendingHighlightWords; - delete node.__pendingHighlightWords; - node.highlightTriggerWords(pending); - } - - node.applyTriggerHighlightState = applyHighlightState; - - // Add hidden widget to store original message - const hiddenWidget = node.addWidget('text', 'orinalMessage', ''); - hiddenWidget.type = CONVERTED_TYPE; - hiddenWidget.hidden = true; - hiddenWidget.computeSize = () => [0, -4]; - node.originalMessageWidget = hiddenWidget; - - // Restore saved value if exists - const tagWidgetIndex = node.widgets.indexOf(result.widget); - const originalMessageWidgetIndex = node.widgets.indexOf(hiddenWidget); - if (node.widgets_values && node.widgets_values.length > 0) { - if (tagWidgetIndex >= 0) { - const savedValue = node.widgets_values[tagWidgetIndex]; - if (savedValue) { - result.widget.value = Array.isArray(savedValue) ? savedValue : []; - } - } - if (originalMessageWidgetIndex >= 0) { - const originalMessage = node.widgets_values[originalMessageWidgetIndex]; - if (originalMessage) { - hiddenWidget.value = originalMessage; - } - } - } - - requestAnimationFrame(() => node.applyTriggerHighlightState?.()); - - groupModeWidget.callback = (value) => { - if (node.originalMessageWidget?.value) { - this.updateTagsBasedOnMode( - node, - node.originalMessageWidget.value, - value, - Boolean(strengthAdjustmentWidget?.value) - ); - } - } - - // Add callback for default_active widget - defaultActiveWidget.callback = (value) => { - // Set all existing tags' active state to the new value - if (node.tagWidget && node.tagWidget.value) { - const updatedTags = node.tagWidget.value.map(tag => ({ - ...tag, - active: value - })); - node.tagWidget.value = updatedTags; - node.applyTriggerHighlightState?.(); - } - } - - if (strengthAdjustmentWidget) { - strengthAdjustmentWidget.callback = (value) => { - const allowStrengthAdjustment = Boolean(value); - if (node.tagWidget) { - node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; - } - this.updateTagsBasedOnMode( - node, - node.originalMessageWidget?.value || "", - groupModeWidget?.value ?? false, - allowStrengthAdjustment - ); - }; - } - - // Override the serializeValue method to properly format trigger words with strength - const originalSerializeValue = result.widget.serializeValue; - result.widget.serializeValue = function() { - const value = this.value || []; - // Transform the values to include strength in the proper format - const transformedValue = value.map(tag => { - // If strength is defined (even if it's 1.0), format as {text: "(original_text:strength)", ...} - if (tag.strength !== undefined && tag.strength !== null) { - return { - ...tag, - text: `(${tag.text}:${tag.strength.toFixed(2)})` - }; - } - return tag; - }); - return transformedValue; - }; - }); - } - }, - - // Handle trigger word updates from Python - handleTriggerWordUpdate(id, graphId, message) { - const node = getNodeFromGraph(graphId, id); - if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") { - console.warn("Node not found or not a TriggerWordToggle:", id); - return; - } - - // Store the original message for mode switching - if (node.originalMessageWidget) { - node.originalMessageWidget.value = message; - } - - if (node.tagWidget) { - // Parse tags based on current group mode - const groupMode = node.widgets[0] ? node.widgets[0].value : false; - const allowStrengthAdjustment = Boolean(node.widgets[2]?.value); - node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; - this.updateTagsBasedOnMode(node, message, groupMode, allowStrengthAdjustment); - } - }, - - // Update tags display based on group mode - updateTagsBasedOnMode(node, message, groupMode, allowStrengthAdjustment = false) { - if (!node.tagWidget) return; - node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; - - const existingTags = node.tagWidget.value || []; - const existingTagState = existingTags.reduce((acc, tag) => { - const key = tag.text; - if (!acc[key]) { - acc[key] = []; - } - acc[key].push({ - active: tag.active, - strength: allowStrengthAdjustment ? tag.strength : null, - }); - return acc; - }, {}); - const consumeExistingState = (text) => { - const states = existingTagState[text]; - if (states && states.length > 0) { - return states.shift(); - } - return null; - }; - - // Get default active state from the widget - const defaultActive = node.widgets[1] ? node.widgets[1].value : true; - - let tagArray = []; - - if (groupMode) { - if (message.trim() === '') { - tagArray = []; - } - // Group mode: split by ',,' and treat each group as a single tag - else if (message.includes(',,')) { - const groups = message.split(/,{2,}/); // Match 2 or more consecutive commas - tagArray = groups - .map(group => group.trim()) - .filter(group => group) - .map(group => { - // Check if this group already exists with strength info - const existing = consumeExistingState(group); - return { - text: group, - // Use existing values if available, otherwise use defaults - active: existing ? existing.active : defaultActive, - strength: existing ? existing.strength : null - }; - }); - } else { - // If no ',,' delimiter, treat the entire message as one group - const existing = consumeExistingState(message.trim()); - tagArray = [{ - text: message.trim(), - // Use existing values if available, otherwise use defaults - active: existing ? existing.active : defaultActive, - strength: existing ? existing.strength : null - }]; - } - } else { - // Normal mode: split by commas and treat each word as a separate tag - tagArray = message - .split(',') - .map(word => word.trim()) - .filter(word => word) - .map(word => { - // Check if this word already exists with strength info - const existing = consumeExistingState(word); - return { - text: word, - // Use existing values if available, otherwise use defaults - active: existing ? existing.active : defaultActive, - strength: existing ? existing.strength : null - }; - }); + for (const char of text) { + if (char === "(") { + depth += 1; + current += char; + continue; } - + if (char === ")") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + if (char === "," && depth === 0) { + const trimmed = current.trim(); + if (trimmed) { + parts.push(trimmed); + } + current = ""; + continue; + } + current += char; + } + + const trimmed = current.trim(); + if (trimmed) { + parts.push(trimmed); + } + + return parts; +} + +function isGroupTag(tag) { + return Array.isArray(tag?.items); +} + +function parseSerializedText(text) { + const normalizedText = typeof text === "string" ? text.trim() : ""; + const strengthMatch = normalizedText.match(/^\((.+):([\d.]+)\)$/); + if (!strengthMatch) { + return { + text: normalizedText, + strength: null, + }; + } + + const parsedStrength = Number(strengthMatch[2]); + return { + text: strengthMatch[1].trim(), + strength: Number.isFinite(parsedStrength) ? parsedStrength : null, + }; +} + +function cloneTag(tag) { + if (!isGroupTag(tag)) { + return { ...tag }; + } + return { + ...tag, + items: tag.items.map((item) => ({ ...item })), + }; +} + +function collectHighlightTokens(wordsArray) { + const tokens = new Set(); + + const addToken = (text) => { + const normalized = normalizeTagText(text); + if (normalized) { + tokens.add(normalized); + } + }; + + wordsArray.forEach((rawWord) => { + if (typeof rawWord !== "string") { + return; + } + + addToken(rawWord); + + const groupParts = rawWord.split(/,{2,}/); + groupParts.forEach((groupPart) => { + addToken(groupPart); + splitTopLevelCommas(groupPart).forEach(addToken); + }); + + splitTopLevelCommas(rawWord).forEach(addToken); + }); + + return tokens; +} + +function buildLegacyTagState(existingTags, allowStrengthAdjustment) { + return existingTags.reduce((acc, tag) => { + const parsed = parseSerializedText(tag.text); + const key = parsed.text; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push({ + active: tag.active, + strength: + allowStrengthAdjustment + ? (tag.strength !== undefined && tag.strength !== null ? tag.strength : parsed.strength) + : null, + }); + return acc; + }, {}); +} + +function buildGroupState(existingTags, allowStrengthAdjustment) { + return existingTags.reduce((acc, tag) => { + const parsed = parseSerializedText(tag.text); + const key = parsed.text; + if (!acc[key]) { + acc[key] = []; + } + + const itemState = {}; + if (Array.isArray(tag.items)) { + tag.items.forEach((item) => { + const itemKey = item.text; + if (!itemState[itemKey]) { + itemState[itemKey] = []; + } + itemState[itemKey].push({ + active: item.active, + }); + }); + } else { + splitTopLevelCommas(tag.text).forEach((itemText) => { + if (!itemState[itemText]) { + itemState[itemText] = []; + } + itemState[itemText].push({ + active: tag.active, + }); + }); + } + + acc[key].push({ + active: tag.active, + strength: + allowStrengthAdjustment + ? (tag.strength !== undefined && tag.strength !== null ? tag.strength : parsed.strength) + : null, + itemState, + }); + return acc; + }, {}); +} + +function consumeQueuedState(stateMap, key) { + const queue = stateMap[key]; + if (queue && queue.length > 0) { + return queue.shift(); + } + return null; +} + +app.registerExtension({ + name: "LoraManager.TriggerWordToggle", + + setup() { + api.addEventListener("trigger_word_update", (event) => { + const { id, graph_id: graphId, message } = event.detail; + this.handleTriggerWordUpdate(id, graphId, message); + }); + }, + + async nodeCreated(node) { + if (node.comfyClass !== "TriggerWord Toggle (LoraManager)") { + return; + } + + node.serialize_widgets = true; + node.addInput("trigger_words", "string", { + shape: 7, + }); + + requestAnimationFrame(async () => { + const wheelSensitivity = getWheelSensitivity(); + const groupModeWidget = node.widgets[0]; + const defaultActiveWidget = node.widgets[1]; + const strengthAdjustmentWidget = node.widgets[2]; + const initialStrengthAdjustment = Boolean(strengthAdjustmentWidget?.value); + + const result = addTagsWidget(node, "toggle_trigger_words", { + defaultVal: [], + }, null, wheelSensitivity, { + allowStrengthAdjustment: initialStrengthAdjustment, + }); + + node.tagWidget = result.widget; + node.tagWidget.allowStrengthAdjustment = initialStrengthAdjustment; + + const applyHighlightState = () => { + if (!node.tagWidget) { + return; + } + + const highlightSet = node._highlightedTriggerWords || new Set(); + const updatedTags = (node.tagWidget.value || []).map((tag) => { + if (Array.isArray(tag.items)) { + const items = tag.items.map((item) => ({ + ...item, + highlighted: highlightSet.size > 0 && highlightSet.has(normalizeTagText(item.text)), + })); + + return { + ...tag, + items, + highlighted: + highlightSet.size > 0 && + (highlightSet.has(normalizeTagText(tag.text)) || + items.some((item) => item.highlighted)), + }; + } + + return { + ...tag, + highlighted: highlightSet.size > 0 && highlightSet.has(normalizeTagText(tag.text)), + }; + }); + + node.tagWidget.value = updatedTags; + }; + + node.highlightTriggerWords = (triggerWords) => { + const wordsArray = Array.isArray(triggerWords) + ? triggerWords + : triggerWords + ? [triggerWords] + : []; + node._highlightedTriggerWords = collectHighlightTokens(wordsArray); + applyHighlightState(); + }; + + if (node.__pendingHighlightWords !== undefined) { + const pending = node.__pendingHighlightWords; + delete node.__pendingHighlightWords; + node.highlightTriggerWords(pending); + } + + node.applyTriggerHighlightState = applyHighlightState; + + const hiddenWidget = node.addWidget("text", "orinalMessage", ""); + hiddenWidget.type = CONVERTED_TYPE; + hiddenWidget.hidden = true; + hiddenWidget.computeSize = () => [0, -4]; + node.originalMessageWidget = hiddenWidget; + + const tagWidgetIndex = node.widgets.indexOf(result.widget); + const originalMessageWidgetIndex = node.widgets.indexOf(hiddenWidget); + if (node.widgets_values && node.widgets_values.length > 0) { + if (tagWidgetIndex >= 0) { + const savedValue = node.widgets_values[tagWidgetIndex]; + if (savedValue) { + result.widget.value = Array.isArray(savedValue) ? savedValue : []; + } + } + if (originalMessageWidgetIndex >= 0) { + const originalMessage = node.widgets_values[originalMessageWidgetIndex]; + if (originalMessage) { + hiddenWidget.value = originalMessage; + } + } + } + + requestAnimationFrame(() => node.applyTriggerHighlightState?.()); + + groupModeWidget.callback = (value) => { + node.tagWidget?.closeGroupEditor?.(); + if (node.originalMessageWidget?.value) { + this.updateTagsBasedOnMode( + node, + node.originalMessageWidget.value, + value, + Boolean(strengthAdjustmentWidget?.value) + ); + } + }; + + defaultActiveWidget.callback = (value) => { + if (!node.tagWidget || !node.tagWidget.value) { + return; + } + + const updatedTags = node.tagWidget.value.map((tag) => { + if (!Array.isArray(tag.items)) { + return { + ...tag, + active: value, + }; + } + + return { + ...tag, + active: value, + items: tag.items.map((item) => ({ + ...item, + active: value, + })), + }; + }); + + node.tagWidget.value = updatedTags; + node.applyTriggerHighlightState?.(); + }; + + if (strengthAdjustmentWidget) { + strengthAdjustmentWidget.callback = (value) => { + const allowStrengthAdjustment = Boolean(value); + if (node.tagWidget) { + node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; + node.tagWidget.closeGroupEditor?.(); + } + this.updateTagsBasedOnMode( + node, + node.originalMessageWidget?.value || "", + groupModeWidget?.value ?? false, + allowStrengthAdjustment + ); + }; + } + + result.widget.serializeValue = function() { + const value = this.value || []; + return value.map((tag) => { + if (Array.isArray(tag.items)) { + return { + ...tag, + text: + tag.strength !== undefined && tag.strength !== null + ? `(${tag.text}:${tag.strength.toFixed(2)})` + : tag.text, + items: tag.items.map((item) => ({ ...item })), + }; + } + + if (tag.strength !== undefined && tag.strength !== null) { + return { + ...tag, + text: `(${tag.text}:${tag.strength.toFixed(2)})`, + }; + } + + return tag; + }); + }; + }); + }, + + handleTriggerWordUpdate(id, graphId, message) { + const node = getNodeFromGraph(graphId, id); + if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") { + console.warn("Node not found or not a TriggerWordToggle:", id); + return; + } + + if (node.originalMessageWidget) { + node.originalMessageWidget.value = message; + } + + if (node.tagWidget) { + const groupMode = node.widgets[0] ? node.widgets[0].value : false; + const allowStrengthAdjustment = Boolean(node.widgets[2]?.value); + node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; + this.updateTagsBasedOnMode(node, message, groupMode, allowStrengthAdjustment); + } + }, + + updateTagsBasedOnMode(node, message, groupMode, allowStrengthAdjustment = false) { + if (!node.tagWidget) { + return; + } + node.tagWidget.closeGroupEditor?.(); + node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; + + const existingTags = (node.tagWidget.value || []).map(cloneTag); + const defaultActive = node.widgets[1] ? node.widgets[1].value : true; + let tagArray = []; + + if (groupMode) { + const existingGroupState = buildGroupState(existingTags, allowStrengthAdjustment); + const groups = message.trim() + ? (message.includes(",,") ? message.split(/,{2,}/) : [message]) + .map((group) => group.trim()) + .filter(Boolean) + : []; + + tagArray = groups.map((group) => { + const existing = consumeQueuedState(existingGroupState, group); + const itemState = existing?.itemState || {}; + const items = splitTopLevelCommas(group).map((itemText) => { + const savedItem = consumeQueuedState(itemState, itemText); + return { + text: itemText, + active: savedItem ? savedItem.active : defaultActive, + highlighted: false, + strength: null, + }; + }); + + return { + text: group, + active: existing ? existing.active : defaultActive, + highlighted: false, + strength: existing ? existing.strength : null, + items, + }; + }); + } else { + const existingTagState = buildLegacyTagState(existingTags, allowStrengthAdjustment); + tagArray = splitTopLevelCommas(message).map((word) => { + const existing = consumeQueuedState(existingTagState, word); + return { + text: word, + active: existing ? existing.active : defaultActive, + highlighted: false, + strength: existing ? existing.strength : null, + }; + }); + } + node.tagWidget.value = tagArray; node.applyTriggerHighlightState?.(); - } + }, });