Skip to content

Instantly share code, notes, and snippets.

@moschel
Forked from justinbmeyer/more.md
Created February 8, 2013 23:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save moschel/4742932 to your computer and use it in GitHub Desktop.
Save moschel/4742932 to your computer and use it in GitHub Desktop.
title tags author lead layout
Weekly Widget 4 - Paginated Grid
open-source jquerypp
justinbmeyer
Learn to use jQuery++'s $.Range to create content aware show / hide buttons.
post

This week's widget demonstrates the awesome power of jQuery++'s range helper. $.Range provides cross browser methods to create, move, and compare text ranges. Text ranges are notoriously a pain in the butt. If you need to create a custom text editor, text highlighter, or other functionality that understands text, $.Range can be a huge help.

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:

<iframe style="width: 100%; height: 300px" src="http://jsfiddle.net/NHMv3/1/embedded/result,html,js" allowfullscreen="allowfullscreen" frameborder="0">JSFiddle</iframe>

What it does

The widget is called on element's who's 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 it's required that:

  • 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 setup the toggling behavior:

this.each(function(el){
  var $el = $(this);
  $el.data('originalHTML', $el.html());
  
  // Hide lines
  // Setup toggling
})

I'll show how to hide the lines and setup 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 code exits 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 walk up the DOM tree, removing all items after removeAfter 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;
}

Setup Toggling

To setup 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 shortend 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 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment