tagAutocomplete.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925
  1. const styleColors = {
  2. "--results-bg": ["#0b0f19", "#ffffff"],
  3. "--results-border-color": ["#4b5563", "#e5e7eb"],
  4. "--results-border-width": ["1px", "1.5px"],
  5. "--results-bg-odd": ["#111827", "#f9fafb"],
  6. "--results-hover": ["#1f2937", "#f5f6f8"],
  7. "--results-selected": ["#374151", "#e5e7eb"],
  8. "--meta-text-color": ["#6b6f7b", "#a2a9b4"],
  9. "--embedding-v1-color": ["lightsteelblue", "#2b5797"],
  10. "--embedding-v2-color": ["skyblue", "#2d89ef"],
  11. }
  12. const browserVars = {
  13. "--results-overflow-y": {
  14. "firefox": "scroll",
  15. "other": "auto"
  16. }
  17. }
  18. // Style for new elements. Gets appended to the Gradio root.
  19. const autocompleteCSS = `
  20. #quicksettings [id^=setting_tac] {
  21. background-color: transparent;
  22. min-width: fit-content;
  23. align-self: center;
  24. }
  25. #quicksettings [id^=setting_tac] > label > span {
  26. margin-bottom: 0px;
  27. }
  28. .autocompleteResults {
  29. position: absolute;
  30. z-index: 999;
  31. max-width: calc(100% - 1.5rem);
  32. margin: 5px 0 0 0;
  33. background-color: var(--results-bg) !important;
  34. border: var(--results-border-width) solid var(--results-border-color) !important;
  35. border-radius: 12px !important;
  36. overflow-y: var(--results-overflow-y);
  37. overflow-x: hidden;
  38. word-break: break-word;
  39. }
  40. .autocompleteResultsList > li:nth-child(odd) {
  41. background-color: var(--results-bg-odd);
  42. }
  43. .autocompleteResultsList > li {
  44. list-style-type: none;
  45. padding: 10px;
  46. cursor: pointer;
  47. }
  48. .autocompleteResultsList > li:hover {
  49. background-color: var(--results-hover);
  50. }
  51. .autocompleteResultsList > li.selected {
  52. background-color: var(--results-selected);
  53. }
  54. .resultsFlexContainer {
  55. display: flex;
  56. }
  57. .acListItem {
  58. white-space: break-spaces;
  59. }
  60. .acMetaText {
  61. position: relative;
  62. flex-grow: 1;
  63. text-align: end;
  64. padding: 0 0 0 15px;
  65. white-space: nowrap;
  66. color: var(--meta-text-color);
  67. }
  68. .acWikiLink {
  69. padding: 0.5rem;
  70. margin: -0.5rem 0 -0.5rem -0.5rem;
  71. }
  72. .acWikiLink:hover {
  73. text-decoration: underline;
  74. }
  75. .acListItem.acEmbeddingV1 {
  76. color: var(--embedding-v1-color);
  77. }
  78. .acListItem.acEmbeddingV2 {
  79. color: var(--embedding-v2-color);
  80. }
  81. `;
  82. async function loadTags(c) {
  83. // Load main tags and aliases
  84. if (allTags.length === 0 && c.tagFile && c.tagFile !== "None") {
  85. try {
  86. allTags = await loadCSV(`${tagBasePath}/${c.tagFile}`);
  87. } catch (e) {
  88. console.error("Error loading tags file: " + e);
  89. return;
  90. }
  91. }
  92. if (c.extra.extraFile && c.extra.extraFile !== "None") {
  93. try {
  94. extras = await loadCSV(`${tagBasePath}/${c.extra.extraFile}`);
  95. } catch (e) {
  96. console.error("Error loading extra file: " + e);
  97. return;
  98. }
  99. }
  100. }
  101. async function loadTranslations(c) {
  102. if (c.translation.translationFile && c.translation.translationFile !== "None") {
  103. try {
  104. let tArray = await loadCSV(`${tagBasePath}/${c.translation.translationFile}`);
  105. tArray.forEach(t => {
  106. if (c.translation.oldFormat)
  107. translations.set(t[0], t[2]);
  108. else
  109. translations.set(t[0], t[1]);
  110. });
  111. } catch (e) {
  112. console.error("Error loading translations file: " + e);
  113. return;
  114. }
  115. }
  116. }
  117. async function syncOptions() {
  118. let newCFG = {
  119. // Main tag file
  120. tagFile: opts["tac_tagFile"],
  121. // Active in settings
  122. activeIn: {
  123. global: opts["tac_active"],
  124. txt2img: opts["tac_activeIn.txt2img"],
  125. img2img: opts["tac_activeIn.img2img"],
  126. negativePrompts: opts["tac_activeIn.negativePrompts"],
  127. thirdParty: opts["tac_activeIn.thirdParty"],
  128. modelList: opts["tac_activeIn.modelList"],
  129. modelListMode: opts["tac_activeIn.modelListMode"]
  130. },
  131. // Results related settings
  132. slidingPopup: opts["tac_slidingPopup"],
  133. maxResults: opts["tac_maxResults"],
  134. showAllResults: opts["tac_showAllResults"],
  135. resultStepLength: opts["tac_resultStepLength"],
  136. delayTime: opts["tac_delayTime"],
  137. useWildcards: opts["tac_useWildcards"],
  138. useEmbeddings: opts["tac_useEmbeddings"],
  139. useHypernetworks: opts["tac_useHypernetworks"],
  140. useLoras: opts["tac_useLoras"],
  141. useLycos: opts["tac_useLycos"],
  142. showWikiLinks: opts["tac_showWikiLinks"],
  143. // Insertion related settings
  144. replaceUnderscores: opts["tac_replaceUnderscores"],
  145. escapeParentheses: opts["tac_escapeParentheses"],
  146. appendComma: opts["tac_appendComma"],
  147. // Alias settings
  148. alias: {
  149. searchByAlias: opts["tac_alias.searchByAlias"],
  150. onlyShowAlias: opts["tac_alias.onlyShowAlias"]
  151. },
  152. // Translation settings
  153. translation: {
  154. translationFile: opts["tac_translation.translationFile"],
  155. oldFormat: opts["tac_translation.oldFormat"],
  156. searchByTranslation: opts["tac_translation.searchByTranslation"],
  157. },
  158. // Extra file settings
  159. extra: {
  160. extraFile: opts["tac_extra.extraFile"],
  161. addMode: opts["tac_extra.addMode"]
  162. },
  163. // Settings not from tac but still used by the script
  164. extraNetworksDefaultMultiplier: opts["extra_networks_default_multiplier"],
  165. extraNetworksSeparator: opts["extra_networks_add_text_separator"],
  166. // Custom mapping settings
  167. keymap: JSON.parse(opts["tac_keymap"]),
  168. colorMap: JSON.parse(opts["tac_colormap"])
  169. }
  170. if (newCFG.alias.onlyShowAlias) {
  171. newCFG.alias.searchByAlias = true; // if only show translation, enable search by translation is necessary
  172. }
  173. // Reload tags if the tag file changed
  174. if (!CFG || newCFG.tagFile !== CFG.tagFile || newCFG.extra.extraFile !== CFG.extra.extraFile) {
  175. allTags = [];
  176. await loadTags(newCFG);
  177. }
  178. // Reload translations if the translation file changed
  179. if (!CFG || newCFG.translation.translationFile !== CFG.translation.translationFile) {
  180. translations.clear();
  181. await loadTranslations(newCFG);
  182. }
  183. // Update CSS if maxResults changed
  184. if (CFG && newCFG.maxResults !== CFG.maxResults) {
  185. gradioApp().querySelectorAll(".autocompleteResults").forEach(r => {
  186. r.style.maxHeight = `${newCFG.maxResults * 50}px`;
  187. });
  188. }
  189. // Apply changes
  190. CFG = newCFG;
  191. // Callback
  192. await processQueue(QUEUE_AFTER_CONFIG_CHANGE, null);
  193. }
  194. // Create the result list div and necessary styling
  195. function createResultsDiv(textArea) {
  196. let resultsDiv = document.createElement("div");
  197. let resultsList = document.createElement("ul");
  198. let textAreaId = getTextAreaIdentifier(textArea);
  199. let typeClass = textAreaId.replaceAll(".", " ");
  200. resultsDiv.style.maxHeight = `${CFG.maxResults * 50}px`;
  201. resultsDiv.setAttribute("class", `autocompleteResults ${typeClass} notranslate`);
  202. resultsDiv.setAttribute("translate", "no");
  203. resultsList.setAttribute("class", "autocompleteResultsList");
  204. resultsDiv.appendChild(resultsList);
  205. return resultsDiv;
  206. }
  207. // Show or hide the results div
  208. function isVisible(textArea) {
  209. let textAreaId = getTextAreaIdentifier(textArea);
  210. let resultsDiv = gradioApp().querySelector('.autocompleteResults' + textAreaId);
  211. return resultsDiv.style.display === "block";
  212. }
  213. function showResults(textArea) {
  214. let textAreaId = getTextAreaIdentifier(textArea);
  215. let resultsDiv = gradioApp().querySelector('.autocompleteResults' + textAreaId);
  216. resultsDiv.style.display = "block";
  217. if (CFG.slidingPopup) {
  218. let caretPosition = getCaretCoordinates(textArea, textArea.selectionEnd).left;
  219. let offset = Math.min(textArea.offsetLeft - textArea.scrollLeft + caretPosition, textArea.offsetWidth - resultsDiv.offsetWidth);
  220. resultsDiv.style.left = `${offset}px`;
  221. } else {
  222. if (resultsDiv.style.left)
  223. resultsDiv.style.removeProperty("left");
  224. }
  225. }
  226. function hideResults(textArea) {
  227. let textAreaId = getTextAreaIdentifier(textArea);
  228. let resultsDiv = gradioApp().querySelector('.autocompleteResults' + textAreaId);
  229. if (!resultsDiv) return;
  230. resultsDiv.style.display = "none";
  231. selectedTag = null;
  232. }
  233. // Function to check activation criteria
  234. function isEnabled() {
  235. if (CFG.activeIn.global) {
  236. // Skip check if the current model was not correctly detected, since it could wrongly disable the script otherwise
  237. if (!currentModelName || !currentModelHash) return true;
  238. let modelList = CFG.activeIn.modelList
  239. .split(",")
  240. .map(x => x.trim())
  241. .filter(x => x.length > 0);
  242. let shortHash = currentModelHash.substring(0, 10);
  243. let modelNameWithoutHash = currentModelName.replace(/\[.*\]$/g, "").trim();
  244. if (CFG.activeIn.modelListMode.toLowerCase() === "blacklist") {
  245. // If the current model is in the blacklist, disable
  246. return modelList.filter(x => x === currentModelName || x === modelNameWithoutHash || x === currentModelHash || x === shortHash).length === 0;
  247. } else {
  248. // If the current model is in the whitelist, enable.
  249. // An empty whitelist is ignored.
  250. return modelList.length === 0 || modelList.filter(x => x === currentModelName || x === modelNameWithoutHash || x === currentModelHash || x === shortHash).length > 0;
  251. }
  252. } else {
  253. return false;
  254. }
  255. }
  256. const WEIGHT_REGEX = /[([]([^()[\]:|]+)(?::(?:\d+(?:\.\d+)?|\.\d+))?[)\]]/g;
  257. const POINTY_REGEX = /<[^\s,<](?:[^\t\n\r,<>]*>|[^\t\n\r,> ]*)/g;
  258. const COMPLETED_WILDCARD_REGEX = /__[^\s,_][^\t\n\r,_]*[^\s,_]__[^\s,_]*/g;
  259. const NORMAL_TAG_REGEX = /[^\s,|<>)\]]+|</g;
  260. const TAG_REGEX = new RegExp(`${POINTY_REGEX.source}|${COMPLETED_WILDCARD_REGEX.source}|${NORMAL_TAG_REGEX.source}`, "g");
  261. // On click, insert the tag into the prompt textbox with respect to the cursor position
  262. async function insertTextAtCursor(textArea, result, tagword) {
  263. let text = result.text;
  264. let tagType = result.type;
  265. let cursorPos = textArea.selectionStart;
  266. var sanitizedText = text
  267. // Run sanitize queue and use first result as sanitized text
  268. sanitizeResults = await processQueueReturn(QUEUE_SANITIZE, null, tagType, text);
  269. if (sanitizeResults && sanitizeResults.length > 0) {
  270. sanitizedText = sanitizeResults[0];
  271. } else {
  272. sanitizedText = CFG.replaceUnderscores ? text.replaceAll("_", " ") : text;
  273. if (CFG.escapeParentheses && tagType === ResultType.tag) {
  274. sanitizedText = sanitizedText
  275. .replaceAll("(", "\\(")
  276. .replaceAll(")", "\\)")
  277. .replaceAll("[", "\\[")
  278. .replaceAll("]", "\\]");
  279. }
  280. }
  281. var prompt = textArea.value;
  282. // Edit prompt text
  283. let editStart = Math.max(cursorPos - tagword.length, 0);
  284. let editEnd = Math.min(cursorPos + tagword.length, prompt.length);
  285. let surrounding = prompt.substring(editStart, editEnd);
  286. let match = surrounding.match(new RegExp(escapeRegExp(`${tagword}`), "i"));
  287. let afterInsertCursorPos = editStart + match.index + sanitizedText.length;
  288. var optionalSeparator = "";
  289. let extraNetworkTypes = [ResultType.hypernetwork, ResultType.lora];
  290. let noCommaTypes = [ResultType.wildcardFile, ResultType.yamlWildcard].concat(extraNetworkTypes);
  291. if (CFG.appendComma && !noCommaTypes.includes(tagType)) {
  292. optionalSeparator = surrounding.match(new RegExp(`${escapeRegExp(tagword)}[,:]`, "i")) !== null ? "" : ", ";
  293. } else if (extraNetworkTypes.includes(tagType)) {
  294. // Use the dedicated separator for extra networks if it's defined, otherwise fall back to space
  295. optionalSeparator = CFG.extraNetworksSeparator || " ";
  296. }
  297. // Replace partial tag word with new text, add comma if needed
  298. let insert = surrounding.replace(match, sanitizedText + optionalSeparator);
  299. // Add back start
  300. var newPrompt = prompt.substring(0, editStart) + insert + prompt.substring(editEnd);
  301. textArea.value = newPrompt;
  302. textArea.selectionStart = afterInsertCursorPos + optionalSeparator.length;
  303. textArea.selectionEnd = textArea.selectionStart
  304. // Since we've modified a Gradio Textbox component manually, we need to simulate an `input` DOM event to ensure it's propagated back to python.
  305. // Uses a built-in method from the webui's ui.js which also already accounts for event target
  306. updateInput(textArea);
  307. // Update previous tags with the edited prompt to prevent re-searching the same term
  308. let weightedTags = [...newPrompt.matchAll(WEIGHT_REGEX)]
  309. .map(match => match[1]);
  310. let tags = newPrompt.match(TAG_REGEX)
  311. if (weightedTags !== null) {
  312. tags = tags.filter(tag => !weightedTags.some(weighted => tag.includes(weighted)))
  313. .concat(weightedTags);
  314. }
  315. previousTags = tags;
  316. // Callback
  317. let returns = await processQueueReturn(QUEUE_AFTER_INSERT, null, tagType, sanitizedText, newPrompt, textArea);
  318. // Return if any queue function returned true (has handled hide/show already)
  319. if (returns.some(x => x === true))
  320. return;
  321. // Hide results after inserting, if it hasn't been hidden already by a queue function
  322. if (!hideBlocked && isVisible(textArea)) {
  323. hideResults(textArea);
  324. }
  325. }
  326. function addResultsToList(textArea, results, tagword, resetList) {
  327. let textAreaId = getTextAreaIdentifier(textArea);
  328. let resultDiv = gradioApp().querySelector('.autocompleteResults' + textAreaId);
  329. let resultsList = resultDiv.querySelector('ul');
  330. // Reset list, selection and scrollTop since the list changed
  331. if (resetList) {
  332. resultsList.innerHTML = "";
  333. selectedTag = null;
  334. resultDiv.scrollTop = 0;
  335. resultCount = 0;
  336. }
  337. // Find right colors from config
  338. let tagFileName = CFG.tagFile.split(".")[0];
  339. let tagColors = CFG.colorMap;
  340. let mode = gradioApp().querySelector('.dark') ? 0 : 1;
  341. let nextLength = Math.min(results.length, resultCount + CFG.resultStepLength);
  342. for (let i = resultCount; i < nextLength; i++) {
  343. let result = results[i];
  344. // Skip if the result is null or undefined
  345. if (!result)
  346. continue;
  347. let li = document.createElement("li");
  348. let flexDiv = document.createElement("div");
  349. flexDiv.classList.add("resultsFlexContainer");
  350. li.appendChild(flexDiv);
  351. let itemText = document.createElement("div");
  352. itemText.classList.add("acListItem");
  353. let displayText = "";
  354. // If the tag matches the tagword, we don't need to display the alias
  355. if (result.aliases && !result.text.includes(tagword)) { // Alias
  356. let splitAliases = result.aliases.split(",");
  357. let bestAlias = splitAliases.find(a => a.toLowerCase().includes(tagword));
  358. // search in translations if no alias matches
  359. if (!bestAlias) {
  360. let tagOrAlias = pair => pair[0] === result.text || splitAliases.includes(pair[0]);
  361. var tArray = [...translations];
  362. if (tArray) {
  363. var translationKey = [...translations].find(pair => tagOrAlias(pair) && pair[1].includes(tagword));
  364. if (translationKey)
  365. bestAlias = translationKey[0];
  366. }
  367. }
  368. displayText = escapeHTML(bestAlias);
  369. // Append translation for alias if it exists and is not what the user typed
  370. if (translations.has(bestAlias) && translations.get(bestAlias) !== bestAlias && bestAlias !== result.text)
  371. displayText += `[${translations.get(bestAlias)}]`;
  372. if (!CFG.alias.onlyShowAlias && result.text !== bestAlias)
  373. displayText += " ➝ " + result.text;
  374. } else { // No alias
  375. displayText = escapeHTML(result.text);
  376. }
  377. // Append translation for result if it exists
  378. if (translations.has(result.text))
  379. displayText += `[${translations.get(result.text)}]`;
  380. // Print search term bolded in result
  381. itemText.innerHTML = displayText.replace(tagword, `<b>${tagword}</b>`);
  382. // Add wiki link if the setting is enabled and a supported tag set loaded
  383. if (CFG.showWikiLinks
  384. && (result.type === ResultType.tag)
  385. && (tagFileName.toLowerCase().startsWith("danbooru") || tagFileName.toLowerCase().startsWith("e621"))) {
  386. let wikiLink = document.createElement("a");
  387. wikiLink.classList.add("acWikiLink");
  388. wikiLink.innerText = "?";
  389. let linkPart = displayText;
  390. // Only use alias result if it is one
  391. if (displayText.includes("➝"))
  392. linkPart = displayText.split(" ➝ ")[1];
  393. // Set link based on selected file
  394. let tagFileNameLower = tagFileName.toLowerCase();
  395. if (tagFileNameLower.startsWith("danbooru")) {
  396. wikiLink.href = `https://danbooru.donmai.us/wiki_pages/${linkPart}`;
  397. } else if (tagFileNameLower.startsWith("e621")) {
  398. wikiLink.href = `https://e621.net/wiki_pages/${linkPart}`;
  399. }
  400. wikiLink.target = "_blank";
  401. flexDiv.appendChild(wikiLink);
  402. }
  403. flexDiv.appendChild(itemText);
  404. // Add post count & color if it's a tag
  405. // Wildcards & Embeds have no tag category
  406. if (result.category) {
  407. // Set the color of the tag
  408. let cat = result.category;
  409. let colorGroup = tagColors[tagFileName];
  410. // Default to danbooru scheme if no matching one is found
  411. if (!colorGroup)
  412. colorGroup = tagColors["danbooru"];
  413. // Set tag type to invalid if not found
  414. if (!colorGroup[cat])
  415. cat = "-1";
  416. flexDiv.style = `color: ${colorGroup[cat][mode]};`;
  417. }
  418. // Post count
  419. if (result.count && !isNaN(result.count)) {
  420. let postCount = result.count;
  421. let formatter;
  422. // Danbooru formats numbers with a padded fraction for 1M or 1k, but not for 10/100k
  423. if (postCount >= 1000000 || (postCount >= 1000 && postCount < 10000))
  424. formatter = Intl.NumberFormat("en", { notation: "compact", minimumFractionDigits: 1, maximumFractionDigits: 1 });
  425. else
  426. formatter = Intl.NumberFormat("en", {notation: "compact"});
  427. let formattedCount = formatter.format(postCount);
  428. let countDiv = document.createElement("div");
  429. countDiv.textContent = formattedCount;
  430. countDiv.classList.add("acMetaText");
  431. flexDiv.appendChild(countDiv);
  432. } else if (result.meta) { // Check if there is meta info to display
  433. let metaDiv = document.createElement("div");
  434. metaDiv.textContent = result.meta;
  435. metaDiv.classList.add("acMetaText");
  436. // Add version info classes if it is an embedding
  437. if (result.type === ResultType.embedding) {
  438. if (result.meta.startsWith("v1"))
  439. itemText.classList.add("acEmbeddingV1");
  440. else if (result.meta.startsWith("v2"))
  441. itemText.classList.add("acEmbeddingV2");
  442. }
  443. flexDiv.appendChild(metaDiv);
  444. }
  445. // Add listener
  446. li.addEventListener("click", function () { insertTextAtCursor(textArea, result, tagword); });
  447. // Add element to list
  448. resultsList.appendChild(li);
  449. }
  450. resultCount = nextLength;
  451. if (resetList)
  452. resultDiv.scrollTop = 0;
  453. }
  454. function updateSelectionStyle(textArea, newIndex, oldIndex) {
  455. let textAreaId = getTextAreaIdentifier(textArea);
  456. let resultDiv = gradioApp().querySelector('.autocompleteResults' + textAreaId);
  457. let resultsList = resultDiv.querySelector('ul');
  458. let items = resultsList.getElementsByTagName('li');
  459. if (oldIndex != null) {
  460. items[oldIndex].classList.remove('selected');
  461. }
  462. // make it safer
  463. if (newIndex !== null) {
  464. items[newIndex].classList.add('selected');
  465. }
  466. // Set scrolltop to selected item if we are showing more than max results
  467. if (items.length > CFG.maxResults) {
  468. let selected = items[newIndex];
  469. resultDiv.scrollTop = selected.offsetTop - resultDiv.offsetTop;
  470. }
  471. }
  472. async function autocomplete(textArea, prompt, fixedTag = null) {
  473. // Return if the function is deactivated in the UI
  474. if (!isEnabled()) return;
  475. // Guard for empty prompt
  476. if (prompt.length === 0) {
  477. hideResults(textArea);
  478. previousTags = [];
  479. tagword = "";
  480. return;
  481. }
  482. if (fixedTag === null) {
  483. // Match tags with RegEx to get the last edited one
  484. // We also match for the weighting format (e.g. "tag:1.0") here, and combine the two to get the full tag word set
  485. let weightedTags = [...prompt.matchAll(WEIGHT_REGEX)]
  486. .map(match => match[1]);
  487. let tags = prompt.match(TAG_REGEX)
  488. if (weightedTags !== null && tags !== null) {
  489. tags = tags.filter(tag => !weightedTags.some(weighted => tag.includes(weighted) && !tag.startsWith("<[")))
  490. .concat(weightedTags);
  491. }
  492. // Guard for no tags
  493. if (!tags || tags.length === 0) {
  494. previousTags = [];
  495. tagword = "";
  496. hideResults(textArea);
  497. return;
  498. }
  499. let tagCountChange = tags.length - previousTags.length;
  500. let diff = difference(tags, previousTags);
  501. previousTags = tags;
  502. // Guard for no difference / only whitespace remaining / last edited tag was fully removed
  503. if (diff === null || diff.length === 0 || (diff.length === 1 && tagCountChange < 0)) {
  504. if (!hideBlocked) hideResults(textArea);
  505. return;
  506. }
  507. tagword = diff[0]
  508. // Guard for empty tagword
  509. if (tagword === null || tagword.length === 0) {
  510. hideResults(textArea);
  511. return;
  512. }
  513. } else {
  514. tagword = fixedTag;
  515. }
  516. results = [];
  517. tagword = tagword.toLowerCase().replace(/[\n\r]/g, "");
  518. // Process all parsers
  519. let resultCandidates = await processParsers(textArea, prompt);
  520. // If one ore more result candidates match, use their results
  521. if (resultCandidates && resultCandidates.length > 0) {
  522. // Flatten our candidate(s)
  523. results = resultCandidates.flat();
  524. // If there was more than one candidate, sort the results by text to mix them
  525. // instead of having them added in the order of the parsers
  526. let shouldSort = resultCandidates.length > 1;
  527. if (shouldSort) {
  528. results = results.sort((a, b) => a.text.localeCompare(b.text));
  529. // Since some tags are kaomoji, we have to add the normal results in some cases
  530. if (tagword.startsWith("<") || tagword.startsWith("*<")) {
  531. // Create escaped search regex with support for * as a start placeholder
  532. let searchRegex;
  533. if (tagword.startsWith("*")) {
  534. tagword = tagword.slice(1);
  535. searchRegex = new RegExp(`${escapeRegExp(tagword)}`, 'i');
  536. } else {
  537. searchRegex = new RegExp(`(^|[^a-zA-Z])${escapeRegExp(tagword)}`, 'i');
  538. }
  539. let genericResults = allTags.filter(x => x[0].toLowerCase().search(searchRegex) > -1).slice(0, CFG.maxResults);
  540. genericResults.forEach(g => {
  541. let result = new AutocompleteResult(g[0].trim(), ResultType.tag)
  542. result.category = g[1];
  543. result.count = g[2];
  544. result.aliases = g[3];
  545. results.push(result);
  546. });
  547. }
  548. }
  549. } else { // Else search the normal tag list
  550. // Create escaped search regex with support for * as a start placeholder
  551. let searchRegex;
  552. if (tagword.startsWith("*")) {
  553. tagword = tagword.slice(1);
  554. searchRegex = new RegExp(`${escapeRegExp(tagword)}`, 'i');
  555. } else {
  556. searchRegex = new RegExp(`(^|[^a-zA-Z])${escapeRegExp(tagword)}`, 'i');
  557. }
  558. // Both normal tags and aliases/translations are included depending on the config
  559. let baseFilter = (x) => x[0].toLowerCase().search(searchRegex) > -1;
  560. let aliasFilter = (x) => x[3] && x[3].toLowerCase().search(searchRegex) > -1;
  561. let translationFilter = (x) => (translations.has(x[0]) && translations.get(x[0]).toLowerCase().search(searchRegex) > -1)
  562. || x[3] && x[3].split(",").some(y => translations.has(y) && translations.get(y).toLowerCase().search(searchRegex) > -1);
  563. let fil;
  564. if (CFG.alias.searchByAlias && CFG.translation.searchByTranslation)
  565. fil = (x) => baseFilter(x) || aliasFilter(x) || translationFilter(x);
  566. else if (CFG.alias.searchByAlias && !CFG.translation.searchByTranslation)
  567. fil = (x) => baseFilter(x) || aliasFilter(x);
  568. else if (CFG.translation.searchByTranslation && !CFG.alias.searchByAlias)
  569. fil = (x) => baseFilter(x) || translationFilter(x);
  570. else
  571. fil = (x) => baseFilter(x);
  572. // Add final results
  573. allTags.filter(fil).forEach(t => {
  574. let result = new AutocompleteResult(t[0].trim(), ResultType.tag)
  575. result.category = t[1];
  576. result.count = t[2];
  577. result.aliases = t[3];
  578. results.push(result);
  579. });
  580. // Add extras
  581. if (CFG.extra.extraFile) {
  582. let extraResults = [];
  583. extras.filter(fil).forEach(e => {
  584. let result = new AutocompleteResult(e[0].trim(), ResultType.extra)
  585. result.category = e[1] || 0; // If no category is given, use 0 as the default
  586. result.meta = e[2] || "Custom tag";
  587. result.aliases = e[3] || "";
  588. extraResults.push(result);
  589. });
  590. if (CFG.extra.addMode === "Insert before") {
  591. results = extraResults.concat(results);
  592. } else {
  593. results = results.concat(extraResults);
  594. }
  595. }
  596. // Slice if the user has set a max result count
  597. if (!CFG.showAllResults) {
  598. results = results.slice(0, CFG.maxResults);
  599. }
  600. }
  601. // Guard for empty results
  602. if (!results || results.length === 0) {
  603. //console.log('No results found for "' + tagword + '"');
  604. hideResults(textArea);
  605. return;
  606. }
  607. addResultsToList(textArea, results, tagword, true);
  608. showResults(textArea);
  609. }
  610. function navigateInList(textArea, event) {
  611. // Return if the function is deactivated in the UI or the current model is excluded due to white/blacklist settings
  612. if (!isEnabled()) return;
  613. let keys = CFG.keymap;
  614. // Close window if Home or End is pressed while not a keybinding, since it would break completion on leaving the original tag
  615. if ((event.key === "Home" || event.key === "End") && !Object.values(keys).includes(event.key)) {
  616. hideResults(textArea);
  617. return;
  618. }
  619. // All set keys that are not None or empty are valid
  620. // Default keys are: ArrowUp, ArrowDown, PageUp, PageDown, Home, End, Enter, Tab, Escape
  621. validKeys = Object.values(keys).filter(x => x !== "None" && x !== "");
  622. if (!validKeys.includes(event.key)) return;
  623. if (!isVisible(textArea)) return
  624. // Return if ctrl key is pressed to not interfere with weight editing shortcut
  625. if (event.ctrlKey || event.altKey) return;
  626. oldSelectedTag = selectedTag;
  627. switch (event.key) {
  628. case keys["MoveUp"]:
  629. if (selectedTag === null) {
  630. selectedTag = resultCount - 1;
  631. } else {
  632. selectedTag = (selectedTag - 1 + resultCount) % resultCount;
  633. }
  634. break;
  635. case keys["MoveDown"]:
  636. if (selectedTag === null) {
  637. selectedTag = 0;
  638. } else {
  639. selectedTag = (selectedTag + 1) % resultCount;
  640. }
  641. break;
  642. case keys["JumpUp"]:
  643. if (selectedTag === null || selectedTag === 0) {
  644. selectedTag = resultCount - 1;
  645. } else {
  646. selectedTag = (Math.max(selectedTag - 5, 0) + resultCount) % resultCount;
  647. }
  648. break;
  649. case keys["JumpDown"]:
  650. if (selectedTag === null || selectedTag === resultCount - 1) {
  651. selectedTag = 0;
  652. } else {
  653. selectedTag = Math.min(selectedTag + 5, resultCount - 1) % resultCount;
  654. }
  655. break;
  656. case keys["JumpToStart"]:
  657. selectedTag = 0;
  658. break;
  659. case keys["JumpToEnd"]:
  660. selectedTag = resultCount - 1;
  661. break;
  662. case keys["ChooseSelected"]:
  663. if (selectedTag !== null) {
  664. insertTextAtCursor(textArea, results[selectedTag], tagword);
  665. } else {
  666. hideResults(textArea);
  667. return;
  668. }
  669. break;
  670. case keys["ChooseFirstOrSelected"]:
  671. if (selectedTag === null) {
  672. selectedTag = 0;
  673. }
  674. insertTextAtCursor(textArea, results[selectedTag], tagword);
  675. break;
  676. case keys["Close"]:
  677. hideResults(textArea);
  678. break;
  679. }
  680. if (selectedTag === resultCount - 1
  681. && (event.key === keys["MoveUp"] || event.key === keys["MoveDown"] || event.key === keys["JumpToStart"] || event.key === keys["JumpToEnd"])) {
  682. addResultsToList(textArea, results, tagword, false);
  683. }
  684. // Update highlighting
  685. if (selectedTag !== null)
  686. updateSelectionStyle(textArea, selectedTag, oldSelectedTag);
  687. // Prevent default behavior
  688. event.preventDefault();
  689. event.stopPropagation();
  690. }
  691. // One-time setup, triggered from onUiUpdate
  692. async function setup() {
  693. // Load external files needed by completion extensions
  694. await processQueue(QUEUE_FILE_LOAD, null);
  695. // Find all textareas
  696. let textAreas = getTextAreas();
  697. // Add event listener to apply settings button so we can mirror the changes to our internal config
  698. let applySettingsButton = gradioApp().querySelector("#tab_settings #settings_submit") || gradioApp().querySelector("#tab_settings > div > .gr-button-primary");
  699. applySettingsButton?.addEventListener("click", () => {
  700. // Wait 500ms to make sure the settings have been applied to the webui opts object
  701. setTimeout(async () => {
  702. await syncOptions();
  703. }, 500);
  704. });
  705. // Add change listener to our quicksettings to change our internal config without the apply button for them
  706. let quicksettings = gradioApp().querySelector('#quicksettings');
  707. let commonQueryPart = "[id^=setting_tac] > label >";
  708. quicksettings?.querySelectorAll(`${commonQueryPart} input, ${commonQueryPart} textarea, ${commonQueryPart} select`).forEach(e => {
  709. e.addEventListener("change", () => {
  710. setTimeout(async () => {
  711. await syncOptions();
  712. }, 500);
  713. });
  714. });
  715. // Add mutation observer for the model hash text to also allow hash-based blacklist again
  716. let modelHashText = gradioApp().querySelector("#sd_checkpoint_hash");
  717. if (modelHashText) {
  718. currentModelHash = modelHashText.title
  719. let modelHashObserver = new MutationObserver((mutationList, observer) => {
  720. for (const mutation of mutationList) {
  721. if (mutation.type === "attributes" && mutation.attributeName === "title") {
  722. currentModelHash = mutation.target.title;
  723. let modelDropdown = gradioApp().querySelector("#setting_sd_model_checkpoint span.single-select")
  724. if (modelDropdown) {
  725. currentModelName = modelDropdown.textContent;
  726. } else {
  727. // Fallback for older versions
  728. modelDropdown = gradioApp().querySelector("#setting_sd_model_checkpoint select");
  729. currentModelName = modelDropdown.value;
  730. }
  731. }
  732. }
  733. });
  734. modelHashObserver.observe(modelHashText, { attributes: true });
  735. }
  736. // Not found, we're on a page without prompt textareas
  737. if (textAreas.every(v => v === null || v === undefined)) return;
  738. // Already added or unnecessary to add
  739. if (gradioApp().querySelector('.autocompleteResults.p')) {
  740. if (gradioApp().querySelector('.autocompleteResults.n') || !CFG.activeIn.negativePrompts) {
  741. return;
  742. }
  743. } else if (!CFG.activeIn.txt2img && !CFG.activeIn.img2img) {
  744. return;
  745. }
  746. textAreas.forEach(area => {
  747. // Return if autocomplete is disabled for the current area type in config
  748. let textAreaId = getTextAreaIdentifier(area);
  749. if ((!CFG.activeIn.img2img && textAreaId.includes("img2img"))
  750. || (!CFG.activeIn.txt2img && textAreaId.includes("txt2img"))
  751. || (!CFG.activeIn.negativePrompts && textAreaId.includes("n"))
  752. || (!CFG.activeIn.thirdParty && textAreaId.includes("thirdParty"))) {
  753. return;
  754. }
  755. // Only add listeners once
  756. if (!area.classList.contains('autocomplete')) {
  757. // Add our new element
  758. var resultsDiv = createResultsDiv(area);
  759. area.parentNode.insertBefore(resultsDiv, area.nextSibling);
  760. // Hide by default so it doesn't show up on page load
  761. hideResults(area);
  762. // Add autocomplete event listener
  763. area.addEventListener('input', debounce(() => autocomplete(area, area.value), CFG.delayTime));
  764. // Add focusout event listener
  765. area.addEventListener('focusout', debounce(() => hideResults(area), 400));
  766. // Add up and down arrow event listener
  767. area.addEventListener('keydown', (e) => navigateInList(area, e));
  768. // CompositionEnd fires after the user has finished IME composing
  769. // We need to block hide here to prevent the enter key from insta-closing the results
  770. area.addEventListener('compositionend', () => {
  771. hideBlocked = true;
  772. setTimeout(() => { hideBlocked = false; }, 100);
  773. });
  774. // Add class so we know we've already added the listeners
  775. area.classList.add('autocomplete');
  776. }
  777. });
  778. // Add style to dom
  779. let acStyle = document.createElement('style');
  780. //let css = gradioApp().querySelector('.dark') ? autocompleteCSS_dark : autocompleteCSS_light;
  781. let mode = gradioApp().querySelector('.dark') ? 0 : 1;
  782. // Check if we are on webkit
  783. let browser = navigator.userAgent.toLowerCase().indexOf('firefox') > -1 ? "firefox" : "other";
  784. let css = autocompleteCSS;
  785. // Replace vars with actual values (can't use actual css vars because of the way we inject the css)
  786. Object.keys(styleColors).forEach((key) => {
  787. css = css.replace(`var(${key})`, styleColors[key][mode]);
  788. })
  789. Object.keys(browserVars).forEach((key) => {
  790. css = css.replace(`var(${key})`, browserVars[key][browser]);
  791. })
  792. if (acStyle.styleSheet) {
  793. acStyle.styleSheet.cssText = css;
  794. } else {
  795. acStyle.appendChild(document.createTextNode(css));
  796. }
  797. gradioApp().appendChild(acStyle);
  798. // Callback
  799. await processQueue(QUEUE_AFTER_SETUP, null);
  800. }
  801. let loading = false;
  802. onUiUpdate(async () => {
  803. if (loading) return;
  804. if (Object.keys(opts).length === 0) return;
  805. if (CFG) return;
  806. loading = true;
  807. // Get our tag base path from the temp file
  808. tagBasePath = await readFile(`tmp/tagAutocompletePath.txt`);
  809. // Load config from webui opts
  810. await syncOptions();
  811. // Rest of setup
  812. setup();
  813. loading = false;
  814. });