Soybooru Ext

Add autocomplete to the Soybooru upload form

  1. // ==UserScript==
  2. // @name Soybooru Ext
  3. // @namespace https://github.com/thoughever
  4. // @match https://booru.soy/*
  5. // @match http://booru.soy/*
  6. // @match https://www.booru.soy/*
  7. // @match http://www.booru.soy/*
  8. // @grant GM_setClipboard
  9. // @version 0.1.2
  10. // @author thoughever
  11. // @license MIT
  12. // @description Add autocomplete to the Soybooru upload form
  13. // ==/UserScript==
  14.  
  15. (function () {
  16.  
  17. "use strict";
  18.  
  19. /* Global settings */
  20. const g_autocomplete_query_delay_ms = 300;
  21.  
  22. /* Utils */
  23. function add_page_styles(styles) {
  24. let tag_styles = document.createElement('style');
  25. tag_styles.type = 'text/css';
  26. tag_styles.innerHTML = styles;
  27. let head = document.getElementsByTagName('head')[0];
  28. head.appendChild(tag_styles);
  29. }
  30.  
  31. function insert_after(reference_elem, new_elem) {
  32. reference_elem.parentNode.insertBefore(new_elem, reference_elem.nextSibling);
  33. }
  34.  
  35. function reverse_str(str){
  36. return [...str].reverse().join("");
  37. }
  38.  
  39. function insert_mid_str(str, i, j, insert_str) {
  40. return `${str.slice(0, i)}${insert_str}${str.slice(j, str.length)}`;
  41. }
  42.  
  43. function legacy_copy_to_clipboard(text) {
  44. let textarea = document.createElement("textarea");
  45. textarea.classList.add("shimmieext-legacy-clipboard-textarea");
  46. textarea.value = text;
  47.  
  48. document.body.appendChild(textarea);
  49. textarea.focus();
  50. textarea.select();
  51. try {
  52. document.execCommand("copy");
  53. } catch (e) {}
  54. document.body.removeChild(textarea);
  55. }
  56.  
  57. function copy_to_clipboard(text) {
  58. try {
  59. GM_setClipboard(text);
  60. } catch(e) {
  61. if(navigator.clipboard) {
  62. navigator.clipboard.writeText(text);
  63. } else {
  64. legacy_copy_to_clipboard(text);
  65. }
  66. }
  67. }
  68.  
  69. const KEYCODE_TAB = 9;
  70. const KEYCODE_ENTER = 13;
  71. const KEYCODE_ARROW_LEFT = 37;
  72. const KEYCODE_ARROW_UP = 38;
  73. const KEYCODE_ARROW_RIGHT = 39;
  74. const KEYCODE_ARROW_DOWN = 40;
  75.  
  76. /* Autocomplete elements */
  77. class AutocompleteDropdown {
  78. constructor() {
  79. this.node = document.createElement("ul");
  80. this.node.classList.add("shimmieext-autocomplete");
  81. this.node.tabIndex = "-1";
  82. let _this = this;
  83. this.node.addEventListener("keydown", function(e) {
  84. let key_code = e.keyCode || e.which;
  85. switch(key_code) {
  86. case KEYCODE_ARROW_UP:
  87. _this.focus_prev();
  88. e.preventDefault();
  89. break;
  90. case KEYCODE_ARROW_DOWN:
  91. _this.focus_next();
  92. e.preventDefault();
  93. break;
  94. case KEYCODE_ARROW_RIGHT:
  95. case KEYCODE_TAB:
  96. _this.select_focused();
  97. e.preventDefault();
  98. }
  99. }, false);
  100. this.items = [];
  101. }
  102.  
  103. focus_prev() {
  104. let prev_li = document.activeElement.previousSibling;
  105. if(prev_li) {
  106. prev_li.focus();
  107. }
  108. }
  109.  
  110. focus_next() {
  111. let next_li = document.activeElement.nextSibling;
  112. if(next_li) {
  113. next_li.focus();
  114. }
  115. }
  116.  
  117. set_focus(i) {
  118. let li = this.items[i];
  119. if(li) {
  120. li.focus();
  121. }
  122. }
  123.  
  124. select_focused() {
  125. let focused_elem = document.activeElement;
  126. if(this.items.includes(focused_elem)) {
  127. focused_elem.click();
  128. }
  129. }
  130.  
  131. select(i) {
  132. let li = this.items[i];
  133. if(li) {
  134. li.click();
  135. }
  136. }
  137.  
  138. clear() {
  139. this.node.replaceChildren();
  140. this.items = [];
  141. }
  142.  
  143. show() {
  144. this.node.classList.remove("shimmieext-hidden");
  145. }
  146.  
  147. hide() {
  148. this.node.classList.add("shimmieext-hidden");
  149. }
  150.  
  151. _add_li(li) {
  152. this.node.appendChild(li);
  153. this.items.push(li);
  154. }
  155.  
  156. add_item(tag, count, on_select) {
  157. let new_li = document.createElement("li");
  158. new_li.classList.add("shimmieext-autocomplete");
  159. new_li.innerHTML = `${tag} (${count})`;
  160. new_li.tabIndex = "0";
  161.  
  162. let _this = this;
  163. let hide_select = function() {
  164. _this.hide();
  165. on_select(tag);
  166. };
  167. new_li.addEventListener("click", function() {
  168. hide_select();
  169. }, false);
  170. new_li.addEventListener("keydown", function(e) {
  171. let key_code = e.keyCode || e.which;
  172. if(key_code === KEYCODE_ENTER) {
  173. hide_select();
  174. e.preventDefault();
  175. }
  176. }, false);
  177.  
  178. this._add_li(new_li);
  179. }
  180. }
  181.  
  182. class AutocompleteField {
  183. constructor(elem_input, delay_query, shimmie_api) {
  184. this.root = elem_input;
  185. this.delay = delay_query;
  186. this.api = shimmie_api;
  187. this.timer = undefined;
  188.  
  189. const _this = this;
  190. this.root.addEventListener("input", function(e) {
  191. let text = _this.root.value;
  192. if(text) {
  193. // Reset delay if another key pressed before finished
  194. _this.clear_timer();
  195. _this.timer = setTimeout(function() {
  196. let cursor_i = e.target.selectionStart;
  197. // If at end of input or space in front
  198. let cursor_at_end = text[cursor_i] === undefined;
  199. if(cursor_at_end || text[cursor_i] === " ") {
  200. // Get word behind cursor
  201. let i = cursor_i - 1;
  202. let c = text[i];
  203. let word_behind = "";
  204. while(c !== undefined && c !== " ") {
  205. word_behind += c;
  206. i--;
  207. c = text[i];
  208. }
  209. let word_start_i = i + 1;
  210. if(word_behind) {
  211. word_behind = reverse_str(word_behind);
  212. _this.api_get_autocomplete(word_behind, function(res_text) {
  213. _this.populate_dropdown(JSON.parse(res_text), word_start_i, cursor_i, cursor_at_end);
  214. });
  215. }
  216. }
  217. }, _this.delay);
  218. }
  219. }, false);
  220.  
  221. this.root.addEventListener("keydown", function(e) {
  222. let key_code = e.keyCode || e.which;
  223. switch(key_code) {
  224. case KEYCODE_TAB:
  225. case KEYCODE_ARROW_DOWN:
  226. case KEYCODE_ENTER:
  227. _this.dropdown.set_focus(0);
  228. _this.clear_timer();
  229. e.preventDefault();
  230. break;
  231. }
  232. }, false);
  233.  
  234. this.dropdown = new AutocompleteDropdown();
  235. this.dropdown.hide();
  236. insert_after(this.root, this.dropdown.node);
  237. }
  238.  
  239. clear_timer() {
  240. if(this.timer) {
  241. clearTimeout(this.timer);
  242. }
  243. }
  244.  
  245. populate_dropdown(res_json, insert_start_i, insert_end_i, add_space) {
  246. this.dropdown.clear();
  247. let _this = this;
  248. for (const [k, v] of Object.entries(res_json)) {
  249. this.dropdown.add_item(k, v, function(selected_tag) {
  250. _this.root.value = insert_mid_str(_this.root.value, insert_start_i, insert_end_i, selected_tag);
  251. if(add_space) {
  252. _this.root.value += " ";
  253. }
  254. _this.root.focus();
  255. });
  256. }
  257. this.dropdown.show();
  258. }
  259.  
  260. api_get_autocomplete(query_text, on_load) {
  261. const xhttp = new XMLHttpRequest();
  262. const _this = this;
  263. xhttp.onload = function() {
  264. on_load(this.responseText);
  265. };
  266. xhttp.open("GET", `${_this.api}/api/internal/autocomplete?s=${query_text}`, true);
  267. xhttp.send();
  268. }
  269. }
  270.  
  271. /* Upload form autocomplete */
  272. function page_upload(shimmie_api) {
  273. let upload_form = document.getElementById("file_upload");
  274. // Prevent form submit on enter press
  275. upload_form.addEventListener("keydown", function(e) {
  276. let key_code = e.keyCode || e.which;
  277. if(key_code === KEYCODE_ENTER){
  278. e.preventDefault();
  279. }
  280. }, false);
  281.  
  282. // Add enter press submit back to post button
  283. let upload_button = document.getElementById("uploadbutton");
  284. upload_button.addEventListener("keydown", function(e) {
  285. let key_code = e.keyCode || e.which;
  286. if(key_code === KEYCODE_ENTER){
  287. upload_form.submit();
  288. }
  289. }, false);
  290.  
  291. let autocomplete_fields = [];
  292. let autocomplete_inputs = upload_form.getElementsByClassName('autocomplete_tags');
  293. for (const input_node of autocomplete_inputs) {
  294. autocomplete_fields.push(new AutocompleteField(input_node, g_autocomplete_query_delay_ms, shimmie_api));
  295. }
  296.  
  297. // Close dropdown on click anywhere else
  298. document.body.addEventListener("click", function() {
  299. for (const field of autocomplete_fields) {
  300. field.dropdown.hide();
  301. }
  302. }, false);
  303. }
  304.  
  305. /* Single page copy tags and autocomplete tag editor */
  306. function page_single_image(shimmie_api) {
  307. let tag_editor_input = document.getElementById("tag_editor");
  308. let tag_editor_autocomplete_field = new AutocompleteField(tag_editor_input, g_autocomplete_query_delay_ms, shimmie_api);
  309.  
  310. let tags = tag_editor_input.value;
  311. let info_table = document.querySelector(".image_info tbody");
  312.  
  313. let new_tr = document.createElement("tr");
  314. let new_td = document.createElement("td");
  315. let new_button = document.createElement("input");
  316.  
  317. new_td.colSpan = "4";
  318.  
  319. new_button.value = "Copy Tags";
  320. new_button.type = "button";
  321. new_button.classList.add("shimmieext-button-copy-tags");
  322. new_button.addEventListener("click", function() {
  323. copy_to_clipboard(tags);
  324. }, false);
  325.  
  326. new_td.appendChild(new_button);
  327. new_tr.appendChild(new_td);
  328. info_table.appendChild(new_tr);
  329. }
  330.  
  331. function main() {
  332. let url = window.location.href.split('/');
  333. let shimmie_api = `${url[0]}//${url[2]}`;
  334.  
  335. let page;
  336. if(url[3] === "post" && url[4] === "view" || url[3] === "random_image" && url[4] === "view") {
  337. page = page_single_image;
  338. } else if(url[3] === "upload") {
  339. page = page_upload;
  340. }
  341.  
  342. if(page) {
  343. add_page_styles("ul.shimmieext-autocomplete {list-style: none; padding: 2px; margin: 0; display: block; outline: none; color: #444444; border: 1px solid #dddddd; z-index: 100;\
  344. font-size: 1.1em; font-family: Helvetica,Arial,sans-serif; text-align: left; background-color: #ffffff; position: absolute; cursor: pointer;}\
  345. li.shimmieext-autocomplete {list-style: none;}\
  346. li.shimmieext-autocomplete:hover, li.shimmieext-autocomplete:focus {font-weight: bold; background-color: #0a78eb; color: #ffffff; }\
  347. .shimmieext-hidden {display: none !important;}\
  348. .shimmieext-button-copy-tags {cursor: pointer;} .shimmieext-button-copy-tags:active {background-color:#E9E9ED;}\
  349. .shimmieext-legacy-clipboard-textarea {top: 0; left: 0; position: fixed;}");
  350.  
  351. page(shimmie_api);
  352. }
  353. }
  354.  
  355. main();
  356.  
  357. })();