/*
* 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 = $(
"<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" +
"</style>");
$('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 = $("<input>").attr('id', 'gadget-quickaccess-input')
.attr("placeholder",
"Enter command or toolbox item to execute")
.css({
clear: "both",
width: "90%"
});
var item_cntnr = $("<div>").attr("id",
"gadget-quickaccess-matches")
.css({
"margin-top": "10px",
"clear": "both"
});
var dialog = $("<div>").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 = $("<div>").addClass("gadget-quickaccess-match-item")
.data({
'item': match_item
});
var key_str = "";
if (match_item.key) {
key_str = "[" + match_item.key + "]";
}
var ks = $("<span>")
.addClass("gadget-quickaccess-match-key")
.css({
display: "inline-block",
width: "2em",
color: "#888"
})
.html(key_str);
var ns = $("<span>")
.addClass("gadget-quickaccess-match-name")
.html(match_item.name);
match.append(ks, ns);
if (match_item.desc) {
var ds = $("<span>")
.addClass("gadget-quickaccess-match-desc")
.html(": " + match_item.desc);
match.append(ds);
}
if (match_item.cat) {
var cs = $("<span>")
.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));