MultiselectJS Tutorial
Table of Contents
1 Introduction
MultiselectJS is a library for implementing multi-selection, the
feature that supports selecting and deselecting elements from a
collection using a pointing device (mouse) or keyboard. The visual
aspects of selection, the shape and location of elements, their
ordering, indicators of selection status, etc. vary from one
application to another. These are the aspects that the client defines,
MultiselectJS manages the rest. Concretely, the client defines a
selection geometry object that specifies all the context dependent
aspects. The library then provides functions, such as click(point)
,
shiftClick(point)
, and shiftArrow(dir)
, that can be directly bound
to the corresponding mouse or keyboard events.
We call these functions selection commands.
This section explains the concepts that underly how MultiselectJS manipulates selections, what the exact role of selection geometries is, and the basic services provided. Understanding the concepts and the terms introduced is helpful, perhaps necessary, for following the later sections that provide step-by-step instructions for implementing multi-selection in contexts of growing complexity.
1.1 Concepts
The multi-selection task is to identify a subset of a collection of
elements. To abstract over what these elements are (DOM objects,
characters in text, polygons drawn on a canvas, and so forth), we
assume that each element is uniquely identified by some
index. Indices can be of any type that can be compared for equality
with ===
, such as numbers, object references, or strings.
The selection state of elements is modeled as a mapping from indices
to booleans, where true
indicates that an element is selected,
false
that it is not. We call such a mapping a selection
mapping. User’s selection actions, such as clicking the mouse on an
element, dragging a ``rubber band’’ around elements, or pressing an
arrow key with the shift modifier key held down, translate to one or
more selection operations that modify the selection mapping.
Each selection operation is associated with a selection domain and selection function. The former determines the set of indices that the operation affects, and the latter whether the indices will be selected, deselected, or toggled.
The user indicates the selection domain through specifying a selection path, a sequence of points in some suitable coordinate space. This selection space could be, for example, the mouse locations in a window or pairs of row and column indices in a grid of elements.
The first point of a selection path arises from a click or a command-click1 and the subsequent points from shift-clicks (or mouse moves, when rubber band selecting). The first point is called the anchor and the last the active end of the path. In the case of a one-element path, the active end coincides with the anchor. The selection domain specified by the current selection path is the active selection domain.
Figure 1 shows concrete instances of the above concepts. The selectable elements are rectangles of arbitrary size, they are placed in arbitrary locations, and they can overlap. We make the following observations:
- The selected elements are items 2, 4, 5, and 6, and hence the
selection mapping maps the indices 2, 4, 5, and 6 to
true
, and the other indices (1 and 3) tofalse
. - The element 4 has been selected with a prior selection operation.
- To select the elements 2, 5, and 6, the user has clicked the location marked with a red circle, and then dragged the mouse (rubber band selection) through several other locations (small blue dots).
- The selection space is the space of mouse coordinates. The sequence of points indicated by the blue dots constitute the selection path. The first point in the sequence is the anchor and the last the active end.
- In most selection contexts, the anchor and active end are the only points that matter in determining the selection domain. Here, the anchor and active end define the opposite corners of a rectangle. All elements that overlap with this rectangle belong to the active selection domain.
Figure 1: A snapshot of multi-selection interaction. The selection path (the blue points) gives rise to the selection domain consisting of the items 2, 5 and 6.
How the selection path determines the selection domain varies from one context to another. This variation is captured by the selection geometry. Concretely, a selection geometry in MultiselectJS is an object that defines the functions:
m2v(point)
that converts mouse coordinates to selection space coordinates;selectionDomain(path, J, cache)
that maps a selectionpath
to a selection domain.J
, when defined, is the result of the previousselectionDomain
call that may help in computing the new selection domain efficiently;cache
is also for taking advantage of past computations.J
andcache
can typically be ignored, but Section 4 demonstrates how they can be used.extendPath(path, point, cache, cursor)
that defines how a new point is added to the selection path. Thecache
andcursor
parameters can usually be ignored; the former is again for speeding up computation based on past results, the latter for selection geometries with atypical treatment of the keyboard cursor.filter(pred)
that computes a selection domain as the set of indices that satisfy the predicatepred
;step(direction, point)
that defines how arrow keys should impact the current keyboard cursor location; anddefaultCursor(direction)
that defines default cursor locations for when the keyboard cursor has not yet been established.
The library has default definitions for each of the selection
geometry’s functions, and often it suffices to implement only a subset
of them. For example, in the selection geometry of Figure
1, the default definitions for m2v
(identity function) and extendPath
(add point as the new active end) can
be used. If a selection context does not support keyboard selection, step
and defaultCursor
need not be defined. If it does not support
selection by a predicate, filter
need not be defined. The only
function that must be defined is selectionDomain
; it computes the
indices of the elements that overlap with the rectangle indicated by
the anchor and active end.
Figure 2: The selection path is the sequence 5, 2. The anchor is 5 and the active end 2. The selection domain is the set {2, 3, 4, 5}.
Figure 2 shows a snapshot from a
selection context that has a different selection geometry. In this
geometry, the selection space coincides with the set of element
indices: the m2v
function maps all mouse positions that fall within
an element’s extents to the index of that element. In this context,
elements are considered to be ordered, so the selectionDomain
function maps a selection path to the range of indices between
(inclusive) the path’s anchor and active end. The anchor is marked
with a red dashed frame and the active end with blue. Here, the user
has clicked first somewhere on Item 5
and then shift-clicked
somewhere on Item 2
. As a result, all elements between these two
items are marked selected.
Another aspect that varies from one selection context to another is if and how the anchor and the active end, and more generally the selection path, are visualized. MultiselectJS leaves these questions to the client, but makes the data needed for those visualizations readily available (see Section 3.7).
Finally, the representation of selection domains and selection
mappings is not fixed, but can be redefined by the client.
This topic, however, is not covered by this tutorial.
All examples use the default representation, based on
JavaScript’s built-in Map
type.
1.2 Selection commands: the meaning of click, command-click, and shift-click
With a selection geometry, a selection state object can be constructed. This object provides the methods that one binds to various user events, commands that the user issues in order to modify a selection.
The basic selection commands that most applications support are click, command-click, and shift-click, though different applications assign slightly different meanings to these operations. The keybindings may vary as well (e.g., Windows’ ctrl-click corresponds to OS X’s command-click). These three commands are the basic building blocks of MultiselectJS in terms of which most other (keyboard and rubber band selection) commands are defined. In a nutshell, the three selection commands work as follows:
- Click deselects all selected elements. The clicked point becomes the anchor and the sole element of the selection path. A new active selection domain is computed from this selection path. The elements in this domain are selected. The current selection function is set to select, so that subsequent shift-click operations will select, rather than deselect, elements.
- In command-click the clicked point becomes the anchor and the sole element of the selection path. A new active selection domain is computed from this selection path. If the anchor is on an already selected element, the selection function is set to deselect, otherwise to select. The elements of the active selection domain (usually just the command-clicked element) are either selected or deselected according to the selection function.
- Shift-click extends the current selection path with a new point. It computes a new selection domain that corresponds to this selection path. The new selection domain replaces the current active selection domain.
MultiselectJS does not insist on particular key bindings for any of the selection operations, but the naming of the functions in its public API reflects the established practice, and our recommendations.
2 Example: selecting from an ordered list of non-overlapping elements
The first example is a horizontal list of elements, in which elements can be selected using the click, command-click, and shift-click commands. The Show animals button displays a list of currently selected elements.
pig | cow | goat | horse | sheep | chicken | duck | turkey | ostrich | mule |
The example and its complete source code can be viewed in separate windows.
The implementation of this example consists of the following:
- Importing the MultiselectJS library.
- Defining the selectable elements.
- Defining a refresh function that visualizes the selection state of the elements.
- Defining a selection geometry object.
- Constructing a
SelectionState
object that maintains all of the selection state. - Defining mouse event handlers to call appropriate functions of
SelectionState
.
We explain each of the steps below.
2.1 Imports
To use MultiselectJS on a page is a matter of importing it as a script. There are no dependencies; we import jQuery because we use it in this tutorial.
<script type="text/javascript" src="../../dist/multiselect.js"></script> <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
2.1.1 multiselect.js
as a module
Alternatively, multiselect.js
is provided as a (CommonJS) module.
It can be installed locally as so:
npm install git+https://github.com/hotdrink/multiselectjs.git
and then used in a project like this:
var multiselect = require("multiselect");
MultiselectJS uses ES6 features.
2.2 Selectable elements
In this example, the selectable elements are HTML table cells. We
give the cells the selectable
class attribute so that they are
easily accessible. The animal_list
span is a placeholder for where
the selected animal names will be shown when the show_animals
button
is clicked.
<table id="selectable_area"> <tr> <td class="selectable">pig</td> <td class="selectable">cow</td> <td class="selectable">goat</td> <td class="selectable">horse</td> <td class="selectable">sheep</td> <td class="selectable">chicken</td> <td class="selectable">duck</td> <td class="selectable">turkey</td> <td class="selectable">ostrich</td> <td class="selectable">mule</td> </tr> </table> <br> <button id="show_animals">Show selected animals</button> <span id="animal_list"></span>
Next, we access the above HTML elements from JavaScript code:
var selectableArea = document.getElementById("selectable_area"); var selectables = selectableArea.getElementsByClassName("selectable");
The selectableArea
object is the target whose events are recognized
as possible selection commands. The array-like object selectables
is the collection of the selectable elements.
2.3 Visualizing the selection state
The following CSS code defines the visual appearance of selectable
elements in both their unselected and selected states.
We turn the .selected
class on when an element is selected and off
when deselected.
<style> .selectable { outline:1px solid; padding:10px; cursor:default; } .selected { background-color: khaki; } </style>
To display the current selection state, MultiselectJS invokes a callback
function after every selection command (unless the library recognizes
that a command had no effect), passing it the current selection state
object. We use the refresh(sel)
function below as the callback. It
iterates over all selectable elements and toggles the selected
class
attribute according to each element’s selection status:
function refresh(sel) { var s = sel.selected(); for(var i=0; i<selectables.length; ++i) { selectables[i].classList.toggle('selected', s.has(i)); }; }
The sel
argument is the selection state object; sel.selected()
returns the set of indices of the selected elements. By default this
is a built-in Map
whose keys are the selected indices (the values
are all true
), but this representation is parameterized and can be
changed by the programmer. The
library can also be configured to track changes, in which case the
callback receives the collection of the changed indices as a second
argument. This mechanism is explained in Section
3.2.
2.4 Selection geometry
The OrderedGeometry
class2 defines the selection geometry for our example.
The _elements
member is a reference to the collection of the selectable elements.
var OrderedGeometry = function (elements) { this._elements = elements; } OrderedGeometry.prototype = Object.create(multiselect.DefaultGeometry.prototype);
OrderedGeometry
inherits from DefaultGeometry
to get the default
implementations of the selection geometry methods. The superclass’
constructor is not called since the base class has no
state. OrderedGeometry
defines two methods: m2v
and
selectionDomain
.
The m2v
function transforms mouse coordinates to selection
space coordinates, which here are the indices of the selectable
elements, integers between 0
and this._elements.length - 1
.
The m2v
function finds the element on which the
mouse coordinate mp
falls on and returns the element’s index.
OrderedGeometry.prototype.m2v = function(mp) { for (var i=0; i<this._elements.length; ++i) { if (pointInRectangle(mp, this._elements[i].getBoundingClientRect())) return i; } }
The helper function pointInRectangle
checks whether a point is
inside a rectangle.
function pointInRectangle(mp, r) { return mp.x >= r.left && mp.x <= r.right && mp.y >= r.top && mp.y <= r.bottom; }
The selectionDomain
function computes a set of element indices
from a selection path. It constructs a Map
object J
,
extracts the anchor and active end from the selection path,
and sets all indices between them to true
in J
.
OrderedGeometry.prototype.selectionDomain = function(path) { var J = new Map(); if (path.length === 0) return J; var a = multiselect.anchor(path); var b = multiselect.activeEnd(path); for (var i=Math.min(a, b); i<=Math.max(a, b); ++i) J.set(i, true); return J; }
The path
may be the empty array, in which case anchor
and
activeEnd
functions would return undefined
. This is the reason
for checking for path.length === 0
. The type of the collection that
selectionDomain
returns must match the expectations of the selection
storage. When using the default selection storage, the return value
must be a built-in Map
object.
2.5 Selection state object
The SelectionState
class maintains all the state of the selection,
including the current selection mapping, selection path, and undo and
redo stacks. It defines methods for the various selection commands
(click
, cmdClick
, shiftClick
, etc.). The SelectionState
constructor’s parameters are the selection geometry, the refresh
callback, a flag that sets change tracking on or off, the maximum
number of undo states, and the selection storage. The last four
can be omitted if their defaults (function(){}
, false
, 10
, and a Map
-based
representation of selection domains) are suitable. Here, the defaults
for the last three are.
var geometry = new OrderedGeometry(selectables); var selection = new multiselect.SelectionState(geometry, refresh);
2.6 Setting up mouse events
This simple example supports only
the click
, cmdClick
, and shiftClick
commands.
It thus suffices to define and register a handler for
just the mousedown event:
function mousedownHandler(evt) { evt.preventDefault(); evt.stopPropagation(); var vp = selection.geometry().m2v({ x: evt.clientX, y: evt.clientY }); switch (multiselect.modifierKeys(evt)) { case multiselect.NONE: selection.click(vp); break; case multiselect.CMD: selection.cmdClick(vp); break; case multiselect.SHIFT: selection.shiftClick(vp); break; } }; selectableArea.addEventListener('mousedown', mousedownHandler, false);
We draw attention to the simplicity of invoking MultiselectJS’s services:
the selection geometry’s m2v
function transforms the mouse position
into a selection space coordinate, which is then passed to the
appropriate method of the selection state object.
Detecting the modifier keys is somewhat messy. MultiselectJS provides the
function modifierKeys(evt)
for translating the event data to
constants that indicate shift, command/ctrl, and option/alt
modifiers. These constants are NONE
, SHIFT
, CMD
, SHIFT_CMD
,
OPT
, and SHIFT_OPT
. The client should define its own function to
distinguish between different modifiers if the one provided is not
adequate.
2.7 Accessing selected elements
To complete the first example we add a handler function that responds
to the “Show selected animals” button click and displays a list of the
selected elements. As in the refresh
function, we use
selection.selected()
to access the indices of the selected elements.
Again, the result is a Map
by default.
function showAnimals() { var s = ""; selection.selected().forEach(function(v, i) { // v is the element value, i the key s = s + selectables[i].textContent + " "; }); document.getElementById("animal_list").textContent = s; } document.getElementById("show_animals").addEventListener("click", showAnimals);
Another means to inspect the current selection state, not used here,
is the isSelected(i)
method that returns true
if the element i
is
selected and false
otherwise. For some selection storage implementations,
repeated calls to isSelected
may be significantly slower than getting
all the selected elements at once with a call to selected()
.
3 Example: selection geometry that is both row-wise ordered and rectangular
This section introduces a selection context that has a rather complex selection geometry. The elements are ordered row-wise, similarly to how characters of text are ordered in an editor. The anchor and the active end can be interpreted either as the end points of a range of elements in this order, or as the corners of a rectangular area. The user can use both of these mechanisms interchangeably. This kind of a dual selection mechanism is offered, for example, in Apple’s Photos application.
This section also shows how to support rubber band selection and selecting using the keyboard, how to support the undo and redo operations, and how to visualize the anchor, the active end, and the rubber band. Again, the example and its complete source code can be viewed in separate windows.
To become familiar with the supported selection features, try clicking, command-clicking, and shift clicking the elements, as well as dragging the mouse to initiate a rubber band selection. Try starting a rubber band selection both on an element and between elements, and notice how the former initiates a row-wise selection and the latter a rectangular selection. Try starting a rubber band deselection with a command-click on a selected element. Try releasing the mouse in a rubber band selection, and then picking it up again with shift-click to continue with the rubber band. To experiment with selecting with the keyboard, make sure that the selected area has the focus. Then try the space and arrow keys with and without the shift and command modifiers. Finally, use the undo and redo operations that are, respectively, bound to option-Z and shift-option-Z keys.
3.1 Selectable elements
The selectable area is a div
. The tabIndex
attribute is defined so that
the div
element can acquire the keyboard focus.
<div id="selectable_area2" class="selectable_area" tabIndex="0"></div>
In this example, JavaScript code generates the selectable elements:
var selectableArea2 = document.getElementById("selectable_area2"); for (var i = 0; i<400; ++i) { var e = document.createElement("span"); e.setAttribute("class", "selectable2"); e.textContent = i; selectableArea2.appendChild(e); } var selectables2 = selectableArea2.getElementsByClassName("selectable2");
We again use CSS and classes to visualize the selection state.
The selectable2
class indicates a selectable element and the selected2
class
an element that is currently selected. The style definitions are as follows:
<style> .selectable_area { border:1px solid; } .selectable2 { outline:1px solid; padding:1px 4px 1px 4px; margin:2px; display:inline-block; } .selected2 { background-color: khaki; } </style>
3.2 Refreshing
As discussed above, every method of the SelectionState
class that
may change the selection state invokes the refresh callback. This
example uses tracking of changes so that the refresh callback
function only needs to iterate over the changed elements, instead of
all selectable elements. When tracking of changes is on, the second
argument to the refresh callback is a collection of the changed
elements. Its type is determined by the selection storage. By
default, it is a Map
object, where each key-value pair (k, v)
indicates that the element k
changed and that its new selection
state is v
, where v
is either true
or false
.
We define a factory function that generates the callback, which we reuse in later examples. The callback iterates over the changed indices, and for each index sets a given class on or off for a DOM-element indicated by that index.
function mkRefresh (elements, cls) { return (function (_, changed) { changed.forEach(function (value, i) { $(elements[i]).toggleClass(cls, value); }); }); }
The refresh function for this current example is:
var refresh2 = mkRefresh(selectables2, 'selected2');
3.3 Selection geometry
The selection geometry again stores a reference to the collection of
the selectable elements. It also stores a reference to a DOM object
surrounding the selectable elements. We use mouse coordinates that are
relative to this parent
object’s location.
var RowwiseGeometry = function (parent, elements) { this._parent = parent; this._elements = elements; } RowwiseGeometry.prototype = Object.create(multiselect.DefaultGeometry.prototype);
Coordinates in the selection space can indicate either an element
index or a point ``in-between’’. We choose to represent a coordinate
as an object that has two members, index
and point
, and decide that
index
is null
for the in-between coordinates.
The m2v
method thus maps a mouse location mp
, which is relative to parent
,
to a coordinate object whose point
member is mp
and whose index
member is either null
or
the index of one of the elements.
RowwiseGeometry.prototype.m2v = function(mp) { for (var i=0; i<this._elements.length; ++i) { var r = getOffsetRectangle(this._parent, this._elements[i]); if (pointInRectangle(mp, r)) return { index: i, point: mp }; } return { index: null, point: mp }; }
The utility function getOffsetRectangle(a, b)
computes b
’s
bounding box in coordinates relative to the top-left corner of a
’s
bounding box. While not needed in this example, we define access
functions to all corners of a rectangle.
function topLeft(r) { return { x: r.left, y: r.top }; } function topRight(r) { return { x: r.right, y: r.top }; } function bottomLeft(r) { return { x: r.left, y: r.bottom }; } function bottomRight(r) { return { x: r.right, y: r.bottom }; } function offsetRectangle(p, r) { return { left: r.left - p.x, top: r.top - p.y, right: r.right - p.x, bottom: r.bottom - p.y }; } function getOffsetRectangle(parent, elem) { return offsetRectangle(topLeft(parent.getBoundingClientRect()), elem.getBoundingClientRect()); }
As mentioned above, the user can select either a range or a rectangular area
of elements. Which mechanism is used depends on from where a selection
command starts: if the anchor is on an element, a range is selected;
if the anchor is in-between elements, a rectangular area is selected.
The selectionDomain
function thus first inspects the anchor’s index
to determine the
kind of coordinate the anchor is, and then interprets the
anchor and the active end either as the endpoints of a range or as the
corners of a rectangle. Again, the case that path
is
empty must be handled.
RowwiseGeometry.prototype.selectionDomain = function(path) { var J = new Map(); if (path.length === 0) return J; var a = multiselect.anchor(path); var b = multiselect.activeEnd(path); if (a.index !== null) { // path defines a range for (var i=Math.min(a.index, b.index); i<=Math.max(a.index, b.index); ++i) J.set(i, true); } else { // path defines a rectangle var r1 = mkRectangle(a.point, b.point); for (var i = 0; i < this._elements.length; ++i) { if (rectangleIntersect(r1, getOffsetRectangle(this._parent, this._elements[i]))) J.set(i, true); } } return J; }
The rectangeIntersect
and mkRectangle
helper functions are as follows:
function rectangleIntersect(r1, r2) { return r1.left <= r2.right && r1.right >= r2.left && r1.top <= r2.bottom && r1.bottom >= r2.top; } function mkRectangle(p1, p2) { return { left: Math.min(p1.x, p2.x), top: Math.min(p1.y, p2.y), right: Math.max(p1.x, p2.x), bottom: Math.max(p1.y, p2.y) }; }
This selection geometry also overrides the extendPath(path, p)
method.
The click
, cmdClick
, and shiftClick
methods call extendPath
to
add a selection space point to the current selection.
Prior to pushing the new point to the path
array, this extendPath
implementation performs two tasks. First,
only the first and last point of the selection path (anchor and active
end) are of importance in this geometry. Therefore, if the path
already has two elements, the previous active end is discarded.3
Second, if the anchor is on an element, we insist that the active end
is also on an element: trying to extend the path with an in-between
point has no effect in this case.
RowwiseGeometry.prototype.extendPath = function(path, p) { if (path.length > 0 && multiselect.anchor(path).index !== null && p.index === null) return null; if (path.length == 2) path.pop(); path.push(p); }
By returning null
when the selection path is not changed, the extendPath
function informs the library that the selection domain does not have to be
recalculated.
3.4 Selection state object
The SelectionState
object is created as in the first example. This time, however,
we set tracking to true
since we defined the refresh2
callback to expect
a map of the changed elements as its second parameter.
var geometry2 = new RowwiseGeometry(selectableArea2, selectables2); var selection2 = new multiselect.SelectionState(geometry2, refresh2, true);
3.5 Mouse events
Setting up mouse events for the current example is a bit more involved because
supporting rubber band selection requires handlers also for the mousemove
and
mouseup
events. Furthermore, in addition to the selection status of the
elements, this example visualizes the anchor, active end, and the rubber band, which
adds a few function calls to the handlers.
The interplay between the handlers of different mouse events can be designed in
many ways—the code below should be considered as one possible
arrangement. The handler for the mousedown
event in the selectable
area (parent
) is registered at all times. After a command that it recognizes, this handler
registers the handlers for the mousemove
and mouseup
events. These are recognized
within the entire document, as the mouse can wander outside of the
selectable area. The handler for the mouseup
event de-registers itself
and the mousemove
handler.
function setupMouseEvents (parent, canvas, selection) { function mousedownHandler(evt) { var mousePos = selection.geometry().m2v(offsetMousePos(parent, evt)); switch (multiselect.modifierKeys(evt)) { case multiselect.NONE: selection.click(mousePos); break; case multiselect.CMD: selection.cmdClick(mousePos); break; case multiselect.SHIFT: selection.shiftClick(mousePos); break; default: return; } selection.geometry().drawIndicators(selection, canvas, true, true, false); document.addEventListener('mousemove', mousemoveHandler, false); document.addEventListener('mouseup', mouseupHandler, false); evt.preventDefault(); evt.stopPropagation(); }; function mousemoveHandler (evt) { evt.preventDefault(); evt.stopPropagation(); var mousePos = selection.geometry().m2v(offsetMousePos(parent, evt)); selection.shiftClick(mousePos); selection.geometry().drawIndicators(selection, canvas, true, true, true); }; function mouseupHandler (evt) { document.removeEventListener('mousemove', mousemoveHandler, false); document.removeEventListener('mouseup', mouseupHandler, false); selection.geometry().drawIndicators(selection, canvas, true, true, false); }; parent.addEventListener('mousedown', mousedownHandler, false); }
There are three further noteworthy issues in the code above.
- A mouse move during rubber band selection is semantically
equivalent to a shift-click. The
mousemoveHandler
thus acquires a selection space coordinate and passes it to theshiftClick
method. - The calls to
drawIndicators
function are what display the anchor, the active end, and the rubber band indicators. These markers are drawn on a HTML5 canvas element that overlaps the selectable area. The three boolean arguments specify which of the three indicators (in the order anchor, active end, rubber band) should be shown;true
means to show,false
to hide. In this example we makedrawIndicators
a method of the geometry object. This is because we reuse thesetupMouseEvents
function in a later example that uses a different selection geometry. A different geometry usually means a different visualization, so it is convenient that the geometry object brings along this visualization function. Section 3.7 describes the implementation of thedrawIndicators
function. - Even though the mouse events are a bit more complex than in the first example, MultiselectJS’s selection services are obtained by the same simple calls to the three different click methods.
We remark that a common feature in multi-selection contexts is drag-and-drop of selected elements. The above event handlers do not recognize the start of a drag-and-drop event.
A few tasks remain. First, the mouse setup code uses the helper function
offsetMousePos
to translate an event’s mouse coordinates to
coordinates relative to another DOM element (parent
). Its implementation
is as follows:
function offsetMousePos(parent, evt) { var p = topLeft(parent.getClientRects()[0]); return { x: evt.clientX - p.x, y: evt.clientY - p.y }; }
Second, the event handlers must be activated:
var canvas2 = createCanvas(selectableArea2); setupMouseEvents(selectableArea2, canvas2, selection2);
Section 3.7 shows how createCanvas
is implemented.
3.6 Keyboard events
Various keyboard commands can accomplish the same selection tasks as
clicks—the selection space point associated with a keyboard command
is the value of the keyboard cursor. After a click, command-click,
and shift-click (in typical selection geometries) the cursor is set to
the active end of the selection path—which is usually the just
clicked point. The cursor can, however, deviate from the active end.
For example, without modifiers the arrow keys move the keyboard cursor
but do not change the selection path. Further, the keyboard cursor can
be defined even if the selection path is empty; this situation arises,
e.g., after an undo
command.
Selection geometry’s step(dir, p)
method determines how arrow keys
move the keyboard cursor. The dir
parameter is one of the constants
UP
, DOWN
, LEFT
, RIGHT
. In this example, step
only computes a
new cursor value if p
is on an element; that is, when p.index
is
not null
. The new cursor object’s point
property is set to the
center point of the element that index
property indicates.
RowwiseGeometry.prototype.step = function (dir, p) { if (p.index === null) return p; // p is an "in-between" point, no change var ind = null; switch (dir) { case multiselect.LEFT: ind = Math.max(p.index - 1, 0); break; case multiselect.RIGHT: ind = Math.min(p.index + 1, this._elements.length-1); break; case multiselect.UP: ind = findClosestP.call(this, this._parent, this._elements, p.index, isAbove); break; case multiselect.DOWN: ind = findClosestP.call(this, this._parent, this._elements, p.index, function (a, b) { return isAbove(b, a); }); break; default: return p; } return { index: ind, point: centerPoint(getOffsetRectangle(this._parent, this._elements[ind])) }; }
Moving left or right is simple: decrement or increment the
cursor’s index. Moving up or down is more complex. There are several
sensible choices for what the next element above and the next
element below could mean, even the notions of above and below are
ambiguous. Here we consider one element to be above another if the
former’s center point is above the top edge of the latter. The next
element above of some element i
is the closest, in distance between
center points, of all elements that are above i
. The next element
below is defined analogously. The findClosestP(parent, elements, i,
pred)
helper function performs these determinations, finding the
closest element to i
that satisfies pred
:
function centerPoint (r) { return { x: (r.left + r.right)/2, y: (r.top + r.bottom)/2 }; } function distance (p1, p2) { var dx = p1.x - p2.x; var dy = p1.y - p2.y; return Math.sqrt(dx * dx + dy * dy); } function isAbove(r1, r2) { return centerPoint(r2).y < r1.top; } function findClosestP(parent, elements, j, pred) { var r = getOffsetRectangle(parent, elements[j]); var candidateIndex = null; var candidateDistance = Number.MAX_VALUE; for (var i=0; i<elements.length; ++i) { var rc = getOffsetRectangle(parent, elements[i]); if (pred(r, rc) && distance(centerPoint(r), centerPoint(rc)) < candidateDistance) { candidateIndex = i; candidateDistance = distance(centerPoint(r), centerPoint(rc)); } } if (candidateIndex === null) return j; else return candidateIndex; }
With the step
function defined, setting up the keyboard events is
straightforward: the event handler for keydown
recognizes the key
combination of a command, invokes the desired SelectionState
’s
method, and calls the function that draws the indicators. To avoid
conflicts with possible other keybindings in the document, the handler
is registered for parent
, the selectable area, so that the bindings
are only in effect when parent
has the focus. We add a mousedown
handler to give parent
the focus when it is clicked.
function setupKeyboardEvents(parent, canvas, selection) { parent.addEventListener('keydown', keydownHandler, false); parent.addEventListener('mousedown', function() { parent.focus(); }, false); function keydownHandler(evt) { var handled = false; var mk = multiselect.modifierKeys(evt); switch (evt.which) { case 37: handled = callArrow(mk, multiselect.LEFT); break; case 38: handled = callArrow(mk, multiselect.UP); break; case 39: handled = callArrow(mk, multiselect.RIGHT); break; case 40: handled = callArrow(mk, multiselect.DOWN); break; case 32: handled = callSpace(mk); break; case 90: handled = callUndoRedo(mk); break; default: return; // exit this handler for unrecognized keys } if (!handled) return; // they key+modifier combination was not recognized selection.geometry().drawIndicators(selection, canvas, true, true, false); evt.preventDefault(); evt.stopPropagation(); } function callUndoRedo (mk) { switch (mk) { case multiselect.OPT: selection.undo(); break; case multiselect.SHIFT_OPT: selection.redo(); break; default: return false; } return true; } function callArrow (mk, dir) { switch (mk) { case multiselect.NONE: selection.arrow(dir); break; case multiselect.CMD: selection.cmdArrow(dir); break; case multiselect.SHIFT: selection.shiftArrow(dir); break; default: return false; } return true; } function callSpace (mk) { switch (mk) { case multiselect.NONE: selection.space(); break; case multiselect.CMD: selection.cmdSpace(); break; case multiselect.SHIFT: selection.shiftSpace(); break; default: return false; } return true; } }
The main switch statement recognizes the arrow
keys, space, and the character z
(for undo and redo), and delegates
to different helper functions. The helper functions inspect the
modifiers and dispatch to the appropriate SelectionState
method,
or return false
if the key binding is not recognized.
A call to setupKeyboardEvents
registers the keyboard event handler:
setupKeyboardEvents(selectableArea2, canvas2, selection2);
To complete the keyboard selection functionality, we override the
defaultCursor
method so that the keyboard cursor has sensible defaults
when nothing has yet been selected: the arrow right and arrow down keys
start from the first element, the arrow left and arrow up keys from the last.
RowwiseGeometry.prototype.defaultCursor = function (dir) { var ind; switch (dir) { case multiselect.RIGHT: case multiselect.DOWN: ind = 0; break; case multiselect.LEFT: case multiselect.UP: ind = this._elements.length - 1; break; default: return undefined; } return { index: ind, point: centerPoint(getOffsetRectangle(this._parent, this._elements[ind])) }; }
3.7 Visualizing anchor, cursor, and rubber band
Sometimes it is useful to show where the anchor, active end, and
keyboard cursor reside. Many expect to see a rectangular rubber band
when selecting via dragging. In “lasso” selection, it is particularly
important to have a visual indicator of the selected area. Displaying
such indicators is outside of MultiselectJS but to help in the task,
SelectionState
provides the methods selectionPath()
and
cursor()
. The former returns the selection path (as an array), the
former the keyboard cursor. Client code can then turn these points to
the desired visual effects.
In this example the visual indicators are drawn on a canvas that is
placed over the selectable area. So that the canvas tracks the
selectable area at all times, the canvas’ size and position are
recalculated whenever the window
object is resized.
function createCanvas (parent) { var canvas = document.createElement("canvas"); canvas.style.position = 'absolute'; parent.insertBefore(canvas, parent.firstChild); $(window).resize(function () { resizeCanvas(); }); resizeCanvas(); return canvas; function resizeCanvas() { var rect = parent.getBoundingClientRect(); canvas.width = rect.right - rect.left; canvas.height = rect.bottom - rect.top; } }
The drawIndicators
function is defined as a method of the
geometry2
object. The function first clears all indicators, then
draws some or all of anchor, cursor, and rubber band based on the
drawAnchor
, drawCursor
, and drawRubber
flags. The tests for
undefined
points are for the cases where there is no
anchor (the selection path is empty) or no cursor.
The anchor is drawn as a circle if it is an in-between point (a point whose
index
is null
) and as a rectangle if it is on an element.
The cursor is not drawn at all for in-between points.
geometry2.drawIndicators = function (selection, canvas, drawAnchor, drawCursor, drawRubber) { var ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); if (drawAnchor) { var p = multiselect.anchor(selection.selectionPath()); if (p !== undefined) { if (p.index === null) { // p is in-between elements drawCircle(ctx, p.point, 4, 'DarkRed'); } else { // p is on some element var r = getOffsetRectangle(canvas, selection.geometry()._elements[p.index]); drawRectangle(ctx, r, 'DarkRed'); } } } if (drawCursor) { var p = selection.cursor(); if (p !== undefined && p.index !== null) { var r = getOffsetRectangle(canvas, selection.geometry()._elements[p.index]); drawRectangle(ctx, r, 'blue'); } } if (drawRubber) { var p1 = multiselect.anchor(selection.selectionPath()); if (p1 !== undefined && p1.index === null) { var p2 = multiselect.activeEnd(selection.selectionPath()); drawRectangle(ctx, mkRectangle(p1.point, p2.point), 'green'); } } }
With the drawCircle
and drawRectangle
helper functions below that
drawIndicators
calls, the second example is complete.
The drawPolyLine
function is included because it is needed
in the next example in Section 4.
function drawCircle (ctx, p, radius, color) { ctx.beginPath(); ctx.strokeStyle = color; ctx.arc(p.x, p.y, radius, 0, Math.PI*2, true); ctx.stroke(); ctx.closePath(); } function drawRectangle (ctx, r, color) { ctx.beginPath(); ctx.strokeStyle = color; ctx.strokeRect(r.left, r.top, r.right-r.left, r.bottom-r.top); ctx.closePath(); } function drawPolyLine (ctx, path, color) { ctx.beginPath(); ctx.strokeStyle = color; if (path.length > 0) { ctx.moveTo(path[0].x, path[0].y); for (var i = 1; i < path.length; ++i) ctx.lineTo(path[i].x, path[i].y); ctx.stroke(); } ctx.closePath(); }
4 Example: snake selection geometry
The third example has a selection geometry where all points of the selection path are relevant; the selection domain is the elements that the selection path touches.4 Please experiment with the selection context to understand how this ``snake’’ selection works.
This example also allows for selecting elements based on a predicate. Instead of numbers, the selectable elements in this example are names of fish. When the Pattern field is modified, all fish names that contain the pattern as a substring become selected. Modifying the pattern updates the active selection domain; the commit button fixes the current active selection domain as an undoable state. All click commands also commit a predicate-selection command, if one is active.
The example and its complete source code can be viewed in separate windows.
Pattern:
The HTML code for the selectable area and the predicate-selecting controls is as follows:
<div id="selectable_area3" class="selectable_area" tabIndex="0"></div><br> Pattern: <input type="text" id="pattern3"></input> <button id="commit_pattern3">Commit</button>
4.1 Selectable elements
The selectable elements are again generated by JavaScript.
The code that defines and populates the fish
array is in fish.js.
var selectableArea3 = document.getElementById("selectable_area3"); for (var i = 0; i<fish.length; ++i) { $(selectableArea3).append("<span class='selectable2'>" + fish[i] + "</span> "); } var selectables3 = selectableArea3.getElementsByClassName("selectable2");
The styles for the selectable2
, selected2
, and selectable_area
are reused
from the previous example, so we do not need any stylesheets.
4.2 Selection geometry
The constructor of the snake geometry is simple. As before, the DOM
object of the selectable area (parent
) and the collection of the
selectable elements (elements
) are stored in the selection geometry
object.
var SnakeGeometry = function (parent, elements) { this._parent = parent; this._elements = elements; } SnakeGeometry.prototype = Object.create(multiselect.DefaultGeometry.prototype);
The snake geometry can use the inherited default definitions of m2v
(identity) and extendPath
(pushes a new point to the array that
represents the selection path).
The selectionDomain
function is more complex. It iterates over all
the line segments defined by two adjacent points on the selection
path, finds the elements that the line segment intersects with, and
adds them to the selection domain. This is quite a bit of work, and
thus the function implements an optimization. When it finishes
computing a selection domain, it writes the current length of the
selection path to a cache. If the path is extended and then
selectionDomain
called again, it suffices to compute the line
segment intersections only with the newly added segments.
SnakeGeometry.prototype.selectionDomain = function(path, J, cache) { if (J === undefined) { J = new Map(); cache.k = 0; } for (var i = cache.k; i < path.length; ++i) { for (var j = 0; j < this._elements.length; ++j) { if (lineRectIntersect(path[i], path[Math.max(0, i-1)], getOffsetRectangle(this._parent, this._elements[j]))) J.set(j, true); } } cache.k = path.length; return J; }
The library calls selectionDomain
with three parameters: path
,
J
, and cache
.
- When
J
isundefined
,selectionDomain
must construct and return a new selection domain object. In this casecache
is a fresh empty object{}
. - When
J
is defined, its value is the most recently computed selection domain andcache
contains the data saved during that computation. WhenJ
is used as a starting point for computing the new selection domain, the same objectJ
can be modified and returned as a result.
All selection commands except shiftClick
set J
to undefined
.
How the cache is manipulated is up to the client. It can be updated
both by the selectionDomain
and extendPath
methods; each call to
either method on the same selection path receives the same cache
object. In this example, the cache object has one property k
, which
is the length of the selection path at the time when the selection
domain was most recently calculated. In this example the extendPath
method does not modify the cache.
The lineRectIntersect
helper function
determines if a line intersects with a rectangle.
function lineRectIntersect(p1, p2, r) { if (!rectangleIntersect(mkRectangle(p1, p2), r)) return false; // if bounding boxes do not overlap, cannot intersect if (pointInRectangle(p1, r) || pointInRectangle(p2, r)) return true; var p = {}; if (lineIntersect(p1, p2, topLeft(r), bottomLeft(r), p) === 1) return true; if (lineIntersect(p1, p2, topLeft(r), bottomRight(r), p) === 1) return true; if (lineIntersect(p1, p2, bottomRight(r), topRight(r), p) === 1) return true; if (lineIntersect(p1, p2, bottomRight(r), bottomLeft(r), p) === 1) return true; return false; }
The lineIntersect
function is more involved.
The code is a bit long, so we show it at the very end of this document
in Section 4.6.
This example allows selecting elements by specifying a predicate.
For this, we must override the filter
method.
It takes a predicate p
and returns a selection domain,
a Map
of all indices for which the predicate is true
.
SnakeGeometry.prototype.filter = function(p) { var J = new Map(); for (var i = 0; i < this._elements.length; ++i) if (p(i)) J.set(i, true); return J; }
4.3 Constructing the selection state object
Constructing the selection state object is as before.
We again create the refresh callback function with the mkRefresh
factory
function, and we set ‘track changes’ to true
.
var geometry3 = new SnakeGeometry(selectableArea3, selectables3); var selection3 = new multiselect.SelectionState(geometry3, mkRefresh(selectables3, 'selected2'), true);
4.4 Visualizing anchor, cursor, and rubber band
Drawing the various indicators is done as before.
We again setup a canvas over the selectable area, and add
the drawIndicators
method to the selection geometry object.
The anchor and cursor are drawn as small circles,
the rubber band indicator as the line segments of
the selection path.
geometry3.drawIndicators = function (selection, canvas, drawAnchor, drawCursor, drawRubber) { var ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); if (drawAnchor) { var p = multiselect.anchor(selection.selectionPath()); if (p !== undefined) drawCircle(ctx, p, 4, 'DarkRed'); } if (drawCursor) { var p = selection.cursor(); if (p !== undefined) drawCircle(ctx, p, 4, 'blue'); } if (drawRubber) drawPolyLine(ctx, selection.selectionPath(), 'green'); }
4.5 Setting up mouse events
This example reuses the previous example’s createCanvas
,
setupMouseEvents
, and setupKeyboardEvents
functions for setting up
the mouse and keyboard events.
var canvas3 = createCanvas(selectableArea3); setupMouseEvents(selectableArea3, canvas3, selection3); setupKeyboardEvents(selectableArea3, canvas3, selection3);
The Pattern textbox, too, must listen to keyboard events. When its contents
change, a new predicate is built and passed to the SelectionState
object’s
predicateSelect
method. The predicate is true for some element \(i\)
if the contents of the textbox is a substring of \(i\)’s string value.
So that the user can commit the result of the predicate selection as an undoable
state, we bind the Commit button’s click to the commit
method.
var pattern3 = $("#pattern3")[0]; $(pattern3).keyup(function () { var str = $(pattern3).val(); selection3.predicateSelect(function(i){ return str !== "" && fish[i].indexOf(str)>-1; }); }); $("#commit_pattern3").click(function(){ selection3.commit(); });
The example is complete, apart from showing the code for computing line intersection.
4.6 Line intersection code
This function is adapted from Prasad Mukesh’s C-code Intersection of Line Segments,
ACM Transaction of Graphics’ Graphics Gems II, p. 7–9, code: p. 473–476, xlines.c
.
/* PORTED FROM: * lines_intersect: AUTHOR: Mukesh Prasad * * This function computes whether two line segments, * respectively joining the input points (x1,y1) -- (x2,y2) * and the input points (x3,y3) -- (x4,y4) intersect. * If the lines intersect, the output variables x, y are * set to coordinates of the point of intersection. * * All values are in integers. The returned value is rounded * to the nearest integer point. * * If non-integral grid points are relevant, the function * can easily be transformed by substituting floating point * calculations instead of integer calculations. * * Entry * x1, y1, x2, y2 Coordinates of endpoints of one segment. * x3, y3, x4, y4 Coordinates of endpoints of other segment. * * Exit * x, y Coordinates of intersection point. * * The value returned by the function is one of: * * DONT_INTERSECT 0 * DO_INTERSECT 1 * COLLINEAR 2 * * Error conditions: * * Depending upon the possible ranges, and particularly on 16-bit * computers, care should be taken to protect from overflow. * * In the following code, 'long' values have been used for this * purpose, instead of 'int'. * */ function sameSigns(a, b) { return a >= 0 && b >= 0 || a < 0 && b < 0; } function lineIntersect( p1, /* First line segment */ p2, p3, /* Second line segment */ p4, p5 /* Output value: * point of intersection */ ) { const DONT_INTERSECT = 0; const DO_INTERSECT = 1; const COLLINEAR = 2; var a1, a2, b1, b2, c1, c2; /* Coefficients of line eqns. */ var r1, r2, r3, r4; /* 'Sign' values */ var denom, offset, num; /* Intermediate values */ /* Compute a1, b1, c1, where line joining points 1 and 2 * is "a1 x + b1 y + c1 = 0". */ a1 = p2.y - p1.y; b1 = p1.x - p2.x; c1 = p2.x * p1.y - p1.x * p2.y; /* Compute r3 and r4. */ r3 = a1 * p3.x + b1 * p3.y + c1; r4 = a1 * p4.x + b1 * p4.y + c1; /* Check signs of r3 and r4. If both point 3 and point 4 lie on * same side of line 1, the line segments do not intersect. */ if ( r3 != 0 && r4 != 0 && sameSigns( r3, r4 )) return ( DONT_INTERSECT ); /* Compute a2, b2, c2 */ a2 = p4.y - p3.y; b2 = p3.x - p4.x; c2 = p4.x * p3.y - p3.x * p4.y; /* Compute r1 and r2 */ r1 = a2 * p1.x + b2 * p1.y + c2; r2 = a2 * p2.x + b2 * p2.y + c2; /* Check signs of r1 and r2. If both point 1 and point 2 lie * on same side of second line segment, the line segments do * not intersect. */ if ( r1 !== 0 && r2 !== 0 && sameSigns( r1, r2 )) return ( DONT_INTERSECT ); /* Line segments intersect: compute intersection point. */ denom = a1 * b2 - a2 * b1; if ( denom === 0 ) return ( COLLINEAR ); // offset = denom < 0 ? - denom / 2 : denom / 2; // /* The denom/2 is to get rounding instead of truncating. It // * is added or subtracted to the numerator, depending upon the // * sign of the numerator. // */ // The calculations for p5 are commented out; // we just need to know if lines intersect or not // num = b1 * c2 - b2 * c1; // p5.x = ( num < 0 ? num - offset : num + offset ) / denom; // num = a2 * c1 - a1 * c2; // p5.y = ( num < 0 ? num - offset : num + offset ) / denom; return DO_INTERSECT; }
Footnotes:
Command-click in OS X corresponds to control-click in Windows. Other operating systems or specific applications might use still different modifier keys.
By class we mean an object that emulates a class following popular JavaScript idioms.
The first example’s selection geometry is also such that
only the anchor and active end matter in computing the selection domain.
To avoid storing unused points in the path arrays,
we could have redefined extendPath
to
discard the intermediate points there too.
Perhaps “lasso” selection, where the user draws a path around the elements to be selected, is a more common freehand selection mechanism. Identifying the elements that intersect with an arbitrary polygon is, however, quite a bit more complex than identifying elements that intersect with a path. For this tutorial, we choose to implement the less complex selection mechanism.