Files
automatic/javascript/autocomplete.js
T
2026-03-26 00:47:14 +00:00

431 lines
14 KiB
JavaScript

/*
* Tag autocomplete for SD.Next prompt textareas.
*
* Ported from Enso's CodeMirror-based autocomplete (autocomplete.ts).
* Uses binary search on sorted tag arrays for O(log n) prefix lookup,
* with substring fallback for 4+ char queries.
*/
// -- Category colors (unified 14-category scheme) --
const CATEGORY_COLORS = {
0: '#0075f8', // general
1: '#cc0000', // artist
2: '#ff4500', // studio
3: '#9900ff', // copyright
4: '#00ab2c', // character
5: '#ed5d1f', // species
6: '#8a66ff', // genre
7: '#00cccc', // medium
8: '#6b7280', // meta
9: '#228b22', // lore
10: '#e67e22', // lens
11: '#f1c40f', // lighting
12: '#1abc9c', // composition
13: '#e84393', // color
};
// -- Utilities (ported from Enso) --
/** Binary search for the first tag where tag.name >= prefix. */
function lowerBound(tags, prefix) {
let lo = 0;
let hi = tags.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (tags[mid].name < prefix) lo = mid + 1;
else hi = mid;
}
return lo;
}
/** Format post count as abbreviated string. */
function formatCount(count) {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${Math.round(count / 1_000)}k`;
return String(count);
}
// -- TagIndex --
class TagIndex {
constructor(data) {
this.categories = data.categories || {};
// Build sorted array of {name, category, count} from raw [name, catId, count] tuples
this.tags = data.tags.map(([name, category, count]) => ({
name: name.toLowerCase(),
display: name,
category,
count,
}));
this.tags.sort((a, b) => a.name.localeCompare(b.name));
}
/** Prefix search with binary search. Returns matches sorted by count descending. */
search(prefix, limit = 20) {
const query = prefix.toLowerCase().replace(/ /g, '_');
if (!query) return [];
const start = lowerBound(this.tags, query);
const matches = [];
for (let i = start; i < this.tags.length && matches.length < limit * 5; i++) {
if (!this.tags[i].name.startsWith(query)) break;
matches.push(this.tags[i]);
}
// Substring fallback for 4+ chars if prefix found nothing
if (matches.length === 0 && query.length >= 4) {
for (let i = 0; i < this.tags.length && matches.length < limit * 5; i++) {
if (this.tags[i].name.includes(query)) matches.push(this.tags[i]);
}
}
matches.sort((a, b) => b.count - a.count);
return matches.slice(0, limit);
}
}
// -- Engine --
const engine = {
indices: new Map(), // name -> TagIndex
categoryColors: { ...CATEGORY_COLORS },
async loadEnabled() {
const enabled = window.opts?.autocomplete_enabled || [];
if (!enabled.length) {
this.indices.clear();
return;
}
const toLoad = enabled.filter((n) => !this.indices.has(n));
const toRemove = [...this.indices.keys()].filter((n) => !enabled.includes(n));
toRemove.forEach((n) => this.indices.delete(n));
await Promise.all(toLoad.map(async (name) => {
try {
const resp = await fetch(`${window.api}/autocomplete/${name}`, { credentials: 'include' });
if (!resp.ok) throw new Error(`${resp.status}`);
const data = await resp.json();
this.indices.set(name, new TagIndex(data));
// Extract category colors from first loaded file
if (data.categories) {
Object.entries(data.categories).forEach(([id, cat]) => {
if (cat.color) this.categoryColors[id] = cat.color;
});
}
log('autocomplete', `loaded ${name}: ${data.tags?.length || 0} tags`);
} catch (e) {
log('autocomplete', `failed to load ${name}: ${e}`);
}
}));
},
searchAll(prefix, limit = 20) {
if (this.indices.size === 0) return [];
const all = [];
this.indices.forEach((index) => {
all.push(...index.search(prefix, limit));
});
// Deduplicate by name, keeping highest count
const seen = new Map();
all.forEach((tag) => {
const existing = seen.get(tag.name);
if (!existing || tag.count > existing.count) seen.set(tag.name, tag);
});
const results = [...seen.values()];
results.sort((a, b) => b.count - a.count);
return results.slice(0, limit);
},
};
// -- Textarea integration --
/** Extract the current word being typed at the cursor position. */
function getCurrentWord(textarea) {
const { value, selectionStart } = textarea;
if (selectionStart !== textarea.selectionEnd) return null; // has selection
// Scan backward from cursor to find word start
let start = selectionStart;
while (start > 0) {
const ch = value[start - 1];
if (ch === ',' || ch === '\n') break;
start--;
}
// Skip leading whitespace
while (start < selectionStart && value[start] === ' ') start++;
const word = value.slice(start, selectionStart);
if (!word) return null;
// Skip if inside angle brackets (LoRA/embedding syntax)
const before = value.slice(0, selectionStart);
const lastOpen = before.lastIndexOf('<');
const lastClose = before.lastIndexOf('>');
if (lastOpen > lastClose) return null;
// Skip if inside wildcard syntax
const wcBefore = before.slice(start);
if (wcBefore.startsWith('__') && !wcBefore.endsWith('__')) return null;
return { word, start, end: selectionStart };
}
/** Insert a tag at the current word position, replacing the typed prefix. */
function insertTag(textarea, tagName) {
const info = getCurrentWord(textarea);
if (!info) return;
const { value } = textarea;
const before = value.slice(0, info.start);
const after = value.slice(info.end);
// Build insertion: tag + separator
const needsCommaBefore = before.length > 0 && !before.trimEnd().endsWith(',') && before.trimEnd().length > 0;
const prefix = needsCommaBefore ? ', ' : '';
let suffix = ', ';
if (after.length > 0 && after.trimStart().startsWith(',')) suffix = ' ';
const insertion = `${prefix}${tagName}${suffix}`;
textarea.value = before.trimEnd() + (before.trimEnd().length > 0 ? ' ' : '') + insertion + after.trimStart();
// Position cursor after the inserted tag + separator
const cursorPos = before.trimEnd().length + (before.trimEnd().length > 0 ? 1 : 0) + insertion.length;
textarea.selectionStart = cursorPos;
textarea.selectionEnd = cursorPos;
// Sync with Gradio
if (typeof updateInput === 'function') updateInput(textarea);
}
// -- Dropdown --
const dropdown = {
el: null,
listEl: null,
selectedIndex: -1,
results: [],
textarea: null,
visible: false,
init() {
this.el = document.createElement('div');
this.el.className = 'autocompleteResults';
this.el.style.display = 'none';
this.listEl = document.createElement('ul');
this.listEl.className = 'autocompleteResultsList';
this.el.appendChild(this.listEl);
document.body.appendChild(this.el);
this.el.addEventListener('mousedown', (e) => e.preventDefault()); // prevent blur on click
this.el.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (!li) return;
const idx = [...this.listEl.children].indexOf(li);
if (idx >= 0 && idx < this.results.length) {
this.selectedIndex = idx;
this.accept();
}
});
},
show(results, textarea) {
if (results.length === 0) { this.hide(); return; }
this.results = results;
this.textarea = textarea;
this.selectedIndex = -1;
this.render();
this.position();
this.el.style.display = '';
this.visible = true;
},
hide() {
this.el.style.display = 'none';
this.visible = false;
this.results = [];
this.selectedIndex = -1;
},
render() {
const replaceUnderscores = window.opts?.autocomplete_replace_underscores ?? true;
this.listEl.innerHTML = '';
this.results.forEach((tag, i) => {
const li = document.createElement('li');
if (i === this.selectedIndex) li.classList.add('selected');
const dot = document.createElement('span');
dot.className = 'autocomplete-category';
dot.style.color = engine.categoryColors[tag.category] || '#888';
dot.textContent = '\u25CF';
const name = document.createElement('span');
name.className = 'autocomplete-tag';
name.textContent = replaceUnderscores ? tag.display.replace(/_/g, ' ') : tag.display;
const count = document.createElement('span');
count.className = 'autocomplete-count';
count.textContent = tag.count > 0 ? formatCount(tag.count) : '';
li.append(dot, name, count);
li.addEventListener('mouseenter', () => {
this.selectedIndex = i;
this.updateSelection();
});
this.listEl.appendChild(li);
});
},
position() {
if (!this.textarea) return;
const rect = this.textarea.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const dropHeight = Math.min(this.el.scrollHeight, 300);
if (spaceBelow >= dropHeight || spaceBelow >= rect.top) {
this.el.style.top = `${rect.bottom + 2}px`;
} else {
this.el.style.top = `${rect.top - dropHeight - 2}px`;
}
this.el.style.left = `${rect.left}px`;
this.el.style.width = `${rect.width}px`;
},
updateSelection() {
[...this.listEl.children].forEach((li, i) => {
li.classList.toggle('selected', i === this.selectedIndex);
});
const selected = this.listEl.children[this.selectedIndex];
if (selected) selected.scrollIntoView({ block: 'nearest' });
},
navigate(dir) {
if (this.results.length === 0) return;
if (this.selectedIndex === -1) {
this.selectedIndex = dir > 0 ? 0 : this.results.length - 1;
} else {
this.selectedIndex = (this.selectedIndex + dir + this.results.length) % this.results.length;
}
this.updateSelection();
},
accept() {
if (this.selectedIndex < 0 || this.selectedIndex >= this.results.length) {
// Tab with no selection: select first
if (this.results.length > 0) {
this.selectedIndex = 0;
this.updateSelection();
}
return;
}
const tag = this.results[this.selectedIndex];
if (this.textarea) insertTag(this.textarea, tag.display);
this.hide();
},
};
// -- Event handlers --
let debounceTimer = null;
function onInput(textarea) {
const minChars = window.opts?.autocomplete_min_chars ?? 3;
const info = getCurrentWord(textarea);
if (!info || info.word.length < minChars) {
dropdown.hide();
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const results = engine.searchAll(info.word);
dropdown.show(results, textarea);
}, 150);
}
function onKeyDown(e) {
if (!dropdown.visible) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
e.stopPropagation();
dropdown.navigate(1);
break;
case 'ArrowUp':
e.preventDefault();
e.stopPropagation();
dropdown.navigate(-1);
break;
case 'Enter':
if (dropdown.selectedIndex >= 0) {
e.preventDefault();
e.stopPropagation();
dropdown.accept();
}
break;
case 'Tab':
e.preventDefault();
e.stopPropagation();
dropdown.accept();
break;
case 'Escape':
e.preventDefault();
e.stopPropagation();
dropdown.hide();
break;
default:
break;
}
}
/** Attach autocomplete to a single textarea. */
function attachAutocomplete(textarea) {
textarea.addEventListener('input', () => onInput(textarea));
textarea.addEventListener('keydown', onKeyDown);
textarea.addEventListener('focusout', () => {
setTimeout(() => dropdown.hide(), 200);
});
}
// -- Prompt textarea IDs --
const PROMPT_IDS = [
'txt2img_prompt', 'txt2img_neg_prompt',
'img2img_prompt', 'img2img_neg_prompt',
'control_prompt', 'control_neg_prompt',
'video_prompt', 'video_neg_prompt',
];
// -- Initialization --
async function initAutocomplete() {
const enabled = window.opts?.autocomplete_enabled || [];
if (!enabled.length) {
log('autocomplete', 'no dictionaries enabled');
return;
}
log('autocomplete', `init: ${enabled.join(', ')}`);
// Inject styles (CSS files in javascript/ are not auto-loaded)
const style = document.createElement('style');
style.textContent = [
'.autocompleteResults { position: fixed; z-index: 9999; max-height: 300px; overflow-y: auto;',
' background: var(--sd-main-background-color, var(--background-fill-primary, #1f2937));',
' border: 1px solid var(--sd-input-border-color, var(--border-color-primary, #374151));',
' border-radius: var(--sd-border-radius, 6px); box-shadow: 0 4px 12px rgba(0,0,0,0.3);',
' font-size: 13px; scrollbar-width: thin; }',
'.autocompleteResultsList { list-style: none; margin: 0; padding: 4px 0; }',
'.autocompleteResultsList > li { display: flex; align-items: center; padding: 4px 10px; cursor: pointer; gap: 8px; line-height: 1.4; }',
'.autocompleteResultsList > li:hover { background: var(--sd-panel-background-color, var(--input-background-fill-focus, #374151)); }',
'.autocompleteResultsList > li.selected { background: var(--sd-main-accent-color, var(--button-primary-background-fill, #4b5563)); }',
'.autocomplete-category { font-size: 10px; flex-shrink: 0; width: 10px; text-align: center; }',
'.autocomplete-tag { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'.autocomplete-count { font-size: 0.8em; opacity: 0.5; flex-shrink: 0; font-variant-numeric: tabular-nums; }',
].join('\n');
document.head.appendChild(style);
dropdown.init();
await engine.loadEnabled();
if (engine.indices.size === 0) {
log('autocomplete', 'no dictionaries loaded');
return;
}
// Attach to all prompt textareas
let attached = 0;
PROMPT_IDS.forEach((id) => {
const textarea = gradioApp().querySelector(`#${id} > label > textarea`);
if (textarea) {
attachAutocomplete(textarea);
attached++;
}
});
log('autocomplete', `attached to ${attached} textareas, ${engine.indices.size} dictionaries`);
// Reload when settings change
onOptionsChanged(async () => {
const newEnabled = window.opts?.autocomplete_enabled || [];
const currentKeys = [...engine.indices.keys()].sort().join(',');
const newKeys = [...newEnabled].sort().join(',');
if (currentKeys !== newKeys) {
log('autocomplete', `reloading: ${newEnabled.join(', ')}`);
await engine.loadEnabled();
}
});
}