User:Inductiveload/quick access.js

/* * quick_access - mouseless access to useful things like toolbox links * and editor command */

(function($, mw) {

"use strict";

var DEBUG = 0; var INFO = 1; var ERROR = 2;

// The config string, must match the above var log_levels = ["debug", "info", "error"];

var QuickAccess = function { this.actions = []; this.log_level = ERROR; this.close_on_focus_loss = false; };

QuickAccess.prototype.init = function { this.log("Init Quick Access");

this.install_portlet; };

QuickAccess.prototype.log = function(level, ...logged) { if (level >= this.log_level) { console.log("QuickAccess: ", logged); } };

QuickAccess.prototype.install_portlet = function {

var self = this;

var portlet = mw.util.addPortletLink(     'p-tb',      '#',      'QuickAccess',      't-quickaccess',      'QuickAccess to all toolbox actions'    );

$(portlet).click(function(e) {     e.preventDefault;      self.activate;    });

// install global key handler (avoids nasty scrolling that happens with   // access keys) $(document).keydown(function(e) {     if (e.shiftKey && e.altKey && e.key === 'A') {        e.preventDefault;        self.activate;      }    });

this.log(DEBUG, "Portlet installed"); };

/**  * Instantiate and show the dialog */ QuickAccess.prototype.activate = function {

/**    * Subclass of an OOjs Dialog used to present the QuickAccess UI     */ function QAccessDialog(qaccess, config) { this.qaccess = qaccess; this.log = qaccess.log; QAccessDialog.super.call(this, config); }   OO.inheritClass(QAccessDialog, OO.ui.ProcessDialog);

QAccessDialog.static.name = 'quickaccessdialog'; QAccessDialog.static.title = 'QuickAccess'; QAccessDialog.escapable = true;

// Customize the initialize function: This is where to add content to the dialog body and set up event handlers. QAccessDialog.prototype.initialize = function {

var self = this;

// inject style rules var style = $(       " \n"+        ".gadget-quickaccess-match-item { font-size: larger; }\n" +        ".gadget-quickaccess-match-selected { background-color: rgb(248,248,255); }\n" +        "\n" +        ".gadget-quickaccess-match-cat { color: #888; font-size: smaller; }\n" +        ".gadget-quickaccess-match-desc { color: #888; font-size: smaller; }\n" +        " "); $('html > head').append(style);

// Call the parent method QAccessDialog.super.prototype.initialize.call(this); // Create and append a layout and some content. this.content = new OO.ui.PanelLayout({       padded: true,        expanded: false      });

var input = $(" ").attr('id', 'gadget-quickaccess-input') .attr("placeholder",         "Enter command or toolbox item to execute") .css({         clear: "both",          width: "90%"        });

var item_cntnr = $(" ").attr("id",         "gadget-quickaccess-matches") .css({         "margin-top": "10px",          "clear": "both"        });

var dialog = $(" ").attr("id", "gadget-quickaccess-dialog") // .css({}) .append(input, item_cntnr);

// Append the icon and label to the DOM this.content.$element.append(dialog[0]);

this.$body.append(this.content.$element);

this.$body.on('focusout', function(evt) {       self.qaccess.log(DEBUG, "Focus lost, autoclosing");        self.close;      });

$('#gadget-quickaccess-input').on('keydown', function(evt) {

self.qaccess.log(DEBUG, "keydown", evt); switch (evt.keyCode) {

case 13: self.run_selected; break;

case 38: // up         case 40: // down self.move_sel(evt.keyCode === 38); break;

default: return; // exit this handler for other keys }

evt.preventDefault; // prevent the default action (scroll / move caret) });

$('#gadget-quickaccess-input').on('input', function(evt) {       self.handle_input(evt);      }); };

QAccessDialog.static.actions = [{ action: 'cancel', label: 'Cancel', flags: ['safe', 'back'] }];

QAccessDialog.prototype.getActionProcess = function(action) { var dialog = this; return new OO.ui.Process(function {       dialog.close({ action: action });     });    };

QAccessDialog.prototype.getReadyProcess = function (data) { var dialog = this; return QAccessDialog.super.prototype.getReadyProcess.call( this, data ) .next( function {          $('#gadget-quickaccess-input').focus;        }, this ); };

QAccessDialog.prototype.getBodyHeight = function { //can this not auto expand? return 300; };

QAccessDialog.prototype.getTeardownProcess = function(data) { var self = this; return QAccessDialog.super.prototype.getTeardownProcess.call(         this, data) .first(function {         // Perform any cleanup as needed          self.getManager.clearWindows;        }, this); };

QAccessDialog.prototype.populate = function(match_item) {

// this.log(DEBUG, "Populating actions");

var self = this; var match = $(" ").addClass("gadget-quickaccess-match-item") .data({         'item': match_item        });

var key_str = "";

if (match_item.key) { key_str = "[" + match_item.key + "]"; }

var ks = $(" ") .addClass("gadget-quickaccess-match-key") .css({         display: "inline-block",          width: "2em",          color: "#888"        }) .html(key_str);

var ns = $(" ") .addClass("gadget-quickaccess-match-name") .html(match_item.name);

match.append(ks, ns);

if (match_item.desc) { var ds = $(" ") .addClass("gadget-quickaccess-match-desc") .html(": " + match_item.desc);

match.append(ds); }

if (match_item.cat) { var cs = $(" ") .addClass("gadget-quickaccess-match-cat") .html(' — ' + match_item.cat);

match.append(cs); }

// the click handler match.click(function {       self.set_sel($(this));

// run the selected item if posisble self.run_selected; });

$('#gadget-quickaccess-matches').append(match); };

QAccessDialog.prototype.populate_from_stored = function {

var self = this; var old_used = self.qaccess.get_used_items;

var added_cnt = 0;

for (var i = 0; i < old_used.length; i++) {

// find the selectors in the new items and add them to the list when // found (this means if the tools doesn't exist on the page, it won't be       // shown, and if the name has changed (eg Alerts (n)), it will be        // correcr        var curr_item = null;

// populate in order of last used, up to our limit for (var j = 0; j < self.qaccess.actions.length && added_cnt <         max_items; j++) { if (this.qaccess.used_item_matches(old_used[i], self.qaccess.actions[ j])) { self.populate(self.qaccess.actions[j]); added_cnt++; }       }      }

// fill the remainder with the existing actions up to the limit // could be dupes here, but really, who cares, it'll be wiped out when // typing and the MRU enties are lost for (var k = 0; k < self.qaccess.actions.length && added_cnt <       max_items; k++) { self.populate(self.qaccess.actions[k]); added_cnt++; }

self.set_sel($(".gadget-quickaccess-match-item:first")); self.updateSize; };

QAccessDialog.prototype.handle_input = function(evt) {

var self = this; var typed = $('#gadget-quickaccess-input').val;

var scores = [];

for (var i = 0; i < this.qaccess.actions.length; i++) { var score = this.qaccess.get_score_for_action(typed, this.qaccess         .actions[i]);

// ignore neutral and failure if (score > 0) { scores.push([score, i]); }     }

scores.sort(function(a, b) {       return b[0] - a[0];      });

scores = scores.slice(0, max_items);

// clear old junk $('#gadget-quickaccess-matches').empty;

for (i = 0; i < scores.length; i++) { self.populate(this.qaccess.actions[scores[i][1]]); }

self.set_sel($(".gadget-quickaccess-match-item:first"));

self.updateSize; };

/**    * Sets the given item to be the selected item */   QAccessDialog.prototype.set_sel = function(selected) { $(".gadget-quickaccess-match-item") .removeClass("gadget-quickaccess-match-selected");

selected.addClass("gadget-quickaccess-match-selected"); };

/**   * Move selection along the item list by the given number of steps */   QAccessDialog.prototype.move_sel = function(up) {

var items = $(".gadget-quickaccess-match-item");

// current index var index = $('.gadget-quickaccess-match-selected').index;

if (index < 0) index = 0; else { index += (up ? -1 : 1);

if (index < 0) index = items.length - 1; else if (index >= items.length) index = 0; }

// remove old item classes items.removeClass("gadget-quickaccess-match-selected");

$(items[index]) .addClass("gadget-quickaccess-match-selected"); };

QAccessDialog.prototype.run_selected = function {

var self = this; var sel = $('.gadget-quickaccess-match-selected');

var item = $(sel[0]).data('item');

if (item !== undefined) { self.close;

self.qaccess.execute_item(item); }   };

var self = this;

var max_items = 10;

var closetitle = "Close"; var closetext = "Close";

// avoid invokation when dialog open if ($("#gadget-quickaccess-dialog").length > 0) { return; }

// save the active element this.activeElem = document.activeElement;

// Make the window. var myAccessDialog = new QAccessDialog(self);

var action_load_promise = self.gather_actions;

action_load_promise.then(function {     // init the list when we have the current items to compare against      myAccessDialog.populate_from_stored;    });

// Create and append a window manager, which will open and close the window. var windowManager = new OO.ui.WindowManager; $('body').append(windowManager.$element);

// Add the window to the window manager using the addWindows method. windowManager.addWindows([myAccessDialog]);

// Open the window! windowManager.openWindow(myAccessDialog);

// sneakily overwrite the dialog transition time - QuickAccess, not // LeisurelyAccess! $(windowManager.$element).find('.oo-ui-window-frame') .css({       'transition': '75ms'      });

// focus the input in just a moment // setTimeout(function {   //   $('#gadget-quickaccess-input').focus;    // }, 300); };

/**  * Class that represents an actions and its description */ var Action = function { this.type = undefined; // for example "portlet link" or "editor tool" this.name = undefined; // the presented name of the action this.desc = undefined; // a longer description, probably a tooltip this.key = undefined; // accesskey, if any this.cat = undefined; //a category string (perhaps the portlet portal title) };

/**  * Find a "match score" for a given item against a provided user string *  * Can use various heuristics, but the simplest are "starts with" (strong) * and "contains" (weaker) *  * Zero score means neutral, negative is no match, more positive is a better * match */ QuickAccess.prototype.get_score_for_action = function(typed, action) {

var haystack = action.name || "";

var raw_search = haystack.toLowerCase.indexOf(typed.toLowerCase);

if (raw_search === 0) { // initial match return 100; } else if (raw_search > 0) { // other substring return 50; }

// no match return -1; };

/**  * Executes a link, either following href ,if useful. or invoking * the click handler if it looks like "#" */ QuickAccess.prototype.execute_link = function(link) {

if (link.attr('href') === "#") { link.click; } else { window.location.href = link.attr('href'); } };

QuickAccess.prototype.santise_title = function(title) { return title.replace(/\[.*\]$/, ""); // strip keys };

QuickAccess.prototype.get_unique_portlet_selector = function(link) {

var li = link.parents("li");

var id = li.attr('id');

// has a unique id   if (id) { return "#" + id; }

// the combination of the classes should be enough var classes = li[0].className.split(/\s+/).join(".");

// add li element for extra awesome return "li" + "." + classes; };

/**  * Look at a singe portlet (nav/tab bar items and convert   * to an Action Item   */  QuickAccess.prototype.get_action_from_portlet = function(portlet) {

this.log(DEBUG, "get_action_from_portlet: " + portlet);

var self = this; var link = $(portlet).find("a");

var name = link.html; var title = link.attr('title') || "";

title = this.santise_title(title); var key = link.attr('accesskey') || "";

// this is a bit slow, coluld be better but is it noticable? var cat = $(link).parents(".portal").children("h3").html;

var action = new Action; action.name = name; action.desc = title; action.selector = self.get_unique_portlet_selector($(link)); action.key = key; action.cat = cat; action.type = "portlet";

return action; };

QuickAccess.prototype.execute_item = function(item) {

if (item.selector) {

var target = $(item.selector + " a");

if (target.length === 1) { this.execute_link(target); } else { this.log("No unique target for selector: " + item.selector); }

} else { this.log("Cannot execute item without selector: " + item.name); }

// remember the item for next time this.store_used_item(item);

// restore the previous focus $('#wpTextbox1').focus; };

/**  * Gather available actions and update the action list. Returns * a promise - this allows us to call early, but only pick up  * results much later after user is typing without blocking the UI   */ QuickAccess.prototype.gather_actions = function {

this.log(DEBUG, "gather_actions");

var self = this; var dfd = new $.Deferred;

setTimeout(function {

// role = navigation includes toolboxen and the top-row tabs var portal_items = $('.portal li, .vector-menu li');

self.log(DEBUG, "Portal items: ", portal_items);

var tmp_actions = [];

for (var i = 0; i < portal_items.length; i++) {

var action = self.get_action_from_portlet(portal_items[i]); tmp_actions.push(action); }

// move over all at once (need a lock? in JS? maybe not?) self.actions = tmp_actions;

self.log(DEBUG, "Completed gather_actions", self.actions.length);

dfd.resolve("Completed action scan"); }, 0);

return dfd.promise; };

/*  * Get previously used items, in order of used (most recent first) */ QuickAccess.prototype.get_used_items = function {

var mru = JSON.parse(localStorage.getItem("gadget-quickaccess-used"));

// not going to sanitise this, worst case it's just junk and breaks the // list if (mru instanceof Array) return mru;

return []; };

/**  * Store an item in the used item list and trim dupes */ QuickAccess.prototype.store_used_item = function(item) {

var self = this; var old = self.get_used_items;

// store enough to indentify this item on a different page // type isn't neededd now but it keeps it clear var to_store = { type: item.type, selector: item.selector };

// now, remove any that match, we'll insert at the front for MRU for (var i = old.length - 1; i >= 0; i--) { if (this.used_item_matches(old[i], item)) { old.splice(i, 1); }   }

old.unshift(to_store);

localStorage.setItem("gadget-quickaccess-used", JSON.stringify(old)); };

QuickAccess.prototype.used_item_matches = function(used, item) { return used.selector == item.selector && used.type == item.type; };

mw.loader.using(['mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets',     'oojs-ui-windows'    ],

function { var access = new QuickAccess; access.init; });

}(jQuery, mediaWiki));