Files
a1111-sd-webui-tagcomplete/tagAutocomplete.js
Dominik Reh f224eda78c Added left/right arrow capture
Also fixes unexpected insertion behavior when moving after the window opened
2022-10-11 23:32:29 +02:00

338 lines
11 KiB
JavaScript

// Style for new elements. Gets appended to the Gradio root.
const autocompleteCSS_dark = `
#autocompleteResults {
position: absolute;
z-index: 999;
margin: 5px 0 0 0;
background-color: #0b0f19 !important;
border: 1px solid #4b5563 !important;
border-radius: 12px !important;
overflow: hidden;
}
#autocompleteResultsList > li:nth-child(odd) {
background-color: #111827;
}
#autocompleteResultsList > li {
list-style-type: none;
padding: 10px;
cursor: pointer;
}
#autocompleteResultsList > li:hover {
background-color: #1f2937;
}
#autocompleteResultsList > li.selected {
background-color: #374151;
}
`;
const autocompleteCSS_light = `
#autocompleteResults {
position: absolute;
z-index: 999;
margin: 5px 0 0 0;
background-color: #ffffff !important;
border: 1.5px solid #e5e7eb !important;
border-radius: 12px !important;
overflow: hidden;
}
#autocompleteResultsList > li:nth-child(odd) {
background-color: #f9fafb;
}
#autocompleteResultsList > li {
list-style-type: none;
padding: 10px;
cursor: pointer;
}
#autocompleteResultsList > li:hover {
background-color: #f5f6f8;
}
#autocompleteResultsList > li.selected {
background-color: #e5e7eb;
}
`;
var acConfig = null;
// Parse the CSV file into a 2D array. Doesn't use regex, so it is very lightweight.
function parseCSV(str) {
var arr = [];
var quote = false; // 'true' means we're inside a quoted field
// Iterate over each character, keep track of current row and column (of the returned array)
for (var row = 0, col = 0, c = 0; c < str.length; c++) {
var cc = str[c], nc = str[c+1]; // Current character, next character
arr[row] = arr[row] || []; // Create a new row if necessary
arr[row][col] = arr[row][col] || ''; // Create a new column (start with empty string) if necessary
// If the current character is a quotation mark, and we're inside a
// quoted field, and the next character is also a quotation mark,
// add a quotation mark to the current column and skip the next character
if (cc == '"' && quote && nc == '"') { arr[row][col] += cc; ++c; continue; }
// If it's just one quotation mark, begin/end quoted field
if (cc == '"') { quote = !quote; continue; }
// If it's a comma and we're not in a quoted field, move on to the next column
if (cc == ',' && !quote) { ++col; continue; }
// If it's a newline (CRLF) and we're not in a quoted field, skip the next character
// and move on to the next row and move to column 0 of that new row
if (cc == '\r' && nc == '\n' && !quote) { ++row; col = 0; ++c; continue; }
// If it's a newline (LF or CR) and we're not in a quoted field,
// move on to the next row and move to column 0 of that new row
if (cc == '\n' && !quote) { ++row; col = 0; continue; }
if (cc == '\r' && !quote) { ++row; col = 0; continue; }
// Otherwise, append the current character to the current column
arr[row][col] += cc;
}
return arr;
}
// Load file
function readFile(filePath) {
let request = new XMLHttpRequest();
request.open("GET", filePath, false);
request.send(null);
return request.responseText;
}
function loadCSV() {
let text = readFile(`file/tags/${acConfig.tagFile}`);
return parseCSV(text);
}
// Debounce function to prevent spamming the autocomplete function
var dbTimeOut;
const debounce = (func, wait = 300) => {
return function(...args) {
if (dbTimeOut) {
clearTimeout(dbTimeOut);
}
dbTimeOut = setTimeout(() => {
func.apply(this, args);
}, wait);
}
}
// Difference function to fix duplicates not being seen as changes in normal filter
function difference(a, b) {
return [...b.reduce( (acc, v) => acc.set(v, (acc.get(v) || 0) - 1),
a.reduce( (acc, v) => acc.set(v, (acc.get(v) || 0) + 1), new Map() )
)].reduce( (acc, [v, count]) => acc.concat(Array(Math.abs(count)).fill(v)), [] );
}
// Create the result list div and necessary styling
function createResultsDiv() {
let resultsDiv = document.createElement("div");
let resultsList = document.createElement('ul');
resultsDiv.setAttribute('id', 'autocompleteResults');
resultsList.setAttribute('id', 'autocompleteResultsList');
resultsDiv.appendChild(resultsList);
return resultsDiv;
}
// The selected tag index. Needs to be up here so hide can access it.
var selectedTag = null;
// Show or hide the results div
var isVisible = false;
function showResults() {
let resultsDiv = gradioApp().querySelector('#autocompleteResults');
resultsDiv.style.display = "block";
isVisible = true;
}
function hideResults() {
let resultsDiv = gradioApp().querySelector('#autocompleteResults');
resultsDiv.style.display = "none";
isVisible = false;
selectedTag = null;
}
// On click, insert the tag into the prompt textbox with respect to the cursor position
function insertTextAtCursor(text, tagword) {
let promptTextbox = gradioApp().querySelector('#txt2img_prompt > label > textarea');
let cursorPos = promptTextbox.selectionStart;
let sanitizedText = acConfig.replaceUnderscores ? text.replaceAll("_", " ") : text;
let optionalComma = (promptTextbox.value[cursorPos] == ",") ? "" : ", ";
// Edit prompt text
var prompt = promptTextbox.value;
promptTextbox.value = prompt.substring(0, cursorPos - tagword.length) + sanitizedText + optionalComma + prompt.substring(cursorPos);
prompt = promptTextbox.value;
// Update cursor position to after the inserted text
promptTextbox.selectionStart = cursorPos + sanitizedText.length;
promptTextbox.selectionEnd = promptTextbox.selectionStart;
// Hide results after inserting
hideResults();
// Update previous tags with the edited prompt to prevent re-searching the same term
let tags = prompt.match(/[^, ]+/g);
previousTags = tags;
}
const colors_dark = ["lightblue", "indianred", "unused", "violet", "lightgreen", "orange"];
const colors_light = ["dodgerblue", "firebrick", "unused", "darkorchid", "darkgreen", "darkorange" ]
function addResultsToList(results, tagword) {
let resultsList = gradioApp().querySelector('#autocompleteResultsList');
resultsList.innerHTML = "";
let colors = gradioApp().querySelector('.dark') ? colors_dark : colors_light;
for (let i = 0; i < results.length; i++) {
let result = results[i];
let li = document.createElement("li");
li.innerHTML = result[0];
li.style = `color: ${colors[result[1]]};`;
li.addEventListener("click", function() { insertTextAtCursor(result[0], tagword); });
resultsList.appendChild(li);
}
}
function updateSelectionStyle(num) {
let resultsList = gradioApp().querySelector('#autocompleteResultsList');
let items = resultsList.getElementsByTagName('li');
for (let i = 0; i < items.length; i++) {
items[i].classList.remove('selected');
}
items[num].classList.add('selected');
}
allTags = [];
previousTags = [];
results = [];
tagword = "";
resultCount = 0;
function autocomplete(prompt) {
// Guard for empty prompt
if (prompt.length == 0) {
hideResults();
return;
}
// Match tags with RegEx to get the last edited one
let tags = prompt.match(/[^, ]+/g);
let diff = difference(tags, previousTags)
previousTags = tags;
// Guard for no difference / only whitespace remaining
if (diff == undefined || diff.length == 0) {
hideResults();
return;
}
tagword = diff[0]
// Guard for empty tagword
if (tagword == undefined || tagword.length == 0) {
hideResults();
return;
}
results = allTags.filter(x => x[0].includes(tagword)).slice(0, acConfig.maxResults);
resultCount = results.length;
// Guard for empty results
if (resultCount == 0) {
hideResults();
return;
}
showResults();
addResultsToList(results, tagword);
}
function navigateInList(event) {
validKeys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", "Escape"];
if (!validKeys.includes(event.key)) return;
if (!isVisible) return
switch (event.key) {
case "ArrowUp":
if (selectedTag == null) {
selectedTag = resultCount - 1;
} else {
selectedTag = (selectedTag - 1 + resultCount) % resultCount;
}
break;
case "ArrowDown":
if (selectedTag == null) {
selectedTag = 0;
} else {
selectedTag = (selectedTag + 1) % resultCount;
}
break;
case "ArrowLeft":
selectedTag = 0;
break;
case "ArrowRight":
selectedTag = resultCount - 1;
break;
case "Enter":
if (selectedTag != null) {
insertTextAtCursor(results[selectedTag][0], tagword);
}
break;
case "Escape":
hideResults();
break;
}
// Update highlighting
if (selectedTag != null)
updateSelectionStyle(selectedTag);
// Prevent default behavior
event.preventDefault();
event.stopPropagation();
}
onUiUpdate(function(){
// One-time CSV setup
if (acConfig == null) acConfig = JSON.parse(readFile("file/tags/config.json"));
if (allTags.length == 0) allTags = loadCSV();
let promptTextbox = gradioApp().querySelector('#txt2img_prompt > label > textarea');
if (promptTextbox == null) return;
if (gradioApp().querySelector('#autocompleteResults') != null) return;
// Only add listeners once
if (!promptTextbox.classList.contains('autocomplete')) {
// Add our new element
var resultsDiv = gradioApp().querySelector('#autocompleteResults') ?? createResultsDiv();
promptTextbox.parentNode.insertBefore(resultsDiv, promptTextbox.nextSibling);
// Hide by default so it doesn't show up on page load
hideResults();
// Add autocomplete event listener
promptTextbox.addEventListener('input', debounce(() => autocomplete(promptTextbox.value), 100));
// Add focusout event listener
promptTextbox.addEventListener('focusout', debounce(() => hideResults(), 400));
// Add up and down arrow event listener
promptTextbox.addEventListener('keydown', function(e) { navigateInList(e); });
// Add class so we know we've already added the listeners
promptTextbox.classList.add('autocomplete');
// Add style to dom
let acStyle = document.createElement('style');
let css = gradioApp().querySelector('.dark') ? autocompleteCSS_dark : autocompleteCSS_light;
if (acStyle.styleSheet) {
acStyle.styleSheet.cssText = css;
} else {
acStyle.appendChild(document.createTextNode(css));
}
gradioApp().appendChild(acStyle);
}
});