MultiselectJS Tutorial

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

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) to false.
  • 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.

selection_concepts.png

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 selection path to a selection domain. J, when defined, is the result of the previous selectionDomain call that may help in computing the new selection domain efficiently; cache is also for taking advantage of past computations. J and cache 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. The cache and cursor 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 predicate pred;
  • step(direction, point) that defines how arrow keys should impact the current keyboard cursor location; and
  • defaultCursor(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.

simple-selection-geometry.png

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:

  1. Importing the MultiselectJS library.
  2. Defining the selectable elements.
  3. Defining a refresh function that visualizes the selection state of the elements.
  4. Defining a selection geometry object.
  5. Constructing a SelectionState object that maintains all of the selection state.
  6. 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.

  1. 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 the shiftClick method.
  2. 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 make drawIndicators a method of the geometry object. This is because we reuse the setupMouseEvents 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 the drawIndicators function.
  3. 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 is undefined, selectionDomain must construct and return a new selection domain object. In this case cache is a fresh empty object {}.
  • When J is defined, its value is the most recently computed selection domain and cache contains the data saved during that computation. When J is used as a starting point for computing the new selection domain, the same object J 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:

1

Command-click in OS X corresponds to control-click in Windows. Other operating systems or specific applications might use still different modifier keys.

2

By class we mean an object that emulates a class following popular JavaScript idioms.

3

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.

4

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.