views:

246

answers:

3

I have a long text and I'd like to offer the user a reading help: The current line should be highlighted. To make it easier, I'll just use the Y coordinate of the mouse (this way, the mouse pointer isn't going to get in the way). I have a big DIV with the id content which fills the whole width and a small DIV with the class content for the text (see here for an example).

I'm using jQuery 1.4. How can I highlight the line of text that is closest to the current mouse position?

+3  A: 

I don't see how you could feasibly do this without explicitly-wrapped text (i.e., newlines or <br> elements).

To the best of my knowledge, there's no way for the DOM to discover where a specific piece of text has wrapped, character-wise nor pixel-wise - including what I know of the Range API - not to mention the dynamic nature text can assume, such as with the text-zooming feature of browsers.

But if you could somehow manage to generate/inject explicit line-endings, then I think I have a solution for you.

EDIT

Thanks to the awesome information in Pekka's answer, I've cobbled together a functional prototype, but it has a significant caveat - works with plain-text content only. Any HTML present the body of the element will be stripped.

<script type="text/javascript" src="http://www.google.com/jsapi"&gt;&lt;/script&gt;
<script type="text/javascript"> google.load("jquery", "1.4.1"); </script>
<script type="text/javascript">

  jQuery.fn.wrapLines = function( openTag, closeTag )
  {
    var dummy = this.clone().css({
            top: -9999,
            left: -9999,
            position: 'absolute',
            width: this.width()
        }).appendTo(this.parent())
      , text = dummy.text().match(/\S+\s+/g);

    var words = text.length
      , lastTopOffset = 0
      , lines = []
      , lineText = ''
    ;

    for ( var i = 0; i < words; ++i )
    {
      dummy.html(
          text.slice(0,i).join('') +
          text[i].replace(/(\S)/, '$1<span/>') +
          text.slice(i+1).join('')
      );

      var topOffset = jQuery( 'span', dummy ).offset().top;

      if ( topOffset !== lastTopOffset && i != 0 )
      {
        lines.push( lineText );
        lineText = text[i];
      } else {
        lineText += text[i];
      }

      lastTopOffset = topOffset;
    }
    lines.push( lineText );

    this.html( openTag + lines.join( closeTag + openTag ) + closeTag );
  };

  $(function()
  {
    $('p').wrapLines( '<span class="line">', '</span>' );
  });

</script>

<style type="text/css">
span.line:hover {
  background-color: lightblue;
}
</style>

<p style="width: 400px;">
 one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three
</p>
Peter Bailey
+1 brilliant, works like a charm! Now there are *two* functional examples.
Pekka
stripping out HTML tags seems like a big limitation, though. Still, +1 for niftiness.
D_N
+3  A: 

The best approach that comes to mind is splitting each line into a <span> or <div> element that has a :hover CSS class with the "highlight" setting set:

span.line:hover { background-color: lightblue; }

That would be the least expensive solution, as the browser is going to take care of all the highlighting itself. If you want fancy effects, you can still achieve that by adding mouseover and mouseout events to every line.

The tough part, of course, is splitting the content into lines at the browser's line break. You need to do that dynamically so the lines actually reflect the positions at which the browser breaks the text.

Maybe the accepted answer to this question is a step into the right direction:

Getting a specific line using jQuery

How it works:

It goes through the entire element (actually, a clone of the element) inserting a element within each word. The span's top-offset is cached - when this offset changes we can assume we're on a new line.

Pekka
Nice find, and a good creative approach in the other question to discovery a line in arbitrary text.
Peter Bailey
I agree, it's a creative approach, but wouldn't it be rather slow if there were, say, 1000 words? You would expect the DOM insertion to be very costly in this sort of situation.
Andy E
@Andy The big plus of this approach in my eyes is that it's JavaScript heavy on page load, but not while the actual highlighting is done, while a solution doing real-time highlighting would be doing calculations the whole time. Also I think the DOM insertion would be justifiable even on a slower machine - but that will depend on the method used, of course. One would have to actually put this together and do some profiling to be sure.
Pekka
@Pekka: See my answer for an example using `getClientRects()` - it's much cleaner and I don't think the calculations are costly because the collection returned contains objects with static properties only.
Andy E
+10  A: 

Not sure if jQuery will help you out much here, but you could take a look at the element.getClientRects method, documented on MSDN and MDC. More specifically, this example at MSDN is sort of similar to what you want to achieve, highlighting lines using a cleverly z-indexed div element that goes behind the text at the co-ordinates returned by getClientRects().

You should be able to achieve the same thing by looping through the TextRectangle objects returned in the document's onmousemove and checking to see if the y value of the mouse cursor is > the top and < the bottom of each rectangle and moving the cleverly z-indexed div to the same position/height.

All the current major browsers support getClientRects().


http://jsbin.com/avuku/15

UPDATED - working in Chrome, IE6/7/8, Firefox, Opera, Safari. The initial problems I had in the other browsers were related to the DIV needing to be display: inline.
UPDATED AGAIN - I had to refer to this answer for some newer questions, so I took the time to update it to recalc the lines on window resize. It looks like others have been playing around too, it's now on revision 15.

Andy E
+1 great stuff!
Pekka
Oh and works cross browser now? Then this wins hands down I'd say.
Pekka
I think you just need to add a `onresize` handler for the window to repopulate the `lines` variable and this will be pretty solid.
Peter Bailey
@Peter Bailey: Yeah, because the objects in the collection are static. I read that in the documentation, just didn't have time to implement it.
Andy E
+1 works with arbitrary markup and there is yet room for optimization (for example, I could ignore changes of the X position and get the last Y position from `highlighter.style.top` and only do most of the work only when the mouse leaves the DIV. Maybe even using a hover listener.
Aaron Digulla
Also, I could use a binary search to look up the correct line.
Aaron Digulla
@Aaron: indeed. The code I provided was intended to be a proof of concept and whilst the theory did interest me enough to build a sample, I only wish I had more time to play around with it. Maybe I should SO less and code more ;-)
Andy E
This is why Stack Overflow is great.
Sam