MultiselectJS — Documentation for Source Code
Table of Contents
- 1. Introduction
- 2. Representing selections
- 3. Selection state
- 3.1. Accessing the selection state of elements
- 3.2. Click functions
- 3.3. Manipulating the selection path
- 3.4. Analyzing elements under point
- 3.5. Empty pairs
- 3.6. Baking
- 3.7. Undo and redo operations
- 3.8. Selecting and deselecting with a predicate
- 3.9. Setting selection geometry
- 3.10. Changes to geometry
- 3.11. Access functions
- 3.12. Keyboard operations
- 4. Selection geometries
- 5. Events
- 6. Library API
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 oftt
,ff
,id
, ornot
).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’sselectionDomain
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. Ifstorage
represents \(\textit{ops}(s_b)\) before the call, after the call it represents \((\textit{op} \circ \textit{ops})(s_b)\). Ifchanged
is notundefined
,changed.value
must at exit have a value that represents the set of indices whose selection state changed (fromtrue
tofalse
or vice versa). Ifchanged.value
is defined when entering the function, the indices it represents are considered to be the indices changed by a preceding call topush
orpop
, and the joint effect is tracked. If \(J_p\) are those indices and \(J_c\) the indices changed by the currentpush
operation, then the resultingchanged.value
is \(J_c \setminus J_p\). Howchanged.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 thechanged
parameter are the same as in thepush
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 whenstorage.size() == 0
.storage.onSelected(J)
Returns
true
if the selection domainJ
is considered to indicate a selected element,false
otherwise. A typical implementation would returnstorage.at(i)
ifi
is the only element inJ
, otherwisefalse
.storage.modifyStorage(cmd)
The
cmd
parameter is a command that indicates howstorage
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
ifJ1
andJ2
are equivalent sets of indices.storage.isEmpty(J)
Returns
true
ifJ
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 indexi
. If_domain.has(i)
is false, theni
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 definesi
. Ifop
’s selection functionf
is not constant (it isid
ornot
), then to obtain the selection status ofi
,i
’s prior status is needed. This prior status is determined by the closest selection operation that hasi
in its domain, which is found as follows. The expression_ops[n].domain.get(i)
has some integral valuek
. The meaning ofk
is the distance in the_ops
array to the closest entry that defines the selection status ofi
. In other words,_ops[n-k].domain.has(i)
istrue
, and for all0 < j < k
,_ops[n-j].domain.has(i)
isfalse
. Ifk > 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 ofi
. - 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 ofi
, 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 toops
, each elementi
in its domain is assigned the distance to the previous operation inops
that definesi
. If none definesi
, the distance is the length ofops
, indicating that the previous definition is the base selection mapping. The composition’s domain is also updated for eachi
, setting the newly added operationop
as the most recent one that defines the selection state ofi
. - The
op
object is given an additional methodapplyOp
, which is a function that applies the primitive selection operation defined byop.f
andop.J
to a selection mapping, and produces a new selection mapping. Section 2.2 explainsmakeOpFunction
. - If
changed
is defined,push
usesdiffOp
to compute the set of indices whose selection status pushingop
changes. The result is aMap
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 ofop
is computed after it has been popped off from_ops
; and the last parameter todiffOn
istrue
to indicate a call frompop
. - 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 ifJ
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. Ifcmd.remove === true
, then the indexcmd.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:
_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}\)._spath
is the selection path._cursor
is the keyboard cursor.
Some additional state is maintained:
_storageStatus
is an indicator of whether the topmost selection operation is active (open for modification) and for which command. It can take the valuesACTIVE_NONE
,ACTIVE_PATH
, orACTIVE_PREDICATE
. If the status isACTIVE_NONE
, then bothshiftClick
andpredicateSelect
methods will first add a new empty pair of selection operators to_storage
. If it isACTIVE_PATH
, thenshiftClick
will not add a new pair, butpredicateSelect
will. The roles are reversed withACTIVE_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 theACTIVE_NONE
status; a path that matches \(P\) corresponds to theACTIVE_PATH
status; and a path that matches \(Q\) (a predicate), corresponds to theACTIVE_PREDICATE
status. Note that a predicate is not stored at all; it need not be, as every call topredicateSelect
defines a new predicate, not a modification to the existing one. Compare this toshiftClick
, where each call modifies the current selection path rather than replaces it._spathCache
is an object passed to the selection geometry’sextendPath
andselectionDomain
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
andredo
, etc. It is only retained before a shift-click command._redoStack
contains the redoable commands, each a pair of primitive selection operations._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 toselectionDomain
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
istt
andop2.f
isff
; - command-click,
op1.f
is eithertt
orff
andop2.f
isid
; - 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.
- An element is added to the indexed family.
- An element is removed from the indexed family.
- The elements’ locations change.
- 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 pointp
(typically as a response to a shift-click) and possibly computes a new value for thecursor
. Thepath
argument is an array. Thecache
argument is an object to which the selection geometry can store data between subsequent calls toextendPath
andselectionDomain
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 objecto
whereo.path
isnull
. - To completely replace the current selection path with some new
path array
path2
, return an objecto
for whicho.path
ispath2
. - To indicate that the cursor should be set to some new value
cursor2
, return an objecto
for whicho.cursor
iscursor2
. Otherwise, when called from any of the click functions or frommodifyPath
, 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
andcmdClick
functions construct a new current cache object{}
and a new current path array[]
. Onlyclick
,cmdClick
,shiftClick
, andmodifyPath
callextendPath
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 andmodifyPath
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
pushesp
topath
, does not use cache, and leaves the cursor modifications to the caller.- Modifications to
step(dir, vp)
Given a direction
dir
and a selection space coordinatevp
,step
computes a new selection space coordinate to be used as the keyboard cursor location. The possible values ofdir
areUP
,DOWN
,LEFT
, andRIGHT
. Thestep
function is never called withNO_DIRECTION
.The default is to return
vp
, which means that arrow commands have no effect. Thestep
function should not modifyvp
(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, andcache
can be used to store arbitrary data for the nextselectionDomain
call for optimization purposes. The cache can also be updated by calls toextendPath
.The
shiftClick
function places the call toselectionDomain
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 toextendPath
several times between two calls toselectionDomain
.The first element in
spath
is the anchor, the last the active end. The helper functionsanchor
andactiveEnd
, 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 whenselectionDomain
is called fromSelectionState
’sshiftClick
method. It is then the current selection domain, computed by the previous call toselectionDomain
. It is not necessary to construct a copy ofJ
; it can be modified in-place.The
cache
argument can be something other than{}
only whenJ
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 theextendPath
function, which can modify it too.A skeleton for how to take advantage of the previous selection domain
J
andcache
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
andextendPath
functions are called from several contexts in the library (notably fromisSelected
). These calls always haveJ
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 parameterdir
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. WhendefaultCursor(dir)
is called as a result of pressing space,dir
has valueNO_DIRECTION
. It is fine to returnundefined
fromdefaultCursor(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;