您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
adds keyboard shortcuts to the AO3 wrangling interface
// ==UserScript== // @name AO3: [Wrangling] Keyboard Shortcuts // @namespace https://greasyfork.org/en/users/906106-escctrl // @description adds keyboard shortcuts to the AO3 wrangling interface // @author escctrl // @version 6.1 // @match https://archiveofourown.org/tags/* // @match https://archiveofourown.org/tag_wranglings* // @match https://archiveofourown.org/tag_wranglers/* // @match https://archiveofourown.org/comments* // @license MIT // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js // @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js // @require https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js // ==/UserScript== /* eslint-disable no-multi-spaces */ /* global jQuery, lightOrDark */ (function($) { 'use strict'; const cPage = findPageType(); if (cPage == "") return; // page that isn't supported or we're in retry later const sCfgName = 'wrangleShortcuts'; // name of dialog and localstorage const sDlgName = '#'+sCfgName; // selector for CSS and jQuery var eDlg; // cached dialog element to speed up selectors // listening to the user's keystrokes and check against what is enabled var mShortcuts = loadPageShortcuts(); if ((mShortcuts.Action.size + mShortcuts.Fandom.size + mShortcuts.Canonical.size) > 0) $(window).on('keydown.wrangling', validateKey); function findPageType() { // simpler than interpreting the URL: determine page type based on classes assigned to #main let main = $('#main'); return $(main).hasClass('tags-wrangle') ? "B" : // bin $(main).hasClass('tags-edit') ? "E" : // edit $(main).hasClass('tags-update') ? "E" : // edit after error $(main).hasClass('tags-show') ? "L" : // landing $(main).hasClass('tags-search') ? "S" : // search $(main).hasClass('tags-new') ? "N" : // new $(main).hasClass('comments-index') ? "C" : // comments $(main).hasClass('comments-show') ? "C" : ""; // comments } /***************** CONFIG DIALOG *****************/ // if no other script has created it yet, write out a "Userscripts" option to the main navigation if ($('#scriptconfig').length == 0) { $('#header').find('nav[aria-label="Site"] li.dropdown').last() .after(`<li class="dropdown" id="scriptconfig"> <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a> <ul class="menu dropdown-menu"></ul></li>`); } // then add this script's config option to navigation dropdown $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_${sCfgName}">Wrangling Keyboard Shortcuts</a></li>`); // 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 // we initialize the configuration dialog only on first click (part of initialization is adding a listener for subsequent clicks) $("#opencfg_"+sCfgName).one("click", createDialog); function createDialog() { // if the background is dark, use the dark UI theme to match let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "dark-hive" : "base"; // adding the jQuery stylesheet to style the dialog, and fixing the interference of AO3's styling $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`) .append(`<style tyle="text/css"> ${sDlgName}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;} ${sDlgName} form {box-shadow: revert; cursor:auto;} ${sDlgName} fieldset { background: revert; box-shadow: revert; border-width: 1px; margin: 1em 0; border-radius: 0.2em; } ${sDlgName} fieldset p { padding-left: 0; padding-right: 0; } ${sDlgName} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;} ${sDlgName} kbd.ui-button { padding: 0.1em; cursor: text; } ${sDlgName} table { background-color: unset; } ${sDlgName} tr, ${sDlgName} tr:hover { border-width: 0; } ${sDlgName} td { vertical-align: middle } ${sDlgName} input.typeshortcut { width: 4em; border-radius: 0.2em; padding: 0.1em 0.5em; } ${sDlgName} input.typetag { width: 30em; border-radius: 0.2em; padding: 0.1em 0.5em; } ${sDlgName} input::placeholder { font-style: italic; opacity: 20%; } ${sDlgName} td.cellshortcut { width: 7em; } ${sDlgName} .ui-tabs-tab a { border-bottom-width: 0; } ${sDlgName} .ui-tabs .ui-tabs-panel { padding-left: 0; padding-right: 0; max-height: 20em; overflow-y: auto; } .fontawesome { width: 1em; height: 1em; vertical-align: -0.125em; display: inline-block; } </style>`); // what's all configured already? const cfgActions = loadAllActions(); const cfgTags = loadAllTags(); let rows = { A: [[], []] }; 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>`; // walk through each page and turn the available & configured shortcuts into HTML tables for (const [p, v] of Object.entries(cfgActions)) { rows[p] = Object.entries(v).map((act) => `<tr> <td class="cellshortcut"><input type="text" class="typeshortcut" maxlength=5 name="${p}-${act[0]}" id="${p}-${act[0]}" value="${act[1][1]}"> →</td> <td>${act[1][0]}</td> </tr>`).join("\n"); } // walk through the configured tags and turn them into HTML tables + add a new empty line rows.A[0] = Object.values(cfgTags.Fan).map((add, ix) => `<tr> <td class="cellshortcut"><input type="text" class="typeshortcut" name="af${ix}[kbd]" maxlength=5 value="${add[0]}"> →</td> <td><input type="text" class="typetag" name="af${ix}[tag]" value="${add[1]}"></td></tr>`); rows.A[0].push(`<tr> <td class="cellshortcut"><input type="text" class="typeshortcut" name="af${rows.A[0].length}[kbd]" maxlength=5 value=""> →</td> <td><input type="text" class="typetag" name="af${rows.A[0].length}[tag]" value=""></td></tr>`); rows.A[1] = Object.values(cfgTags.Can).map((add, ix) => `<tr> <td class="cellshortcut"><input type="text" class="typeshortcut" name="ac${ix}[kbd]" maxlength=5 value="${add[0]}"> →</td> <td><input type="text" class="typetag" name="ac${ix}[tag]" value="${add[1]}"></td></tr>`); rows.A[1].push(`<tr> <td class="cellshortcut"><input type="text" class="typeshortcut" name="ac${rows.A[1].length}[kbd]" maxlength=5 value=""> →</td> <td><input type="text" class="typetag" name="ac${rows.A[1].length}[tag]" value=""></td></tr>`); rows.A[0] = rows.A[0].join("\n"); rows.A[1] = rows.A[1].join("\n"); // wrapper div for the dialog $("#main").append(`<div id="${sCfgName}"></div>`); // building the dialog $(sDlgName).html(`<form><fieldset><legend>Shortcuts for buttons, checkboxes, etc</legend> <p>Click or tap into the textfield and press the key combination you'd like to use. Choose a combination of<br/> • 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> (aka <span class="fontawesome">${iconWindows}</span> or ⌘/Command) key +<br> • optional: <kbd class="ui-button ui-corner-all">Shift</kbd> key + <br> • required: a letter or number for each shortcut.</p> <p>If you don't want to use a shortcut for any of these available actions, just leave its field empty.</p> <div id="tabs"> <ul><li><a href="#tab-bin">Bin</a></li> <li><a href="#tab-edit">Edit</a></li> <li><a href="#tab-cmt">Comments</a></li> <li><a href="#tab-land">Landing</a></li> <li><a href="#tab-search">Search</a></li> <li><a href="#tab-new">New</a></li></ul> <div id="tab-edit"> <table>${rows.E}</table> <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"' : ''}> <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> </div> <div id="tab-bin"> <table>${rows.B}</table> <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"' : ''}> <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> <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 for jumping to the previous/next page in the bin with the <kbd class="ui-button ui-corner-all">←</kbd> <kbd class="ui-button ui-corner-all">→</kbd> cursor keys.</p> </div> <div id="tab-cmt"><table>${rows.C}</table></div> <div id="tab-land"><table>${rows.L}</table></div> <div id="tab-search"> <table>${rows.S}</table> <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> <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 for jumping to the previous/next page of search results with the <kbd class="ui-button ui-corner-all">←</kbd> <kbd class="ui-button ui-corner-all">→</kbd> cursor keys.</p> </div> <div id="tab-new"><table>${rows.N}</table></div> </div> <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> </fieldset> <fieldset id="addtags"><legend>Shortcuts to add Fandom and Canonical tags</legend> <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, or both on each of those pages.</p> <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> <p>Fandoms:</p> <table>${rows.A[0]}</table> <button class="ui-button ui-widget ui-corner-all" id="addmore">+ Add more</button> <p>Canonicals:</p> <table>${rows.A[1]}</table> <button class="ui-button ui-widget ui-corner-all" id="addmore">+ Add more</button> </fieldset> </form>`); // adding placeholders as hint to user - easier here after the fact than coding it into all the <input>s $(sDlgName).find("input.typeshortcut" ).prop('placeholder', 'shortcut'); $(sDlgName).find("input.typetag" ).prop('placeholder', 'tag name'); /* JQUERYUI TIME: TURNING PLAIN HTML INTO A NICE CONFIG DIALOG */ $( function() { $(sDlgName).find("#tabs" ).tabs({ collapsible: true, show: { effect: "blind", duration: 500 }, hide: { effect: "blind", duration: 500 } }); } ); $( function() { $(sDlgName).find("input[type='checkbox']" ).checkboxradio(); } ); let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px) // initialize the dialog $( sDlgName ).dialog({ appendTo: "#main", modal: true, title: 'Wrangling Keyboard Shortcuts', draggable: true, resizable: false, autoOpen: false, width: dialogwidth > 700 ? 700 : dialogwidth * 0.9, // optimizing the size of the GUI in case it's a mobile device position: {my:"center top", at: "center top", of: window}, buttons: [ { id: "button-reset", text: "Reset", click: resetDialog }, { id: "button-save", text: "Save", click: storeNewShortcuts }, { id: "button-cancel", text: "Cancel", click: closeDialog } ] }); $("#opencfg_"+sCfgName).on("click", openDialog); // on any subsequent clicks, open the configuration dialog again openDialog(); // and right now, finally open the dialog } function openDialog() { $( sDlgName ).dialog('open'); // open the dialog again eDlg = $(sDlgName)[0]; // finally caching the element for performance $(window).off('keydown.wrangling'); // stop listening to the wrangling shortcuts while we reconfigure them // users can click into a field and hit the combo they want to use $(eDlg).on('keydown.shortcuts', "input.typeshortcut", function(e) { let field = e.target; // when a user deletes a previously stored config, we don't want to show an error if (e.key == "Backspace" || e.key == "Delete") { e.target.value = ""; hideHint(e.target); dupeCheck(e.target); } // skipping if it's a special key (e.g. Enter) or if none/several modifiers are pressed at the same time // 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 if ( e.key.length > 1 || (e.altKey + e.ctrlKey + e.metaKey) !== 1 ) return; // if this is a valid new combo, we don't want the browser to react to it (e.g. open a menu item) e.preventDefault(); e.stopPropagation(); // combine into the 3-letter combo that we'll store and compare later // value of e.key is uppercase when shift is pressed, so we have to normalize e.target.value = `${e.altKey ? "A" : e.ctrlKey ? "C" : "M"}${e.shiftKey ? "S": " "}${e.key.toLowerCase()}`; // remove any previous hints for incorrect input hideHint(e.target); // if any combo is a duplicate on the same page (ignoring the empty ones), we throw an error dupeCheck(e.target); }); // users can enable/disable shortcuts for tags and we have to re-run the dupecheck $(eDlg).on('change.shortcuts', "input[type='checkbox']", function(e) { dupeCheck(e.target); }); // as the browser recognizes the value of the input changed (when a letter/number is typed), we check what was entered for validity $(eDlg).on('input.shortcuts', "input.typeshortcut", function(e) { // if what was entered wasn't a proper combo (like, simply typing in the field) if (!e.target.value.match(/^[CAM][S ][a-z]$/)) { e.target.value = ""; // empty it out showHint(e.target); // show a hint to user } }); // event triggers if addmore button is clicked $( eDlg ).on("click.addmore", "#addmore", (e)=>{ e.preventDefault(); let prevrow = $( e.target ).prev().find('tr:last-of-type'); // grab the previous row's ac#/af# and increment by one let next = $( prevrow ).find('input.typeshortcut').attr('name'); next = parseInt(next.match(/\d+/)[0])+1; // clone the last row and just re-number it let newrow = prevrow.clone(true, true).get(0); newrow.innerHTML = newrow.innerHTML.replace(/"(af|ac)\d+\[/g, `"$1${next}[`); // add a new line in the table $( prevrow ).after(newrow); }); } // if the window resizes the dialog would move off of the screen $(window).on('resize', function(e) { if ($(eDlg).dialog("isOpen")) { // don't need to worry about this if the dialog wasn't opened before // optimizing the size of the GUI in case it's a mobile device let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px) dialogwidth = dialogwidth > 700 ? 700 : dialogwidth * 0.9; let maxheight = parseInt($("body").css("height")); $(eDlg).dialog("option", "width", dialogwidth) // resize the dialog .dialog("option", "position", {my:"center top", at: "center top", of: window} ); // reposition the dialog } }); function closeDialog() { $(eDlg).off('keydown.shortcuts'); // stop listening to the config shortcut inputs $(eDlg).off('input.shortcuts'); // stop checking inputs for validity of values $(eDlg).find("#addmore").off("click.addmore"); // stop listening to Add More button clicks if ((mShortcuts.Action.size + mShortcuts.Fandom.size + mShortcuts.Canonical.size) > 0) $(window).on('keydown.wrangling', validateKey); // listening to the user's keystrokes again $( eDlg ).dialog( "close" ); } function resetDialog() { // we ask one more time in case it was an accident, but then we empty out all key-combo fields if (confirm("Are you sure you want to delete all configured shortcuts?\nPress OK to proceed.")) { $(eDlg).find('input.typeshortcut, input.typetag').prop('value', ""); $(eDlg).find('input[type="checkbox"]').prop('checked', false); } // we don't store or close the dialog, so users still have to click Save (or Cancel) } function dupeCheck(element) { // reset all errors for a moment, we start fresh $(eDlg).find('.ui-tabs-tab, .typeshortcut').removeClass('ui-state-error'); $(eDlg).find('#dupewarning').remove(); $('#button-save').button('enable'); ['B', 'E', 'C', 'L', 'S', 'N'].forEach( (page) => { // grab all shortcut inputs for this page (in case of tags: only if tag shortcuts are enabled for this page) let combos = $(eDlg).find(`input.typeshortcut[name^="${page}-"]`); // action if ($(eDlg).find(`input[type="checkbox"][name^="${page}-a_f"]`).prop('checked')) combos = $(combos).add('input.typeshortcut[name^="af"]', eDlg); // fandom tags if ($(eDlg).find(`input[type="checkbox"][name^="${page}-a_c"]`).prop('checked')) combos = $(combos).add('input.typeshortcut[name^="ac"]', eDlg); // canonical tags // reduce to those where a shortcut was entered combos = $(combos).filter(function() { return $(this).prop('value').length > 0 }); // make shortcut combos unique with Set() and check if it's now fewer entries -> there were duplicates let allkbd = $(combos).toArray().map((inp) => inp.value); let uniquekbd = new Set( allkbd ); if (uniquekbd.size !== allkbd.length) { // general errors reporting: highlight this tab, show the error message, disable the save button $(eDlg).find(`a[href^="#tab-${page.toLowerCase()}"]`).parent().addClass('ui-state-error'); // only add the error message at bottom if it's not already shown if ($(eDlg).find('#dupewarning').length == 0) { $(eDlg).find('form').append(` <div id="dupewarning" class="ui-state-error ui-corner-all" style="padding: 0.2em 0.5em;"><span class="ui-icon ui-icon-alert"></span> You configured multiple actions with the same shortcut on a page. Please check your configuration!</div>`); } $(eDlg).find('#button-save').button('disable'); // we remove all entries in uniquekbd from the list of combos ONCE. anything that remains is a duplicate let dupes = new Set( allkbd.filter((inp) => !uniquekbd.delete(inp)) ); $(combos).filter( (ix, inp) => dupes.has($(inp).prop('value')) ).addClass('ui-state-error'); } }); } function showHint(element) { // only add the hint if it's not already shown if ($(element).parent().next().find('.ui-state-highlight').length == 0) $(element).parent().next().append(` <div class="ui-state-highlight ui-corner-all" style="padding: 0.2em 0.5em;"><span class="ui-icon ui-icon-info"></span> Please use the Ctrl, Alt or Meta key in your shortcut!</div>`); } function hideHint(element) { $(element).parent().next().find('.ui-state-highlight').remove(); } /***************** STORAGE *****************/ function loadPageShortcuts() { // the shortcuts map we build up on page load only contains those which pertain to the viewed page type // 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 let cfgs = { Action: new Map(), Fandom: new Map(), Canonical: new Map() }; // load actions stored as shortcut -> action let empty = { B: [], E: [], L: [], C: [], S: [], N: [] }; let storage = JSON.parse(localStorage.getItem(sCfgName+'_act') || JSON.stringify(empty) ); cfgs.Action = new Map(storage[cPage]); // load tags stored as shortcut -> tag (and the pages where they are enabled) empty = { Fan: [], Can: [], FPage: "", CPage: "", NewTab: "Y" }; storage = JSON.parse(localStorage.getItem(sCfgName+'_tag') || JSON.stringify(empty) ); // the page we're on: B, E, or S - only return tags (fandom or canonicals) if enabled on the page if (storage.FPage.includes(cPage)) cfgs.Fandom = new Map(storage.Fan); if (storage.CPage.includes(cPage)) cfgs.Canonical = new Map (storage.Can); cfgs.NewTab = storage.NewTab; return cfgs; } function loadAllActions() { // what's all supported? const available = { L: { "o_e": ["open Tag Edit page", ""], "o_t": ["open Comments page", ""], "o_w": ["open Works page", ""], "o_m": ["open Mergers page", ""], "o_c": ["open Canonical Tag's page", ""] }, B: { "c_w": ["click the 'Wrangle' button", ""], "f_f": ["focus on the Fandom text field", ""], "f_s": ["focus on the Synonym Of text field<br/>(if you have the Wrangle from the Bin script)", ""], "c_s": ["submit the Synonym Of<br/>(if you have the Wrangle from the Bin script)", ""] }, E: { "o_t": ["open Comments page", ""], "o_w": ["open Works page", ""], "o_m": ["open Mergers page", ""], "o_c": ["open Canonical Tag's page", ""], "c_s": ["click the 'Save' button", ""], "c_k": ["toggle the Canonical checkbox", ""], "c_u": ["toggle the Unwragleable checkbox", ""], "c_af": ["toggle all Fandoms' checkboxes (select all/none)", ""], "c_ac": ["toggle all Characters' checkboxes (select all/none)", ""], "c_ar": ["toggle all Relationships' checkboxes (select all/none", ""], "c_am": ["toggle all Metatags' checkboxes (select all/none)", ""], "c_asub": ["toggle all Subtags's checkboxes (select all/none)", ""], "c_asyn": ["toggle all Synonyms' checkboxes (select all/none)", ""], "f_s": ["focus on the Synonym Of text field", ""], "f_t": ["focus on the Tag Name text field", ""], "f_f": ["focus on the Add Fandom text field", ""], "f_c": ["focus on the Add Character text field", ""], "f_m": ["focus on the Add Metatag text field", ""], "f_sub": ["focus on the Add Subtag text field", ""], "f_syn": ["focus on the Add Synonym text field", ""], "c_cf": ["copy all Fandoms<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""], "c_cc": ["copy all Characters<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""], "c_cr": ["copy all Relationships<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""], "c_cs": ["copy all Synonyms<br/>(if you have the Copy Characters & Syns To Clipboard script)", ""] }, S: { "c_s": ["click the 'Search' button", ""], "f_t": ["focus on the Tag Name text field", ""], "f_f": ["focus on the Fandom text field", ""] }, N: { "c_s": ["click the 'Create Tag' button", ""], "f_t": ["focus on the Tag Name text field", ""], "c_k": ["toggle the Canonical checkbox", ""], "c_f": ["select the Tag Type: Fandom radio button", ""], "c_c": ["select the Tag Type: Character radio button", ""], "c_r": ["select the Tag Type: Relationship radio button", ""], "c_a": ["select the Tag Type: Additional Tag radio button", ""] }, C: { "o_e": ["open Tag Edit page", ""], "o_w": ["open Works page", ""], "o_m": ["open Mergers page", ""], "c_s": ["click the 'Comment' button", ""], "f_c": ["focus on the Comment text field", ""] } } // grab what's been configured so far -- this is stored in reverse, as { page: { ["shortcut", "action"],... } } // because we need it searchable by shortcut on most page loads, and only need the reverse if the config dialog is opened let empty = { B: [], E: [], L: [], C: [], S: [], N: [] }; let storage = JSON.parse(localStorage.getItem(sCfgName+'_act') || JSON.stringify(empty) ); // run through what's been configured and apply it to the corresponding available actions so it becomes action -> shortcut for (const [p, v] of Object.entries(storage)) { // walk through each page for (const e of v.values()) { // walk through each [shortcuts, action] within page available[p][e[1]][1] = e[0]; // sets stored keyboard combo in corresponding available[] entry } } return available; } function loadAllTags() { // load what's been stored as shortcut -> tag (and the pages where they are enabled) let empty = { Fan: [], Can: [], FPage: "", CPage: "", NewTab: "Y" }; let storage = JSON.parse(localStorage.getItem(sCfgName+'_tag') || JSON.stringify(empty) ); return storage; } function storeNewShortcuts() { // regular action shortcuts let kbd = { B: [], E: [], L: [], C: [], S: [], N: [] }; // grabbing the input fields with content let cfgs = $(eDlg).find('#tabs input.typeshortcut').filter(function() { return $(this).prop('value').length > 0 }); // in the end, we want again something as { page: [ ["shortcut", "action"] ] } $(cfgs).each(function() { let cfg = [$(this).prop('value'), $(this).prop('name').slice(2)]; let page = $(this).prop('name').slice(0,1); kbd[page].push(cfg); }); localStorage.setItem(sCfgName+'_act', JSON.stringify(kbd)); // now the tag shortcuts kbd = { Fan: [], Can: [], FPage: "", CPage: "", NewTab: "Y" } // grabbing checkbox about where to open the o_X page links kbd.NewTab = $(eDlg).find('#link-tab').prop('checked') ? "Y" : "N"; // grabbing the pages on which we're enabling tag shortcuts cfgs = $(eDlg).find('input[id$="a_f"], input[id$="a_c"]').filter(function() { return $(this).prop('checked') }); $(cfgs).each(function() { let page = $(this).prop('name').slice(0,1); let type = $(this).prop('name').slice(-1).toUpperCase() + "Page"; kbd[type] += page; }); // grabbing the configured tags and their shortcuts cfgs = $(eDlg).find('#addtags tr'); $(cfgs).each(function() { let type = $(this).find('input.typetag').prop('name').slice(0,2) == "af" ? "Fan" : "Can"; let cfg = [$(this).find('input.typeshortcut').prop('value'), $(this).find('input.typetag').prop('value')]; 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) }); localStorage.setItem(sCfgName+'_tag', JSON.stringify(kbd)); mShortcuts = loadPageShortcuts(); // reload with new values in case they changed closeDialog(); } function migrateStorage() { localStorage.removeItem('kbdshortcuts'); localStorage.removeItem('kbdpages'); // I really considered writing a huge migration logic, but holy cow that would've been extensive. I'm sorry. } /***************** SHORTCUT HANDLING *****************/ // basic functions that interact with the elements const clickButton = el => el.click(); const focusOnField = el => el.focus(); const checkBox = el => el.click(); const addTag = (el, tag) => { el.focus(); el.value = tag; el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" })); } function validateKey(e) { // skipping if it's a special key (e.g. Enter) or if none/several modifiers are pressed at the same time if ( e.key.length > 1 || (e.altKey + e.ctrlKey + e.metaKey) !== 1 ) return; // combine into something easier to compare to an array of stored shortcuts // value of e.key is uppercase when shift is pressed, so we have to normalize let pressed = `${e.altKey ? "A" : e.ctrlKey ? "C" : "M"}${e.shiftKey ? "S": " "}${e.key.toLowerCase()}`; //console.log(pressed, mShortcuts); // if that combo isn't configured anywhere, we skip if (!mShortcuts.Action.has(pressed) && !mShortcuts.Fandom.has(pressed) && !mShortcuts.Canonical.has(pressed)) return; // if this is one of our combos, we don't want the browser to react to it (e.g. open a menu item) e.preventDefault(); e.stopPropagation(); // now we gotta determine what we're supposed to do... a combo of page type + action to perform let action = mShortcuts.Fandom.has(pressed) ? 'a_f,'+mShortcuts.Fandom.get(pressed) : mShortcuts.Canonical.has(pressed) ? 'a_c,'+mShortcuts.Canonical.get(pressed) : mShortcuts.Action.get(pressed); if (action.startsWith("o_")) openPage(action); // openPage is separate because it's too similar on all pages else { switch (cPage) { case "B": handleBin(action); break; case "E": handleEdit(action); break; case "L": handleLanding(action); break; case "S": handleSearch(action); break; case "N": handleNew(action); break; case "C": handleComments(action); break; default: break; } } } function openPage(a) { // with cPage we'll determine how to find the plain link to the tag in question let url = cPage == "L" ? window.location.href : $('#main > .heading a.tag').prop('href'); // 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) let end = a == "o_e" ? "/edit" : a == "o_t" ? "/comments" : a == "o_w" ? "/works" : a == "o_m" ? "/wrangle?page=1&show=mergers" : ""; // unless we're loading the canonical of a viewed syn! if (a == "o_c") { // bow out gracefully if the viewed tag isn't a syn, and therefore no canonical exists if ( (cPage == "L" && $('div.merger').length == 0) || (cPage == "E" && $('input#tag_syn_string ~ p.actions a').length == 0) ) { console.log(`Wrangling Shortcuts: You tried to open the canonical tag's page, but this tag isn't synned anywhere`); return; } // if a canonical exists, we grab that tag's URL from the link/button url = cPage == "L" ? $('div.merger p a').prop('href')+"/edit" : cPage == "E" ? $('input#tag_syn_string ~ p.actions a').prop('href') : ""; } let target = mShortcuts.NewTab == "Y" ? "_blank" : "_self"; if (url !== "") window.open(url+end, target); else console.log(`Wrangling Shortcuts: You tried to go somewhere but I couldn't find the link`); } function handleLanding(a) { // nothing to do here. the only supported actions are going to other pages, so we shouldn't ever get to this function console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Landing Page`); } function handleBin(a) { if (a == "c_w") clickButton($("#wrangulator p.submit input[type='submit']")[0]); // mass-wrangle tags else if (a == "f_f") focusOnField($("#fandom_string_autocomplete")[0]); // fandoms field else if (a.startsWith("a_f")) { let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add addTag($("#fandom_string_autocomplete")[0], tag); } // the following only work if you use the script Wrangle Stright From The Bins else if (a == "f_s" && $("#syn_tag_autocomplete_autocomplete").length > 0) focusOnField($("#syn_tag_autocomplete_autocomplete")[0]); // syns field else if (a == "c_s" && $("button[name='wrangle_existing']").length > 0) clickButton($("button[name='wrangle_existing']")[0]); // submit syns else if (a.startsWith("a_c") && $("#syn_tag_autocomplete_autocomplete").length > 0) { let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add addTag($("#syn_tag_autocomplete_autocomplete")[0], tag); } else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Bin Page`); } function handleEdit(a) { if (a == "c_k") clickButton($("#tag_canonical")[0]); // canonical checkbox else if (a == "c_u") clickButton($("#tag_unwrangleable")[0]); // unwrangleable checkbox else if (a == "c_s") clickButton($("#edit_tag p.submit input[type='submit']")[0]); // save changes else if (a == "c_af") clickButton($("#parent_Fandom_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all fandoms else if (a == "c_ac") clickButton($("#parent_Character_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all chars else if (a == "c_ar") clickButton($("#child_Relationship_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all rels else if (a == "c_am") clickButton($("#parent_MetaTag_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all metatags else if (a == "c_asub") clickButton($("#child_SubTag_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all subtags else if (a == "c_asyn") clickButton($("#child_Merger_associations_to_remove_checkboxes input[type='checkbox']")); // toggle all syns else if (a == "f_s") focusOnField($("#tag_syn_string_autocomplete")[0]); // Syn Of field else if (a == "f_t") focusOnField($("#tag_name")[0]); // tag name field else if (a == "f_f") focusOnField($("#tag_fandom_string_autocomplete")[0]); // fandoms textfield else if (a == "f_c") focusOnField($("#tag_character_string_autocomplete")[0]); // characters textfield else if (a == "f_m") focusOnField($("#tag_meta_tag_string_autocomplete")[0]); // metatags textfield else if (a == "f_sub") focusOnField($("#tag_sub_tag_string_autocomplete")[0]); // subtags textfield else if (a == "f_syn") focusOnField($("#tag_merger_string_autocomplete")[0]); // syns/mergers textfield else if (a.startsWith("a_f")) { // add tag in Fandom field let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add addTag($("#tag_fandom_string_autocomplete")[0], tag); } else if (a.startsWith("a_c")) { // add tag in Syn Of field console.log(a); let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add addTag($("#tag_syn_string_autocomplete")[0], tag); } // the following only work if you use the script "Copy Characters & Syns To Clipboard" // they're the only <button> within the .actions bar else if (a == "c_cf" && $("#parent_Fandom_associations_to_remove_checkboxes").parent().find(".actions button").length > 0) clickButton($("#parent_Fandom_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all fandoms else if (a == "c_cc" && $("#parent_Character_associations_to_remove_checkboxes").parent().find(".actions button").length > 0) clickButton($("#parent_Character_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all chars else if (a == "c_cr" && $("#child_Relationship_associations_to_remove_checkboxes").parent().find(".actions button").length > 0) clickButton($("#child_Relationship_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all rels else if (a == "c_cs" && $("#child_Merger_associations_to_remove_checkboxes").parent().find(".actions button").length > 0) clickButton($("#child_Merger_associations_to_remove_checkboxes").parent().find(".actions button")[0]); // copy all syns else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Edit Page`); } function handleSearch(a) { if (a == "c_s") clickButton($("#new_tag_search p.submit input[type='submit']")[0]); // start search else if (a == "f_t") focusOnField($("#new_tag_search #tag_search_name")[0]); // focus on tag name field else if (a == "f_f") focusOnField($("#new_tag_search #tag_search_fandoms_autocomplete")[0]); // focus on fandom field else if (a.startsWith("a_f")) { // add tag in Fandom field let tag = a.slice(4); // first four letters shaved off gives us the tag we're trying to add addTag($("#new_tag_search #tag_search_fandoms_autocomplete")[0], tag); } else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Tag Search Page`); } function handleNew(a) { if (a == "c_s") clickButton($("#new_tag p.submit input[type='submit']")[0]); // submit new tag else if (a == "f_t") focusOnField($("#tag_name")[0]); // focus on tag name field else if (a == "c_k") clickButton($("#tag_canonical")[0]); // toggle canonical checkbox else if (a == "c_f") clickButton($("#tag_type_fandom")[0]); // select fandom radiobutton else if (a == "c_c") clickButton($("#tag_type_character")[0]); // select character radiobutton else if (a == "c_r") clickButton($("#tag_type_relationship")[0]); // select relationship radiobutton else if (a == "c_a") clickButton($("#tag_type_freeform")[0]); // select additional tag radiobutton else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the New Tag Page`); } function handleComments(a) { if (a == "c_s") clickButton($("#add_comment p.submit input[type='submit']")[0]); // submit comment else if (a == "f_c") focusOnField($("#add_comment textarea")[0]); // focus on toplevel comment textarea else console.log(`Wrangling Shortcuts: You tried to do an action (${a}) that's not supported on the Comments Page`); } })(jQuery);