MultiselectJS — Documentation for Source Code

Jaakko Järvi (jarvi@cse.tamu.edu), Sean Parent

Table of Contents

1 Introduction

This document describes the implementation of the MultiselectJS library. MultiselectJS encapsulates the state of maintaining selections of elements in indexable collections. It helps in implementing multi-selection in varied contexts in GUIs. The implementation faithfully follows the abstractions described in the manuscript One Way to Select Many by Jaakko Järvi and Sean Parent.

1.1 Definitions

\( \newcommand{\true}{\mathsf{true}} \newcommand{\false}{\mathsf{false}} \newcommand{\selset}{{\mathbf{2}}} \newcommand{\esp}[2]{\mathsf{op}^{#1}_{#2}} \newcommand{\inds}{\mathsf{s\_dom}} \) To understand the implementation, it is useful to know some of the definitions from the manuscript. The descriptions here are terse, the manuscript gives more thorough explanations.

  • The library assumes that for any collection of elements \(M\), there is an indexed family \(x: I \to M\) and subsequently only deals with the index set \(I\).
  • Which elements of a collection are selected and which are not is represented by a selection mapping \(s: I \to \selset\), where \(\selset = \{\true, \false \}\); \(s(i) = \true\) indicates that the element \(x_i\) is selected and \(s(i) = \false\) that it is not.
  • Let \(x: I \to M\) be a collection, \(J \subseteq I\), and \(f: \selset \to \selset\) a mapping. \(J\) and \(f\) (uniquely) determine a primitive selection operation:

    \begin{equation*} \esp{f}{J}: (I \to \selset) \to (I \to \selset), s \mapsto \lambda i.\left\{ \begin{array}{ll} f(s(i)), & i \in J\\ s(i), & i \notin J \end{array} \right. \end{equation*}

    The function \(f\) in \(\esp{f}{J}\) is the selection function and the set \(J\) the selection domain. There are four possible selection functions: \(\lambda x. x\), \(\lambda x. \neg x\), \(\lambda x.\true\), and \(\lambda x.\false\). The selection domain is the set of indices to which the selection function applies.

A primitive selection operation \(\esp{f}{J}\) can be applied to a selection mapping \(s\) to obtain a new selection mapping \(\esp{f}{J}(s)\). Selection operations compose: starting from the “no elements selected” selection mapping \(e: I \to \selset{}, i \mapsto \false\), the selection mapping that results after applying a series of primitive selection operations \(\esp{f_1}{J_1}, \esp{f_2}{J_2}, \ldots, \esp{f_n}{J_n}\) is

\begin{equation*} (\esp{f_n}{J_n} \circ \esp{f_{n-1}}{J_{n-1}} \circ \ldots \circ \esp{f_1}{J_1})(e). \end{equation*}

In a nutshell, the library operates as follows. The selection mapping of the elements of a collection is maintained in two parts: (1) a “base” selection mapping \(s_b\) and (2) a composition of selection operations \(\mathit{ops}\). The current selection mapping is then obtained as \(\mathit{ops}(s_b)\). In other words, the element \(x_i\) is selected if \(\mathit{ops}(s_b)(i) = \true\), and not selected if it is \(\false\).

Various selection commands, triggered by mouse or keyboard events, impact the selection mapping by adding new selection operations to \(\mathit{ops}\), or by replacing or removing the most recently added operation(s). The selection domain of the most recently added operation is the active selection domain. The least recently added operation can also be removed with its effect “baked” into the base selection mapping permanently. This baking operation changes both \(\mathit{ops}\) to a new composition \(\mathit{ops}'\) and \(s_b\) to a new base selection mapping \(s'_b\), but the current selection mapping observed by the user does not change. That is, \(\mathit{ops}(s_b) = \mathit{ops}'(s'_b)\).

User commands indicate points in the mouse coordinate space. It is the task of a selection geometry object to interpret how sequences of these points correspond to element indices. For this, a selection geometry defines two functions: m2v to translate a single point in the mouse coordinate space into a point in the selection space, and selectionDomain to translate a path of selection space points, the selection path, into a set of element indices, the selection domain. Each user command modifies the current selection path. The first element of the a selection path is called the anchor, the last the active end.

Finally, as described above, the selection state of an element \(i\) is \(\mathit{ops}(s_b)(i)\). In practice, the function application \(\mathit{ops}(s_b)\) is not executed every time the selection state of some index is needed, but rather the library maintains the result of \(\mathit{ops}(s_b)\) in a data structure that modifies both \(\mathit{ops}\) and \(s_b\) as necessary. Further, the library defines an API that allows one to redefine how \(\mathit{ops}(s_b)\) is represented.

1.2 Code conventions and utilities

Member variables and functions that start with an underscore are intended to be private to the class they are defined in. Client code should not refer to such members.

ECMAScript 6 allows default parameters in functions but the browsers do not yet widely support them. Therefore the follwing helper function.

  function or_default(a, v) { return a !== undefined ? a : v; }

2 Representing selections

The library is parameterized over (1) the selection geometry; (2) how it represents the domain of primitive selection operations, i.e., the set \(J\) in \(\esp{f}{J}\); and (3) how it (jointly) represents the primitive selection operation composition and the base selection, i.e., the value \(\textit{ops}(s_b)\). To give a name for this third parameter, we refer to it as the selection storage.

These three parameters must be compatible with each other. Concretely, the objects that represent selection domains attached to primitive selection operations are constructed by the selection geometry’s selectionDomain function. This representation must be the same representation that selection storage uses.

2.1 Selection functions

Implementations of the four possible selection functions, of type bool \(\to\) bool, are as follows:

  function tt(_)  { return true; };  tt.constant  = true;
  function ff(_)  { return false; }; ff.constant  = true;
  function id(b)  { return b; };     id.constant  = false;
  function not(b) { return !b; };    not.constant = false;

Only these functions (rather than arbitrary user-defined functions of type bool \(\to\) bool) are used as selection functions of selection operations. This is because some library functions need to (1) know whether the function is constant and (2) compare functions for equality. Both of these determinations can be done easily for arbitrary functions of type bool \(\to\) bool but determining constness based on the constant flag and function equality via object identity comparisons is even easier.

2.2 Primitive selection operations

The makeOp(f, domain) factory function constructs a primitive selection operation. Its parameters are:

  • f — the selection function (one of tt, ff, id, or not).
  • domain — the selection domain. The type of this value is not dictated by the library, but it must be compatible with the selection geometry and with the selection storage (see Section 2.3). Domain objects are constructed by the selection geometry’s selectionDomain function.

Both arguments to makeOp are stored as members of the resulting object, as f and domain.

  function makeOp (f, domain) { return { f: f, domain: domain }; }

2.3 Selection storage

The selection storage maintains the composition of selection operations and the base selection mapping. It represents the value \(\textit{ops}(s_b)\). Even though in our formal treatment of multi-selection the \(\textit{ops}\) composition and the base selection mapping \(s_b\) are maintained separately, we combine them in our implementation, so that the client cannot modify them directly. It is certainly possible to keep them separate, and our reference implementation internally does so. We are, however, not aware of use cases where in an arbitrary selection state the base selection mapping would have to be replaced by another one (i.e., in order to apply the current composition of primitive selection operations over another base selection mapping). This is why the selection storage API has no means to modify \(s_b\) directly.

2.3.1 Selection storage interface

Let storage be a selection storage representing \(\textit{ops}(s_b)\), i an index to an element, \(T_J\) the type used for representing domains of primitive selection operations, \(J\) a selection domain of type \(T_J\), and op a primitive selection operation whose domain is represented using \(T_J\). Then the following expressions must be valid, and they must have the semantics as described below:

  • storage.at(i)

    Returns \(\textit{ops}(s_b)(i)\).

  • storage.selected()

    Returns the set of indices of the selected elements, that is \(\{i \in I\ |\ \textit{ops}(s_b)(i)\}\), as an object of type \(T_J\).

  • storage.push(op, changed)

    Adds a new primitive selection operation op to the front of the op-composition. If storage represents \(\textit{ops}(s_b)\) before the call, after the call it represents \((\textit{op} \circ \textit{ops})(s_b)\). If changed is not undefined, changed.value must at exit have a value that represents the set of indices whose selection state changed (from true to false or vice versa). If changed.value is defined when entering the function, the indices it represents are considered to be the indices changed by a preceding call to push or pop, and the joint effect is tracked. If \(J_p\) are those indices and \(J_c\) the indices changed by the current push operation, then the resulting changed.value is \(J_c \setminus J_p\). How changed.value is represented is up to the selection storage.

  • storage.pop(changed)

    Precondition: storage.size() >= 1.

    Removes a primitive selection operation from the front of the op-composition. If storage represents \((\textit{op} \circ \textit{ops})(s_b)\) before the call, after the the call it represents \(\textit{ops}(s_b)\). The meaning and requirements for the changed parameter are the same as in the push function.

    Returns the removed primitive selection operation.

  • storage.top()

    Precondition: storage.size() >= 1.

    Returns a reference to the first (most recently pushed) primitive selection operation. That is, if storage represents \(\textit{op} \circ \textit{ops}(s_b)\), returns \(\textit{op}\).

  • storage.top2()

    Precondition: storage.size() >= 2.

    Returns a reference to the second (second most recently pushed) primitive selection operation. That is, if storage represents \(\textit{op}_a \circ \textit{op}_b \circ \textit{ops}(s_b)\), returns \(\textit{op}_b\).

  • storage.size()

    Returns the number of primitive selection operations in the composition represented by \(\textit{ops}\). (If \(\textit{ops}\) is empty, all of the selection state is represented in the base selection mapping \(s_b\) portion of \(\textit{ops}(s_b)\).)

  • storage.bake()

    Removes one primitive selection operation (the least recently pushed), applies it to the base selection mapping, and makes the result the new base. That is, if storage represents \((\textit{ops} \circ \textit{op})(s_b)\), it is modified to represent \(\textit{ops}(s'_b)\), where \(s'_b = \textit{op}(s_b))\). The function has no effect when storage.size() == 0.

  • storage.onSelected(J)

    Returns true if the selection domain J is considered to indicate a selected element, false otherwise. A typical implementation would return storage.at(i) if i is the only element in J, otherwise false.

  • storage.modifyStorage(cmd)

    The cmd parameter is a command that indicates how storage should be modified. What commands are accepted and what their effects are is defined by the client. Example functionality to provide through this method include reacting to removing indices, adding indices, and reordering indices (if they are stored in a data structure where ordering matters).

  • storage.equalDomains(J1, J2)

    Returns true if J1 and J2 are equivalent sets of indices.

  • storage.isEmpty(J)

    Returns true if J is an empty set of indices.

2.4 Default selection storage

By default, the library uses JavaScript’s built-in Set type for the base selection mapping and Map type for selection domains. These are features of ECMAScript 6 and supported by modern browsers. The benefit of using Set and Map over JavaScript’s built-in property maps is that Set and Map accept any types as keys, whereas property maps’ keys are strings. Object references as keys are convenient. For example, a Map from object references to objects is a natural indexed family for a set of DOM-elements. The order of iteration of the elements in Map and Set is predefined to be the order in which the elements are inserted to the collection.

2.4.1 Maps and sets utility functions

We use a few helper functions to deal with sets and maps.

  function isEmpty(collection) { return collection.size === 0; }
  function isSingleton(collection) { return collection.size === 1; }

  function firstKey(collection) {  
    // The body should be:
    //   return collection.keys().next().value; 
    // but Safari 8 does not support .next, therefore the workarounds below

    if (typeof collection.keys().next === 'function') {
      return collection.keys().next().value;
    } else {
      var it = collection.keys();
      for (var v of it) return v;
      return undefined;
    }
  }

  function equalKeys(a, b) { 
    if (a.size !== b.size) return false;
    for (var i of a.keys()) if (!b.has(i)) return false;
    return true;
  }

The firstKey function returns the first key of a collection in insertion order, or undefined if the collection is empty. The isEmpty, isSingleton, and firstKey functions work for both Set and Map types. The equalKeys function implements set equality between two Set objects, and set equality of keys between two Map objects.

2.4.2 Default base selection mapping

In our formalism, a primitive selection operation is a function of type \((I \to \selset) \to (I \to \selset)\). Concretely, however, the selection storage object implements this mapping, and how it is syntactically expressed may vary from one selection storage to another.

The default selection storage uses a built-in Map for representing selection domains, and the following function to construct a function object of type \((I \to \selset) \to (I \to \selset)\) out of a primitive selection operation object.

  function makeOpFunction (op) {
    if (op.f.constant) {
      return function (s) {
        return function (i) {
          return (op.domain.has(i)) ? op.f() : s(i);
        }
      }
    } else {
      return function (s) {
        return function (i) {
          return (op.domain.has(i)) ? op.f(s(i)) : s(i);
        }
      }
    }
  }

The function call operator of the selection operator object takes one of two definitions, based on whether f is a constant function or not; if f is constant, there is no need to access the previous state s(i) if i is within the operator’s domain.

2.4.3 Map-based selection storage

The MapStorage constructor creates a Map-based selection storage object. In this representation, the selection domain of a primitive selection operation is a Map object. Conceptually, however, a selection domain is a set; we take this set to be the set of all keys present in the map object. The values in the map are used internally to implement a faster retrieval of the selection status of an element, as explained below.

    function MapStorage () {
      this._ops = [];                
      this._baked = makeBaseSelectionMapping();

      this._domain = new Map(); 
      this._gen = 0;         
   }

   // member functions of func
   <<map-storage-at>>
   <<map-storage-selected>>
   <<map-storage-push>>
   <<map-storage-pop>>
   <<map-storage-top>>
   <<map-storage-top2>>
   <<map-storage-size>>
   <<map-storage-bake>>
   <<map-storage-on-selected>>
   <<map-storage-modify-storage>>
   <<map-storage-equal-domains>>
   <<map-storage-is-empty-domain>>

   // helper functions
   <<map-storage-diff-op>>

The primitive selection operations in the composition are stored in the _ops array and the base selection mapping in the _baked variable.

The keys of the _domain map are the union of the domains of all selection operations in _ops. The counter _gen grows with each added selection operation.

Assume _ops represents the value \(f \circ g \circ h\). Then _ops is the array [h, g, f]. The selection status of an element \(i\) in \((f \circ g \circ h)(s)\), where \(s\) is the base selection mapping _baked, could simply be determined by computing \(f(g(h(s)))(i)\). Often the result, however, does not depend on all functions in the composition. For example, if \(f\)’s selection function is constant and \(i\) belongs to \(f\)’s domain, then \(f(g(h(s)))(i) = f(s)(i)\). Or if \(i\) is in neither \(f\)’s nor \(g\)’s domain, then \(f(g(h(s)))(i) = h(s)(i)\). The storage object maintains extra information to avoid these unnecessary evaluations.

The basic scheme is that _domain maps each index i to the first primitive selection operation whose domain includes i, and the domain of that primitive selection operation maps i to the next primitive selection operation whose domain includes i, and so forth. In more details:

  • If _domain.has(i) is true, then _ops[_ops.length-1 - (gen - _domain.get(i))] is the first (counting backwards from the end of the array) primitive selection operation that defines a value for index i. If _domain.has(i) is false, then i does not belong to the domain of any of the primitive selection operations in _ops.
  • The value of _gen is increased with every addition of a primitive selection operation to the composition. The purpose of _gen is to avoid updating every element of _domain when new operations are added to _ops.
  • Assume _ops[n] is the first primitive selection operation that defines i. If op’s selection function f is not constant (it is id or not), then to obtain the selection status of i, i’s prior status is needed. This prior status is determined by the closest selection operation that has i in its domain, which is found as follows. The expression _ops[n].domain.get(i) has some integral value k. The meaning of k is the distance in the _ops array to the closest entry that defines the selection status of i. In other words, _ops[n-k].domain.has(i) is true, and for all 0 < j < k, _ops[n-j].domain.has(i) is false. If k > n, i belongs to the domain of no prior primitive selection operation; i’s selection state is then determined only by the base selection mapping _baked, as _baked(i).

The implementation of the at method takes advantage of the above encodings:

  MapStorage.prototype.at = function(i) { 
    var self = this;
    return evaluate(this._domain.has(i) ? (this._ops.length-1) - (this._gen-this._domain.get(i)) : -1, i)(i); 

    // determine selection state of i but only access the elements 
    // of ops (staring from ind) that have i in their domain
    function evaluate(ind, i) {
       if (ind < 0) return self._baked; // i defined in the base selection mapping baked
       else {
         var op = self._ops[ind];
         return op.applyOp(function (j) { return evaluate(ind - op.domain.get(i), j)(i); });
         // the call to evaluate is wrapped to a lambda to make the call lazy.
         // op will only call the lambda if op.f.constant is false.
         // For explanation of applyOp, see the push function
       }
    } 
 }
  • First, if an element i is not in the selection domain of some selection operation, the selection function of that operation is never evaluated when determining the selection status of i.
  • Second, if an element i is in the domain of a selection operation whose selection function is constant, and that function is evaluated to find out the selection status of i, then no further selection functions are invoked.

The methods that the selection storage interface requires are implemented as follows:

  MapStorage.prototype.selected = function () {
    var J = new Map();
    for (var i of this._baked.selectedIndices()) if (this.at(i)) J.set(i, true);
    for (var i of this._domain.keys()) if (this.at(i)) J.set(i, true);
    return J;
  }
  • Returns the set of selected indices. It suffices to iterate over all elements in _domain and all elements in the _baked selection mapping, and check whether they are selected or not.
  MapStorage.prototype.push = function (op, changed) {
    if (changed !== undefined) 
      changed.value = diffOp(op, this, changed.value, false);
    this._ops.push(op);
    ++(this._gen);
    var self = this;
    op.domain.forEach(function(_, i) {
      op.domain.set(i, self._domain.has(i) ? self._gen - self._domain.get(i) : self._ops.length);
      self._domain.set(i, self._gen); 
    });
    op.applyOp = makeOpFunction(op);
  }
  • Adds op to the composition.
  • When op is pushed to ops, each element i in its domain is assigned the distance to the previous operation in ops that defines i. If none defines i, the distance is the length of ops, indicating that the previous definition is the base selection mapping. The composition’s domain is also updated for each i, setting the newly added operation op as the most recent one that defines the selection state of i.
  • The op object is given an additional method applyOp, which is a function that applies the primitive selection operation defined by op.f and op.J to a selection mapping, and produces a new selection mapping. Section 2.2 explains makeOpFunction.
  • If changed is defined, push uses diffOp to compute the set of indices whose selection status pushing op changes. The result is a Map where each key is an element’s index and value the element’s new selection state.
  • The joint change of a sequence of push and pop operations can be tracked jointly. The changed.value member may contain a set of indices changed by a prior operation; diffInMask will exclude indices changed twice.

      function diffOp(op, m, changed, flip) {
        if (changed === undefined) changed = new Map();
        op.domain.forEach(function(_, i) {
          var b = m.at(i);
          if (op.f(b) !== b) { 
            if (changed.has(i)) changed.delete(i); 
            else changed.set(i, flip ? b : op.f(b)); 
          }
        });
        return changed;
      }
    
    • op is the primitive selection operation whose effect is tracked.
    • m is a selection mapping (the selection storage).
    • changed is the set of indices changed by prior pushs or pops.
    • flip indicates whether we are observing the effect of pushing (flip === false) or popping (flip === true) op.
  MapStorage.prototype.pop = function (changed) {
    var n = this._ops.length;
    var op = this._ops.pop();
    --(this._gen);
    var self = this;
    // domain updated for those elements that are in op.domain
    op.domain.forEach(function (_, i) {
      if (op.domain.get(i) >= n) self._domain.delete(i); // no op defines i
      else self._domain.set(i, self._domain.get(i) - op.domain.get(i)); 
    });
    if (changed !== undefined) {
      changed.value = diffOp(op, self, changed.value, true);
    }
    return op;
  }
  • Removes the most recently pushed operation from the composition.
  • Tracking changes is similar to that in push, except that the effect of op is computed after it has been popped off from _ops; and the last parameter to diffOn is true to indicate a call from pop.
  • Precondition: ops not empty.
  MapStorage.prototype.top = function () { return this._ops[this._ops.length - 1]; }
  • Returns a reference to the most recently pushed primitive selection operation.
  • Precondition: _ops not empty.
  MapStorage.prototype.top2 = function () { return this._ops[this._ops.length - 2]; }
  • Returns a reference to the second-most recently pushed primitive selection operation.
  • Precondition: _ops has at least two elements.
  MapStorage.prototype.size = function () { return this._ops.length; }
  • Returns the number of operations in _ops.
  MapStorage.prototype.bake = function () { return this._baked.bake(this._shift()); }          

  MapStorage.prototype._shift = function () {
    var op = this._ops.shift();
    var self = this;
    op.domain.forEach(function(_, i) {
      if (self._domain.get(i) - self._gen === self._ops.length) { self._domain.delete(i); }
      // if lastOp the only op that defines i, remove i from domain
    });
    return op;
  }
  • Removes the least recently pushed operation from the _ops composition and bakes it to the base selection mapping _baked.
  • Precondition: _ops not empty.
  MapStorage.prototype.onSelected = function (J) {
    return isSingleton(J) && this.at(firstKey(J));
  }
  • The selection domain J is considered to be on a selected area if J contains exactly one element that is currently selected.
  MapStorage.prototype.modifyStorage = function (cmd) {
    if (cmd.remove !== true) return; // command not recognized
    var i = cmd.value;
    if (!this._domain.has(i)) return; // nothing to remove

    // find the first op in ops that defines i
    var j = (this._ops.length - 1) - (this._gen - this._domain.get(i));

    while (j >= 0) {
      var d = this._ops[j].domain.get(i);
      this._ops[j].domain.delete(i);
      j -= d;
    }
    this._domain.delete(i);
    this._baked.set(i, false);
  }
  • Modify the storage according to cmd. The only command supported is for removing an index. If cmd.remove === true, then the index cmd.value is removed from _domain, from all domains in _ops, and from _baked.
  MapStorage.prototype.equalDomains = function (J1, J2) { return equalKeys(J1, J2); }
  MapStorage.prototype.isEmpty = function (J) { return isEmpty(J); }

3 Selection state

The SelectionState class stores all state of a multi-selection, that is, the information contained in the “selection state tuple” described in the manuscript. SelectionState is (most of) the public API of MultiselectJS. Its constructor’s parameters are:

  • a selection geometry (see Section 4),
  • a callback (refresh) that defines how to display the selection state (the default is a function that does nothing),
  • a flag that controls whether change tracking should be used or not (the default is false: no tracking),
  • the maximum number of undo operations (the default is 10), and
  • the selection storage (see Section 2.3).
  function SelectionState (geometry, refresh, tracking, maxUndo, 
                           storage) {
    this._geometry = geometry;

    refresh = or_default(refresh, function () {});
    this._tracking = or_default(tracking, false);

    var self = this;
    if (this._tracking) this._refresh = function (c) { refresh(self, c.value); };
    else this._refresh = function () { refresh(self); }

    this._maxOps = Math.max(2, 2 * or_default(maxUndo, 10));
    this._storage = or_default(storage, new MapStorage());
    this._storageStatus = ACTIVE_NONE;        

    this._spath = [];
    this._spathCache = {};
    this._cursor = undefined;
    this._redoStack = [];
    
    this._queuedCommand = function () {};
  }

  const ACTIVE_NONE = 0, ACTIVE_PREDICATE = 1, ACTIVE_PATH = 2;

The refresh function is always passed the selection state object. If s is the selection object, then s.selected() function returns the currently selected elements in the format decided by the selection storage. If tracking is true, refresh calls will also include an argument that contains the indices of the changed elements. The _tracking method encapsulates this logic. The type of the argument for changed elements depends on the implementation of the selection storage. With the default selection storage, it is a Map where each key is an element’s index and value the element’s new selection state, either true or false. Tracking may in some cases simplify visualizing the selection, but it requires additional computation and memory, so its use is optional.

The maximum number of selection operations, _maxOps, is twice maxUndo, as each undoable operation consists of two selection operations. At least one pair of selection operations must be allowed, otherwise shift-click will not work as expected (if each operation is immediately baked into the permanent selection mapping, shift-click would not remember the prior state of the elements under the current selection domain).

The elements of the selection state tuple described in the manuscript are represented by SelectionState’s member variables:

  1. _storage contains both the base selection mapping \(s\) and and the composition of primitive selection operations \(\textit{ops}\). It is an invariant that _storage always has an even number of selection operations in \(\textit{ops}\).
  2. _spath is the selection path.
  3. _cursor is the keyboard cursor.

Some additional state is maintained:

  1. _storageStatus is an indicator of whether the topmost selection operation is active (open for modification) and for which command. It can take the values ACTIVE_NONE, ACTIVE_PATH, or ACTIVE_PREDICATE. If the status is ACTIVE_NONE, then both shiftClick and predicateSelect methods will first add a new empty pair of selection operators to _storage. If it is ACTIVE_PATH, then shiftClick will not add a new pair, but predicateSelect will. The roles are reversed with ACTIVE_PREDICATE.

    The _storageStatus member is not part of the selection state tuple described in the formal model in the manuscript. There the same effect is achieved with the selection path: an undefined path (the \(\bot\) value) corresponds to the ACTIVE_NONE status; a path that matches \(P\) corresponds to the ACTIVE_PATH status; and a path that matches \(Q\) (a predicate), corresponds to the ACTIVE_PREDICATE status. Note that a predicate is not stored at all; it need not be, as every call to predicateSelect defines a new predicate, not a modification to the existing one. Compare this to shiftClick, where each call modifies the current selection path rather than replaces it.

  2. _spathCache is an object passed to the selection geometry’s extendPath and selectionDomain functions. It can be used for storing previous results used in path or selection domain computations, so that future computations can be done faster. The cache is set to {} at every click and command-click, undo and redo, etc. It is only retained before a shift-click command.
  3. _redoStack contains the redoable commands, each a pair of primitive selection operations.
  4. _queuedCommand is a one-element command queue, which is used in combining several consecutive shift-click operations to one. This optimization reduces the number of calls made to selectionDomain during a rubber-band selection.

3.1 Accessing the selection state of elements

The selection state of the element at index i is given by the isSelected(i) function. The complexity of isSelected depends on the selection storage; it might not be a constant time operation.

  SelectionState.prototype.isSelected = function (i) { 
    this._flush();
    return this._storage.at(i);
  }

The _flush method is described with the shift-click command; its purpose is to complete a possibly pending shift-click command, stored in _queuedCommand. The _flush method is called at the beginning of many of the methods of SelectionState.

The selected function returns the set of currently selected elements. How this set is represented is determined by the selection storage.

  SelectionState.prototype.selected = function () {
    this._flush();
    return this._storage.selected();
  }

3.2 Click functions

The three basic selection commands are click(vp), cmdClick(vp), and shiftClick(vp). They follow the specification in the manuscript, with a couple additional details.

3.2.1 Click

  SelectionState.prototype.click = function(vp) {
    this._flush();
    this._spath = []; 
    this._spathCache = {};

    this._modifyPathAndCursor(vp);
    this._storageStatus = ACTIVE_PATH;

    var J1 = this._geometry.selectionDomain(this._spath, undefined, this._spathCache);
    if (clickIsNop.call(this, J1)) return this;
    var J0 = this._storage.selected();

    var changed = this._makeEmptyTrackingSet(); // undefined or {changed: undefined}
    this._storage.push(makeOp(ff, J0), changed);
    this._storage.push(makeOp(tt, J1), changed);

    this._bake();
    this._refresh(changed);

    return this;
  };

The click(vp) function expects a selection space coordinate, which will (usually) become the new anchor and the new keyboard cursor. The method clears the selection path and the path cache, then extends the empty path with vp and updates the cursor using the _modifyPathAndCursor method. This method is merely a wrapper that calls the selection geometry’s extendPath method.

In the common case the new path will be [vp]. In some selection geometries it migth be the empty path []. This can be the case in geometries that have coordinate values that should not be stored to the selection path, such as points outside any selectable element. By delegating the updating of the path to extendPath, the client is given full control over path manipulation.

The click function clears the current selection, then adds the elements of the selection domain to the selection. This effect is achieved by pushing two primitive selection operations: first ff over all selected elements, then tt over the new selection domain.

The helper function clickIsNop(J) detects clicks that would not have any effect on the selection state. Such clicks do not push new primitive selection operations, so that unnecessary undo states are not created. The refresh function is not called either.

Shift-click sets the _storageStatus to ACTIVE_PATH to indicate that if the next operation is shift-click, it should modify the topmost selection operation that is pushed to _storage, rather than push a new pair of primitive selection operations.

If _storage grows to contain too many primitive selection operations, _bake removes one undoable operation (two primitive selection operations) from _storage.

The _refresh callback is invoked with the set of changed elements (which is undefined if tracking is off). Prior to the calls to _storage.push, the call to _makeEmptyTrackingSet initializes the changed set if tracking is on, and leaves it undefined if it is off. After the push calls, changed either remains undefined or changed.value contains the changed indices to pass on to _refresh. The _makeEmptyTrackingSet is defined as follows.

  SelectionState.prototype._makeEmptyTrackingSet = function() { 
    return this._tracking ? { value: undefined } : undefined;
  }

3.2.2 Wrapping calls to extendPath

The extendPath function has a complex protocol for returning its results back to the caller, explained in Section 4. The _modifyPathAndCursor function wraps a call to extendPath and takes care of the details of the protocol. The _modifyPathAndCursor function may modify both the _spath and the _cursor members.

  SelectionState.prototype._modifyPathAndCursor = function (vp) {
    var r = this._geometry.extendPath(this._spath, vp, this._spathCache, this._cursor);

    if (r === undefined || r === null) {
      this._cursor = activeEnd(this._spath);
      return r;
    }
    if (r.cursor !== undefined) this._cursor = r.cursor;
    if (r.path.isArray()) this._spath = r.path;
    return r.path;
  }

In most selection geometries, the extendPath function only affects the selection path, and the cursor is always set to be the path’s active end. One can think of selection geometries where this would not be the desired behavior, and therefore extendPath takes the cursor as a parameter as well, and can compute a new value for the cursor. If extendPath sets the cursor, it returns an object that has a cursor property. If extendPath returns null or an object with the property path with value null, _modifyPathAndCursor returns null. This is an indication that the path did not change, or it changed but in a way that does not affect the selection domain.

The rationale for the protocol is that in the common case where cursor always follows the active end, the client-defined extendPath does not need to use the cursor parameter, and either needs no return statement at all or returns null for some points. For exotic selection geometries, returning an object that has the cursor and path members gives the client full control of the cursor and path modifications.

3.2.3 Detecting a click or command-click that would have no effect

Assume op1 and op2 are the two topmost selection operations of the _storage’s primitive selection operation composition. Then, if the most recent command was

  • click, op1.f is tt and op2.f is ff;
  • command-click, op1.f is either tt or ff and op2.f is id;
  • shift-click, predicate-select, undo, or redo, either of the conditions of click or command-click can hold.

A click is deemed a nop if the previous command was also a click, and if the new selection domain is equal to the previous one:

  function clickIsNop(J) {      
    return this._storage.size() >= 2 &&
      this._storage.top2().f === ff && this._storage.top().f === tt && 
      this._storage.equalDomains(J, this._storage.top().domain);
  }

A command-click operation toggles, so for it to have no effect, J must be empty. Though this guarantees no change to elements’ selection status, it is not yet a sufficient condition for a nop, since the selection mode may have to change. This case results in two indistinguishable undo states. The selection mode, whether the next shift-click will select or deselect, is determined by the selection function of the topmost primitive selection operation.

  function cmdClickIsNop(J, mode) {      
    return this._storage.size() >= 2 &&
      this._storage.top2().f === id && this._storage.top().f === mode &&
      this._storage.isEmpty(J) && 
      this._storage.isEmpty(this._storage.top().domain);
  }

The above rules can in rare cases let equivalent undo states through. It would be possible to add additional checking to the undo operation or to cmdClick, but it is perhaps not worth the complication.

3.2.4 Command-click

The implementation differs from click on several counts: first, the selection mode depends on whether the clicked point vp is on a selected element or not; second, command-clicking does not clear the current selection; third, the conditions for detecting a nop differ; and fourth, computing which elements were changed is different. The selmode parameter is explained below; it is not needed in typical selection contexts.

  SelectionState.prototype.cmdClick = function (vp, selmode) {
    this._flush();
    this._spath = []; 
    this._spathCache = {};
    this._modifyPathAndCursor(vp);
    this._storageStatus = ACTIVE_PATH;

    var J = this._geometry.selectionDomain(this._spath, undefined, this._spathCache);

    var mode;
    if (selmode === undefined) mode = this._storage.onSelected(J) ? ff : tt;
    else mode = selmode ? tt : ff;

    if (cmdClickIsNop.call(this, J, mode)) return this;

    var changed = this._makeEmptyTrackingSet();
    this._storage.push(makeOp(id, this._geometry.selectionDomain([], undefined, {})), changed);
    this._storage.push(makeOp(mode, J), changed);

    this._bake();
    this._refresh(changed);

    return this;
  }

The selection mode is determined by whether the point vp is considered to be on an element that is selected or not on such an element. This determination is made by selection storage’s onSelected method.

The selection mode can also be set explicitly: if the second parameter selmode is true, the mode variable is set to tt, and if false, to ff. This mechanism is meant for applications that have a “non-standard” way of choosing the selection mode, such as a particular modifier key to deselect.

3.2.5 Shift-click

The semantics of shift-clicking guarantees that the effect of two consecutive shift-clicks, say, at points \(p_1\) and \(p_2\), is the same as first extending the selection path with \(p_1\), then shift-clicking at \(p_2\). This property can be taken advantage of when many shift-click events happen in rapid succession. This is why a shift-click command is queued, instead of being executed immediately. At most one command can be queued at a time.

  SelectionState.prototype.shiftClick = function (vp) {
    if (this._modifyPathAndCursor(vp) === null) return this;

    if (this._queuedCommand.pending) return this;
    // pending is either false or not defined at all

    this._queuedCommand = makeDelayedShiftClickCommand(this);
    setTimeout(this._queuedCommand, 0);
    return this;
  }

Shift-click first extends the selection path and sets the cursor with _modifyPathAndCursor; the return value null indicates that spath was not changed and that a new selection domain need not be computed.

If a shift-click command is currently pending, nothing more needs to be done. Eventually that pending command will get to execute, with the selection path that was was just extended with vp. Only when extendPath returns non-null and no command is pending, a new command is created with makeDelayedShiftClickCommand and scheduled.

  function makeDelayedShiftClickCommand(sel) {
    var cmd = function () {
      if (cmd.pending === false) return null; // the command has already been run
      cmd.pending = false;

      if (sel._storageStatus !== ACTIVE_PATH) { 
        sel._storageStatus = ACTIVE_PATH; 
        sel._addEmptyPair(); 
      }

      var changed = sel._makeEmptyTrackingSet();
      var op = sel._storage.pop(changed);

      var mode = op.f;
      var J = sel._geometry.selectionDomain(sel._spath, op.domain, sel._spathCache);

      sel._storage.push(makeOp(mode, J), changed);
      sel._refresh(changed);
    };

    cmd.pending = true;
    return cmd;
  }

The parameter sel to makeDelayedShiftClickCommand is the selection state. The function constructs a command, marks it as pending, and returns it.

Due to how the scheduling is arranged, a command may be executed more than once. Therefore the command tests first if it is still pending or not, and returns immediately if it has already been executed.

When the command gets to execute, it must check whether to add a new empty pair of selection operations. Adding a new empty pair is necessary, for example, after undo, redo, and predicateSelect, but it must not be done after a click, command-click or another shift-click. This mechanism is to prevent shift-click from overwriting a selection domain that is already in a “committed” state.

After popping the topmost _storage element, the selection domain is computed; the old selection domain is passed as a hint to the selection domain calculations. The selectionDomain function is allowed to modify the hint parameter oldJ; it is no longer used after it is popped from the storage, so it can be moved to a new primitive selection operation.

The effect of the pop and push calls is to change the domain of the topmost selection operation, and thus to modify the current selection domain. The selection function of the operation remains the same.

3.2.6 Flush

The _flush method is simply a call to the queued command. Flushing does not remove the queued command object; it may still be executed later by the main event loop or another call to _flush. Hence, each delayed command must know how to behave if executed more than once (they should be no-ops after the first invocation). Only shiftClick method schedules commands.

  SelectionState.prototype._flush = function () { 
    this._queuedCommand();
  }

3.3 Manipulating the selection path

Some selection contexts call for additional ways of manipulating the selection path. For example, the visualization of a lasso selection could make the corners of the lasso-polygon visible and draggable. This would require modifying an arbitrary point of the selection path, not just adding a point at the end. Such operations can be implemented via shift-click. The parameter vp in shiftClick(vp) does not have to be a selection space coordinate. In particular, it can be an instruction on how the path should be modified (the vp object could even contain the entire new path as one of its fields). The selection geometry’s extendPath(path, vp, cache, cursor) method decides how path should be modified by vp. The extendPath can also compute a new value for cursor and pass it to the caller as a cursor member of the return value. The client can implement arbitrary modification operations in this manner; the same delayed scheduling mechanism of shift-click commands applies regardless of how spath is modified.

3.4 Analyzing elements under point

The onSelected(p) function determines if a selection space point p is on a selected element. Many selection contexts need this functionality, e.g., to decide whether to interpret a click as a selection operation or as a beginning of a drag-and-drop. The cmdClick function needs this same information for choosing between selecting and deselecting. Usually a click is considered to be on a selected element if it indicates exactly one selected element, that is, if the selection domain computed from the path consisting of the clicked point is a singleton whose only element is selected. The criteria may, however, depend on the selection context, so therefore we delegate the decision to _storage.onSelected function.

  SelectionState.prototype.onSelected = function (vp) {
    this._flush();
    var path = [];
    var r = this._geometry.extendPath(path, vp, {}, undefined); // called with a temporary empty cache and an undefined cursor
    if (r !== undefined && r !== null && r.path !== null) path = r.path;
    var J = this._geometry.selectionDomain(path, undefined, {}); // called with a temporary empty cache
    return this._storage.onSelected(J);
  };

3.5 Empty pairs

  SelectionState.prototype._addEmptyPair = function () {
    this._storage.push(makeOp(id, this._geometry.selectionDomain([], undefined, {})));
    this._storage.push(makeOp(tt, this._geometry.selectionDomain([], undefined, {})));
  }

The calls to selectionDomain with an empty path and cache are to construct empty selection domains.

3.6 Baking

The _bake function is a utility called by click and cmdClick to remove the oldest two selection operations from _storage when its maximum size is exceeded.

  SelectionState.prototype._bake = function () {
    if (this._storage.size() > this._maxOps) {
      this._storage.bake();
      this._storage.bake();
    }
    return this;
  }

3.7 Undo and redo operations

Undoing and redoing is simply removing from and adding to the operation composition _storage. Similar to other user operations, undo and redo push and pop primitive selection operations in pairs. Both undo and redo leave the _storage stack in a state where the selection path is empty.

Both operations mark the commit status as ACTIVE_NONE. This is important. Assume it was not done. After undo some earlier selection operation op is at the top of _storage stack. The selection path that determined op’s selection domain, however, is no longer available. Click and command-click would still behave well as they would not modify the top element. A shift-click at this state, however, would likely produce surprising results—shift-click replaces the topmost selection operation with a new operation that has a different domain, and would thus cause seemingly random elements to become either selected or unselected. By setting the commit status to ACTIVE_NONE, shift-click is forced add a new empty pair of selection operations.

      SelectionState.prototype.undo = function () {
        this._flush();

        if (this._storage.size() >= 2) {
          var changed = this._makeEmptyTrackingSet();
          this._redoStack.push(this._storage.pop(changed));
          this._redoStack.push(this._storage.pop(changed));
          this._refresh(changed);
          this._spath = [];
          this._spathCache = {};

          this._storageStatus = ACTIVE_NONE;
 
          // redoStack is not cleared ever,
          // so we limit its size (to the same as that of undo stack)
          if (this._redoStack.length > this._maxOps) {
            this._redoStack.shift();
            this._redoStack.shift();
          }
        }
        return this;
      }

      SelectionState.prototype.redo = function () {
        this._flush();

        if (this._redoStack.length >= 2) {
          var changed = this._makeEmptyTrackingSet();
          this._storage.push(this._redoStack.pop(), changed);
          this._storage.push(this._redoStack.pop(), changed);
          this._refresh(changed);

          this._spath = [];
          this._spathCache = {};
          this._storageStatus = ACTIVE_NONE;
        }
  
        return this;
      }

Undo and redo clear the selection path, but do not modify the cursor. An alternative design choice would be to clear the cursor (set it to undefined). It seems that there is no harm in keeping the value, but there might be harm in clearing it if the user is solely selecting with the keyboard. One could imagine preserving the anchor from the path; it could be useful in some cases, but it could also lead to surprising behavior if the next command after undo was shift-click.

The semantics of redo could be chosen differently; any click operation could clear the entire redo stack. We chose to not do that, but instead every selection operation that is popped by undo is pushed to the redo stack; it is thus possible to, e.g., undo twice, redo once, select more with various clicks, and then redo again. We do limit the redo stack size to the maximum size of the undo stack.

3.8 Selecting and deselecting with a predicate

Some applications provide means to select or deselect elements based on properties of the elements, such as selecting all file names that end with “.pdf”. The predicateSelect(predicate, state) method implements this functionality. It relies on the selection geometry’s filter(predicate) method to compute the subset of the indices that satisfy predicate, to be used as the new selection domain. If state is false, the effect is to deselect, otherwise to select. A predicateSelect call following another predicateSelect call with the same state replaces the topmost selection operation (so it modifies the active selection domain and behaves analogously to shift-click in this sense); all other calls first add a new pair of selection operations.

  SelectionState.prototype.predicateSelect = function (predicate, state) {
    if (state !== false) mode = tt; else mode = ff;

    this._flush();

    if (this._storageStatus !== ACTIVE_PREDICATE || 
        this._storage.size() >= 2 && this._storage.top().f !== mode) { // mode changed
      this._storageStatus = ACTIVE_PREDICATE; 
      this._spath = [];
      this._spathCache = {};
      this._addEmptyPair(); 
    }

    var J = this._geometry.filter(predicate);

    var changed = this._makeEmptyTrackingSet();
    this._storage.pop(changed);
    this._storage.push(makeOp(mode, J), changed);
    this._refresh(changed);

    return this;
  }

The commit function makes the current state not active, so that a subsequent predicate-selection operation (and a shift-click operation) will be forced to add a new selection operation pair.

SelectionState.prototype.commit = function () {
  this._flush();
  this._storageStatus = ACTIVE_NONE;
}

3.9 Setting selection geometry

The selection geometry object may change in such a way as to require resetting the selection path. For example, if the positions of selectable elements change on a window, then selection space points may no longer correspond to the same elements; invoking selectionDomain again for the current selection path might produce a different result. The resetPath function is for clearing the path in such situations.

Prior to changing the path, the possible pending command must be flushed. Further, a commit is necessary so that if the next operation is shift-click or predicate-selection, the previous selection domain is not ``hijacked’’.

  SelectionState.prototype.resetPath = function () {
    this.commit();
    this._spath = []; 
    this._spathCache = {};
    this._cursor = undefined;
    return this;
  }

The entire selection geometry can be changed on the fly. Prior to doing this, path and cursor must be reset.

  SelectionState.prototype.setGeometry = function (geometry) {
    this.resetPath();
    this._geometry = geometry;
    return this;
  }

3.10 Changes to geometry

Some changes to the geometry may be such that a full resetPath is not necessary, and that the cursor or even the path can be preserved. We consider four kinds of changes to the selection geometry.

  1. An element is added to the indexed family.
  2. An element is removed from the indexed family.
  3. The elements’ locations change.
  4. The elements’ order changes.

We distinguish between two kinds of selection geometries: ordered and unordered. In ordered geometries, the selection space coordinates form a (possibly only partial) order. In an ordered geometry, adding or removing an element might mean that the selection space coordinates of some of the existing elements change, similarly for when the elements’ order changes. In an unordered geometry, the selection space coordinates change only when the elements’ location changes.

The representation for selection domains can also be ordered or unordered. For example, a bit-vector representation is ordered. Thus, inserting or removing elements may necessitate shifting the representation left or right.

The selection state must be updated to reflect changes in the selection geometry. Both the current selection state in _storage and the selection path and cursor may have to be modified. We can identify commonalities in how to handle these kinds of changes in different types of coordinate systems and selection storages. However, instead of providing a large number of operations for various types of changes, we provide general mechanisms for performing an arbitrary change to the selection storage and to the selection path and keyboard cursor. The selection storage can be modified using the modifyStorage function, which merely delegates to the selection storage object. The cmd parameter can be any value the storage object understands.

  SelectionState.prototype.modifyStorage = function (cmd) {
    this._flush();
    this._storage.modifyStorage(cmd);
    return this;
  }

The selection path and cursor can be modified using the modifyPath function.

  SelectionState.prototype.modifyPath = function (vp) {
    this._flush();
    this._modifyPathAndCursor(vp);
    return this;
  }

The parameter vp can be any object or value, and it signifies how the path and cursor should be modified. Similarly to shiftClick(vp), modifyPath(vp) delegates these modifications to extendPath. The extendPath function should thus be written to accept all possible path modification commands, whether they come via shiftClick or via modifyPath.

The modifyPath function first calls _flush, so that the current selection domain and selection path are in sync.

To give an example of when modifyPath might be used, consider an ordered selection geometry where the selection space coordinates are element indices. The selection path might be [2, 5], to indicate the range of elements from 2 to 5, and the cursor 5. If, say, the element 3 was removed, the path should be updated to [2, 4] and the cursor to 4.

3.11 Access functions

The following are the ``getter’’ functions for the geometry, cursor, and selection path:

SelectionState.prototype.geometry = function () { return this._geometry; }
SelectionState.prototype.cursor = function () { return this._cursor; }
SelectionState.prototype.selectionPath = function () { return this._spath; }

The client needs to access the geometry object for the m2v method that transforms mouse coordinate points to selection space, and the cursor and selection path to visualize the keyboard cursor, anchor, and rubber band. Other than making these pieces of data readily available, MultiselectJS leaves the visualization to the client.

3.12 Keyboard operations

Keyboard operations are simple wrappers over click, cmdClick and shiftClick functions. Each of the space functions has the same effect as a similarly modified click function on the position indicated by the keyboard cursor. If a cursor cannot be established, we choose to do nothing (rather than call the corresponding click method with undefined). Because the keyboard operations delegate to click methods, _flush calls are not needed.

SelectionState.prototype.space = function () {
  if (!this._acquireCursor(NO_DIRECTION)) return this;
  return this.click(this._cursor);
};
SelectionState.prototype.cmdSpace = function (dir) {
  if (!this._acquireCursor(or_default(dir, NO_DIRECTION))) return this;
  return this.cmdClick(this._cursor);
};
SelectionState.prototype.shiftSpace = function (dir) {
  if (!this._acquireCursor(or_default(dir, NO_DIRECTION))) return this; 
  return this.shiftClick(this._cursor);
};

The _acquireCursor(dir) function returns the current cursor if it is not undefined; otherwise it sets the cursor to a default value obtained from the selection geometry; the default of that default is undefined. The dir parameter is one of UP, DOWN, LEFT, RIGHT when called from the arrow functions and NO_DIRECTION when called from the space functions. The purpose of the dir parameter is to allow for a different default for different arrow keys. For example, the default for down arrow could be a point indicating the first index, and up arrow the last.

  SelectionState.prototype._acquireCursor = function (dir) {
    this._cursor = or_default(this._cursor, this._geometry.defaultCursor(dir));
    return !(this._noCursor());
  }
  SelectionState.prototype._noCursor = function () { return this._cursor === undefined; }

Note that the client calls space, cmdSpace, or shiftSpace without an argument, and thus dir is undefined. Thus, if _cursor is not defined, the default is queried with NO_DIRECTION. The arrow methods call the cmdSpace and shiftSpace methods with a direction argument, and therefore the default is queried with that same direction argument.

The arrow methods are as follows:

SelectionState.prototype.arrow = function (dir) {
  if (this._noCursor()) { this._acquireCursor(dir); return this; }
  this._cursor = this._geometry.step(dir, this._cursor);
  return this;
}
SelectionState.prototype.cmdArrow = function (dir) {
  if (this._noCursor()) return this.cmdSpace(dir);
  else return this.cmdSpace(dir).arrow(dir);
};
SelectionState.prototype.shiftArrow = function (dir) {
  if (this._noCursor()) return this.shiftSpace(dir);
  if (this._spath.length == 0) this.shiftSpace(dir);
  return this.arrow(dir).shiftSpace(dir);
}

In all three arrow methods, if the cursor is undefined the cursor position is taken to be whatever default the geometry provides. The shiftArrow function does not move the cursor before the shiftSpace call and cmdArrow does not move it after the cmdSpace call. This seems like the most natural behavior.

4 Selection geometries

Aspects of selection that vary from one context to another are bundled into a selection geometry object. The library provides the DefaultGeometry class, from which different geometry classes can inherit.

  var DefaultGeometry = function () {};

  DefaultGeometry.prototype = {
    m2v : function (mp) { return mp; },
    extendPath : function (path, vp, cache, cursor) { path.push(vp); },
    step : function (dir, vp) { return vp; },
    selectionDomain : function(spath, J, cache) { 
      var m = new Map();
      for (var i of spath) m.set(i, true); 
      return m;
    },
    defaultCursor : function(dir) { return undefined; },
    filter : undefined
  };

The functions of a selection geometry are:

  • m2v(mpoint)

    Transforms a point in the client’s coordinate system (e.g., the mouse coordinate system) to a coordinate in the selection space (see Section 3.2.3 in the manuscript). In the default geometry this mapping is an identity.

  • extendPath(path, p, cache, cursor)

    Extends the path with a new point p (typically as a response to a shift-click) and possibly computes a new value for the cursor. The path argument is an array. The cache argument is an object to which the selection geometry can store data between subsequent calls to extendPath and selectionDomain functions to optimize the selection domain calculations.

    The results are conveyed to the caller as follows:

    • Modifications to path are visible to the caller.
    • To indicate to the caller that the path did not change in a way that would require recomputing the selection domain, return null or an object o where o.path is null.
    • To completely replace the current selection path with some new path array path2, return an object o for which o.path is path2.
    • To indicate that the cursor should be set to some new value cursor2, return an object o for which o.cursor is cursor2. Otherwise, when called from any of the click functions or from modifyPath, the cursor will be set to the last element of the path.

    The extendPath function is allowed to change the path and cursor in arbitrary ways.

    The click and cmdClick functions construct a new current cache object {} and a new current path array []. Only click, cmdClick, shiftClick, and modifyPath call extendPath with the current path, cursor, and cache object. All other calling contexts use fresh path and cache objects, and an undefined cursror, and thus do not affect the current path, cache or cursor.

    If a selection geometry uses caching, extendPath should thus initialize the cache every time it receives an empty cache object. All cache information should be stored to the cache object, not to the selection geometry! It is OK, however, to cache some information to the selection geometry to be observed, say, in functions that visualize cursors and paths. The guarantee that the library gives is that only the click functions and modifyPath have access to and can modify the current cache object.

    Here is a sketch of how to use cache in extendPath:

    extendPath = function(path, p, cache, cursor) { 
      if (Object.keys({cache}).length == 0) {
        // initialize cache
      }      
      // modify path and possibly cursor, use cache
    }
    

    The default geometry’s implementation of extendPath pushes p to path, does not use cache, and leaves the cursor modifications to the caller.

  • step(dir, vp)

    Given a direction dir and a selection space coordinate vp, step computes a new selection space coordinate to be used as the keyboard cursor location. The possible values of dir are UP, DOWN, LEFT, and RIGHT. The step function is never called with NO_DIRECTION.

    The default is to return vp, which means that arrow commands have no effect. The step function should not modify vp (it can be an alias of an object on the selection path), but rather construct a new point object and return that.

  • selectionDomain(spath, J, cache)

    Computes the selection domain, a set of indices from the spath array of selection space points. J is a prior selection domain that may be useful in computing a new one, and cache can be used to store arbitrary data for the next selectionDomain call for optimization purposes. The cache can also be updated by calls to extendPath.

    The shiftClick function places the call to selectionDomain into a command that it schedules to be executed later (a one-element-long queue is maintained for pending commands). Because of this, the selection path (and cache and cursor) can be modified by calls to extendPath several times between two calls to selectionDomain.

    The first element in spath is the anchor, the last the active end. The helper functions anchor and activeEnd, part of the multiselect module’s API, extract these values. Often only these elements are relevant for determining the selection domain.

      function anchor(path) { 
         if (path.length === 0) return undefined; 
         return path[0]; 
      };
      function activeEnd(path) { 
         if (path.length === 0) return undefined; 
         return path[path.length - 1]; 
      };
    

    The J argument is defined only when selectionDomain is called from SelectionState’s shiftClick method. It is then the current selection domain, computed by the previous call to selectionDomain. It is not necessary to construct a copy of J; it can be modified in-place.

    The cache argument can be something other than {} only when J is defined. A new empty cache is created at click and command-click (and after undo, redo, etc.). This cache object is stored in the selection state object as the current cache. The same current cache shared with the extendPath function, which can modify it too.

    A skeleton for how to take advantage of the previous selection domain J and cache is shown below.

    selectionDomain = function(spath, J, cache) { 
      if (Object.keys({cache}).length == 0) {
        // initialize cache
      } 
      if (J === undefined) {
        // create a new selection domain object, e.g., as:
        J = new Map(); 
      }
      // populate J, possibly using cache
      }
      return J;
    }
    

    Note that selectionDomain and extendPath functions are called from several contexts in the library (notably from isSelected). These calls always have J undefined, and they use a new empty cache object that does not interfere with the current cache.

    If spath has exactly one element, call it \(p\), the computed selection domain should have at most one element. In selection geometries that allow overlapping elements, one might for example return the singleton set consisting of the index of the topmost element under \(p\). This requirement is not strict—nothing breaks if it is not followed, but the established convention is that clicks and command-clicks can only select one element at a time.

    The default geometry defines selectionDomain to map the path elements to the elements of the selection domain.

  • defaultCursor(dir)

    The defaultCursor(dir) function provides default values for the keyboard cursor. It is called from either the space or arrow methods, when no cursor has yet been established. When called as a result of pressing one of the arrow keys, defaultCursor receives the parameter dir to indicate which arrow key was pressed—the default may depend on the key. For example, with horizontally stacked sequentially ordered elements, the down-arrow could start at the topmost element and the up-arrow from the bottom element. When defaultCursor(dir) is called as a result of pressing space, dir has value NO_DIRECTION. It is fine to return undefined from defaultCursor(dir); nothing breaks. There just will be no default value.

5 Events

The MultiselectJS library does not encapsulate the code for setting up events that should be translated to selection operations. This is because the different contexts of multi-selection can vary in so many ways: different key bindings may be chosen, the set of operations that are supported may vary, dragging and dropping the selected elements may or may not be supported and the ways to distinguish between a click to select and a click to start a drag can vary.

We provide a few definitions intended to help in implementing event handling.

5.1 Detecting mouse/keyboard events

We define a set of constants to correspond to particular choices of modifier keys that can be held down at the time of a mouse click or an arrow or space key press. The modifierKeys function extracts the modifier key information from an event object. Both meta and control keys are accepted as the command modifier.

const M_NONE = 1, 
      M_SHIFT = 2, 
      M_CMD = 3, 
      M_SHIFT_CMD = 4, 
      M_OPT = 5, 
      M_SHIFT_OPT = 6;

function modifierKeys (evt) {
  
  if (evt.shiftKey && isCmdKey(evt)) return M_SHIFT_CMD;
  if (isCmdKey(evt)) return M_CMD;
  if (evt.shiftKey && evt.altKey) return M_SHIFT_OPT;
  if (evt.altKey) return M_OPT;
  if (evt.shiftKey) return M_SHIFT;
  return M_NONE;
  
  function isCmdKey (evt) { return evt.metaKey || evt.ctrlKey; }
}

6 Library API

The public names exported from the library are as follows.

  exports.SelectionState = SelectionState;

  exports.DefaultGeometry = DefaultGeometry;
  exports.anchor = anchor;
  exports.activeEnd = activeEnd;

  exports.UP = UP; 
  exports.DOWN = DOWN; 
  exports.LEFT = LEFT; 
  exports.RIGHT = RIGHT;
  exports.NO_DIRECTION = NO_DIRECTION;

  // Helpers for defining event handlers
  exports.modifierKeys = modifierKeys;

  exports.NONE = M_NONE;
  exports.SHIFT = M_SHIFT;
  exports.CMD = M_CMD;
  exports.SHIFT_CMD = M_SHIFT_CMD;
  exports.OPT = M_OPT;
  exports.SHIFT_OPT = M_SHIFT_OPT;