Files
2026-07-15 10:26:43 -07:00

1258 lines
36 KiB
JavaScript

import { autocompleteState } from './state.js';
import { getImageUrl, loadLoraTriggerWords } from './api.js';
// Shared thumbnail element for autocomplete
let sharedThumbnail = null;
let thumbnailTimeout = null;
let currentPreviewKey = null; // Track current preview to avoid redrawing
let autocompleteTimeout = null;
function getCanvasScale() {
if (window.app?.canvas?.ds) {
return window.app.canvas.ds.scale || 1;
}
return 1;
}
function getCaretCoordinates(element) {
const caretPos = element.selectionStart;
const rect = element.getBoundingClientRect();
const scale = getCanvasScale();
const div = document.createElement('div');
const style = window.getComputedStyle(element);
const layoutStyles = [
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
'letterSpacing', 'textTransform', 'wordSpacing',
'textIndent', 'whiteSpace', 'paddingLeft', 'paddingRight',
'paddingTop', 'paddingBottom', 'lineHeight', 'width', 'boxSizing'
];
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.whiteSpace = 'pre-wrap';
div.style.wordWrap = 'break-word';
layoutStyles.forEach(prop => div.style[prop] = style[prop]);
div.textContent = element.value.substring(0, caretPos);
const span = document.createElement('span');
span.textContent = element.value.substring(caretPos) || '.';
div.appendChild(span);
document.body.appendChild(div);
const spanOffsetLeft = (span.offsetLeft - element.scrollLeft) * scale;
const spanOffsetTop = (span.offsetTop - element.scrollTop) * scale;
const lineHeight = parseFloat(style.lineHeight) ||
parseFloat(style.fontSize) * 1.2;
const scaledLineHeight = lineHeight * scale;
document.body.removeChild(div);
return {
left: rect.left + spanOffsetLeft,
top: rect.top + spanOffsetTop,
fontSize: parseFloat(style.fontSize) * scale + 'px',
lineHeight: scaledLineHeight
};
}
function getOrCreateThumbnail() {
if (!sharedThumbnail) {
sharedThumbnail = document.createElement('div');
sharedThumbnail.className = 'autocomplete-thumbnail';
sharedThumbnail.style.position = 'fixed';
sharedThumbnail.style.zIndex = '10001';
document.body.appendChild(sharedThumbnail);
}
return sharedThumbnail;
}
function getPreviewUrl(nameOrPath, type) {
if (type === 'character') {
return getImageUrl(nameOrPath, 'character');
} else if (type === 'lora') {
return `/lora_preview/${encodeURIComponent(nameOrPath)}`;
} else if (type === 'embedding') {
return `/embedding_preview/${encodeURIComponent(nameOrPath)}`;
}
return null;
}
function repositionThumbnail(dropdown) {
if (!sharedThumbnail || sharedThumbnail.style.display !== 'block') {
return;
}
const dropRect = dropdown.getBoundingClientRect();
const thumbnailWidth = 128 + 10;
let left = dropRect.right + 10;
if (left + thumbnailWidth > window.innerWidth) {
left = dropRect.left - thumbnailWidth;
}
sharedThumbnail.style.left = left + 'px';
sharedThumbnail.style.top = dropRect.top + 'px';
}
function showThumbnailForElement(
dropdown, nameOrPath, previewType, immediate = false
) {
const previewKey = `${previewType}:${nameOrPath}`;
if (currentPreviewKey === previewKey &&
sharedThumbnail?.style.display === 'block') {
repositionThumbnail(dropdown);
return;
}
if (thumbnailTimeout) {
clearTimeout(thumbnailTimeout);
thumbnailTimeout = null;
}
const thumbnail = getOrCreateThumbnail();
const previewUrl = getPreviewUrl(nameOrPath, previewType);
if (!previewUrl) {
currentPreviewKey = null;
return;
}
currentPreviewKey = previewKey;
const renderAndPosition = () => {
const currentImg = thumbnail.querySelector('img');
if (!currentImg || currentImg.src !== previewUrl) {
thumbnail.innerHTML = `<img src="${previewUrl}" ` +
`style="width:128px;height:auto;display:block;" />`;
}
thumbnail.style.display = 'block';
repositionThumbnail(dropdown);
};
if (immediate) {
renderAndPosition();
} else {
thumbnailTimeout = setTimeout(renderAndPosition, 300);
}
}
function hideThumbnail() {
if (thumbnailTimeout) {
clearTimeout(thumbnailTimeout);
thumbnailTimeout = null;
}
if (sharedThumbnail) {
sharedThumbnail.style.display = 'none';
}
currentPreviewKey = null;
}
function detectContext(input) {
const cursorPos = input.selectionStart;
const text = input.value.substring(0, cursorPos);
// Check for @ artist prefix (Anima model convention)
const artistMatch = text.match(/(?:^|[,\n])\s*(@([^,\n]*))$/);
if (artistMatch) {
const fullMatch = artistMatch[1]; // includes @
const searchTerm = artistMatch[2]; // excludes @
return {
type: 'artist',
searchTerm,
// start covers the @ so completion replaces @term
start: cursorPos - fullMatch.length,
end: cursorPos
};
}
// Check for LoRA syntax: <lora:
const loraMatch = text.match(/<lora:([^:>]*)$/);
if (loraMatch) {
return {
type: 'lora',
searchTerm: loraMatch[1] || '',
start: cursorPos - loraMatch[1].length
};
}
// Check for embedding syntax: embedding:
const embedMatch = text.match(/\bembedding:([^\s,]*)$/i);
if (embedMatch) {
return {
type: 'embedding',
searchTerm: embedMatch[1] || '',
start: cursorPos - embedMatch[1].length
};
}
// Check for character syntax: character:name[:outfit[:part]]
const charMatch = text.match(
/\bcharacter:([^:,\n]*):?([^:,\n]*):?([^,\n]*)$/i
);
if (charMatch) {
const namePart = charMatch[1] || '';
const outfitPart = charMatch[2] || '';
const partPart = charMatch[3] || '';
// Count colons to determine what we're completing
const charPrefix = text.substring(
text.lastIndexOf('character:'),
cursorPos
);
const colonCount = (charPrefix.match(/:/g) || []).length;
if (colonCount === 3) {
// Completing part (top/bottom)
return {
type: 'character-part',
searchTerm: partPart,
start: cursorPos - partPart.length
};
} else if (colonCount === 2) {
// Completing outfit name
return {
type: 'character-outfit',
searchTerm: outfitPart,
start: cursorPos - outfitPart.length,
characterName: namePart
};
} else {
// Completing character name
return {
type: 'character',
searchTerm: namePart,
start: cursorPos - namePart.length
};
}
}
// Check for tag preset syntax: tag:
const tagMatch = text.match(/\btag:([^\s,]*)$/i);
if (tagMatch) {
return {
type: 'tagpreset',
searchTerm: tagMatch[1] || '',
start: cursorPos - tagMatch[1].length
};
}
// Check for wildcard syntax: wildcard:
const wildcardMatch = text.match(/\bwildcard:([^\s,]*)$/i);
if (wildcardMatch) {
return {
type: 'wildcard',
searchTerm: wildcardMatch[1] || '',
start: cursorPos - wildcardMatch[1].length
};
}
// Default to tag search - track full tag boundaries
const lastComma = text.lastIndexOf(',', cursorPos - 1);
const lastNewline = text.lastIndexOf('\n', cursorPos - 1);
let start = Math.max(lastComma, lastNewline) + 1;
while (start < cursorPos && /[ \t]/.test(text[start])) {
start++;
}
const afterCursor = input.value.substring(cursorPos);
const nextComma = afterCursor.indexOf(',');
const nextNewline = afterCursor.indexOf('\n');
let end;
if (nextComma === -1 && nextNewline === -1) {
end = input.value.length;
} else if (nextComma === -1) {
end = cursorPos + nextNewline;
} else if (nextNewline === -1) {
end = cursorPos + nextComma;
} else {
end = cursorPos + Math.min(nextComma, nextNewline);
}
const fullTag = input.value.substring(start, end).trim();
return {
type: 'tag',
searchTerm: fullTag,
start: start,
end: end
};
}
function isAutocompleteEnabled() {
const isPresetManager = window.location.pathname.includes(
'character_editor.html'
);
if (isPresetManager) {
// Check Preset Manager specific setting
const key = "Comfy.Settings.Mudknight Utils.Autocomplete" +
".PresetManagerEnabled";
const value = localStorage.getItem(key);
return value !== 'false';
}
// Check ComfyUI setting
const key = "Comfy.Settings.Mudknight Utils.Autocomplete.Enabled";
const value = localStorage.getItem(key);
return value !== 'false';
}
function showAutocomplete(input, context) {
if (!isAutocompleteEnabled()) {
hideAutocomplete();
return;
}
const { type, searchTerm, start } = context;
// For prefix-triggered types, show immediately after typing prefix
if (type === 'lora' || type === 'embedding' ||
type === 'character' || type === 'character-outfit' ||
type === 'character-part' || type === 'tagpreset' ||
type === 'wildcard' || type === 'artist') {
// Allow showing with empty search term
if (searchTerm === undefined) {
hideAutocomplete();
return;
}
} else {
// For tags, require at least 2 characters
if (!searchTerm || searchTerm.length < 2) {
hideAutocomplete();
return;
}
}
autocompleteState.contextType = type;
let filtered = [];
// Filter based on context type
if (type === 'artist') {
// Filter to danbooru category 1 (artist) only, stripping @
// from the search term since tags themselves don't have it.
const searchLower = searchTerm.toLowerCase().replace(/ /g, '_');
filtered = autocompleteState.tags
.filter(item =>
item.category === 1 &&
!autocompleteState.blacklist.has(
item.tag.toLowerCase().trim()
) &&
(searchLower === '' ||
item.tag.toLowerCase().includes(searchLower))
)
.slice(0, 10)
.map(item => ({
display: item.tag.replace(/_/g, ' '),
value: item.tag.replace(/_/g, ' '),
count: item.count,
category: item.category,
type: 'tag'
}));
} else if (type === 'lora') {
const searchLower = searchTerm.toLowerCase();
const basenameCounts = {};
for (const lora of autocompleteState.loras) {
const base = lora.name.split('/').pop();
basenameCounts[base] = (basenameCounts[base] || 0) + 1;
}
filtered = autocompleteState.loras
.filter(item =>
searchLower === '' ||
item.name.toLowerCase().includes(searchLower)
)
.slice(0, 10)
.map(item => {
const base = item.name.split('/').pop();
const label = basenameCounts[base] === 1 ? base : item.name;
return {
display: label,
value: label,
type: 'lora',
hasPreview: item.hasPreview || false,
previewName: item.name,
previewPath: item.path // Store full path as fallback
};
});
} else if (type === 'embedding') {
const searchLower = searchTerm.toLowerCase();
filtered = autocompleteState.embeddings
.filter(item =>
searchLower === '' ||
item.name.toLowerCase().includes(searchLower)
)
.slice(0, 10)
.map(item => ({
display: item.name,
value: item.name,
type: 'embedding',
hasPreview: item.hasPreview || false,
previewPath: item.path
}));
} else if (type === 'character') {
const searchLower = searchTerm.toLowerCase();
filtered = autocompleteState.characterPresets
.filter(item =>
searchLower === '' ||
item.tag.toLowerCase().includes(searchLower)
)
.slice(0, 10)
.map(item => ({
display: item.characterName || item.tag,
value: item.characterName || item.tag,
type: 'character',
hasPreview: item.hasImage || false,
characterName: item.characterName,
presetType: 'character'
}));
} else if (type === 'character-outfit') {
// For now, just show "default" as the only outfit
const searchLower = searchTerm.toLowerCase();
filtered = ['default']
.filter(outfit =>
searchLower === '' ||
outfit.includes(searchLower)
)
.map(outfit => ({
display: outfit,
value: outfit,
type: 'character-outfit'
}));
} else if (type === 'character-part') {
const parts = ['top', 'bottom'];
const searchLower = searchTerm.toLowerCase();
filtered = parts
.filter(part =>
searchLower === '' ||
part.includes(searchLower)
)
.map(part => ({
display: part,
value: part,
type: 'character-part'
}));
} else if (type === 'tagpreset') {
const searchLower = searchTerm.toLowerCase();
filtered = autocompleteState.tagPresets
.filter(item =>
searchLower === '' ||
item.tag.toLowerCase().includes(searchLower)
)
.slice(0, 10)
.map(item => ({
display: item.tag.replace(/_/g, ' '),
value: item.tag.replace(/_/g, ' '),
type: 'tagpreset',
presetType: 'tag'
}));
} else if (type === 'wildcard') {
const searchLower = searchTerm.toLowerCase();
filtered = autocompleteState.wildcardPresets
.filter(item =>
searchLower === '' ||
item.tag.toLowerCase().includes(searchLower)
)
.slice(0, 10)
.map(item => ({
display: item.tag.replace(/_/g, ' '),
value: item.tag.replace(/_/g, ' '),
type: 'wildcard',
presetType: 'wildcard'
}));
} else {
// Tag search with category, alias, and preset support
// Replace spaces with underscores for matching
const searchLower = searchTerm.toLowerCase().replace(/ /g, '_');
// Filter matching tags (no preset merging)
let matching = autocompleteState.tags
.filter(item => {
// Check blacklist
const tagKey = item.tag.toLowerCase().trim();
const aliasKey = item.aliasFor ?
item.aliasFor.toLowerCase().trim() : null;
// Block if tag itself is blacklisted
if (autocompleteState.blacklist.has(tagKey)) {
return false;
}
// Block if this is an alias of a blacklisted tag
if (aliasKey && autocompleteState.blacklist.has(aliasKey)) {
return false;
}
// Then check search match
return item.tag.toLowerCase().includes(searchLower);
})
.map(item => ({
display: item.tag.replace(/_/g, ' '),
value: item.isAlias ?
item.aliasFor.replace(/_/g, ' ') :
item.tag.replace(/_/g, ' '),
count: item.count,
category: item.category,
isAlias: item.isAlias,
aliasFor: item.aliasFor ?
item.aliasFor.replace(/_/g, ' ') : undefined,
isPreset: item.isPreset || false,
isCustom: item.isCustom || false,
presetType: item.presetType,
characterName: item.characterName,
hasImage: item.hasImage || false,
type: 'tag'
}));
// Filter aliases if setting enabled
if (autocompleteState.hideAliasesWithMain) {
const mainTagsPresent = new Map();
matching.forEach(item => {
if (!item.isAlias) {
const normalizedValue =
item.value.toLowerCase().replace(/ /g, '_');
mainTagsPresent.set(normalizedValue, true);
}
});
matching = matching.filter(item => {
if (!item.isAlias || !item.aliasFor) {
return true;
}
const mainTag =
item.aliasFor.toLowerCase().replace(/ /g, '_');
const aliasTag =
item.display.toLowerCase().replace(/ /g, '_');
if (mainTagsPresent.has(mainTag)) {
const searchMatchesAlias =
aliasTag.startsWith(searchLower);
const searchMatchesMain =
mainTag.startsWith(searchLower);
return searchMatchesAlias && !searchMatchesMain;
}
return true;
});
}
if (autocompleteState.presetsFirst) {
matching.sort((a, b) => {
// Presets always come first
if (a.isPreset && !b.isPreset) return -1;
if (!a.isPreset && b.isPreset) return 1;
// Within same group, sort by count
return b.count - a.count;
});
} else {
// Just sort by count
matching.sort((a, b) => b.count - a.count);
filtered = matching.slice(0, 50); // Only sort/process top 50, show 10
}
filtered = matching.slice(0, 10);
}
if (filtered.length === 0) {
hideAutocomplete();
return;
}
autocompleteState.filteredTags = filtered;
autocompleteState.selectedIndex = 0;
autocompleteState.activeElement = input;
autocompleteState.currentWord = searchTerm;
autocompleteState.wordStart = start;
const dropdown = document.getElementById('autocompleteDropdown');
const coords = getCaretCoordinates(input);
dropdown.innerHTML = '';
dropdown.style.display = 'block';
dropdown.style.position = 'fixed';
dropdown.style.fontSize = coords.fontSize;
filtered.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'autocomplete-item' +
(index === 0 ? ' selected' : '');
if (item.type === 'tag' && item.category !== undefined) {
div.classList.add(`tag-category-${item.category}`);
}
if (item.type === 'tag') {
const categoryLabel = getCategoryLabel(item.category);
const presetLabel = item.isPreset ?
' <span class="preset-label">PRESET</span>' : '';
const customLabel = item.isCustom ?
' <span class="preset-label">CUSTOM</span>' : '';
if (item.isAlias) {
div.innerHTML = `
<span class="autocomplete-tag">
<span class="alias-name">${item.display}</span>
<span class="alias-arrow"> → </span>
<span class="alias-target">${item.value}</span>
${categoryLabel ?
`<span class="category-label" style="color: grey;">
${categoryLabel}
</span>` : ''}${presetLabel}${customLabel}
</span>
<span class="autocomplete-count">${item.count}</span>
`;
} else {
div.innerHTML = `
<span class="autocomplete-tag">
${item.display}
${categoryLabel ?
`<span class="category-label" style="color: grey;">
${categoryLabel}
</span>` : ''}${presetLabel}${customLabel}
</span>
<span class="autocomplete-count">${item.count}</span>
`;
}
if (item.isPreset && item.presetType === 'character' &&
item.hasImage && item.characterName) {
div.dataset.characterName = item.characterName;
div.dataset.previewType = 'character';
}
} else {
div.innerHTML = `<span class="autocomplete-tag">` +
`${item.display}</span>`;
if (item.hasPreview || item.type === 'character') {
div.dataset.previewType = item.type;
if (item.type === 'character' && item.characterName) {
div.dataset.characterName = item.characterName;
} else {
div.dataset.previewPath = item.previewPath;
div.dataset.previewName = item.previewName;
}
}
}
div.onclick = () => selectAutocomplete(index);
dropdown.appendChild(div);
});
let top = coords.top + coords.lineHeight + 5;
if (top + dropdown.offsetHeight > window.innerHeight) {
top = coords.top - dropdown.offsetHeight - 5;
}
dropdown.style.left = coords.left + 'px';
dropdown.style.top = top + 'px';
updateThumbnailForSelectedItem();
}
function updateThumbnailForSelectedItem() {
const dropdown = document.getElementById('autocompleteDropdown');
if (!dropdown || dropdown.style.display !== 'block') {
return;
}
const selectedElement =
dropdown.querySelector('.autocomplete-item.selected');
if (selectedElement && selectedElement.dataset.previewType) {
const type = selectedElement.dataset.previewType;
const id = selectedElement.dataset.previewPath ||
selectedElement.dataset.characterName ||
selectedElement.dataset.previewName;
showThumbnailForElement(dropdown, id, type, true);
} else {
hideThumbnail();
}
}
function getCategoryLabel(category) {
const labels = {
1: '(artist)',
3: '(copyright)',
4: '(character)',
5: '(meta)'
};
return labels[category] || '';
}
export function hideAutocomplete() {
const dropdown = document.getElementById('autocompleteDropdown');
if (dropdown) {
dropdown.style.display = 'none';
}
hideThumbnail();
autocompleteState.activeElement = null;
autocompleteState.selectedIndex = -1;
autocompleteState.filteredTags = [];
autocompleteState.contextType = 'tag';
}
function findItemInFullDataset(type, searchTerm) {
const searchLower = searchTerm.toLowerCase().replace(/ /g, '_');
if (type === 'tag') {
const match = autocompleteState.tags
.filter(item => {
const tagKey = item.tag.toLowerCase().trim();
const aliasKey = item.aliasFor ?
item.aliasFor.toLowerCase().trim() : null;
if (autocompleteState.blacklist.has(tagKey)) return false;
if (aliasKey &&
autocompleteState.blacklist.has(aliasKey)) return false;
return item.tag.toLowerCase().includes(searchLower);
})
.sort((a, b) => (b.count || 0) - (a.count || 0))[0];
if (!match) return null;
return {
display: match.tag.replace(/_/g, ' '),
value: match.isAlias ?
match.aliasFor.replace(/_/g, ' ') :
match.tag.replace(/_/g, ' '),
type: 'tag'
};
}
if (type === 'artist') {
const match = autocompleteState.tags
.filter(item =>
item.category === 1 &&
!autocompleteState.blacklist.has(
item.tag.toLowerCase().trim()
) &&
item.tag.toLowerCase().includes(searchLower)
)
.sort((a, b) => (b.count || 0) - (a.count || 0))[0];
if (!match) return null;
return {
display: match.tag.replace(/_/g, ' '),
value: match.tag.replace(/_/g, ' '),
type: 'tag'
};
}
if (type === 'lora') {
const basenameCounts = {};
for (const lora of autocompleteState.loras) {
const base = lora.name.split('/').pop();
basenameCounts[base] = (basenameCounts[base] || 0) + 1;
}
for (const item of autocompleteState.loras) {
if (item.name.toLowerCase().includes(searchLower)) {
const base = item.name.split('/').pop();
const label = basenameCounts[base] === 1 ? base : item.name;
return {
display: label,
value: label,
type: 'lora',
hasPreview: item.hasPreview || false,
previewName: item.name,
previewPath: item.path
};
}
}
return null;
}
if (type === 'embedding') {
for (const item of autocompleteState.embeddings) {
if (item.name.toLowerCase().includes(searchLower)) {
return {
display: item.name,
value: item.name,
type: 'embedding',
hasPreview: item.hasPreview || false,
previewPath: item.path
};
}
}
return null;
}
if (type === 'character') {
for (const item of autocompleteState.characterPresets) {
if (item.tag.toLowerCase().includes(searchLower)) {
return {
display: item.characterName || item.tag,
value: item.characterName || item.tag,
type: 'character',
hasPreview: item.hasImage || false,
characterName: item.characterName,
presetType: 'character'
};
}
}
return null;
}
if (type === 'character-outfit') {
for (const outfit of ['default']) {
if (outfit.includes(searchLower)) {
return {
display: outfit,
value: outfit,
type: 'character-outfit'
};
}
}
return null;
}
if (type === 'character-part') {
for (const part of ['top', 'bottom']) {
if (part.includes(searchLower)) {
return {
display: part,
value: part,
type: 'character-part'
};
}
}
return null;
}
if (type === 'tagpreset') {
for (const item of autocompleteState.tagPresets) {
if (item.tag.toLowerCase().includes(searchLower)) {
return {
display: item.tag.replace(/_/g, ' '),
value: item.tag.replace(/_/g, ' '),
type: 'tagpreset',
presetType: 'tag'
};
}
}
return null;
}
if (type === 'wildcard') {
for (const item of autocompleteState.wildcardPresets) {
if (item.tag.toLowerCase().includes(searchLower)) {
return {
display: item.tag.replace(/_/g, ' '),
value: item.tag.replace(/_/g, ' '),
type: 'wildcard',
presetType: 'wildcard'
};
}
}
return null;
}
return null;
}
function selectAutocomplete(index) {
if (!autocompleteState.activeElement ||
index < 0 ||
index >= autocompleteState.filteredTags.length) { return; }
const input = autocompleteState.activeElement;
const context = detectContext(input);
// Re-check the current input state at completion time, since
// filteredTags may reflect a debounced (stale) search term.
// If the selected item still matches the fresh search, keep it.
// Otherwise, fall back to the first item that does match.
const freshSearch = context.searchTerm
.toLowerCase().replace(/ /g, '_');
const tagMatches = t =>
t.display.toLowerCase().replace(/ /g, '_')
.includes(freshSearch) ||
t.value.toLowerCase().replace(/ /g, '_')
.includes(freshSearch);
const selectedItem = autocompleteState.filteredTags[index];
const item = tagMatches(selectedItem)
? selectedItem
: (autocompleteState.filteredTags.find(tagMatches)
?? findItemInFullDataset(context.type, freshSearch)
?? selectedItem);
const text = input.value;
let newText, newCursorPos;
// Determine suffix based on context and item type
let suffix;
// Check if this is a keyword (character, tag, embedding)
const isKeyword = ['character', 'tag', 'embedding', 'wildcard'].includes(
item.value.toLowerCase()
);
if (isKeyword && context.type === 'tag') {
// Keywords get colon when selected from normal tag context
suffix = ':';
} else if (isKeyword && context.type === 'wildcard') {
// Keywords get colon when selected from normal tag context
suffix = ':';
} else if (context.type === 'character' ||
context.type === 'character-outfit') {
// Jump to next colon for character contexts
suffix = ':';
} else if (context.type === 'tagpreset') {
// Tag presets end with comma, not colon
suffix = autocompleteState.insertComma !== false ? ', ' : '';
} else if (context.type === 'character-part') {
// End with comma for outfit parts
suffix = autocompleteState.insertComma !== false ? ', ' : '';
} else {
// Default comma suffix for everything else
suffix = autocompleteState.insertComma !== false ? ', ' : '';
}
if (context.type === 'lora') {
const beforeLora =
text.lastIndexOf('<lora:', input.selectionStart) + 6;
// Find the closing > of the current <lora:...> tag, but
// don't cross a <, comma, or newline — those mean the tag
// was never closed and we shouldn't consume further text.
const afterCursor = text.substring(input.selectionStart);
const stopMatch = afterCursor.match(/[>,\n<]/);
const after = (stopMatch && stopMatch[0] === '>')
? afterCursor.substring(stopMatch.index + 1)
: afterCursor;
newText = `${text.substring(0, beforeLora)}${item.value}:1.0>, ${after}`;
newCursorPos = beforeLora + item.value.length + 7;
// Apply the lora tag immediately, then async-append trigger
// words if the setting is enabled and words are available.
input.value = newText;
input.setSelectionRange(newCursorPos, newCursorPos);
if (input._comfyWidget) {
input._comfyWidget.value = input.value;
if (input._comfyWidget.callback) {
input._comfyWidget.callback(input.value);
}
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
hideAutocomplete();
input.focus();
const insertTriggers = localStorage.getItem(
'Comfy.Settings.Mudknight Utils.Autocomplete' +
'.LoRATriggerWords'
) !== 'false';
console.log(
'[Autocomplete] LoRA trigger words enabled:',
insertTriggers,
'item:', item
);
if (insertTriggers) {
// Prefer item.previewPath (includes extension) so the
// backend can resolve the file without guessing.
const loraPath = item.previewPath || item.value;
console.log('[Autocomplete] Fetching trigger words for:', loraPath);
loadLoraTriggerWords(loraPath).then(words => {
console.log('[Autocomplete] Trigger words received:', words);
if (!words.length) return;
// Insert trigger words after the closing > of the tag.
// Locate the tag we just inserted by searching from
// the cursor position backwards.
const cur = input.value;
const insertPos = beforeLora + item.value.length + 5;
const triggerStr = ', ' + words.join(', ');
input.value =
cur.substring(0, insertPos) +
triggerStr +
cur.substring(insertPos);
const triggerCursorPos = insertPos + triggerStr.length;
input.setSelectionRange(
triggerCursorPos, triggerCursorPos
);
if (input._comfyWidget) {
input._comfyWidget.value = input.value;
if (input._comfyWidget.callback) {
input._comfyWidget.callback(input.value);
}
}
input.dispatchEvent(
new Event('input', { bubbles: true })
);
input.dispatchEvent(
new Event('change', { bubbles: true })
);
});
}
return;
} else if (context.type === 'embedding') {
const beforeEmbed =
text.lastIndexOf('embedding:', input.selectionStart) + 10;
const rest = text.substring(input.selectionStart);
const after = rest.includes(',')
? rest.substring(rest.indexOf(','))
: rest;
newText =
`${text.substring(0, beforeEmbed)}${item.value}${suffix}${after}`;
newCursorPos = beforeEmbed + item.value.length + suffix.length;
} else if (context.type === 'character') {
const beforeChar =
text.lastIndexOf('character:', input.selectionStart) + 10;
const rest = text.substring(input.selectionStart);
// Find the actual end boundary (comma, newline, or end of string)
let endPos = input.selectionStart;
while (endPos < text.length &&
text[endPos] !== ',' &&
text[endPos] !== '\n') {
endPos++;
}
const after = text.substring(endPos);
newText =
`${text.substring(0, beforeChar)}${item.value}${suffix}${after}`;
newCursorPos = beforeChar + item.value.length + suffix.length;
} else if (context.type === 'character-outfit') {
// Find position after first colon
const charStart =
text.lastIndexOf('character:', input.selectionStart);
const firstColon = text.indexOf(':', charStart + 10);
const beforeOutfit = firstColon + 1;
// Find the actual end boundary
let endPos = input.selectionStart;
while (endPos < text.length &&
text[endPos] !== ',' &&
text[endPos] !== '\n') {
endPos++;
}
const after = text.substring(endPos);
newText =
`${text.substring(0, beforeOutfit)}${item.value}${suffix}${after}`;
newCursorPos = beforeOutfit + item.value.length + suffix.length;
} else if (context.type === 'character-part') {
// Find position after second colon
const charStart =
text.lastIndexOf('character:', input.selectionStart);
const firstColon = text.indexOf(':', charStart + 10);
const secondColon = text.indexOf(':', firstColon + 1);
const beforePart = secondColon + 1;
// Find the actual end boundary
let endPos = input.selectionStart;
while (endPos < text.length &&
text[endPos] !== ',' &&
text[endPos] !== '\n') {
endPos++;
}
const after = text.substring(endPos);
newText =
`${text.substring(0, beforePart)}${item.value}${suffix}${after}`;
newCursorPos = beforePart + item.value.length + suffix.length;
} else if (context.type === 'tagpreset') {
const beforeTag =
text.lastIndexOf('tag:', input.selectionStart) + 4;
// Find the actual end boundary
let endPos = input.selectionStart;
while (endPos < text.length &&
text[endPos] !== ',' &&
text[endPos] !== '\n') {
endPos++;
}
const after = text.substring(endPos);
newText =
`${text.substring(0, beforeTag)}${item.value}${suffix}${after}`;
newCursorPos = beforeTag + item.value.length + suffix.length;
} else if (context.type === 'wildcard') {
const beforeWildcard =
text.lastIndexOf('wildcard:', input.selectionStart) + 9;
let endPos = input.selectionStart;
while (endPos < text.length &&
text[endPos] !== ',' &&
text[endPos] !== '\n') {
endPos++;
}
const after = text.substring(endPos);
newText =
`${text.substring(0, beforeWildcard)}${item.value}${suffix}${after}`;
newCursorPos = beforeWildcard + item.value.length + suffix.length;
} else if (context.type === 'artist') {
// Prepend @ to the completed value, replacing from context.start
// which covers the original @ the user typed.
const tag = '@' + item.value
.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
newText = text.substring(0, context.start) + tag + suffix +
text.substring(context.end);
newCursorPos = context.start + tag.length + suffix.length;
} else {
// Replace the entire tag from start to end
let tag = item.value.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
newText = text.substring(0, context.start) + tag + suffix +
text.substring(context.end);
newCursorPos = context.start + tag.length + suffix.length;
}
input.value = newText;
input.setSelectionRange(newCursorPos, newCursorPos);
// Sync with ComfyUI LiteGraph widgets (classic UI)
if (input._comfyWidget) {
input._comfyWidget.value = input.value;
if (input._comfyWidget.callback) {
input._comfyWidget.callback(input.value);
}
}
// Notify reactive frameworks (like Vue/ComfyUI Nodes 2.0) that the value changed
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
hideAutocomplete();
input.focus();
// If we just inserted a colon, immediately show autocomplete for next part
if (suffix === ':') {
// Small delay to let the input update
setTimeout(() => {
const context = detectContext(input);
showAutocomplete(input, context);
}, 10);
}
}
function handleAutocompleteKeydown(e, input) {
const dropdown = document.getElementById('autocompleteDropdown');
if (!isAutocompleteEnabled() || dropdown.style.display !== 'block') {
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
autocompleteState.selectedIndex =
(autocompleteState.selectedIndex + 1) %
autocompleteState.filteredTags.length;
updateAutocompleteSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
autocompleteState.selectedIndex =
(autocompleteState.selectedIndex - 1 +
autocompleteState.filteredTags.length) %
autocompleteState.filteredTags.length;
updateAutocompleteSelection();
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
selectAutocomplete(autocompleteState.selectedIndex);
} else if (e.key === 'Escape') {
hideAutocomplete();
}
}
function updateAutocompleteSelection() {
const items = document.querySelectorAll('.autocomplete-item');
items.forEach((item, index) => {
item.classList.toggle('selected',
index === autocompleteState.selectedIndex);
if (index === autocompleteState.selectedIndex) {
item.scrollIntoView({ block: 'nearest' });
}
});
// Update thumbnail for the newly selected item
updateThumbnailForSelectedItem();
}
export function setupAutocomplete(input, insertComma = true) {
if (!input._autocompleteSetup) {
input._autocompleteSetup = true;
input._insertComma = insertComma;
input.addEventListener('input', (e) => {
const enabled = input._checkEnabled ?
input._checkEnabled() : true;
if (!enabled) return;
autocompleteState.insertComma = input._insertComma;
const context = detectContext(input);
// Bypass debounce for @ artist prefix if setting is enabled
const artistEnabled = localStorage.getItem(
'Comfy.Settings.Mudknight Utils.Autocomplete' +
'.ArtistAtPrefix'
) !== 'false';
if (context.type === 'artist' && artistEnabled) {
if (autocompleteTimeout) {
clearTimeout(autocompleteTimeout);
}
showAutocomplete(input, context);
return;
}
// Debounce: wait 100ms after typing stops
if (autocompleteTimeout) {
clearTimeout(autocompleteTimeout);
}
autocompleteTimeout = setTimeout(() => {
showAutocomplete(input, detectContext(input));
}, 100);
});
// Hide on cursor movement (mouse clicks)
input.addEventListener('click', () => {
hideAutocomplete();
});
input.addEventListener('keydown', (e) => {
const enabled = input._checkEnabled ?
input._checkEnabled() : true;
const dropdown =
document.getElementById('autocompleteDropdown');
if (!enabled) return;
// If dropdown is visible, handle navigation
if (dropdown.style.display === 'block') {
autocompleteState.insertComma = input._insertComma;
handleAutocompleteKeydown(e, input);
}
// Hide on left/right arrow movement
if (['ArrowLeft', 'ArrowRight'].includes(e.key)) {
hideAutocomplete();
}
});
input.addEventListener('blur', (e) => {
setTimeout(() => {
if (autocompleteState.activeElement === input) {
hideAutocomplete();
}
}, 200);
});
}
}
export function initAutocomplete() {
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('autocompleteDropdown');
const active = autocompleteState.activeElement;
if (dropdown &&
dropdown.style.display === 'block' &&
!dropdown.contains(e.target) &&
e.target !== active) {
hideAutocomplete();
}
});
}