Implementing a ‘ruler’ with Raphael and jQuery

Investigating a click-and-drag ruler to measure dimensions on-screen.

I used jQuery for event handling and such, and Raphael for drawing the lines. Since it’s just two straight lines, I probably could have done it just as easily with html elements (e.g. setting two borders on a positioned div…) That would probably end up being more robust concerning cross-browser compatibility. Oh well. See the demo here.

There’s nothing innovative here, just something I need to implement for a project. It turns out to be a bit involved to handle all parts of the interaction. The basic idea is:

  • on mousedown: register the starting point, and create the lines and labels (Ruler.start())
  • on mousemove: update the end point and redraw the lines and labels (Ruler.move())
  • on mouseup: hide the lines and labels (Ruler.finish())
var Ruler = function (elem) {
  elem = $(elem);
  this.elem = elem[0];
  this.raph = new Raphael(elem[0],elem.width(),elem.height());

  var b = this;
  elem.mousedown(function(e) { b.start(e); });
  elem.mouseup(function(e) { b.finish(e); });
  elem.mousemove(function(e) { b.move(e); });
};

The full implementation came to about 125 lines, tested in Firefox 3, and IE 7. I’ll cover some of the tricky issues here.

Bias

The ruler feels more natural if it follows the direction the mouse is moved—if I drag the mouse left or right, then it draws the horizontal segment first, followed by the vertical; if I drag the mouse up or down initially, it draws vertical then horizontal.

The code waits for at least a 10-pixel movement from the starting point to determine the user intent, and then fixes that as the bias for the ruler. This affects the ordering of the line segments, as well as the placement of the text labels.

  bias: function(left,top) {
    var dx = Math.abs(this.dx);
    var dy = Math.abs(this.dy)
    if ( (dx+dy) > 10 )
      this.dir = (dx > dy) ? 'h':'v';
  },

The lines

The two line segments are drawn as a single Raphael path element. Instead of re-creating the path for each movement, it updates the points in the path instead. Raphael makes this easy:

  draw_lines: function(left,top) {
    var p = this.path.path;
    if ( this.dir == 'h' )
      p[1].arg = [left, this.start_at.top];
    else
      p[1].arg = [this.start_at.left, top];
    p[2].arg = [left, top];
    this.path.redraw();
  },

The text labels

The labels showing the horizontal and vertical distance are drawn with two dynamically-created divs, positioned absolutely. I want these to be treated as passive annotations on the page, but since they are actual DOM elements, they are treated just like any other text on the page—the get selected when the mouse goes over them while the mouse button is down (i.e. while dragging the ruler).

So, when the ruler drag is started and it is short, the mouse is over the text labels, and the browser selects the text as it is supposed to. Not a nice effect. I found this solution for making the text non-selectable in a cross-browser manner, but with limited success.

  draw_labels: function(left,top) {
    var hl, ht, vl, vt;
    if ( this.dir == 'h' ) {
      // horz label
      hl = (this.start_at.left + left)/2;
      ht = this.start_at.top;
      // vert label
      vl = left + 2;
      vt = (this.start_at.top + top)/2;
    } else {
      // horz label
      hl = (this.start_at.left + left)/2;
      ht = top;
      // vert label
      vl = this.start_at.left + 2;
      vt = (this.start_at.top + top)/2;
    }
    $(this.hlabel).text(this.dx).
      css({left: (this.offset.left + hl)+"px",
           top: (this.offset.top + ht)+"px"});
    $(this.vlabel).text(this.dy).
      css({left: (this.offset.left + vl)+"px",
           top: (this.offset.top + vt)+"px"});
  },

mouseup outside of the div

If the mouse leaves the #paper div, and then the user releases the mouse, the Ruler.finish() method never gets called. This has a very simple solution: in Ruler.start(), don’t do anything if the ruler is already in progress (the previous mousedown), so the user just has to click again inside the div to finish it.

Am I making this too hard? Feel free to point me to easier/cleaner/better solutions…