netzstaub

beatz & funkz

Wednesday, March 9, 2005

Javascript experiences: tooltips

I finally wrote my first “real” javascript program this weekend. I have a lot of articles about biology. Each article can have tables, figures, and references to other articles. Furthermore, each article has a kind of small introductory text, called the “definition”. I wanted to have tooltips showing these figures, tables and definitions when dragging the mouse over a link. For example, if a paragraph features a link to another article, I wanted to have a tooltip showing the title, author and definition of the other article showing up. This would also be the first “real” program written with my “lisp to javascript” compiler.

JavaScript Tooltips using CSS

After surfing the web, searching for a nice way to do popups in javascript, I found the following solution Creating Pop-Up Notes with CSS and JavaScript Part I. The code in the article uses CSS and javascript to show/hide and position a DIV element containing the popup elements. I used the same approach to show and hide my popups. However, I customized the part where the popup DIVs are attached to the links. Also, in some cases the DIV I wanted to show as a tooltip was already present inside the HTML page. The solution here was to clone the DIV element, to modify its properties so it would be hidden, and to reattach it to the BODY element. There can be multiple links using the same DIV as a tooltip. The tooltips are referred by an ID, which is the ID of the DIV they are created from in the HTML. The tooltips are then stored in an array, and reference by their ID. The code to find a tooltip is found in the function GET-TTIP:

(defvar *ttips* (array))

(defun get-ttip (ttip-id)
     (whenlet (ttip (aref *ttips* ttip-id))
       (return ttip))
     (whenlet (tpreview (document.get-element-by-id ttip-id))
       (let ((ttip (tpreview.clone-node t)))
         (setf ttip.style.visibility :hidden
           ttip.style.position :absolute
           ttip.style.width "40%"
           ttip.style.padding "10px"
           ttip.style.margin "10px"
           ttip.style.top 0
           ttip.style.left 0
           ttip.style.z-index 1
           ttip.id undefined
           ttip.orig-id tpreview.id)
         (let ((body (aref (document.get-elements-by-tag-name "body") 0)))
           (body.append-child ttip))
         (return ttip))))

GET-TTIP gets compiled to the following JavaScript code:

var TTIPS = [ ];

function getTtip(ttipId) {
   var ttip = TTIPS[ttipId];
   if (ttip)
         return ttip;
   var tpreview = document.getElementById(ttipId);
   if (tpreview) {
      var ttip = tpreview.cloneNode(true);
      ttip.style.visibility = "hidden";
      ttip.style.position = "absolute";
      ttip.style.width = "40%";
      ttip.style.padding = "10px";
      ttip.style.margin = "10px";
      ttip.style.top = 0;
      ttip.style.left = 0;
      ttip.style.zIndex = 1;
      ttip.id = undefined;
      ttip.origId = tpreview.id;
      var body = document.getElementsByTagName("body")[0];
      body.appendChild(ttip);
      return ttip
   }
}

Appending to the BODY tag is important, because else the positioning using CSS will give unpredictable results. I didn’t get this at first, and kept on attaching the cloned DIVs to a random element, and got some very weird results. Another very important step is to clear the ID of the cloned DIV. Having two or more DIVs with the same ID has some very strange effects in Mozilla/Firefox.

Adding callback handlers

The next step, after cloning the DIVs, is to add onmouseover and onmouseout handlers for the links we want to have tooltips. As a lispy person, I immediately thought of using closures. My first attempt however was doomed to fail. I created the closures, but somehow noticed that javascript hasn’t got real closures: all my handlers were referencering the same tooltip through the captured variable. However, I was pretty sure that a closure creation time, the tooltip variable was set to the correct value. How could this happen? This prompted me to read a bit more about JavaScript closures. I found a really nice article in the comp.lang.javascript FAQ, as well as some articles about how horrible closures can be in respect to memory leaks.

An excursion into JavaScript closures

My closure problem is easily explained by looking at how javascript closures work. In javascript, everything, including arrays, functions, and of course objects, are objects. And objects are nothing more than a collection of properties, identifed by a string, which store values, which can be a number, a string, or another object. So when a function is executed, an object is created. This object has properties for the arguments passed to the function, as well as properties for the local variables created in the function. When a closure is created inside this function, its scope refers to the parent function, which itself refers to the global scope. For example, when we have the following function:

(defun test-function (a b)
   (let ((c 1))
      (defun inner-function (z)
         (return (+ z c)))))

which compiles to the following javascript:

function testFunction(a, b) {
   var c = 1;
   function innerFunction(z) {
         return z + c
   }
}

the memory representation of the functions looks like this. The scope of TEST-FUNCTION refers to the global scope. Furthermore, it has the properties a, b and c. C is set to 1, A and B are filled at invocation time. innerFunction points to the function INNER-FUNCTION. When INNER-FUNCTION refers to C, the scope link is followed, and the value of C in the TEST-FUNCTION is used. And that will be the last value that C got during the invocation of TEST-FUNCTION.

That is the trick here. The first function I wrote to setup the callback handlers iterated over all the node, and captured the DIV variable. It looked a bit like this:

(defun test-function (a b)
   (let ((func-array (array)))
      (dolist (i (array 1 2 3 4 5 6))
        (setf (aref func-array i)
              (lambda (x) (+ i x))))))

which compiles to (the GENSYM-ed symbols are a bit ugly, I know):

function testFunction(a, b) {
   var funcArray = [  ];
   var tmpList6 = [ 1, 2, 3, 4, 5, 6 ];
   for (var tmpI5 = 0, i = tmpList6[tmpI5]; !(tmpI5 == tmpList6.length); tmpI5 = ++tmpI5, i = tmpList6[tmpI5]) {
      funcArray[i] = function (x) { i + x };
   }
}

The problem with this code is that all closures in funcArray refer to the same “i” property. Thus they will all return the same. One solution to this is to create the closure in its own function, thus forcing the creation of a new scope object for the closure. The same can be done by using the WITH construct, which creates an explicit scope object. The above can be written as:

(defun test-function (a b)
   (let ((func-array (array)))
      (dolist (i (array 1 2 3 4 5 6))
        (with ((create :i i))
          (setf (aref func-array i)
                (lambda (x) (+ i x)))))))

which compiles to (notice the DOLIST expansion. I didn’t use the “for (.. in ..)” construct because it won’t work on strange collection items like HtmlCollection. I didn’t search really deep into this problem though, the macro works fine for me.):

function testFunction(a, b) {
   var funcArray = [  ];
   var tmpList14 = [ 1, 2, 3, 4, 5, 6 ];
   for (var tmpI13 = 0, i = tmpList14[tmpI13];
        !(tmpI13 == tmpList14.length);
        tmpI13 = ++tmpI13, i = tmpList14[tmpI13]) {
      with ({ "i"  :  i }) {
         funcArray[i] = function (x) { i + x };
      }
   }
}

This construct will have the desired effect of creating a lexical scope for “i”. This is the approach I used, too.

Back to callback handlers

The code for setting the handlers is both contained in a new functions and uses with, because I didn’t quite get it at the time. I’ve been to lazy to change it :) Here it is:

(defun set-ttip (node ttip)
  (with ((create :ttip ttip))
        (setf node.onmouseover (lambda (event) (show-note ttip event))
              node.onmouseout (lambda () (hide-note ttip)))))

which compiles to:

function setTtip(node, ttip) {
   with ({ "ttip"  :  ttip }) {
      node.onmouseover = function (event) { showNote(ttip, event) };
      node.onmouseout = function () { hideNote(ttip) };
   }
}
;; this should be equally fine though
       (defun set-ttip (node ttip)
         (setf node.onmouseover (lambda (event) (show-note ttip event))
               node.onmouseout (lambda () (hide-note ttip))))

Setting up the tooltips

The links having tooltips have a special class, which can be
either “seealsolink” (reference to another article), “authorlink”
(reference to an author), “tablelink” (links a table of the current
article) or “figlink” (links a figure of the current article). To
setup the tooltips we search for all nodes with the TAG-NAME “A”
and the corresponding class. Then, the ID of the referenced object
is extracted from the HREF property of the node, and the
corresponding DIV is search using GET-TTIP. Finally, the callbacks
are set up. The code looks like this (including the toplevel code
registering the onload callback). We have to do this after loading
the document, else the DOM tree won’t be present.

    (defun get-elements-by-tag-name-and-class-name (node tag-name class-name)
     (let ((res (array)))
       (do-list (elem (node.get-elements-by-tag-name tag-name))
         (when (eql elem.class-name class-name)
           (res.push elem)))
       (return res)))

   (defun setup-tooltips (link-type)
     (dolist (a (get-elements-by-tag-name-and-class-name
                 document "a" link-type))
       (whenlet (match (a.href.match (regex "([a-zA-Z0-9_]+).html$")))
         (let ((ttip-id (aref match 1))
           (ttip (get-ttip ttip-id)))
           (when ttip
         (set-ttip a ttip))))))
   (defun ttip-setup ()
     (setup-tooltips "seealsolink")
     (setup-tooltips "tablelink")
     (setup-tooltips "authorlink")
     (setup-tooltips "figlink"))
   (setf window.onload ttip-setup)))))

which compiles to:

function getElementsByTagNameAndClassName(node, tagName, className) {
   var res = [  ];
   var tmpList16 = node.getElementsByTagName(tagName);
   for (var tmpI15 = 0, elem = tmpList16[tmpI15]; !(tmpI15 == tmpList16.length); tmpI15 = ++tmpI15, elem = tmpList16[tmpI15]) {
      if (elem.className == className)
            res.push(elem);
   }
   return res
}
function setupTooltips(linkType) {
   var tmpList18 = getElementsByTagNameAndClassName(document, "a", linkType);
   for (var tmpI17 = 0, a = tmpList18[tmpI17]; !(tmpI17 == tmpList18.length); tmpI17 = ++tmpI17, a = tmpList18[tmpI17]) {
      var match = a.href.match(/([a-zA-Z0-9_]+).html$/);
      if (match) {
         var ttipId = match[1];
         var ttip = getTtip(ttipId);
         if (ttip)
               setTtip(a, ttip);
      }
   }
}
function ttipSetup() {
   setupTooltips("seealsolink");
   setupTooltips("tablelink");
   setupTooltips("authorlink");
   setupTooltips("figlink")
}
window.onload = ttipSetup;

The callbacks, and ugly compatibility
hacking

The callbacks themselves are quite simple, but setting up
cross-browser compatibility is a chore (and it doesn’t even work on
IEMac :/). However here it is. First we need to set up the correct
function to get the mouse coordinates.

       (defvar get-coordinates)

       (defun ie-coordinates (event)
     (return (array (+ event.client-x document.body.scroll-left)
            (+ event.client-y document.body.scroll-top))))
       (defun not-ie-coordinates (event)
     (return (array event.page-x event.page-y)))
       (defun opera-coordinates (event)
     (if event.page-x
         (return (array event.page-x  event.page-y))
         (return (array event.client-x event.client-y))))
       (if window.opera
       (setf get-coordinates opera-coordinates)
       (if (> (navigator.user-agent.index-of "MSIE") -1)
           (setf get-coordinates ie-coordinates)
           (setf get-coordinates not-ie-coordinates)))

The next step is to get the correct size of the DIVs, of the
window, the scroll position, etc… to get a nice clipping around
the tooltips:

       (defun get-window-size ()
     (if (!= window.inner-height nil)
         (return (array inner-width inner-height))
         (with-slots (client-height client-width) document.body
           (return (array client-width client-height)))))
       (defun get-window-scroll ()
     (if (!= window.inner-height nil)
         (return (array page-x-offset page-y-offset))
         (with-slots (scroll-top scroll-left) document.body
           (return (array scroll-left scroll-top)))))
       (defun get-elem-size (elem)
     (if elem.offset-width
         (return (array elem.offset-width elem.offset-height))
         (return (array elem.style.pixel-width elem.style.pixel-height))))

The callbacks themselves are quite straightforward though :)

       (defun show-note (ttip event)
     (when event
       ;; was fuer ein geiler clipping code
       (let ((w-x (aref (get-window-scroll) 0))
         (w-y (aref (get-window-scroll) 1))
         (w-width (aref (get-window-size) 0))
         (w-height (aref (get-window-size) 1))
         (width (aref (get-elem-size ttip) 0))
         (height (aref (get-elem-size ttip) 1))
         (e-x (aref (get-coordinates event) 0))
         (e-y (aref (get-coordinates event) 1))
         (right (*math.min (- (+ w-x w-width) 10) (+ width e-x)))
         (bottom (*math.min (- (+ w-y w-height) 20) (+ height e-y)))
         (left (*math.max w-x (- right width)))
         (top (*math.max w-y (- bottom height))))
         #+nil
         (alert (+ "w-x " w-x " w-y " w-y " w-width " w-width " w-height " w-height
               " width " width " height " height
               " e-x " e-x " e-y " e-y
               " right " right " bottom " bottom " left " left " top " top))
         (setf ttip.style.top  (+ 10 top)
           ttip.style.left (+ 10 left)
           ;;ttip.style.display :block
           ttip.style.visibility :visible)
         #+nil
         (alert (+ "top " ttip.style.top " left " ttip.style.left
               " padding " ttip.style.padding " margin " ttip.style.margin
               " width " (aref (get-elem-size ttip) 0)
               " height " (aref (get-elem-size ttip) 1)))
         )))
       (defun hide-note (ttip)
     (setf ttip.style.visibility :hidden))

(Note that we can use the LISP reader to comment out parts of
our javascript code here. This is useful when debugging, for
example).

Adding a bit of CSS voodoo

Finally, here is the CSS code that needs to be added to have
nice tooltips. I’m no webdesigner, and I know that what I’ve done
is bad and naughty, but the CSS is riddled with absolute sizes and
other stuff. It looks good though :)

  ;; tooltips
       (".artpreview"
    :position :absolute
       :z-index     1
       :width       "50%"
       :background  "#F5F6F6"
       :padding     "10px"
       :border      "1px solid #DFE0E0"
       :-moz-border-radius "8px"
       :visibility  :hidden)
       (".artpreview .blockquote"
       :background "#fff")

This generates the following CSS:

.artpreview {
   position:absolute;
   z-index:1;
   width:50%;
   background:#F5F6F6;
   padding:10px;
   border:1px solid #DFE0E0;
   -moz-border-radius:8px;
   visibility:hidden;
}
.artpreview .blockquote {
   background:#fff;
}

You can see an example of all this here.

posted by manuel at 10:42 am  

5 Comments »

  1. How is it that you are compiling Lisp into javascript?

    Comment by Steve — March 10, 2005 @ 7:11 am

  2. I grew bored of having to edit separate files with a separate syntax in order to include javascripts in my webpages. It is not really a “compiler” though, more a converter from Lisp syntax to Javascript syntax. It supports macros and compiler-macros though, so you can do pretty funny stuff with it. You can check it out at svn://bknr.net/trunk/bknr/src/js

    However, beware that this sourcecode has turned into a real mess, and that it will be rewritten from scratch sometimes soon. I’ll write a neat manual for it then :)

    Comment by manuel — March 10, 2005 @ 10:57 am

  3. Cool, I’ll keep a look out for it.

    Comment by Steve — March 10, 2005 @ 5:41 pm

  4. Hmmm, this is nice and sexy!

    But I’m having a bit of trouble with one thing; Making it work in IE.

    Even the example page you have set up doesn’t seem to allow the div tag to become visible. I have been trying to isolate the problem, so I’ll keep you posted on my progress, but if you have any ideas that would be great.

    I’m using IE 6.0.2900.2180 if this makes any difference, with full javascripting enabled.

    Comment by Radderz — March 15, 2005 @ 12:34 pm

  5. Yes, I noticed the IE badness. I’ll see what I can do, if you find a solution, I’d be really grateful if you could post it here :)

    Comment by manuel — March 15, 2005 @ 4:10 pm

RSS feed for comments on this post.

Leave a comment

Powered by WordPress