// ==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.0
// @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-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 ul.primary.navigation 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">`)
.prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`)
.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; }
</style>`);
// what's all configured already?
const cfgActions = loadAllActions();
const cfgTags = loadAllTags();
let rows = { A: [[], []] };
// 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 Windows logo 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", at: "center top"},
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);
});
}
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);