<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Bitovi Blog - UX and UI design, JavaScript and Frontend development
Loading

Weekly Widget 4 - Show More with $.Range

Weekly Widget 4 - Show More with $.Range

Justin Meyer

Justin Meyer

Twitter Reddit

This week's widget demonstrates the awesome power of jQuery++'s range helper. Text ranges are notoriously a pain in the butt, with major differences in API and implementation across browsers. Similar to jQuery with DOM elements, $.Range provides a simpler API and cross browser methods to create, move, and compare text ranges. If you need to create a custom text editor, text highlighter, or other functionality that understands text, $.Range can be a huge help. For example:

// Get a text range for #text
var range = $('#text').range();
// Move the start 5 characters to the right
range.start('+5');
// Move the end 5 characters to the left
range.end('-5');
// Return the range text
range.toString(); // is some
// Select the current range
range.select();

Before reading this article it will be very helpful to read the API overview of range.

The widget

The widget hides text past a certain number of lines. It replaces that text with a + button, allowing the user to see the hidden content:

What it does

The widget is called on an element whose text should be hidden like:

$('article').more({
    moreHTML: "<a href='javascript://' class='more'>...</a>",
    moreWidth: 30,
    lessHTML: " <a href='javascript://' class='less'>less</a>",
    lines: 4
})

Where:

  • moreHTML - the html of the button to expand the text
  • moreWidth - the width of the more button on the page
  • lessHTML - the html of the button to hide the text
  • lines - the number of lines to show

You might be thinking that this widget should be relatively simple and could be done without $.Range ... WRONG!

$.Range is almost certainly necessary because:

  • The content's lines are not uniform in height. The 5th line might end at 100px in one element, at 110px in another.
  • We remove just enough of the last line, if necessary, to make room for the more button.
  • Not every piece of content has lines number of lines.

How it works

First, I create a jQuery widget by adding a more method to $.fn and normalizing the options passed to more:

$.fn.more = function(options){
  options = $.extend({
      lessHTML: " <a href='javascript://' class='less'>-</a>",
      moreHTML: " <a href='javascript://' class='more'>+</a>",
      moreWidth: 50,
      lines: 2
  }, options || {});
})

This allows someone to call $("article").more() without options and have relatively sane defaults in place.

Next, I iterate through each item in the collection, save the original html for showing later, hide the right lines, and set up the toggling behavior:

this.each(function(el){
  var $el = $(this);
  $el.data('originalHTML', $el.html());

  // Hide lines
  // Set up toggling
})

I'll show how to hide the lines and set up toggling in the next two sections.

Hiding the right lines

The more widget needs to find the last character on line number lines where adding moreHTML will not create another line. It then needs to remove all content within the container (this or $el) after that point and insert moreHTML in its place. The algorithm is roughly:

  1. Go through each character, check it's vertical position. If the character is on a new line, keep going until you've reached lines+1 lines.
  2. Find the last character that can be visible and also have room for moreHTML.
  3. Remove all text after range.

Lets break each of those three steps down:

Go through each character; check it's vertical position. If the character is on a new line, keep going until you've reached lines+1 lines.

To accomplish this, I first create the range I'll be moving through each character, a range on the last character in the container and a range on the first non-whitespace character within the container:

var range = $el.range(),
    end = range.clone().collapse(false).start("-1"),
    start = nextChar( range.collapse().end("+1"), end ).clone(),

Outside $.fn.more I created nextChar and prevChar that move a range character by character until either a text character is hit or a boundary. When dealing with ranges, you need to make sure you don't move the range past your container!

Next, I maintain the number of lines we've seen and the current position of the line:

prevRect = start.rect(),
lines = 0;

Finally, move range through each character, checking its position against the previous line's position until lines is equal to options.lines or we've reached the end of the container.

while(range.compare("START_TO_START",end) != 0){
  range.end("+1").start("+1");

  var rect = range.rect();

  if( rect && (rect.top -prevRect.top  > 4) ) {
    lines++;

    if(lines == options.lines) break;

    prevStart = range.clone()
    prevRect = rect;
  }
}

Note: For this widget, a new line has to be at least 4 pixels lower than the previous character.

Find the last character that can be visible and also have room for moreHTML.

If we've seen lines number of lines, range represents the first character of that following line. So, I move range to the last character on the previous line and then to the last non-whitespace character:

if(lines === options.lines){
  range.end('-1').start('-1');
}
prevChar(range, start)

Next, I start moving the range right again until there's enough room between the range's character and the right side of the container:

var movedLeft = false,
    offset = $el.offset(),
    width = $el.width();

while(range.compare("START_TO_START",start) != -1 ){
  if( range.rect(true).left <= (offset.left+width-options.moreWidth) ) {
     break;
  }
  movedLeft = true;
  range.end("-1").start("-1")
}

The plugin exits and does nothing if a moreHTML button does not need to be added:

if(!movedLeft && (lines < options.lines ) ) {
  return
}

Past this point, range reprsents the last character that should be displayed.

Remove all text after range.

I start by removing all the text after range in the current text node.

var parent = range.start().container;
if( parent.nodeType === Node.TEXT_NODE ) {
  parent.nodeValue = 
    parent.nodeValue.slice(0,range.start().offset+1)
}

Next, I remove all the DOM nodes after the parent node I just found. I start by removing all siblings after the current node, then I walk up the DOM tree, removing all its parents siblings after the current node also, until I reach the container.

var removeAfter =  parent;

while(removeAfter !== this){
  var parentEl = removeAfter.parentNode,
      childNodes = parentEl.childNodes,
      index = $.inArray(removeAfter,childNodes );

  for(var i = parentEl.childNodes.length-1; i > index; i--){
    parentEl.removeChild( childNodes[i] );
  }
  removeAfter = parentEl;
}

Set up Toggling

To set up the toggling behavior, I add moreHTML immediately after the HTMLElement that range was within:

if( parent.nodeType === Node.TEXT_NODE ||
  parent.nodeType === Node.CDATA_SECTION_NODE ) {
  parent = parent.parentElement
}
$(parent).append(options.moreHTML);

I save the shortened HTML content so we don't have to recalculate it if someone clicks the showLess button:

$el.data('shortenedHTML',$el.html())

Finally, I listen to clicks on more or less and update the container's html accordingly:

.on("click","a.more",function(){
  $el.html($el.data('originalHTML')+options.lessHTML)
})
.on("click","a.less",function(){
  $el.html($el.data('shortenedHTML'))
});

Conclusion

A few random concluding thoughts:

  • $.Range is a great way to understand the text layout of a page.
  • $.Range would be a great low-level tool for creating a custom, cross-browser rich text editor.
  • Checking the position of each character could be time consuming. A binary search could make things faster, same with moving the range word by word instead of character by character.

Lets hear some suggestions for next week!