diff --git a/web/comfyui/lora_loader.js b/web/comfyui/lora_loader.js index 827a7739..f67ed974 100644 --- a/web/comfyui/lora_loader.js +++ b/web/comfyui/lora_loader.js @@ -11,6 +11,7 @@ import { } from "./utils.js"; import { addLorasWidget } from "./loras_widget.js"; import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js"; +import { applySelectionHighlight } from "./trigger_word_highlight.js"; app.registerExtension({ name: "LoraManager.LoraLoader", @@ -178,7 +179,10 @@ app.registerExtension({ this.lorasWidget = addLorasWidget( this, "loras", - {}, + { + onSelectionChange: (selection) => + applySelectionHighlight(this, selection), + }, (value) => { // Prevent recursive calls if (isUpdating) return; diff --git a/web/comfyui/lora_stacker.js b/web/comfyui/lora_stacker.js index 09ade049..263d831d 100644 --- a/web/comfyui/lora_stacker.js +++ b/web/comfyui/lora_stacker.js @@ -11,6 +11,7 @@ import { } from "./utils.js"; import { addLorasWidget } from "./loras_widget.js"; import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js"; +import { applySelectionHighlight } from "./trigger_word_highlight.js"; app.registerExtension({ name: "LoraManager.LoraStacker", @@ -84,10 +85,17 @@ app.registerExtension({ } }); - const result = addLorasWidget(this, "loras", {}, (value) => { - // Prevent recursive calls - if (isUpdating) return; - isUpdating = true; + const result = addLorasWidget( + this, + "loras", + { + onSelectionChange: (selection) => + applySelectionHighlight(this, selection), + }, + (value) => { + // Prevent recursive calls + if (isUpdating) return; + isUpdating = true; try { // Update this stacker's direct trigger toggles with its own active loras diff --git a/web/comfyui/loras_widget.js b/web/comfyui/loras_widget.js index af875a15..80048274 100644 --- a/web/comfyui/loras_widget.js +++ b/web/comfyui/loras_widget.js @@ -29,12 +29,17 @@ export function addLorasWidget(node, name, opts, callback) { // Initialize default value const defaultValue = opts?.defaultVal || []; + const onSelectionChange = typeof opts?.onSelectionChange === "function" + ? opts.onSelectionChange + : null; // Create preview tooltip instance const previewTooltip = new PreviewTooltip({ modelType: "loras" }); // Selection state - only one LoRA can be selected at a time let selectedLora = null; + let currentLorasData = parseLoraValue(defaultValue); + let lastSelectionKey = "__none__"; let pendingFocusTarget = null; const PREVIEW_SUPPRESSION_AFTER_DRAG_MS = 500; @@ -60,13 +65,51 @@ export function addLorasWidget(node, name, opts, callback) { }; // Function to select a LoRA - const selectLora = (loraName) => { + const buildSelectionPayload = (loraName) => { + if (!loraName) { + return null; + } + + const entry = currentLorasData.find((lora) => lora.name === loraName); + if (!entry) { + return null; + } + + return { + name: entry.name, + active: !!entry.active, + entry: { ...entry }, + }; + }; + + const emitSelectionChange = (payload, options = {}) => { + if (!onSelectionChange) { + return; + } + + const key = payload + ? `${payload.name || ""}|${payload.active ? "1" : "0"}` + : "__null__"; + + if (!options.force && key === lastSelectionKey) { + return; + } + + lastSelectionKey = key; + onSelectionChange(payload); + }; + + const selectLora = (loraName, options = {}) => { selectedLora = loraName; // Update visual feedback for all entries container.querySelectorAll('.lm-lora-entry').forEach(entry => { const entryLoraName = entry.dataset.loraName; updateEntrySelection(entry, entryLoraName === selectedLora); }); + + if (!options.silent) { + emitSelectionChange(buildSelectionPayload(loraName)); + } }; // Add keyboard event listener to container @@ -88,6 +131,7 @@ export function addLorasWidget(node, name, opts, callback) { // Parse the loras data const lorasData = parseLoraValue(value); + currentLorasData = lorasData; const focusSequence = []; const updateWidgetValue = (newValue) => { @@ -247,6 +291,14 @@ export function addLorasWidget(node, name, opts, callback) { if (loraIndex >= 0) { lorasData[loraIndex].active = newActive; + if (selectedLora === name) { + emitSelectionChange({ + name, + active: newActive, + entry: { ...lorasData[loraIndex] }, + }); + } + const newValue = formatLoraValue(lorasData); updateWidgetValue(newValue); } @@ -359,13 +411,13 @@ export function addLorasWidget(node, name, opts, callback) { pendingFocusTarget = { name, type: "strength" }; }); - // Handle focus - strengthEl.addEventListener('focus', () => { - pendingFocusTarget = null; - // Auto-select all content - strengthEl.select(); - selectLora(name); - }); + // Handle focus + strengthEl.addEventListener('focus', () => { + pendingFocusTarget = null; + // Auto-select all content + strengthEl.select(); + selectLora(name); + }); // Handle input changes const commitStrengthValue = () => { @@ -577,6 +629,16 @@ export function addLorasWidget(node, name, opts, callback) { updateEntrySelection(entry, entryLoraName === selectedLora); }); + const selectionExists = selectedLora + ? currentLorasData.some((lora) => lora.name === selectedLora) + : false; + + if (selectedLora && !selectionExists) { + selectLora(null); + } else if (selectedLora) { + emitSelectionChange(buildSelectionPayload(selectedLora)); + } + if (pendingFocusTarget) { const focusTarget = pendingFocusTarget; const safeName = escapeLoraName(focusTarget.name); @@ -596,7 +658,7 @@ export function addLorasWidget(node, name, opts, callback) { if (typeof targetInput.select === "function") { targetInput.select(); } - selectLora(focusTarget.name); + selectLora(focusTarget.name, { silent: true }); }); } } diff --git a/web/comfyui/tags_widget.js b/web/comfyui/tags_widget.js index fea65ef3..648f7fc1 100644 --- a/web/comfyui/tags_widget.js +++ b/web/comfyui/tags_widget.js @@ -78,11 +78,11 @@ export function addTagsWidget(node, name, opts, callback) { let tagCount = 0; normalizedTags.forEach((tagData, index) => { - const { text, active } = tagData; + const { text, active, highlighted } = tagData; const tagEl = document.createElement("div"); tagEl.className = "comfy-tag"; - - updateTagStyle(tagEl, active); + + updateTagStyle(tagEl, active, highlighted); tagEl.textContent = text; tagEl.title = text; // Set tooltip for full content @@ -94,7 +94,14 @@ export function addTagsWidget(node, name, opts, callback) { // Toggle active state for this specific tag using its index const updatedTags = [...widget.value]; updatedTags[index].active = !updatedTags[index].active; - updateTagStyle(tagEl, updatedTags[index].active); + updateTagStyle( + tagEl, + updatedTags[index].active, + updatedTags[index].highlighted + ); + + tagEl.dataset.active = updatedTags[index].active ? "true" : "false"; + tagEl.dataset.highlighted = updatedTags[index].highlighted ? "true" : "false"; widget.value = updatedTags; }); @@ -130,7 +137,7 @@ export function addTagsWidget(node, name, opts, callback) { }; // Helper function to update tag style based on active state - function updateTagStyle(tagEl, active) { + function updateTagStyle(tagEl, active, highlighted = false) { const baseStyles = { padding: "3px 10px", // Adjusted vertical padding to balance text borderRadius: "6px", @@ -160,12 +167,24 @@ export function addTagsWidget(node, name, opts, callback) { textAlign: "center", // Center text horizontally }; + 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, { @@ -173,19 +192,24 @@ export function addTagsWidget(node, name, opts, callback) { backgroundColor: "rgba(45, 55, 72, 0.7)", color: "rgba(226, 232, 240, 0.8)", borderColor: "rgba(226, 232, 240, 0.2)", + ...highlightStyles, }); } // Add hover effect tagEl.onmouseenter = () => { tagEl.style.transform = "translateY(-1px)"; - tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)"; + 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 = "0 1px 2px rgba(0,0,0,0.1)"; + 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"; } // Store the value as array @@ -215,4 +239,4 @@ export function addTagsWidget(node, name, opts, callback) { }; return { minWidth: 300, minHeight: defaultHeight, widget }; -} \ No newline at end of file +} diff --git a/web/comfyui/trigger_word_highlight.js b/web/comfyui/trigger_word_highlight.js new file mode 100644 index 00000000..5eee6576 --- /dev/null +++ b/web/comfyui/trigger_word_highlight.js @@ -0,0 +1,166 @@ +import { api } from "../../scripts/api.js"; +import { + getConnectedTriggerToggleNodes, + getLinkFromGraph, + getNodeKey, +} from "./utils.js"; + +const TRIGGER_WORD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const triggerWordCache = new Map(); + +const LORA_NODE_CLASSES = new Set([ + "Lora Loader (LoraManager)", + "Lora Stacker (LoraManager)", + "WanVideo Lora Select (LoraManager)", +]); + +function normalizeTriggerWordList(triggerWords) { + if (!triggerWords) { + return []; + } + + if (triggerWords instanceof Set) { + return Array.from(triggerWords) + .map((word) => (word == null ? "" : String(word)).trim()) + .filter(Boolean); + } + + if (!Array.isArray(triggerWords)) { + return [(triggerWords == null ? "" : String(triggerWords)).trim()].filter( + Boolean + ); + } + + return triggerWords + .map((word) => (word == null ? "" : String(word)).trim()) + .filter(Boolean); +} + +export async function fetchTriggerWordsForLora(loraName) { + if (!loraName) { + return []; + } + + const cached = triggerWordCache.get(loraName); + if (cached && Date.now() - cached.timestamp < TRIGGER_WORD_CACHE_TTL) { + return cached.words; + } + + const response = await api.fetchApi( + `/lm/loras/get-trigger-words?name=${encodeURIComponent(loraName)}`, + { method: "GET" } + ); + + if (!response?.ok) { + const errorText = response ? await response.text().catch(() => "") : ""; + throw new Error(errorText || `Failed to fetch trigger words for ${loraName}`); + } + + const data = (await response.json().catch(() => ({}))) || {}; + const triggerWords = Array.isArray(data.trigger_words) + ? data.trigger_words.filter((word) => typeof word === "string") + : []; + const normalized = triggerWords + .map((word) => word.trim()) + .filter((word) => word.length > 0); + + triggerWordCache.set(loraName, { + words: normalized, + timestamp: Date.now(), + }); + + return normalized; +} + +export function highlightTriggerWordsAlongChain(startNode, triggerWords) { + const normalizedWords = normalizeTriggerWordList(triggerWords); + highlightNodeRecursive(startNode, normalizedWords, new Set()); +} + +export async function applySelectionHighlight(node, selection) { + if (!node) { + return; + } + + node.__lmSelectionHighlightToken = + (node.__lmSelectionHighlightToken || 0) + 1; + const requestId = node.__lmSelectionHighlightToken; + + const loraName = selection?.name; + const isActive = !!selection?.active; + + if (!loraName || !isActive) { + highlightTriggerWordsAlongChain(node, []); + return; + } + + try { + const triggerWords = await fetchTriggerWordsForLora(loraName); + if (node.__lmSelectionHighlightToken !== requestId) { + return; + } + highlightTriggerWordsAlongChain(node, triggerWords); + } catch (error) { + console.error("Error fetching trigger words for highlight:", error); + if (node.__lmSelectionHighlightToken === requestId) { + highlightTriggerWordsAlongChain(node, []); + } + } +} + +function highlightNodeRecursive(node, triggerWords, visited) { + if (!node) { + return; + } + + const nodeKey = getNodeKey(node); + if (!nodeKey || visited.has(nodeKey)) { + return; + } + visited.add(nodeKey); + + highlightTriggerWordsOnNode(node, triggerWords); + + if (!node.outputs) { + return; + } + + for (const output of node.outputs) { + if (!output?.links?.length) { + continue; + } + + for (const linkId of output.links) { + const link = getLinkFromGraph(node.graph, linkId); + if (!link) { + continue; + } + + const targetNode = node.graph?.getNodeById?.(link.target_id); + if (!targetNode) { + continue; + } + + if (LORA_NODE_CLASSES.has(targetNode.comfyClass)) { + highlightNodeRecursive(targetNode, triggerWords, visited); + } + } + } +} + +function highlightTriggerWordsOnNode(node, triggerWords) { + const connectedToggles = getConnectedTriggerToggleNodes(node); + if (!connectedToggles.length) { + return; + } + + connectedToggles.forEach((toggleNode) => { + if (typeof toggleNode?.highlightTriggerWords === "function") { + toggleNode.highlightTriggerWords(triggerWords); + } else { + toggleNode.__pendingHighlightWords = Array.isArray(triggerWords) + ? [...triggerWords] + : triggerWords; + } + }); +} diff --git a/web/comfyui/trigger_word_toggle.js b/web/comfyui/trigger_word_toggle.js index 47b7a0a0..30ddf450 100644 --- a/web/comfyui/trigger_word_toggle.js +++ b/web/comfyui/trigger_word_toggle.js @@ -33,6 +33,66 @@ app.registerExtension({ node.tagWidget = result.widget; + const normalizeTagText = (text) => + (typeof text === 'string' ? text.trim().toLowerCase() : ''); + + 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; @@ -52,6 +112,8 @@ app.registerExtension({ } } + requestAnimationFrame(() => node.applyTriggerHighlightState?.()); + const groupModeWidget = node.widgets[0]; groupModeWidget.callback = (value) => { if (node.widgets[3].value) { @@ -69,6 +131,7 @@ app.registerExtension({ active: value })); node.tagWidget.value = updatedTags; + node.applyTriggerHighlightState?.(); } } }); @@ -147,5 +210,6 @@ app.registerExtension({ } node.tagWidget.value = tagArray; + node.applyTriggerHighlightState?.(); } });