mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 13:12:12 -03:00
feat(ui): add trigger word highlighting for selected LoRAs
- Import applySelectionHighlight in lora_loader and lora_stacker - Pass onSelectionChange callback to loras_widget to handle selection changes - Implement selection tracking and payload building in loras_widget - Emit selection changes when LoRA selection is modified - Update tags_widget to support highlighted tag styling This provides visual feedback when LoRAs are selected by highlighting associated trigger words in the interface.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
166
web/comfyui/trigger_word_highlight.js
Normal file
166
web/comfyui/trigger_word_highlight.js
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user