User:Inductiveload/quick access.js

From Wikisource
Jump to navigation Jump to search
Note: After saving, changes may not occur immediately. Click here to learn how to bypass your browser's cache.
  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Cmd-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (Cmd-Shift-R on a Mac)
  • Internet Explorer: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Clear the cache in Tools → Preferences

For details and instructions about other browsers, see Wikipedia:Bypass your cache.

/*
 * 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));