AO3: [Wrangling] Keyboard Shortcuts

adds keyboard shortcuts to the AO3 wrangling interface

  1. // ==UserScript==
  2. // @name AO3: [Wrangling] Keyboard Shortcuts
  3. // @namespace https://greasyfork.org/en/users/906106-escctrl
  4. // @description adds keyboard shortcuts to the AO3 wrangling interface
  5. // @author escctrl
  6. // @version 6.1
  7. // @match https://archiveofourown.org/tags/*
  8. // @match https://archiveofourown.org/tag_wranglings*
  9. // @match https://archiveofourown.org/tag_wranglers/*
  10. // @match https://archiveofourown.org/comments*
  11. // @license MIT
  12. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
  13. // @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
  15. // @require https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js
  16. // ==/UserScript==
  17.  
  18. /* eslint-disable no-multi-spaces */
  19. /* global jQuery, lightOrDark */
  20.  
  21. (function($) {
  22. 'use strict';
  23. const cPage = findPageType();
  24. if (cPage == "") return; // page that isn't supported or we're in retry later
  25.  
  26. const sCfgName = 'wrangleShortcuts'; // name of dialog and localstorage
  27. const sDlgName = '#'+sCfgName; // selector for CSS and jQuery
  28. var eDlg; // cached dialog element to speed up selectors
  29.  
  30. // listening to the user's keystrokes and check against what is enabled
  31. var mShortcuts = loadPageShortcuts();
  32. if ((mShortcuts.Action.size + mShortcuts.Fandom.size + mShortcuts.Canonical.size) > 0) $(window).on('keydown.wrangling', validateKey);
  33.  
  34. function findPageType() {
  35. // simpler than interpreting the URL: determine page type based on classes assigned to #main
  36. let main = $('#main');
  37. return $(main).hasClass('tags-wrangle') ? "B" : // bin
  38. $(main).hasClass('tags-edit') ? "E" : // edit
  39. $(main).hasClass('tags-update') ? "E" : // edit after error
  40. $(main).hasClass('tags-show') ? "L" : // landing
  41. $(main).hasClass('tags-search') ? "S" : // search
  42. $(main).hasClass('tags-new') ? "N" : // new
  43. $(main).hasClass('comments-index') ? "C" : // comments
  44. $(main).hasClass('comments-show') ? "C" : ""; // comments
  45. }
  46.  
  47. /***************** CONFIG DIALOG *****************/
  48.  
  49. // if no other script has created it yet, write out a "Userscripts" option to the main navigation
  50. if ($('#scriptconfig').length == 0) {
  51. $('#header').find('nav[aria-label="Site"] li.dropdown').last()
  52. .after(`<li class="dropdown" id="scriptconfig">
  53. <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
  54. <ul class="menu dropdown-menu"></ul></li>`);
  55. }
  56. // then add this script's config option to navigation dropdown
  57. $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_${sCfgName}">Wrangling Keyboard Shortcuts</a></li>`);
  58.  
  59. // NOTE: we try to not have to run through all the config dialog logic on every page load. it rarely gets opened once you have the config down
  60. // we initialize the configuration dialog only on first click (part of initialization is adding a listener for subsequent clicks)
  61. $("#opencfg_"+sCfgName).one("click", createDialog);
  62.  
  63. function createDialog() {
  64. // if the background is dark, use the dark UI theme to match
  65. let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "dark-hive" : "base";
  66.  
  67. // adding the jQuery stylesheet to style the dialog, and fixing the interference of AO3's styling
  68. $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`)
  69. .append(`<style tyle="text/css">
  70. ${sDlgName}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;}
  71. ${sDlgName} form {box-shadow: revert; cursor:auto;}
  72. ${sDlgName} fieldset { background: revert; box-shadow: revert; border-width: 1px; margin: 1em 0; border-radius: 0.2em; }
  73. ${sDlgName} fieldset p { padding-left: 0; padding-right: 0; }
  74. ${sDlgName} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;}
  75. ${sDlgName} kbd.ui-button { padding: 0.1em; cursor: text; }
  76. ${sDlgName} table { background-color: unset; }
  77. ${sDlgName} tr, ${sDlgName} tr:hover { border-width: 0; }
  78. ${sDlgName} td { vertical-align: middle }
  79. ${sDlgName} input.typeshortcut { width: 4em; border-radius: 0.2em; padding: 0.1em 0.5em; }
  80. ${sDlgName} input.typetag { width: 30em; border-radius: 0.2em; padding: 0.1em 0.5em; }
  81. ${sDlgName} input::placeholder { font-style: italic; opacity: 20%; }
  82. ${sDlgName} td.cellshortcut { width: 7em; }
  83. ${sDlgName} .ui-tabs-tab a { border-bottom-width: 0; }
  84. ${sDlgName} .ui-tabs .ui-tabs-panel { padding-left: 0; padding-right: 0; max-height: 20em; overflow-y: auto; }
  85. .fontawesome { width: 1em; height: 1em; vertical-align: -0.125em; display: inline-block; }
  86. </style>`);
  87.  
  88. // what's all configured already?
  89. const cfgActions = loadAllActions();
  90. const cfgTags = loadAllTags();
  91. let rows = { A: [[], []] };
  92.  
  93. let iconWindows = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><title>Windows logo</title><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="currentColor" d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>`;
  94.  
  95. // walk through each page and turn the available & configured shortcuts into HTML tables
  96. for (const [p, v] of Object.entries(cfgActions)) {
  97. rows[p] = Object.entries(v).map((act) => `<tr>
  98. <td class="cellshortcut"><input type="text" class="typeshortcut" maxlength=5 name="${p}-${act[0]}" id="${p}-${act[0]}" value="${act[1][1]}"> &rarr;</td>
  99. <td>${act[1][0]}</td>
  100. </tr>`).join("\n");
  101. }
  102. // walk through the configured tags and turn them into HTML tables + add a new empty line
  103. rows.A[0] = Object.values(cfgTags.Fan).map((add, ix) => `<tr>
  104. <td class="cellshortcut"><input type="text" class="typeshortcut" name="af${ix}[kbd]" maxlength=5 value="${add[0]}"> &rarr;</td>
  105. <td><input type="text" class="typetag" name="af${ix}[tag]" value="${add[1]}"></td></tr>`);
  106. rows.A[0].push(`<tr>
  107. <td class="cellshortcut"><input type="text" class="typeshortcut" name="af${rows.A[0].length}[kbd]" maxlength=5 value=""> &rarr;</td>
  108. <td><input type="text" class="typetag" name="af${rows.A[0].length}[tag]" value=""></td></tr>`);
  109. rows.A[1] = Object.values(cfgTags.Can).map((add, ix) => `<tr>
  110. <td class="cellshortcut"><input type="text" class="typeshortcut" name="ac${ix}[kbd]" maxlength=5 value="${add[0]}"> &rarr;</td>
  111. <td><input type="text" class="typetag" name="ac${ix}[tag]" value="${add[1]}"></td></tr>`);
  112. rows.A[1].push(`<tr>
  113. <td class="cellshortcut"><input type="text" class="typeshortcut" name="ac${rows.A[1].length}[kbd]" maxlength=5 value=""> &rarr;</td>
  114. <td><input type="text" class="typetag" name="ac${rows.A[1].length}[tag]" value=""></td></tr>`);
  115. rows.A[0] = rows.A[0].join("\n");
  116. rows.A[1] = rows.A[1].join("\n");
  117.  
  118. // wrapper div for the dialog
  119. $("#main").append(`<div id="${sCfgName}"></div>`);
  120.  
  121. // building the dialog
  122. $(sDlgName).html(`<form><fieldset><legend>Shortcuts for buttons, checkboxes, etc</legend>
  123. <p>Click or tap into the textfield and press the key combination you'd like to use. Choose a combination of<br/>
  124. &#8226; required: one of <kbd class="ui-button ui-corner-all">Ctrl</kbd>, <kbd class="ui-button ui-corner-all">Alt</kbd>, or <kbd class="ui-button ui-corner-all">Meta</kbd>
  125. (aka <span class="fontawesome">${iconWindows}</span> or &#8984;/Command) key +<br>
  126. &#8226; optional: <kbd class="ui-button ui-corner-all">Shift</kbd> key + <br>
  127. &#8226; required: a letter or number for each shortcut.</p>
  128. <p>If you don't want to use a shortcut for any of these available actions, just leave its field empty.</p>
  129. <div id="tabs">
  130. <ul><li><a href="#tab-bin">Bin</a></li>
  131. <li><a href="#tab-edit">Edit</a></li>
  132. <li><a href="#tab-cmt">Comments</a></li>
  133. <li><a href="#tab-land">Landing</a></li>
  134. <li><a href="#tab-search">Search</a></li>
  135. <li><a href="#tab-new">New</a></li></ul>
  136. <div id="tab-edit">
  137. <table>${rows.E}</table>
  138. <p><label for="E-a_f">Enable Fandoms shortcuts</label><input type="checkbox" name="E-a_f" id="E-a_f" ${cfgTags.FPage.includes("E") ? 'checked="checked"' : ''}>
  139. <label for="E-a_c">Enable Synonym Of shortcuts</label><input type="checkbox" name="E-a_c" id="E-a_c" ${cfgTags.CPage.includes("E") ? 'checked="checked"' : ''}></p>
  140. </div>
  141. <div id="tab-bin">
  142. <table>${rows.B}</table>
  143. <p><label for="B-a_f">Enable Fandoms shortcuts</label><input type="checkbox" name="B-a_f" id="B-a_f" ${cfgTags.FPage.includes("B") ? 'checked="checked"' : ''}>
  144. <label for="B-a_c">Enable Synonym Of shortcuts</label><input type="checkbox" name="B-a_c" id="B-a_c" ${cfgTags.CPage.includes("B") ? 'checked="checked"' : ''}></p>
  145. <p style="margin: 0 0.5em;">Check out the <a href="https://greasyfork.org/en/scripts/479026">AO3: Use Arrow-Keys to Navigate</a> script
  146. for jumping to the previous/next page in the bin with the <kbd class="ui-button ui-corner-all">&larr;</kbd> <kbd class="ui-button ui-corner-all">&rarr;</kbd> cursor keys.</p>
  147. </div>
  148. <div id="tab-cmt"><table>${rows.C}</table></div>
  149. <div id="tab-land"><table>${rows.L}</table></div>
  150. <div id="tab-search">
  151. <table>${rows.S}</table>
  152. <p><label for="S-a_f">Enable Fandoms shortcuts</label><input type="checkbox" name="S-a_f" id="S-a_f" ${cfgTags.FPage.includes("S") ? 'checked="checked"' : ''}></p>
  153. <p style="margin: 0 0.5em;">Check out the <a href="https://greasyfork.org/en/scripts/479026">AO3: Use Arrow-Keys to Navigate</a> script
  154. for jumping to the previous/next page of search results with the <kbd class="ui-button ui-corner-all">&larr;</kbd> <kbd class="ui-button ui-corner-all">&rarr;</kbd> cursor keys.</p>
  155. </div>
  156. <div id="tab-new"><table>${rows.N}</table></div>
  157. </div>
  158. <p><label for="link-tab">Open page links in a new tab</label><input type="checkbox" name="link-tab" id="link-tab" ${cfgTags.NewTab == "Y" ? 'checked="checked"' : ''}></p>
  159. </fieldset>
  160. <fieldset id="addtags"><legend>Shortcuts to add Fandom and Canonical tags</legend>
  161. <p>Step 1: Tick the checkboxes on the Bin, Edit, and/or Search tabs above. Choose if you want to use shortcuts to add fandoms, to syn to canonical tags,
  162. or both on each of those pages.</p>
  163. <p>Step 2: In the lists below, define the tag name and the corresponding shortcut. It'll always be the same, on every enabled page.</p>
  164. <p>Fandoms:</p>
  165. <table>${rows.A[0]}</table>
  166. <button class="ui-button ui-widget ui-corner-all" id="addmore">+ Add more</button>
  167. <p>Canonicals:</p>
  168. <table>${rows.A[1]}</table>
  169. <button class="ui-button ui-widget ui-corner-all" id="addmore">+ Add more</button>
  170. </fieldset>
  171. </form>`);
  172.  
  173. // adding placeholders as hint to user - easier here after the fact than coding it into all the <input>s
  174. $(sDlgName).find("input.typeshortcut" ).prop('placeholder', 'shortcut');
  175. $(sDlgName).find("input.typetag" ).prop('placeholder', 'tag name');
  176.  
  177. /* JQUERYUI TIME: TURNING PLAIN HTML INTO A NICE CONFIG DIALOG */
  178. $( function() { $(sDlgName).find("#tabs" ).tabs({
  179. collapsible: true,
  180. show: { effect: "blind", duration: 500 },
  181. hide: { effect: "blind", duration: 500 }
  182. }); } );
  183. $( function() { $(sDlgName).find("input[type='checkbox']" ).checkboxradio(); } );
  184.  
  185. let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
  186.  
  187. // initialize the dialog
  188. $( sDlgName ).dialog({
  189. appendTo: "#main",
  190. modal: true,
  191. title: 'Wrangling Keyboard Shortcuts',
  192. draggable: true,
  193. resizable: false,
  194. autoOpen: false,
  195. width: dialogwidth > 700 ? 700 : dialogwidth * 0.9, // optimizing the size of the GUI in case it's a mobile device
  196. position: {my:"center top", at: "center top", of: window},
  197. buttons: [
  198. {
  199. id: "button-reset",
  200. text: "Reset",
  201. click: resetDialog
  202. },
  203. {
  204. id: "button-save",
  205. text: "Save",
  206. click: storeNewShortcuts
  207. },
  208. {
  209. id: "button-cancel",
  210. text: "Cancel",
  211. click: closeDialog
  212. }
  213. ]
  214. });
  215.  
  216. $("#opencfg_"+sCfgName).on("click", openDialog); // on any subsequent clicks, open the configuration dialog again
  217. openDialog(); // and right now, finally open the dialog
  218. }
  219.  
  220. function openDialog() {
  221. $( sDlgName ).dialog('open'); // open the dialog again
  222. eDlg = $(sDlgName)[0]; // finally caching the element for performance
  223. $(window).off('keydown.wrangling'); // stop listening to the wrangling shortcuts while we reconfigure them
  224.  
  225. // users can click into a field and hit the combo they want to use
  226. $(eDlg).on('keydown.shortcuts', "input.typeshortcut", function(e) {
  227. let field = e.target;
  228.  
  229. // when a user deletes a previously stored config, we don't want to show an error
  230. if (e.key == "Backspace" || e.key == "Delete") {
  231. e.target.value = "";
  232. hideHint(e.target);
  233. dupeCheck(e.target);
  234. }
  235.  
  236. // skipping if it's a special key (e.g. Enter) or if none/several modifiers are pressed at the same time
  237. // if JS is asked to add up booleans like e.altKey etc, it treats them as 0 and 1: true+true+false = 1+1+0 = 2
  238. if ( e.key.length > 1 || (e.altKey + e.ctrlKey + e.metaKey) !== 1 ) return;
  239.  
  240. // if this is a valid new combo, we don't want the browser to react to it (e.g. open a menu item)
  241. e.preventDefault();
  242. e.stopPropagation();
  243.  
  244. // combine into the 3-letter combo that we'll store and compare later
  245. // value of e.key is uppercase when shift is pressed, so we have to normalize
  246. e.target.value = `${e.altKey ? "A" : e.ctrlKey ? "C" : "M"}${e.shiftKey ? "S": " "}${e.key.toLowerCase()}`;
  247.  
  248. // remove any previous hints for incorrect input
  249. hideHint(e.target);
  250.  
  251. // if any combo is a duplicate on the same page (ignoring the empty ones), we throw an error
  252. dupeCheck(e.target);
  253. });
  254.  
  255. // users can enable/disable shortcuts for tags and we have to re-run the dupecheck
  256. $(eDlg).on('change.shortcuts', "input[type='checkbox']", function(e) {
  257. dupeCheck(e.target);
  258. });
  259.  
  260. // as the browser recognizes the value of the input changed (when a letter/number is typed), we check what was entered for validity
  261. $(eDlg).on('input.shortcuts', "input.typeshortcut", function(e) {
  262. // if what was entered wasn't a proper combo (like, simply typing in the field)
  263. if (!e.target.value.match(/^[CAM][S ][a-z]$/)) {
  264. e.target.value = ""; // empty it out
  265. showHint(e.target); // show a hint to user
  266. }
  267. });
  268.  
  269. // event triggers if addmore button is clicked
  270. $( eDlg ).on("click.addmore", "#addmore", (e)=>{
  271. e.preventDefault();
  272. let prevrow = $( e.target ).prev().find('tr:last-of-type');
  273.  
  274. // grab the previous row's ac#/af# and increment by one
  275. let next = $( prevrow ).find('input.typeshortcut').attr('name');
  276. next = parseInt(next.match(/\d+/)[0])+1;
  277.  
  278. // clone the last row and just re-number it
  279. let newrow = prevrow.clone(true, true).get(0);
  280. newrow.innerHTML = newrow.innerHTML.replace(/"(af|ac)\d+\[/g, `"$1${next}[`);
  281.  
  282. // add a new line in the table
  283. $( prevrow ).after(newrow);
  284. });
  285. }
  286.  
  287. // if the window resizes the dialog would move off of the screen
  288. $(window).on('resize', function(e) {
  289. if ($(eDlg).dialog("isOpen")) { // don't need to worry about this if the dialog wasn't opened before
  290.  
  291. // optimizing the size of the GUI in case it's a mobile device
  292. let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
  293. dialogwidth = dialogwidth > 700 ? 700 : dialogwidth * 0.9;
  294.  
  295. let maxheight = parseInt($("body").css("height"));
  296.  
  297. $(eDlg).dialog("option", "width", dialogwidth) // resize the dialog
  298. .dialog("option", "position", {my:"center top", at: "center top", of: window} ); // reposition the dialog
  299.  
  300. }
  301. });
  302.  
  303. function closeDialog() {
  304. $(eDlg).off('keydown.shortcuts'); // stop listening to the config shortcut inputs
  305. $(eDlg).off('input.shortcuts'); // stop checking inputs for validity of values
  306. $(eDlg).find("#addmore").off("click.addmore"); // stop listening to Add More button clicks
  307. if ((mShortcuts.Action.size + mShortcuts.Fandom.size + mShortcuts.Canonical.size) > 0)
  308. $(window).on('keydown.wrangling', validateKey); // listening to the user's keystrokes again
  309. $( eDlg ).dialog( "close" );
  310. }
  311.  
  312. function resetDialog() {
  313. // we ask one more time in case it was an accident, but then we empty out all key-combo fields
  314. if (confirm("Are you sure you want to delete all configured shortcuts?\nPress OK to proceed.")) {
  315. $(eDlg).find('input.typeshortcut, input.typetag').prop('value', "");
  316. $(eDlg).find('input[type="checkbox"]').prop('checked', false);
  317. }
  318. // we don't store or close the dialog, so users still have to click Save (or Cancel)
  319. }
  320.  
  321. function dupeCheck(element) {
  322.  
  323. // reset all errors for a moment, we start fresh
  324. $(eDlg).find('.ui-tabs-tab, .typeshortcut').removeClass('ui-state-error');
  325. $(eDlg).find('#dupewarning').remove();
  326. $('#button-save').button('enable');
  327.  
  328. ['B', 'E', 'C', 'L', 'S', 'N'].forEach( (page) => {
  329.  
  330. // grab all shortcut inputs for this page (in case of tags: only if tag shortcuts are enabled for this page)
  331. let combos = $(eDlg).find(`input.typeshortcut[name^="${page}-"]`); // action
  332. if ($(eDlg).find(`input[type="checkbox"][name^="${page}-a_f"]`).prop('checked')) combos = $(combos).add('input.typeshortcut[name^="af"]', eDlg); // fandom tags
  333. if ($(eDlg).find(`input[type="checkbox"][name^="${page}-a_c"]`).prop('checked')) combos = $(combos).add('input.typeshortcut[name^="ac"]', eDlg); // canonical tags
  334.  
  335. // reduce to those where a shortcut was entered
  336. combos = $(combos).filter(function() { return $(this).prop('value').length > 0 });
  337.  
  338. // make shortcut combos unique with Set() and check if it's now fewer entries -> there were duplicates
  339. let allkbd = $(combos).toArray().map((inp) => inp.value);
  340. let uniquekbd = new Set( allkbd );
  341. if (uniquekbd.size !== allkbd.length) {
  342.  
  343. // general errors reporting: highlight this tab, show the error message, disable the save button
  344. $(eDlg).find(`a[href^="#tab-${page.toLowerCase()}"]`).parent().addClass('ui-state-error');
  345. // only add the error message at bottom if it's not already shown
  346. if ($(eDlg).find('#dupewarning').length == 0) {
  347. $(eDlg).find('form').append(`
  348. <div id="dupewarning" class="ui-state-error ui-corner-all" style="padding: 0.2em 0.5em;"><span class="ui-icon ui-icon-alert"></span>
  349. You configured multiple actions with the same shortcut on a page. Please check your configuration!</div>`);
  350. }
  351. $(eDlg).find('#button-save').button('disable');
  352.  
  353. // we remove all entries in uniquekbd from the list of combos ONCE. anything that remains is a duplicate
  354. let dupes = new Set( allkbd.filter((inp) => !uniquekbd.delete(inp)) );
  355. $(combos).filter( (ix, inp) => dupes.has($(inp).prop('value')) ).addClass('ui-state-error');
  356. }
  357. });
  358. }
  359.  
  360. function showHint(element) {
  361. // only add the hint if it's not already shown
  362. if ($(element).parent().next().find('.ui-state-highlight').length == 0) $(element).parent().next().append(`
  363. <div class="ui-state-highlight ui-corner-all" style="padding: 0.2em 0.5em;"><span class="ui-icon ui-icon-info"></span>
  364. Please use the Ctrl, Alt or Meta key in your shortcut!</div>`);
  365. }
  366. function hideHint(element) {
  367. $(element).parent().next().find('.ui-state-highlight').remove();
  368. }
  369.  
  370. /***************** STORAGE *****************/
  371.  
  372. function loadPageShortcuts() {
  373. // the shortcuts map we build up on page load only contains those which pertain to the viewed page type
  374. // that allows us to store the same shortcut for different actions on different pages - if we only load this page, there won't be duplicates
  375. let cfgs = { Action: new Map(), Fandom: new Map(), Canonical: new Map() };
  376.  
  377. // load actions stored as shortcut -> action
  378. let empty = { B: [], E: [], L: [], C: [], S: [], N: [] };
  379. let storage = JSON.parse(localStorage.getItem(sCfgName+'_act') || JSON.stringify(empty) );
  380. cfgs.Action = new Map(storage[cPage]);
  381.  
  382. // load tags stored as shortcut -> tag (and the pages where they are enabled)
  383. empty = { Fan: [], Can: [], FPage: "", CPage: "", NewTab: "Y" };
  384. storage = JSON.parse(localStorage.getItem(sCfgName+'_tag') || JSON.stringify(empty) );
  385.  
  386. // the page we're on: B, E, or S - only return tags (fandom or canonicals) if enabled on the page
  387. if (storage.FPage.includes(cPage)) cfgs.Fandom = new Map(storage.Fan);
  388. if (storage.CPage.includes(cPage)) cfgs.Canonical = new Map (storage.Can);
  389.  
  390. cfgs.NewTab = storage.NewTab;
  391.  
  392. return cfgs;
  393. }
  394.  
  395. function loadAllActions() {
  396.  
  397. // what's all supported?
  398. const available = {
  399. L: { "o_e": ["open Tag Edit page", ""],
  400. "o_t": ["open Comments page", ""],
  401. "o_w": ["open Works page", ""],
  402. "o_m": ["open Mergers page", ""],
  403. "o_c": ["open Canonical Tag's page", ""] },
  404. B: { "c_w": ["click the 'Wrangle' button", ""],
  405. "f_f": ["focus on the Fandom text field", ""],
  406. "f_s": ["focus on the Synonym Of text field<br/>(if you have the Wrangle from the Bin script)", ""],
  407. "c_s": ["submit the Synonym Of<br/>(if you have the Wrangle from the Bin script)", ""] },
  408. E: { "o_t": ["open Comments page", ""],
  409. "o_w": ["open Works page", ""],
  410. "o_m": ["open Mergers page", ""],
  411. "o_c": ["open Canonical Tag's page", ""],
  412. "c_s": ["click the 'Save' button", ""],
  413. "c_k": ["toggle the Canonical checkbox", ""],
  414. "c_u": ["toggle the Unwragleable checkbox", ""],
  415. "c_af": ["toggle all Fandoms' checkboxes (select all/none)", ""],
  416. "c_ac": ["toggle all Characters' checkboxes (select all/none)", ""],
  417. "c_ar": ["toggle all Relationships' checkboxes (select all/none", ""],
  418. "c_am": ["toggle all Metatags' checkboxes (select all/none)", ""],
  419. "c_asub": ["toggle all Subtags's checkboxes (select all/none)", ""],
  420. "c_asyn": ["toggle all Synonyms' checkboxes (select all/none)", ""],
  421. "f_s": ["focus on the Synonym Of text field", ""],
  422. "f_t": ["focus on the Tag Name text field", ""],
  423. "f_f": ["focus on the Add Fandom text field", ""],
  424. "f_c": ["focus on the Add Character text field", ""],
  425. "f_m": ["focus on the Add Metatag text field", ""],
  426. "f_sub": ["focus on the Add Subtag text field", ""],
  427. "f_syn": ["focus on the Add Synonym text field", ""],
  428. "c_cf": ["copy all Fandoms<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""],
  429. "c_cc": ["copy all Characters<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""],
  430. "c_cr": ["copy all Relationships<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""],
  431. "c_cs": ["copy all Synonyms<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""] },
  432. S: { "c_s": ["click the 'Search' button", ""],
  433. "f_t": ["focus on the Tag Name text field", ""],
  434. "f_f": ["focus on the Fandom text field", ""] },
  435. N: { "c_s": ["click the 'Create Tag' button", ""],
  436. "f_t": ["focus on the Tag Name text field", ""],
  437. "c_k": ["toggle the Canonical checkbox", ""],
  438. "c_f": ["select the Tag Type: Fandom radio button", ""],
  439. "c_c": ["select the Tag Type: Character radio button", ""],
  440. "c_r": ["select the Tag Type: Relationship radio button", ""],
  441. "c_a": ["select the Tag Type: Additional Tag radio button", ""] },
  442. C: { "o_e": ["open Tag Edit page", ""],
  443. "o_w": ["open Works page", ""],
  444. "o_m": ["open Mergers page", ""],
  445. "c_s": ["click the 'Comment' button", ""],
  446. "f_c": ["focus on the Comment text field", ""] }
  447. }
  448.  
  449. // grab what's been configured so far -- this is stored in reverse, as { page: { ["shortcut", "action"],... } }
  450. // because we need it searchable by shortcut on most page loads, and only need the reverse if the config dialog is opened
  451. let empty = { B: [], E: [], L: [], C: [], S: [], N: [] };
  452. let storage = JSON.parse(localStorage.getItem(sCfgName+'_act') || JSON.stringify(empty) );
  453.  
  454. // run through what's been configured and apply it to the corresponding available actions so it becomes action -> shortcut
  455. for (const [p, v] of Object.entries(storage)) { // walk through each page
  456. for (const e of v.values()) { // walk through each [shortcuts, action] within page
  457. available[p][e[1]][1] = e[0]; // sets stored keyboard combo in corresponding available[] entry
  458. }
  459. }
  460. return available;
  461. }
  462.  
  463. function loadAllTags() {
  464. // load what's been stored as shortcut -> tag (and the pages where they are enabled)
  465. let empty = { Fan: [], Can: [], FPage: "", CPage: "", NewTab: "Y" };
  466. let storage = JSON.parse(localStorage.getItem(sCfgName+'_tag') || JSON.stringify(empty) );
  467. return storage;
  468. }
  469.  
  470. function storeNewShortcuts() {
  471. // regular action shortcuts
  472. let kbd = { B: [], E: [], L: [], C: [], S: [], N: [] };
  473.  
  474. // grabbing the input fields with content
  475. let cfgs = $(eDlg).find('#tabs input.typeshortcut').filter(function() { return $(this).prop('value').length > 0 });
  476.  
  477. // in the end, we want again something as { page: [ ["shortcut", "action"] ] }
  478. $(cfgs).each(function() {
  479. let cfg = [$(this).prop('value'), $(this).prop('name').slice(2)];
  480. let page = $(this).prop('name').slice(0,1);
  481. kbd[page].push(cfg);
  482. });
  483.  
  484. localStorage.setItem(sCfgName+'_act', JSON.stringify(kbd));
  485.  
  486. // now the tag shortcuts
  487. kbd = { Fan: [], Can: [], FPage: "", CPage: "", NewTab: "Y" }
  488.  
  489. // grabbing checkbox about where to open the o_X page links
  490. kbd.NewTab = $(eDlg).find('#link-tab').prop('checked') ? "Y" : "N";
  491.  
  492. // grabbing the pages on which we're enabling tag shortcuts
  493. cfgs = $(eDlg).find('input[id$="a_f"], input[id$="a_c"]').filter(function() { return $(this).prop('checked') });
  494. $(cfgs).each(function() {
  495. let page = $(this).prop('name').slice(0,1);
  496. let type = $(this).prop('name').slice(-1).toUpperCase() + "Page";
  497. kbd[type] += page;
  498. });
  499.  
  500. // grabbing the configured tags and their shortcuts
  501. cfgs = $(eDlg).find('#addtags tr');
  502. $(cfgs).each(function() {
  503. let type = $(this).find('input.typetag').prop('name').slice(0,2) == "af" ? "Fan" : "Can";
  504. let cfg = [$(this).find('input.typeshortcut').prop('value'), $(this).find('input.typetag').prop('value')];
  505. if (cfg[1] !== "") kbd[type].push(cfg); // store it only if a tag was entered. we can store tag without shortcut (then it won't be used)
  506. });
  507.  
  508. localStorage.setItem(sCfgName+'_tag', JSON.stringify(kbd));
  509. mShortcuts = loadPageShortcuts(); // reload with new values in case they changed
  510.  
  511. closeDialog();
  512. }
  513.  
  514. function migrateStorage() {
  515. localStorage.removeItem('kbdshortcuts');
  516. localStorage.removeItem('kbdpages');
  517. // I really considered writing a huge migration logic, but holy cow that would've been extensive. I'm sorry.
  518. }
  519.  
  520. /***************** SHORTCUT HANDLING *****************/
  521.  
  522. // basic functions that interact with the elements
  523. const clickButton = el => el.click();
  524. const focusOnField = el => el.focus();
  525. const checkBox = el => el.click();
  526. const addTag = (el, tag) => {
  527. el.focus();
  528. el.value = tag;
  529. el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" }));
  530. }
  531.  
  532. function validateKey(e) {
  533. // skipping if it's a special key (e.g. Enter) or if none/several modifiers are pressed at the same time
  534. if ( e.key.length > 1 || (e.altKey + e.ctrlKey + e.metaKey) !== 1 ) return;
  535.  
  536. // combine into something easier to compare to an array of stored shortcuts
  537. // value of e.key is uppercase when shift is pressed, so we have to normalize
  538. let pressed = `${e.altKey ? "A" : e.ctrlKey ? "C" : "M"}${e.shiftKey ? "S": " "}${e.key.toLowerCase()}`;
  539. //console.log(pressed, mShortcuts);
  540.  
  541. // if that combo isn't configured anywhere, we skip
  542. if (!mShortcuts.Action.has(pressed) && !mShortcuts.Fandom.has(pressed) && !mShortcuts.Canonical.has(pressed)) return;
  543.  
  544. // if this is one of our combos, we don't want the browser to react to it (e.g. open a menu item)
  545. e.preventDefault();
  546. e.stopPropagation();
  547.  
  548. // now we gotta determine what we're supposed to do... a combo of page type + action to perform
  549. let action = mShortcuts.Fandom.has(pressed) ? 'a_f,'+mShortcuts.Fandom.get(pressed) :
  550. mShortcuts.Canonical.has(pressed) ? 'a_c,'+mShortcuts.Canonical.get(pressed) :
  551. mShortcuts.Action.get(pressed);
  552.  
  553. if (action.startsWith("o_")) openPage(action); // openPage is separate because it's too similar on all pages
  554. else {
  555. switch (cPage) {
  556. case "B": handleBin(action); break;
  557. case "E": handleEdit(action); break;
  558. case "L": handleLanding(action); break;
  559. case "S": handleSearch(action); break;
  560. case "N": handleNew(action); break;
  561. case "C": handleComments(action); break;
  562. default: break;
  563. }
  564. }
  565. }
  566.  
  567. function openPage(a) {
  568. // with cPage we'll determine how to find the plain link to the tag in question
  569. let url = cPage == "L" ? window.location.href : $('#main > .heading a.tag').prop('href');
  570.  
  571. // a defines the page we're trying to open (for canonicals we don't need to add anything at the end, they already lead to /edit pages)
  572. let end = a == "o_e" ? "/edit" :
  573. a == "o_t" ? "/comments" :
  574. a == "o_w" ? "/works" :
  575. a == "o_m" ? "/wrangle?page=1&show=mergers" : "";
  576.  
  577. // unless we're loading the canonical of a viewed syn!
  578. if (a == "o_c") {
  579. // bow out gracefully if the viewed tag isn't a syn, and therefore no canonical exists
  580. if ( (cPage == "L" && $('div.merger').length == 0) || (cPage == "E" && $('input#tag_syn_string ~ p.actions a').length == 0) ) {
  581. console.log(`Wrangling Shortcuts: You tried to open the canonical tag's page, but this tag isn't synned anywhere`);
  582. return;
  583. }
  584. // if a canonical exists, we grab that tag's URL from the link/button
  585. url = cPage == "L" ? $('div.merger p a').prop('href')+"/edit" :
  586. cPage == "E" ? $('input#tag_syn_string ~ p.actions a').prop('href') : "";
  587. }
  588.  
  589. let target = mShortcuts.NewTab == "Y" ? "_blank" : "_self";
  590.  
  591. if (url !== "") window.open(url+end, target);
  592. else console.log(`Wrangling Shortcuts: You tried to go somewhere but I couldn't find the link`);
  593. }
  594. function handleLanding(a) {
  595. // nothing to do here. the only supported actions are going to other pages, so we shouldn't ever get to this function
  596. console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Landing Page`);
  597. }
  598. function handleBin(a) {
  599. if (a == "c_w") clickButton($("#wrangulator p.submit input[type='submit']")[0]); // mass-wrangle tags
  600. else if (a == "f_f") focusOnField($("#fandom_string_autocomplete")[0]); // fandoms field
  601. else if (a.startsWith("a_f")) {
  602. let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add
  603. addTag($("#fandom_string_autocomplete")[0], tag);
  604. }
  605. // the following only work if you use the script Wrangle Stright From The Bins
  606. else if (a == "f_s" && $("#syn_tag_autocomplete_autocomplete").length > 0) focusOnField($("#syn_tag_autocomplete_autocomplete")[0]); // syns field
  607. else if (a == "c_s" && $("button[name='wrangle_existing']").length > 0) clickButton($("button[name='wrangle_existing']")[0]); // submit syns
  608. else if (a.startsWith("a_c") && $("#syn_tag_autocomplete_autocomplete").length > 0) {
  609. let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add
  610. addTag($("#syn_tag_autocomplete_autocomplete")[0], tag);
  611. }
  612. else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Bin Page`);
  613. }
  614. function handleEdit(a) {
  615. if (a == "c_k") clickButton($("#tag_canonical")[0]); // canonical checkbox
  616. else if (a == "c_u") clickButton($("#tag_unwrangleable")[0]); // unwrangleable checkbox
  617. else if (a == "c_s") clickButton($("#edit_tag p.submit input[type='submit']")[0]); // save changes
  618. else if (a == "c_af") clickButton($("#parent_Fandom_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all fandoms
  619. else if (a == "c_ac") clickButton($("#parent_Character_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all chars
  620. else if (a == "c_ar") clickButton($("#child_Relationship_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all rels
  621. else if (a == "c_am") clickButton($("#parent_MetaTag_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all metatags
  622. else if (a == "c_asub") clickButton($("#child_SubTag_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all subtags
  623. else if (a == "c_asyn") clickButton($("#child_Merger_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all syns
  624. else if (a == "f_s") focusOnField($("#tag_syn_string_autocomplete")[0]); // Syn Of field
  625. else if (a == "f_t") focusOnField($("#tag_name")[0]); // tag name field
  626. else if (a == "f_f") focusOnField($("#tag_fandom_string_autocomplete")[0]); // fandoms textfield
  627. else if (a == "f_c") focusOnField($("#tag_character_string_autocomplete")[0]); // characters textfield
  628. else if (a == "f_m") focusOnField($("#tag_meta_tag_string_autocomplete")[0]); // metatags textfield
  629. else if (a == "f_sub") focusOnField($("#tag_sub_tag_string_autocomplete")[0]); // subtags textfield
  630. else if (a == "f_syn") focusOnField($("#tag_merger_string_autocomplete")[0]); // syns/mergers textfield
  631. else if (a.startsWith("a_f")) { // add tag in Fandom field
  632. let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add
  633. addTag($("#tag_fandom_string_autocomplete")[0], tag);
  634. }
  635. else if (a.startsWith("a_c")) { // add tag in Syn Of field
  636. console.log(a);
  637. let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add
  638. addTag($("#tag_syn_string_autocomplete")[0], tag);
  639. }
  640. // the following only work if you use the script "Copy Characters & Syns To Clipboard"
  641. // they're the only <button> within the .actions bar
  642. else if (a == "c_cf" && $("#parent_Fandom_associations_to_remove_checkboxes").parent().find(".actions button").length > 0)
  643. clickButton($("#parent_Fandom_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all fandoms
  644. else if (a == "c_cc" && $("#parent_Character_associations_to_remove_checkboxes").parent().find(".actions button").length > 0)
  645. clickButton($("#parent_Character_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all chars
  646. else if (a == "c_cr" && $("#child_Relationship_associations_to_remove_checkboxes").parent().find(".actions button").length > 0)
  647. clickButton($("#child_Relationship_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all rels
  648. else if (a == "c_cs" && $("#child_Merger_associations_to_remove_checkboxes").parent().find(".actions button").length > 0)
  649. clickButton($("#child_Merger_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all syns
  650. else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Edit Page`);
  651. }
  652. function handleSearch(a) {
  653. if (a == "c_s") clickButton($("#new_tag_search p.submit input[type='submit']")[0]); // start search
  654. else if (a == "f_t") focusOnField($("#new_tag_search #tag_search_name")[0]); // focus on tag name field
  655. else if (a == "f_f") focusOnField($("#new_tag_search #tag_search_fandoms_autocomplete")[0]); // focus on fandom field
  656. else if (a.startsWith("a_f")) { // add tag in Fandom field
  657. let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add
  658. addTag($("#new_tag_search #tag_search_fandoms_autocomplete")[0], tag);
  659. }
  660. else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Tag Search Page`);
  661. }
  662. function handleNew(a) {
  663. if (a == "c_s") clickButton($("#new_tag p.submit input[type='submit']")[0]); // submit new tag
  664. else if (a == "f_t") focusOnField($("#tag_name")[0]); // focus on tag name field
  665. else if (a == "c_k") clickButton($("#tag_canonical")[0]); // toggle canonical checkbox
  666. else if (a == "c_f") clickButton($("#tag_type_fandom")[0]); // select fandom radiobutton
  667. else if (a == "c_c") clickButton($("#tag_type_character")[0]); // select character radiobutton
  668. else if (a == "c_r") clickButton($("#tag_type_relationship")[0]); // select relationship radiobutton
  669. else if (a == "c_a") clickButton($("#tag_type_freeform")[0]); // select additional tag radiobutton
  670. else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the New Tag Page`);
  671. }
  672. function handleComments(a) {
  673. if (a == "c_s") clickButton($("#add_comment p.submit input[type='submit']")[0]); // submit comment
  674. else if (a == "f_c") focusOnField($("#add_comment textarea")[0]); // focus on toplevel comment textarea
  675. else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Comments Page`);
  676. }
  677.  
  678. })(jQuery);