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 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>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.
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.
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:
- 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.
- Find the last character that can be visible and also have room for
moreHTML
. - 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.
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.
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;
}
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'))
});
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!