1311 lines
37 KiB
JavaScript
1311 lines
37 KiB
JavaScript
/**
|
|
* Tag Autocomplete UI
|
|
*
|
|
* Provides A1111-style tag autocomplete functionality:
|
|
* - Popup when typing (like A1111's tag autocomplete)
|
|
* - Support Danbooru/e621 tag databases
|
|
* - Keyboard navigation (up/down/tab/enter/escape)
|
|
* - Mouse click selection
|
|
* - Alias support and display
|
|
*/
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
|
|
// Global state
|
|
let autocompletePopup = null;
|
|
let currentTextarea = null;
|
|
let currentWordInfo = null;
|
|
let selectedIndex = -1;
|
|
let currentResults = [];
|
|
|
|
// Small in-memory response cache. It keeps typing responsive without making the
|
|
// backend scan the large tag database for every repeated prefix.
|
|
const AUTOCOMPLETE_CACHE_MAX = 80;
|
|
const AUTOCOMPLETE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
const autocompleteCache = new Map();
|
|
let autocompleteWarmupStarted = false;
|
|
let wildcardManifest = null;
|
|
let wildcardManifestPromise = null;
|
|
|
|
// Frequency tracking
|
|
const FREQUENCY_STORAGE_KEY = "a1111_tag_frequency";
|
|
let tagFrequency = {}; // tag_name -> { count, lastUsed }
|
|
const RECENCY_WINDOW_DAYS = 30;
|
|
|
|
function normalizeUsageEntry(entry) {
|
|
if (typeof entry === "number") {
|
|
return {
|
|
count: Math.max(0, Number(entry) || 0),
|
|
lastUsed: 0,
|
|
};
|
|
}
|
|
|
|
if (entry && typeof entry === "object") {
|
|
return {
|
|
count: Math.max(0, Number(entry.count) || 0),
|
|
lastUsed: Math.max(0, Number(entry.lastUsed) || 0),
|
|
};
|
|
}
|
|
|
|
return { count: 0, lastUsed: 0 };
|
|
}
|
|
|
|
function normalizeFrequencyKey(tagName) {
|
|
return String(tagName || "").trim().toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Load tag frequency data from localStorage
|
|
*/
|
|
function loadTagFrequency() {
|
|
try {
|
|
const stored = localStorage.getItem(FREQUENCY_STORAGE_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
tagFrequency = Object.fromEntries(
|
|
Object.entries(parsed || {}).map(([tag, usage]) => [
|
|
normalizeFrequencyKey(tag),
|
|
normalizeUsageEntry(usage),
|
|
])
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to load tag frequency:", e);
|
|
tagFrequency = {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save tag frequency data to localStorage
|
|
*/
|
|
function saveTagFrequency() {
|
|
try {
|
|
localStorage.setItem(FREQUENCY_STORAGE_KEY, JSON.stringify(tagFrequency));
|
|
} catch (e) {
|
|
console.warn("Failed to save tag frequency:", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increment usage count for a tag
|
|
* @param {string} tagName - The tag name
|
|
*/
|
|
function incrementTagFrequency(tagName) {
|
|
const key = normalizeFrequencyKey(tagName);
|
|
if (!key) return;
|
|
const current = normalizeUsageEntry(tagFrequency[key]);
|
|
tagFrequency[key] = {
|
|
count: current.count + 1,
|
|
lastUsed: Date.now(),
|
|
};
|
|
saveTagFrequency();
|
|
}
|
|
|
|
/**
|
|
* Get usage count for a tag
|
|
* @param {string} tagName - The tag name
|
|
* @returns {number} Usage count
|
|
*/
|
|
function getTagFrequency(tagName) {
|
|
return normalizeUsageEntry(tagFrequency[normalizeFrequencyKey(tagName)]).count;
|
|
}
|
|
|
|
function getTagLastUsed(tagName) {
|
|
return normalizeUsageEntry(tagFrequency[normalizeFrequencyKey(tagName)]).lastUsed;
|
|
}
|
|
|
|
function getUsageScore(tagName) {
|
|
const count = getTagFrequency(tagName);
|
|
if (count <= 0) return 0;
|
|
|
|
const frequencyScore = Math.log2(count + 1);
|
|
const lastUsed = getTagLastUsed(tagName);
|
|
if (!lastUsed) {
|
|
return frequencyScore;
|
|
}
|
|
|
|
const ageDays = Math.max(0, Date.now() - lastUsed) / (1000 * 60 * 60 * 24);
|
|
const recencyScore = Math.max(0, 1 - ageDays / RECENCY_WINDOW_DAYS);
|
|
return frequencyScore + recencyScore * 0.75;
|
|
}
|
|
|
|
// Load frequency data on startup
|
|
loadTagFrequency();
|
|
|
|
// Tag type colors (matching backend TAG_TYPES)
|
|
const TAG_COLORS = {
|
|
0: "#87ceeb", // general - lightblue
|
|
1: "#cd5c5c", // artist - indianred
|
|
3: "#ee82ee", // copyright - violet
|
|
4: "#90ee90", // character - lightgreen
|
|
5: "#ffa500", // meta - orange
|
|
};
|
|
const WILDCARD_CONTENT_COLOR = "#e6edf3";
|
|
|
|
function appendWildcardPathLabel(container, wildcardPath) {
|
|
const segments = String(wildcardPath || "").split("/").filter(Boolean);
|
|
if (segments.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const prefixSegments = segments.slice(0, -1);
|
|
prefixSegments.forEach((segment) => {
|
|
const segmentSpan = document.createElement("span");
|
|
segmentSpan.className = "acPathPart";
|
|
segmentSpan.textContent = `${segment}/`;
|
|
container.appendChild(segmentSpan);
|
|
});
|
|
|
|
const leafSpan = document.createElement("span");
|
|
leafSpan.textContent = segments[segments.length - 1];
|
|
leafSpan.style.fontWeight = "600";
|
|
leafSpan.style.color = "var(--fg-color, #fff)";
|
|
container.appendChild(leafSpan);
|
|
}
|
|
|
|
function normalizeAutocompleteQuery(query, kind = "") {
|
|
let normalized = String(query || "").trim().toLowerCase();
|
|
if (kind === "wildcard_file" || kind === "wildcard_folder") {
|
|
if (normalized.startsWith("__")) {
|
|
normalized = normalized.slice(2);
|
|
}
|
|
if (normalized.endsWith("__")) {
|
|
normalized = normalized.slice(0, -2);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function getSuggestionRelevance(tag, rawQuery) {
|
|
const query = normalizeAutocompleteQuery(rawQuery, tag.kind);
|
|
if (!query) return 0;
|
|
|
|
const candidates = [tag.name];
|
|
if (tag.matched_alias) {
|
|
candidates.unshift(tag.matched_alias);
|
|
}
|
|
|
|
const normalizedCandidates = candidates.map((candidate) =>
|
|
normalizeAutocompleteQuery(candidate, tag.kind)
|
|
);
|
|
|
|
if (normalizedCandidates.some((candidate) => candidate === query)) {
|
|
return 3;
|
|
}
|
|
if (normalizedCandidates.some((candidate) => candidate.startsWith(query))) {
|
|
return 2;
|
|
}
|
|
if (normalizedCandidates.some((candidate) => candidate.includes(query))) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function getSortQuery(request) {
|
|
if (!request) {
|
|
return "";
|
|
}
|
|
if (request.mode === "wildcard_contents") {
|
|
return request.extra?.content_query || "";
|
|
}
|
|
return request.query || "";
|
|
}
|
|
|
|
function getRequestQueryKey(request) {
|
|
if (!request) {
|
|
return "";
|
|
}
|
|
if (request.mode === "wildcard_contents") {
|
|
return request.extra?.content_query || "";
|
|
}
|
|
return request.query || "";
|
|
}
|
|
|
|
function buildAutocompleteCacheKey(query, limit, mode, extra = {}) {
|
|
const keyPayload = {
|
|
mode,
|
|
query: normalizeAutocompleteQuery(query),
|
|
limit,
|
|
wildcard_name: extra.wildcard_name || "",
|
|
content_query: extra.content_query || "",
|
|
};
|
|
return JSON.stringify(keyPayload);
|
|
}
|
|
|
|
function setCachedSuggestions(query, limit, mode, extra, results) {
|
|
const payload = {
|
|
mode,
|
|
query: normalizeAutocompleteQuery(query),
|
|
limit,
|
|
wildcard_name: extra.wildcard_name || "",
|
|
content_query: extra.content_query || "",
|
|
};
|
|
const key = buildAutocompleteCacheKey(query, limit, mode, extra);
|
|
autocompleteCache.set(key, {
|
|
at: Date.now(),
|
|
payload,
|
|
results: Array.isArray(results) ? results : [],
|
|
});
|
|
|
|
while (autocompleteCache.size > AUTOCOMPLETE_CACHE_MAX) {
|
|
autocompleteCache.delete(autocompleteCache.keys().next().value);
|
|
}
|
|
}
|
|
|
|
function getCachedSuggestions(query, limit, mode, extra = {}) {
|
|
const key = buildAutocompleteCacheKey(query, limit, mode, extra);
|
|
const cached = autocompleteCache.get(key);
|
|
if (!cached) {
|
|
return null;
|
|
}
|
|
|
|
if (Date.now() - cached.at > AUTOCOMPLETE_CACHE_TTL_MS) {
|
|
autocompleteCache.delete(key);
|
|
return null;
|
|
}
|
|
|
|
return cached.results.slice();
|
|
}
|
|
|
|
function getCachedPrefixSuggestions(request, limit = 20) {
|
|
const rawQuery = getRequestQueryKey(request);
|
|
const query = normalizeAutocompleteQuery(rawQuery);
|
|
if (!query) {
|
|
return null;
|
|
}
|
|
|
|
for (const cached of autocompleteCache.values()) {
|
|
if (Date.now() - cached.at > AUTOCOMPLETE_CACHE_TTL_MS) {
|
|
continue;
|
|
}
|
|
|
|
if (!cached.payload || cached.payload.mode !== request.mode) {
|
|
continue;
|
|
}
|
|
if (
|
|
request.mode === "wildcard_contents" &&
|
|
cached.payload.wildcard_name !== (request.extra?.wildcard_name || "")
|
|
) {
|
|
continue;
|
|
}
|
|
if (!query.startsWith(cached.payload.query)) {
|
|
continue;
|
|
}
|
|
|
|
const matches = cached.results.filter(
|
|
(tag) => getSuggestionRelevance(tag, rawQuery) > 0
|
|
);
|
|
if (matches.length > 0) {
|
|
return matches.slice(0, limit);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function warmupAutocompleteDatabase() {
|
|
if (autocompleteWarmupStarted) {
|
|
return;
|
|
}
|
|
autocompleteWarmupStarted = true;
|
|
|
|
fetch("/a1111_prompt/autocomplete/warmup", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ mode: "tag" }),
|
|
}).catch((error) => {
|
|
autocompleteWarmupStarted = false;
|
|
console.warn("[A1111 Autocomplete] Warm-up request failed:", error);
|
|
});
|
|
}
|
|
|
|
async function loadWildcardManifest() {
|
|
if (wildcardManifest) {
|
|
return wildcardManifest;
|
|
}
|
|
if (wildcardManifestPromise) {
|
|
return wildcardManifestPromise;
|
|
}
|
|
|
|
wildcardManifestPromise = fetch("/a1111_prompt/autocomplete/wildcards")
|
|
.then(async (response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`Wildcard manifest request failed: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
const results = Array.isArray(data.results) ? data.results : [];
|
|
results.forEach((entry, index) => {
|
|
entry._searchText = String(entry.name || "").toLowerCase();
|
|
entry._manifestOrder = index;
|
|
});
|
|
wildcardManifest = results;
|
|
return wildcardManifest;
|
|
})
|
|
.catch((error) => {
|
|
wildcardManifestPromise = null;
|
|
console.warn("[A1111 Autocomplete] Wildcard manifest load failed:", error);
|
|
return null;
|
|
});
|
|
|
|
return wildcardManifestPromise;
|
|
}
|
|
|
|
function searchWildcardManifest(entries, rawQuery, limit = 200) {
|
|
const query = normalizeAutocompleteQuery(rawQuery, "wildcard_file");
|
|
if (!query) {
|
|
return entries.slice(0, limit);
|
|
}
|
|
|
|
const matches = [];
|
|
for (const entry of entries) {
|
|
const searchText = entry._searchText;
|
|
let relevance = 0;
|
|
if (searchText === query) {
|
|
relevance = 3;
|
|
} else if (searchText.startsWith(query)) {
|
|
relevance = 2;
|
|
} else if (searchText.includes(query)) {
|
|
relevance = 1;
|
|
} else {
|
|
continue;
|
|
}
|
|
matches.push({ entry, relevance });
|
|
}
|
|
|
|
matches.sort((left, right) => {
|
|
if (left.relevance !== right.relevance) {
|
|
return right.relevance - left.relevance;
|
|
}
|
|
const leftFolder = left.entry.kind === "wildcard_folder" ? 0 : 1;
|
|
const rightFolder = right.entry.kind === "wildcard_folder" ? 0 : 1;
|
|
if (leftFolder !== rightFolder) {
|
|
return leftFolder - rightFolder;
|
|
}
|
|
if (left.entry.name.length !== right.entry.name.length) {
|
|
return left.entry.name.length - right.entry.name.length;
|
|
}
|
|
return left.entry._manifestOrder - right.entry._manifestOrder;
|
|
});
|
|
|
|
return matches.slice(0, limit).map((match) => match.entry);
|
|
}
|
|
|
|
/**
|
|
* Create the autocomplete popup element
|
|
* @returns {HTMLElement} The popup element
|
|
*/
|
|
export function createAutocompletePopup() {
|
|
if (autocompletePopup) return autocompletePopup;
|
|
|
|
autocompletePopup = document.createElement("div");
|
|
autocompletePopup.className = "a1111-autocomplete-popup";
|
|
autocompletePopup.id = "a1111-autocomplete-popup";
|
|
|
|
// Clear blur timeout if user clicks on popup
|
|
autocompletePopup.addEventListener("mousedown", (e) => {
|
|
if (currentTextarea && currentTextarea._a1111_autocomplete_blur_timeout) {
|
|
clearTimeout(currentTextarea._a1111_autocomplete_blur_timeout);
|
|
currentTextarea._a1111_autocomplete_blur_timeout = null;
|
|
}
|
|
}, true);
|
|
|
|
// Use ComfyUI theme variables for consistent theming
|
|
autocompletePopup.style.cssText = `
|
|
position: fixed;
|
|
z-index: 10000;
|
|
background: var(--comfy-menu-bg, #353535);
|
|
border: 2px solid var(--border-color, #555);
|
|
border-radius: 6px;
|
|
width: max-content;
|
|
min-width: 250px;
|
|
max-width: 800px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
display: none;
|
|
padding: 4px 0;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 13px;
|
|
color: var(--fg-color, #fff);
|
|
`;
|
|
|
|
// Add scrollbar styling that respects theme
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
.a1111-autocomplete-popup::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
.a1111-autocomplete-popup::-webkit-scrollbar-track {
|
|
background: var(--comfy-input-bg, #222);
|
|
}
|
|
.a1111-autocomplete-popup::-webkit-scrollbar-thumb {
|
|
background: var(--border-color, #555);
|
|
border-radius: 4px;
|
|
}
|
|
.a1111-autocomplete-popup::-webkit-scrollbar-thumb:hover {
|
|
background: var(--content-hover-bg, #666);
|
|
}
|
|
.autocomplete-item {
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid var(--border-color, #444);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
transition: background-color 0.15s ease;
|
|
gap: 20px;
|
|
}
|
|
.autocomplete-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.autocomplete-item:hover {
|
|
background-color: rgba(255, 255, 255, 0.05);
|
|
}
|
|
.autocomplete-item.selected {
|
|
background-color: rgba(74, 158, 255, 0.25) !important;
|
|
border-left: 3px solid #4a9eff;
|
|
padding-left: 13px !important;
|
|
}
|
|
.autocomplete-item .acPathPart:nth-child(3n+1) {
|
|
color: var(--live-translation-color-1, lightskyblue);
|
|
}
|
|
.autocomplete-item .acPathPart:nth-child(3n+2) {
|
|
color: var(--live-translation-color-2, palegoldenrod);
|
|
}
|
|
.autocomplete-item .acPathPart:nth-child(3n+3) {
|
|
color: var(--live-translation-color-3, darkseagreen);
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
document.body.appendChild(autocompletePopup);
|
|
return autocompletePopup;
|
|
}
|
|
|
|
/**
|
|
* Get cursor coordinates in the viewport
|
|
* @param {HTMLTextAreaElement} textarea - The textarea element
|
|
* @param {number} cursorPos - Cursor position in text
|
|
* @returns {Object} Object with x, y coordinates
|
|
*/
|
|
// From https://github.com/component/textarea-caret-position
|
|
const CARET_PROPERTIES = [
|
|
'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY',
|
|
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle',
|
|
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily',
|
|
'textAlign', 'textTransform', 'textIndent', 'textDecoration',
|
|
'letterSpacing', 'wordSpacing', 'tabSize', 'MozTabSize',
|
|
'whiteSpace', 'wordBreak', 'wordWrap', 'overflowWrap'
|
|
];
|
|
|
|
/**
|
|
* Get cursor coordinates in the viewport
|
|
* @param {HTMLTextAreaElement} element - The textarea element
|
|
* @param {number} position - Cursor position in text
|
|
* @returns {Object} Object with x, y, height coordinates
|
|
*/
|
|
function getCaretCoordinates(element, position) {
|
|
if (typeof window === 'undefined') return { x: 0, y: 0, height: 0 };
|
|
|
|
// Create a mirror div to replicate the textarea's style in 1:1 layout space
|
|
const div = document.createElement('div');
|
|
div.id = 'input-textarea-caret-position-mirror-div';
|
|
document.body.appendChild(div);
|
|
|
|
try {
|
|
const style = div.style;
|
|
const computed = window.getComputedStyle(element);
|
|
|
|
// Transfer all layout-affecting properties
|
|
CARET_PROPERTIES.forEach(prop => {
|
|
style[prop] = computed[prop];
|
|
});
|
|
|
|
// Reset positioning for the mirror
|
|
style.position = 'absolute';
|
|
style.visibility = 'hidden';
|
|
style.pointerEvents = 'none';
|
|
style.top = '0';
|
|
style.left = '0';
|
|
|
|
// Force scrollbar behavior to match accurately for width calculations
|
|
if (element.scrollHeight > element.clientHeight) {
|
|
style.overflowY = 'scroll';
|
|
} else {
|
|
style.overflowY = 'hidden';
|
|
}
|
|
|
|
// Replicate text content up to the caret
|
|
div.textContent = element.value.substring(0, position);
|
|
|
|
const span = document.createElement('span');
|
|
span.textContent = element.value.substring(position) || '.';
|
|
div.appendChild(span);
|
|
|
|
// Get layout-local coordinates
|
|
// offsetTop/Left are relative to the div's border box
|
|
const layoutX = span.offsetLeft;
|
|
const layoutY = span.offsetTop;
|
|
const layoutHeight = parseInt(computed.lineHeight) || 20;
|
|
|
|
// Calculate scaling factor between layout and viewport
|
|
// This is crucial for ComfyUI's zoom/pan
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
// Use offsetWidth as the layout width reference
|
|
// If offsetWidth is 0 (not visible), fallback to 1 to avoid division by zero
|
|
const layoutWidth = element.offsetWidth || parseFloat(computed.width) || 1;
|
|
const scale = rect.width / layoutWidth;
|
|
|
|
// Map local layout coordinates to viewport coordinates
|
|
// We account for:
|
|
// 1. Textarea's viewport position (rect.left/top)
|
|
// 2. Local caret offset in layout units (layoutX/Y)
|
|
// 3. Current scroll position (element.scrollLeft/Top)
|
|
// 4. Transform scale (scale)
|
|
// 5. Borders (computed border width)
|
|
|
|
const borderLeft = parseInt(computed.borderLeftWidth) || 0;
|
|
const borderTop = parseInt(computed.borderTopWidth) || 0;
|
|
|
|
return {
|
|
x: rect.left + (layoutX + borderLeft - element.scrollLeft) * scale,
|
|
y: rect.top + (layoutY + borderTop - element.scrollTop) * scale,
|
|
height: layoutHeight * scale
|
|
};
|
|
} finally {
|
|
// Cleanup mirror div from DOM
|
|
if (div && div.parentNode) {
|
|
div.parentNode.removeChild(div);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show autocomplete suggestions
|
|
* @param {Array} suggestions - Array of tag suggestions
|
|
* @param {HTMLElement} textarea - The textarea element
|
|
* @param {Object} wordInfo - Current word information
|
|
*/
|
|
export function showAutocompleteSuggestions(suggestions, textarea, wordInfo, request = null) {
|
|
if (!suggestions || suggestions.length === 0) {
|
|
hideAutocompletePopup();
|
|
return;
|
|
}
|
|
|
|
const popup = createAutocompletePopup();
|
|
const sortedSuggestions = sortByFrequency(suggestions, getSortQuery(request));
|
|
|
|
currentResults = sortedSuggestions;
|
|
selectedIndex = -1;
|
|
currentTextarea = textarea;
|
|
currentWordInfo = wordInfo;
|
|
|
|
popup.innerHTML = "";
|
|
|
|
sortedSuggestions.forEach((tag, index) => {
|
|
const item = document.createElement("div");
|
|
item.className = "autocomplete-item";
|
|
|
|
const nameSpan = document.createElement("span");
|
|
const isWildcardFile = tag.kind === "wildcard_file";
|
|
const isWildcardFolder = tag.kind === "wildcard_folder";
|
|
const isWildcardContent = tag.kind === "wildcard_content";
|
|
nameSpan.style.cssText = `
|
|
color: ${isWildcardContent ? WILDCARD_CONTENT_COLOR : TAG_COLORS[tag.type] || "var(--fg-color, #ccc)"};
|
|
font-weight: 500;
|
|
flex: 1;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
let displayName = (isWildcardFile || isWildcardFolder) ? `__${tag.name}__` : tag.name;
|
|
if (isWildcardFile || isWildcardFolder) {
|
|
nameSpan.textContent = "";
|
|
appendWildcardPathLabel(nameSpan, tag.name);
|
|
} else if (tag.matched_alias) {
|
|
displayName = `${tag.matched_alias} → ${tag.name}`;
|
|
nameSpan.style.fontStyle = "italic";
|
|
nameSpan.textContent = displayName;
|
|
} else {
|
|
nameSpan.textContent = displayName;
|
|
}
|
|
|
|
const frequency = getTagFrequency(tag.name);
|
|
if (frequency > 0) {
|
|
const freqSpan = document.createElement("span");
|
|
freqSpan.style.cssText = `
|
|
color: #4a9eff;
|
|
font-size: 10px;
|
|
margin-left: 6px;
|
|
padding: 2px 4px;
|
|
background: rgba(74, 158, 255, 0.15);
|
|
border-radius: 3px;
|
|
`;
|
|
freqSpan.textContent = `★${frequency}`;
|
|
nameSpan.appendChild(freqSpan);
|
|
}
|
|
|
|
const countSpan = document.createElement("span");
|
|
countSpan.style.cssText = `
|
|
color: var(--descrip-text, #888);
|
|
font-size: 11px;
|
|
margin-left: 8px;
|
|
`;
|
|
if (isWildcardFile || isWildcardFolder || isWildcardContent) {
|
|
countSpan.textContent = tag.meta || "Wildcard file";
|
|
} else {
|
|
countSpan.textContent = formatCount(tag.count);
|
|
}
|
|
|
|
item.appendChild(nameSpan);
|
|
item.appendChild(countSpan);
|
|
item.addEventListener("click", () => insertTag(index));
|
|
popup.appendChild(item);
|
|
});
|
|
|
|
// Calculate position using robust caret logic
|
|
const coords = getCaretCoordinates(textarea, wordInfo.start);
|
|
const popupHeight = Math.min(300, currentResults.length * 40); // Estimate
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
// Decide whether to show above or below
|
|
let top = coords.y + coords.height + 5;
|
|
if (top + popupHeight > viewportHeight - 10) {
|
|
// Show above if it doesn't fit below
|
|
top = coords.y - popupHeight - 5;
|
|
}
|
|
|
|
popup.style.left = `${Math.max(10, Math.min(coords.x, window.innerWidth - 360))}px`;
|
|
popup.style.top = `${top}px`;
|
|
popup.style.display = "block";
|
|
setSelectedIndex(-1);
|
|
}
|
|
|
|
/**
|
|
* Sort suggestions by frequency and relevance
|
|
* @param {Array} suggestions - Array of tag suggestions
|
|
* @param {string} query - Active query text for relevance grouping
|
|
* @returns {Array} Sorted suggestions
|
|
*/
|
|
function sortByFrequency(suggestions, query = "") {
|
|
return suggestions.map((tag, index) => {
|
|
const frequency = getTagFrequency(tag.name);
|
|
const count = Number(tag.count) || 0;
|
|
const usageScore = getUsageScore(tag.name);
|
|
const relevance = getSuggestionRelevance(tag, query);
|
|
|
|
return {
|
|
...tag,
|
|
_originalIndex: index,
|
|
_frequency: frequency,
|
|
_count: count,
|
|
_usageScore: usageScore,
|
|
_relevance: relevance,
|
|
};
|
|
}).sort((a, b) => {
|
|
const exactDelta = Number(b._relevance === 3) - Number(a._relevance === 3);
|
|
if (exactDelta !== 0) {
|
|
return exactDelta;
|
|
}
|
|
|
|
const hasUsage = a._usageScore > 0 || b._usageScore > 0;
|
|
if (hasUsage && Math.abs(b._usageScore - a._usageScore) > 0.05) {
|
|
return b._usageScore - a._usageScore;
|
|
}
|
|
|
|
if (b._relevance !== a._relevance) {
|
|
return b._relevance - a._relevance;
|
|
}
|
|
|
|
if (!hasUsage && Math.abs(b._usageScore - a._usageScore) > 0.05) {
|
|
return b._usageScore - a._usageScore;
|
|
}
|
|
|
|
if (b._count !== a._count) {
|
|
return b._count - a._count;
|
|
}
|
|
|
|
return a._originalIndex - b._originalIndex;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hide the autocomplete popup
|
|
*/
|
|
export function hideAutocompletePopup() {
|
|
if (autocompletePopup) {
|
|
autocompletePopup.style.display = "none";
|
|
selectedIndex = -1;
|
|
currentResults = [];
|
|
currentTextarea = null;
|
|
currentWordInfo = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the selected index and update visual selection
|
|
* @param {number} index - Index to select
|
|
*/
|
|
function setSelectedIndex(index) {
|
|
if (!autocompletePopup || currentResults.length === 0) return;
|
|
|
|
// Remove previous selection
|
|
const items = autocompletePopup.querySelectorAll(".autocomplete-item");
|
|
items.forEach(item => {
|
|
item.classList.remove("selected");
|
|
});
|
|
|
|
// Set new selection, allowing -1 for "no selection"
|
|
selectedIndex = Math.max(-1, Math.min(index, currentResults.length - 1));
|
|
|
|
if (selectedIndex >= 0 && items[selectedIndex]) {
|
|
items[selectedIndex].classList.add("selected");
|
|
|
|
// Scroll into view if needed
|
|
items[selectedIndex].scrollIntoView({
|
|
block: "nearest",
|
|
behavior: "smooth"
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert the selected tag into the textarea
|
|
* @param {number} index - Index of tag to insert (optional, uses selectedIndex if not provided)
|
|
*/
|
|
function insertTag(index = selectedIndex) {
|
|
if (!currentTextarea || !currentWordInfo || index < 0 || index >= currentResults.length) {
|
|
return;
|
|
}
|
|
|
|
const tag = currentResults[index];
|
|
const textarea = currentTextarea;
|
|
const wordInfo = currentWordInfo;
|
|
|
|
incrementTagFrequency(tag.name);
|
|
|
|
const text = textarea.value;
|
|
const isWildcard = tag.kind === "wildcard_file" || tag.kind === "wildcard_folder";
|
|
const isWildcardContent = tag.kind === "wildcard_content";
|
|
let tagName = tag.completion || tag.name;
|
|
|
|
if (!isWildcard && !isWildcardContent) {
|
|
// Configurable A1111 formatting (usually on by default)
|
|
tagName = tagName.replace(/_/g, " ");
|
|
tagName = tagName.replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
}
|
|
|
|
const beforeWord = text.substring(0, wordInfo.start);
|
|
const afterWord = text.substring(wordInfo.end);
|
|
|
|
// Smart separator logic from reference
|
|
// Only add if not already present
|
|
let suffix = "";
|
|
const matchAfter = afterWord.match(/^\s*[,:]/);
|
|
if (!isWildcard && !isWildcardContent && !matchAfter) {
|
|
suffix = ", ";
|
|
}
|
|
|
|
const newText = beforeWord + tagName + suffix + afterWord;
|
|
const newCursorPos = beforeWord.length + tagName.length + suffix.length;
|
|
|
|
hideAutocompletePopup();
|
|
|
|
textarea.value = newText;
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
textarea.focus();
|
|
}
|
|
|
|
/**
|
|
* Handle keyboard navigation in the popup
|
|
* @param {KeyboardEvent} e - The keyboard event
|
|
* @returns {boolean} True if event was handled
|
|
*/
|
|
function handleKeyboardNavigation(e) {
|
|
if (!autocompletePopup || autocompletePopup.style.display === "none") {
|
|
return false;
|
|
}
|
|
|
|
const length = currentResults.length;
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
if (selectedIndex === -1) {
|
|
setSelectedIndex(0);
|
|
} else {
|
|
setSelectedIndex((selectedIndex + 1) % length);
|
|
}
|
|
return true;
|
|
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
if (selectedIndex === -1) {
|
|
setSelectedIndex(length - 1);
|
|
} else {
|
|
setSelectedIndex((selectedIndex - 1 + length) % length);
|
|
}
|
|
return true;
|
|
|
|
case "Tab":
|
|
case "Enter":
|
|
if (selectedIndex === -1) {
|
|
// No selection, allow default behavior (like newline)
|
|
hideAutocompletePopup();
|
|
return false;
|
|
}
|
|
e.preventDefault();
|
|
insertTag();
|
|
return true;
|
|
|
|
case "Escape":
|
|
e.preventDefault();
|
|
hideAutocompletePopup();
|
|
return true;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format post count for display
|
|
* @param {number} count - Post count
|
|
* @returns {string} Formatted count
|
|
*/
|
|
function formatCount(count) {
|
|
if (count >= 1000000) {
|
|
return `${(count / 1000000).toFixed(1)}M`;
|
|
} else if (count >= 1000) {
|
|
return `${(count / 1000).toFixed(1)}k`;
|
|
}
|
|
return count.toString();
|
|
}
|
|
|
|
/**
|
|
* Fetch tag suggestions from the API
|
|
* @param {string} query - The search query
|
|
* @param {number} limit - Maximum number of results
|
|
* @returns {Promise<Array>} Array of tag suggestions
|
|
*/
|
|
export async function fetchTagSuggestions(query, limit = 20, mode = "tag", extra = {}) {
|
|
const isWildcard = mode === "wildcard" || mode === "wildcard_contents";
|
|
if (!query) return [];
|
|
if (!isWildcard && query.length < 2) return [];
|
|
const effectiveLimit = isWildcard ? Math.max(limit, 200) : limit;
|
|
|
|
if (mode === "wildcard") {
|
|
const entries = await loadWildcardManifest();
|
|
if (entries) {
|
|
return searchWildcardManifest(entries, query, effectiveLimit);
|
|
}
|
|
}
|
|
|
|
const cached = getCachedSuggestions(query, effectiveLimit, mode, extra);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/a1111_prompt/autocomplete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
query: query,
|
|
limit: effectiveLimit,
|
|
mode: mode,
|
|
search_aliases: true,
|
|
extra_files: ["extra-quality-tags.csv"], // Include quality tags
|
|
...extra,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.warn("Autocomplete API error:", response.status);
|
|
return [];
|
|
}
|
|
|
|
const data = await response.json();
|
|
const results = data.results || [];
|
|
setCachedSuggestions(query, effectiveLimit, mode, extra, results);
|
|
return results;
|
|
} catch (error) {
|
|
console.warn("Autocomplete fetch error:", error);
|
|
|
|
// Reset selection state to prevent inserting stale data on keyboard events
|
|
currentResults = [];
|
|
selectedIndex = -1;
|
|
|
|
// Show error in popup
|
|
const popup = createAutocompletePopup();
|
|
popup.innerHTML = `
|
|
<div style="padding: 10px; color: #ff6b6b; font-size: 12px; border-bottom: 1px solid #444;">
|
|
<strong>Error:</strong> Failed to fetch tags from backend.
|
|
</div>
|
|
`;
|
|
popup.style.display = "block";
|
|
return null; // Return null to indicate error, differentiating from empty results
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset tag frequency data
|
|
* Useful for clearing usage statistics
|
|
*/
|
|
export function resetTagFrequency() {
|
|
if (confirm("Reset all tag frequency data? This will clear your usage statistics.")) {
|
|
tagFrequency = {};
|
|
saveTagFrequency();
|
|
console.log("[Autocomplete] Tag frequency data reset");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export tag frequency data for backup
|
|
* @returns {string} JSON string of frequency data
|
|
*/
|
|
export function exportTagFrequency() {
|
|
return JSON.stringify(tagFrequency, null, 2);
|
|
}
|
|
|
|
/**
|
|
* Get statistics about tag usage
|
|
* @returns {Object} Statistics object
|
|
*/
|
|
export function getFrequencyStats() {
|
|
const entries = Object.entries(tagFrequency).map(([tag, usage]) => [
|
|
tag,
|
|
normalizeUsageEntry(usage),
|
|
]);
|
|
const totalTags = entries.length;
|
|
const totalUses = entries.reduce((sum, [_, usage]) => sum + usage.count, 0);
|
|
const topTags = entries
|
|
.sort((a, b) => {
|
|
if (b[1].count !== a[1].count) {
|
|
return b[1].count - a[1].count;
|
|
}
|
|
return b[1].lastUsed - a[1].lastUsed;
|
|
})
|
|
.slice(0, 10)
|
|
.map(([tag, usage]) => ({
|
|
tag,
|
|
count: usage.count,
|
|
lastUsed: usage.lastUsed,
|
|
}));
|
|
|
|
return {
|
|
totalTags,
|
|
totalUses,
|
|
topTags
|
|
};
|
|
}
|
|
|
|
// Expose utility functions to window for console access
|
|
if (typeof window !== 'undefined') {
|
|
window.A1111Autocomplete = {
|
|
resetFrequency: resetTagFrequency,
|
|
exportFrequency: exportTagFrequency,
|
|
getStats: getFrequencyStats
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the current word being typed at cursor position
|
|
* @param {string} text - The full text
|
|
* @param {number} cursorPos - Current cursor position
|
|
* @returns {Object} Object with word, start, and end positions
|
|
*/
|
|
export function getCurrentWord(text, cursorPos) {
|
|
// Find word boundaries - tags are separated by commas, spaces, parentheses, colons, brackets, etc.
|
|
// Matching reference regex logic: [^\s,|<>():\[\]]
|
|
let start = cursorPos;
|
|
let end = cursorPos;
|
|
|
|
const isSeparator = (char) => /[\s,()|<>:\[\]]/.test(char);
|
|
|
|
// Move start back to word boundary
|
|
while (start > 0 && !isSeparator(text[start - 1])) {
|
|
start--;
|
|
}
|
|
|
|
// Move end forward to word boundary
|
|
while (end < text.length && !isSeparator(text[end])) {
|
|
end++;
|
|
}
|
|
|
|
return {
|
|
word: text.slice(start, end).trim(),
|
|
start,
|
|
end,
|
|
};
|
|
}
|
|
|
|
function getAutocompleteRequest(word) {
|
|
const completedWildcard = word.match(/^__([^,]+?)__([^, ]*)$/);
|
|
if (completedWildcard) {
|
|
return {
|
|
mode: "wildcard_contents",
|
|
query: word,
|
|
minLength: 4,
|
|
extra: {
|
|
wildcard_name: completedWildcard[1],
|
|
content_query: completedWildcard[2] || "",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (word.startsWith("__")) {
|
|
return {
|
|
mode: "wildcard",
|
|
query: word,
|
|
minLength: 2,
|
|
extra: {},
|
|
};
|
|
}
|
|
|
|
return {
|
|
mode: "tag",
|
|
query: word,
|
|
minLength: 2,
|
|
extra: {},
|
|
};
|
|
}
|
|
|
|
// Extension registration
|
|
app.registerExtension({
|
|
name: "A1111PromptNode.Autocomplete",
|
|
|
|
setup() {
|
|
warmupAutocompleteDatabase();
|
|
loadWildcardManifest();
|
|
},
|
|
|
|
async nodeCreated(node) {
|
|
if (
|
|
node.comfyClass !== "A1111Prompt" &&
|
|
node.comfyClass !== "A1111PromptNegative"
|
|
)
|
|
return;
|
|
|
|
const textWidget = node.widgets?.find((w) => w.name === "text");
|
|
if (!textWidget) return;
|
|
warmupAutocompleteDatabase();
|
|
|
|
let boundTextarea = null;
|
|
let monitorFrame = null;
|
|
|
|
const bindTextarea = (textarea) => {
|
|
if (!textarea || textarea === boundTextarea) {
|
|
return;
|
|
}
|
|
|
|
node._autocompleteCleanup?.();
|
|
boundTextarea = textarea;
|
|
let suppressAutocomplete = false;
|
|
let debounceTimeout = null;
|
|
let requestVersion = 0;
|
|
|
|
const scheduleAutocomplete = (delay = 50) => {
|
|
clearTimeout(debounceTimeout);
|
|
const scheduledVersion = ++requestVersion;
|
|
|
|
debounceTimeout = setTimeout(async () => {
|
|
if (!textarea.isConnected) {
|
|
return;
|
|
}
|
|
|
|
const cursorPos = textarea.selectionStart;
|
|
const wordInfo = getCurrentWord(textarea.value, cursorPos);
|
|
|
|
const request = getAutocompleteRequest(wordInfo.word);
|
|
if (request.query.length < request.minLength) {
|
|
hideAutocompletePopup();
|
|
return;
|
|
}
|
|
|
|
const cachedSuggestions =
|
|
request.mode === "wildcard"
|
|
? null
|
|
: getCachedPrefixSuggestions(request);
|
|
if (cachedSuggestions && cachedSuggestions.length > 0) {
|
|
showAutocompleteSuggestions(
|
|
cachedSuggestions,
|
|
textarea,
|
|
wordInfo,
|
|
request
|
|
);
|
|
}
|
|
|
|
const suggestions = await fetchTagSuggestions(
|
|
request.query,
|
|
20,
|
|
request.mode,
|
|
request.extra
|
|
);
|
|
|
|
if (suggestions === null) {
|
|
return;
|
|
}
|
|
|
|
if (scheduledVersion !== requestVersion || !textarea.isConnected) {
|
|
return;
|
|
}
|
|
|
|
const latestWordInfo = getCurrentWord(
|
|
textarea.value,
|
|
textarea.selectionStart
|
|
);
|
|
if (
|
|
latestWordInfo.word !== wordInfo.word ||
|
|
latestWordInfo.start !== wordInfo.start ||
|
|
latestWordInfo.end !== wordInfo.end
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (suggestions.length > 0) {
|
|
showAutocompleteSuggestions(suggestions, textarea, latestWordInfo, request);
|
|
} else {
|
|
hideAutocompletePopup();
|
|
}
|
|
}, delay);
|
|
};
|
|
|
|
// Add input event listener for autocomplete
|
|
const onInput = async (e) => {
|
|
if (suppressAutocomplete) {
|
|
suppressAutocomplete = false;
|
|
requestVersion++;
|
|
hideAutocompletePopup();
|
|
return;
|
|
}
|
|
|
|
scheduleAutocomplete();
|
|
};
|
|
textarea.addEventListener("input", onInput);
|
|
|
|
// Add keydown event listener for navigation
|
|
const onKeydown = (e) => {
|
|
if (e.key === "Backspace") {
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
if (start === end && start > 0) {
|
|
const char = textarea.value[start - 1];
|
|
// If the character being deleted is a separator, suppress the next input event
|
|
if (/[\s,()|<>:\[\]]/.test(char)) {
|
|
suppressAutocomplete = true;
|
|
} else {
|
|
suppressAutocomplete = false;
|
|
}
|
|
} else {
|
|
suppressAutocomplete = false;
|
|
}
|
|
} else {
|
|
// Any other key clears the suppression
|
|
suppressAutocomplete = false;
|
|
}
|
|
|
|
if (handleKeyboardNavigation(e)) {
|
|
// Event was handled by autocomplete
|
|
return;
|
|
}
|
|
};
|
|
textarea.addEventListener("keydown", onKeydown);
|
|
|
|
// Hide popup on blur (with delay to allow clicks)
|
|
const onBlur = () => {
|
|
textarea._a1111_autocomplete_blur_timeout = setTimeout(() => {
|
|
hideAutocompletePopup();
|
|
textarea._a1111_autocomplete_blur_timeout = null;
|
|
}, 200);
|
|
};
|
|
textarea.addEventListener("blur", onBlur);
|
|
|
|
// Hide popup when clicking outside
|
|
const outsideClickListener = (e) => {
|
|
if (autocompletePopup &&
|
|
!autocompletePopup.contains(e.target) &&
|
|
e.target !== textarea) {
|
|
hideAutocompletePopup();
|
|
}
|
|
};
|
|
document.addEventListener("click", outsideClickListener);
|
|
|
|
// Store for cleanup
|
|
node._outsideClickListener = outsideClickListener;
|
|
node._autocompleteCleanup = () => {
|
|
requestVersion++;
|
|
clearTimeout(debounceTimeout);
|
|
textarea.removeEventListener("input", onInput);
|
|
textarea.removeEventListener("keydown", onKeydown);
|
|
textarea.removeEventListener("blur", onBlur);
|
|
document.removeEventListener("click", outsideClickListener);
|
|
if (node._outsideClickListener === outsideClickListener) {
|
|
node._outsideClickListener = null;
|
|
}
|
|
if (textarea._a1111_autocomplete_blur_timeout) {
|
|
clearTimeout(textarea._a1111_autocomplete_blur_timeout);
|
|
textarea._a1111_autocomplete_blur_timeout = null;
|
|
}
|
|
if (currentTextarea === textarea) {
|
|
hideAutocompletePopup();
|
|
}
|
|
if (boundTextarea === textarea) {
|
|
boundTextarea = null;
|
|
}
|
|
};
|
|
};
|
|
|
|
const monitorTextarea = () => {
|
|
if (node._autocompleteRemoved) {
|
|
return;
|
|
}
|
|
|
|
const textarea = textWidget.inputEl;
|
|
if (textarea && textarea.isConnected) {
|
|
bindTextarea(textarea);
|
|
} else if (boundTextarea) {
|
|
node._autocompleteCleanup?.();
|
|
}
|
|
|
|
monitorFrame = requestAnimationFrame(monitorTextarea);
|
|
};
|
|
|
|
monitorFrame = requestAnimationFrame(monitorTextarea);
|
|
node._autocompleteMonitorCleanup = () => {
|
|
if (monitorFrame) {
|
|
cancelAnimationFrame(monitorFrame);
|
|
monitorFrame = null;
|
|
}
|
|
};
|
|
|
|
// Add cleanup on node removal
|
|
const onRemoved = node.onRemoved;
|
|
node.onRemoved = function () {
|
|
if (onRemoved) onRemoved.apply(this, arguments);
|
|
this._autocompleteRemoved = true;
|
|
|
|
// Remove the specific document click listener for this node
|
|
if (this._outsideClickListener) {
|
|
document.removeEventListener("click", this._outsideClickListener);
|
|
this._outsideClickListener = null;
|
|
}
|
|
|
|
this._autocompleteMonitorCleanup?.();
|
|
this._autocompleteMonitorCleanup = null;
|
|
this._autocompleteCleanup?.();
|
|
this._autocompleteCleanup = null;
|
|
};
|
|
},
|
|
onRemoved() {
|
|
// Global cleanup if needed when extension is removed
|
|
autocompletePopup?.parentNode?.removeChild(autocompletePopup);
|
|
autocompletePopup = null;
|
|
},
|
|
});
|