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:
Will Miao
2025-11-07 16:08:56 +08:00
parent f76343f389
commit 388ff7f5b4
6 changed files with 350 additions and 22 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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 });
});
}
}

View File

@@ -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 };
}
}

View 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;
}
});
}

View File

@@ -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?.();
}
});