Files
mudknight-comfyui-mudknight…/web/modules/autocomplete.js
T

381 lines
10 KiB
JavaScript

import { autocompleteState } from './state.js';
function detectContext(input) {
const cursorPos = input.selectionStart;
const text = input.value.substring(0, cursorPos);
// Check for LoRA syntax: <lora: (with optional content after)
const loraMatch = text.match(/<lora:([^:>]*)$/);
if (loraMatch) {
console.log('Detected LoRA context:', loraMatch[1]);
return {
type: 'lora',
searchTerm: loraMatch[1] || '',
start: cursorPos - loraMatch[1].length
};
}
// Check for embedding syntax: embedding: (with optional content)
const embedMatch = text.match(/\bembedding:([^\s,]*)$/i);
if (embedMatch) {
console.log('Detected embedding context:', embedMatch[1]);
return {
type: 'embedding',
searchTerm: embedMatch[1] || '',
start: cursorPos - embedMatch[1].length
};
}
// Default to tag search (existing logic)
let start = text.lastIndexOf(',', cursorPos - 1) + 1;
while (start < cursorPos && text[start] === ' ') {
start++;
}
let end = text.indexOf(',', cursorPos);
if (end === -1) end = text.length;
const searchTerm = text.substring(start, end).trim();
return {
type: 'tag',
searchTerm: searchTerm,
start: start,
end: end
};
}
function showAutocomplete(input, context) {
const { type, searchTerm, start } = context;
// For LoRA and embedding, show immediately after typing prefix
// For tags, require at least 2 characters
if (type === 'tag' && (!searchTerm || searchTerm.length < 2)) {
hideAutocomplete();
return;
}
// For LoRA/embedding, allow showing with 0 characters
if ((type === 'lora' || type === 'embedding') &&
searchTerm === undefined) {
hideAutocomplete();
return;
}
autocompleteState.contextType = type;
let filtered = [];
console.log(`Searching ${type}:`, searchTerm);
// Filter based on context type
if (type === 'lora') {
const searchLower = searchTerm.toLowerCase();
filtered = autocompleteState.loras
.filter(item =>
searchLower === '' ||
item.name.toLowerCase().includes(searchLower)
)
.slice(0, 10)
.map(item => ({
display: item.name,
value: item.name,
type: 'lora'
}));
console.log('Filtered LoRAs:', filtered.length);
} 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'
}));
console.log('Filtered embeddings:', filtered.length);
} else {
// Tag search with category and alias support
// Replace spaces with underscores for matching
const searchLower = searchTerm.toLowerCase().replace(/ /g, '_');
filtered = autocompleteState.tags
.filter(item =>
item.tag.toLowerCase().includes(searchLower)
)
.slice(0, 10)
.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,
type: 'tag'
}));
}
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');
dropdown.innerHTML = '';
filtered.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'autocomplete-item';
if (index === 0) {
div.classList.add('selected');
}
// Add category class for tags
if (item.type === 'tag' && item.category !== undefined) {
div.classList.add(`tag-category-${item.category}`);
}
// Different display based on type
if (item.type === 'tag') {
if (item.isAlias) {
// Alias format: alias -> tag (category) count
const categoryLabel = getCategoryLabel(item.category);
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">${categoryLabel}
</span>` : ''}
</span>
<span class="autocomplete-count">${item.count}</span>
`;
} else {
// Regular tag format: tag (category) count
const categoryLabel = getCategoryLabel(item.category);
div.innerHTML = `
<span class="autocomplete-tag">
${item.display}
${categoryLabel ?
`<span class="category-label">${categoryLabel}
</span>` : ''}
</span>
<span class="autocomplete-count">${item.count}</span>
`;
}
} else {
// LoRA/embedding format
div.innerHTML = `
<span class="autocomplete-tag">${item.display}</span>
`;
}
div.onclick = () => selectAutocomplete(index);
dropdown.appendChild(div);
});
const rect = input.getBoundingClientRect();
dropdown.style.left = rect.left + 'px';
dropdown.style.top = (rect.bottom + 5) + 'px';
dropdown.style.width = rect.width + 'px';
dropdown.style.display = 'block';
}
function getCategoryLabel(category) {
const labels = {
1: '(artist)',
3: '(copyright)',
4: '(character)',
5: '(meta)'
};
return labels[category] || '';
}
export function hideAutocomplete() {
const dropdown = document.getElementById('autocompleteDropdown');
dropdown.style.display = 'none';
autocompleteState.activeElement = null;
autocompleteState.selectedIndex = -1;
autocompleteState.filteredTags = [];
autocompleteState.contextType = 'tag';
}
function selectAutocomplete(index) {
if (!autocompleteState.activeElement ||
index < 0 ||
index >= autocompleteState.filteredTags.length) {
return;
}
const input = autocompleteState.activeElement;
const item = autocompleteState.filteredTags[index];
const text = input.value;
const cursorPos = input.selectionStart;
let newText, newCursorPos;
if (autocompleteState.contextType === 'lora') {
// Complete LoRA syntax: <lora:name:1.0>
const beforeLora = text.lastIndexOf('<lora:', cursorPos) + 6;
const afterCursor = text.substring(cursorPos);
// Find the end of the lora tag
let endPos = cursorPos;
if (afterCursor.includes('>')) {
endPos = cursorPos + afterCursor.indexOf('>') + 1;
}
const before = text.substring(0, beforeLora);
const after = text.substring(endPos);
// Don't add comma for LoRA tags
newText = `${before}${item.value}:1.0>${after}`;
newCursorPos = before.length + item.value.length + 5;
} else if (autocompleteState.contextType === 'embedding') {
// Complete embedding syntax: embedding:name
const beforeEmbed =
text.lastIndexOf('embedding:', cursorPos) + 10;
const before = text.substring(0, beforeEmbed);
// Find next comma or end
let endPos = cursorPos;
const afterCursor = text.substring(cursorPos);
const commaPos = afterCursor.indexOf(',');
if (commaPos !== -1) {
endPos = cursorPos + commaPos;
} else {
endPos = text.length;
}
const after = text.substring(endPos);
// Check if comma insertion is enabled
const insertComma = autocompleteState.insertComma !== false;
const suffix = insertComma ? ', ' : '';
newText = `${before}${item.value}${suffix}${after.trimStart()}`;
newCursorPos = before.length + item.value.length + suffix.length;
} else {
// Tag completion (existing logic with insertComma support)
let selectedTag = item.value;
selectedTag = selectedTag.replace(/_/g, ' ');
selectedTag = selectedTag.replace(
/\(/g, '\\('
).replace(/\)/g, '\\)');
const context = detectContext(input);
const before = text.substring(0, context.start);
const after = text.substring(context.end || cursorPos);
// Check if comma insertion is enabled
const insertComma = autocompleteState.insertComma !== false;
const suffix = insertComma ? ', ' : '';
newText = before + selectedTag + suffix + after.trimStart();
newCursorPos = before.length + selectedTag.length + suffix.length;
}
input.value = newText;
input.setSelectionRange(newCursorPos, newCursorPos);
hideAutocomplete();
input.focus();
}
function handleAutocompleteKeydown(e, input) {
const dropdown = document.getElementById('autocompleteDropdown');
if (dropdown.style.display !== 'block') {
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (autocompleteState.selectedIndex <
autocompleteState.filteredTags.length - 1) {
autocompleteState.selectedIndex++;
updateAutocompleteSelection();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (autocompleteState.selectedIndex > 0) {
autocompleteState.selectedIndex--;
updateAutocompleteSelection();
}
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (autocompleteState.selectedIndex >= 0) {
e.preventDefault();
selectAutocomplete(autocompleteState.selectedIndex);
}
} else if (e.key === 'Escape') {
e.preventDefault();
hideAutocomplete();
}
}
function updateAutocompleteSelection() {
const items = document.querySelectorAll('.autocomplete-item');
items.forEach((item, index) => {
if (index === autocompleteState.selectedIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
}
export function setupAutocomplete(input, insertComma = true) {
// Store the insertComma setting in the state
if (!input._autocompleteSetup) {
input._autocompleteSetup = true;
input._insertComma = insertComma;
input.addEventListener('input', (e) => {
// Update state with current element's setting
autocompleteState.insertComma = input._insertComma;
const context = detectContext(input);
showAutocomplete(input, context);
});
input.addEventListener('keydown', (e) => {
// Update state with current element's setting
autocompleteState.insertComma = input._insertComma;
handleAutocompleteKeydown(e, input);
});
input.addEventListener('blur', (e) => {
setTimeout(() => {
if (autocompleteState.activeElement === input) {
hideAutocomplete();
}
}, 200);
});
}
}
export function initAutocomplete() {
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('autocompleteDropdown');
if (dropdown &&
dropdown.style.display === 'block' &&
!dropdown.contains(e.target) &&
e.target !== autocompleteState.activeElement) {
hideAutocomplete();
}
});
}