truncate vs dotdotdot (v3)

Revision 3 of this benchmark created on


Preparation HTML

<script src="https://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<div id="test1" style="width: 200px; line-height: 20px;">
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</div>
<div id="test2" style="width: 200px; line-height: 20px;">
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</div>
<div id="test3" style="width: 200px; line-height: 20px;">
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</div>

Setup

(function ($, undefined) {
    
      var BLOCK_TAGS = ['table', 'thead', 'tbody', 'tfoot', 'tr', 'col', 'colgroup', 'object', 'embed', 'param', 'ol', 'ul', 'dl', 'blockquote', 'select', 'optgroup', 'option', 'textarea', 'script', 'style'];
    
      function setText(element, text) {
        if (element.innerText) {
          element.innerText = text;
        } else if (element.nodeValue) {
          element.nodeValue = text;
        } else if (element.textContent) {
          element.textContent = text;
        } else {
          return false;
        }
      }
    
      function truncateTextContent($element, $rootNode, $after, options) {
        var element = $element[0];
        var original = $element.text();
    
        var maxChunk = '';
        var mid, chunk;
        var low = 0;
        var high = original.length;
    
        // Binary Search
        while (low <= high) {
          mid = low + ((high - low) >> 1); // Integer division
    
          chunk = $.trim(original.substr(0, mid + 1)) + options.ellipsis;
          setText(element, chunk);
    
          if ($rootNode.height() > options.maxHeight) {
            high = mid - 1;
          } else {
            low = mid + 1;
            maxChunk = maxChunk.length > chunk.length ? maxChunk : chunk;
          }
        }
    
        if (maxChunk.length > 0) {
          setText(element, maxChunk);
          return true;
        } else {
    
          // Backtrack
          var $parent = $element.parent();
          $element.remove();
    
          var afterLength = $after ? $after.length : 0;
    
          if ($parent.contents().length > afterLength) {
    
            var $n = $parent.contents().eq(-1 - afterLength);
            return truncateTextContent($n, $rootNode, $after, options);
    
          } else {
    
            var $prev = $parent.prev();
            var $e = $prev.contents().eq(-1);
            var e = $e[0];
    
            if (e) {
              setText(e, $e.text() + options.ellipsis);
              $parent.remove();
    
              if ($after.length) {
                $prev.append($after);
              }
              return true;
            }
          }
          return false;
        }
      }
    
      function truncateNestedNode($element, $rootNode, $after, options) {
        var element = $element[0];
    
        var $children = $element.contents();
        var $child, child;
    
        var index = 0;
        var length = $children.length;
        var truncated = false;
    
        $element.empty();
    
        for (; index < length && !truncated; index++) {
    
          $child = $children.eq(index);
          child = $child[0];
    
          if (child.nodeType === child.COMMENT_NODE) {
            continue;
          }
    
          element.appendChild(child);
    
          if ($after.length) {
            if ($.inArray(element.tagName.toLowerCase(), BLOCK_TAGS) >= 0) {
              $element.after($after);
            } else {
              $element.append($after);
            }
          }
    
          if ($rootNode.height() > options.maxHeight) {
            if (child.nodeType === child.TEXT_NODE) {
              truncated = truncateTextContent($child, $rootNode, $after, options);
            } else {
              truncated = truncateNestedNode($child, $rootNode, $after, options);
            }
          }
    
          if (!truncated && $after.length) { $after.remove(); }
    
        }
    
        return truncated;
      }
    
      function Truncate(element, options) {
        this.element = element;
        this.$element = $(element);
    
        this._name = 'truncate';
        this._defaults = {
          lines: 1,
          ellipsis: '… ',
          showMore: '<a href="#">More</a>',
          showLess: '<a href="#">Less</a>'
        };
    
        this.options = $.extend({}, this._defaults, options);
        this.options.maxHeight = parseInt(this.options.lines, 10) * parseInt(this.options.lineHeight, 10);
    
        this.$after = $(this.options.showMore, this.$element);
    
        this.original = element.innerHTML;
        this.cached = null;
    
        this.update();
      }
    
      Truncate.prototype = {
    
        update: function (html) {
          // Update HTML if provided, otherwise default to current inner HTML.
          if (html) {
            this.original = this.element.innerHTML = html;
          }
    
          // Check if already meets height requirement
          if (this.$element.height() <= this.options.maxHeight) {
            return;
          }
    
          truncateNestedNode(this.$element, this.$element, this.$after, this.options);
          this.cachedHTML = this.element.innerHTML;
        },
    
        expand: function () {
          this.element.innerHTML = this.originalHTML + this.options.showLess;
        },
    
        collapse: function () {
          this.element.innerHTML = this.cachedHTML;
        }
    
      };
    
      // Lightweight plugin wrapper preventing multiple instantiations
      $.fn.truncate = function (options) {
        return this.each(function () {
          if (!$.data(this, 'jquery-truncate')) {
            $.data(this, 'jquery-truncate', new Truncate(this, options));
          }
        });
      };
    
    })(jQuery);
    
    
    /*
     *  jQuery dotdotdot 1.6.15
     *
     *  Copyright (c) Fred Heusschen
     *  www.frebsite.nl
     *
     *  Plugin website:
     *  dotdotdot.frebsite.nl
     *
     *  Dual licensed under the MIT and GPL licenses.
     *  http://en.wikipedia.org/wiki/MIT_License
     *  http://en.wikipedia.org/wiki/GNU_General_Public_License
     */
    
    (function( $, undef )
    {
        if ( $.fn.dotdotdot )
        {
                return;
        }
    
        $.fn.dotdotdot = function( o )
        {
                if ( this.length == 0 )
                {
                        $.fn.dotdotdot.debug( 'No element found for "' + this.selector + '".' );
                        return this;
                }
                if ( this.length > 1 )
                {
                        return this.each(
                                function()
                                {
                                        $(this).dotdotdot( o );
                                }
                        );
                }
    
    
                var $dot = this;
    
                if ( $dot.data( 'dotdotdot' ) )
                {
                        $dot.trigger( 'destroy.dot' );
                }
    
                $dot.data( 'dotdotdot-style', $dot.attr( 'style' ) || '' );
                $dot.css( 'word-wrap', 'break-word' );
                if ($dot.css( 'white-space' ) === 'nowrap')
                {
                        $dot.css( 'white-space', 'normal' );
                }
    
                $dot.bind_events = function()
                {
                        $dot.bind(
                                'update.dot',
                                function( e, c )
                                {
                                        e.preventDefault();
                                        e.stopPropagation();
    
                                        opts.maxHeight = ( typeof opts.height == 'number' )
                                                ? opts.height
                                                : getTrueInnerHeight( $dot );
    
                                        opts.maxHeight += opts.tolerance;
    
                                        if ( typeof c != 'undefined' )
                                        {
                                                if ( typeof c == 'string' || c instanceof HTMLElement )
                                                {
                                                        c = $('<div />').append( c ).contents();
                                                }
                                                if ( c instanceof $ )
                                                {
                                                        orgContent = c;
                                                }
                                        }
    
                                        $inr = $dot.wrapInner( '<div class="dotdotdot" />' ).children();
                                        $inr.contents()
                                                .detach()
                                                .end()
                                                .append( orgContent.clone( true ) )
                                                .find( 'br' )
                                                .replaceWith( '  <br />  ' )
                                                .end()
                                                .css({
                                                        'height'        : 'auto',
                                                        'width'         : 'auto',
                                                        'border'        : 'none',
                                                        'padding'       : 0,
                                                        'margin'        : 0
                                                });
    
                                        var after = false,
                                                trunc = false;
    
                                        if ( conf.afterElement )
                                        {
                                                after = conf.afterElement.clone( true );
                                            after.show();
                                                conf.afterElement.detach();
                                        }
    
                                        if ( test( $inr, opts ) )
                                        {
                                                if ( opts.wrap == 'children' )
                                                {
                                                        trunc = children( $inr, opts, after );
                                                }
                                                else
                                                {
                                                        trunc = ellipsis( $inr, $dot, $inr, opts, after );
                                                }
                                        }
                                        $inr.replaceWith( $inr.contents() );
                                        $inr = null;
    
                                        if ( $.isFunction( opts.callback ) )
                                        {
                                                opts.callback.call( $dot[ 0 ], trunc, orgContent );
                                        }
    
                                        conf.isTruncated = trunc;
                                        return trunc;
                                }
    
                        ).bind(
                                'isTruncated.dot',
                                function( e, fn )
                                {
                                        e.preventDefault();
                                        e.stopPropagation();
    
                                        if ( typeof fn == 'function' )
                                        {
                                                fn.call( $dot[ 0 ], conf.isTruncated );
                                        }
                                        return conf.isTruncated;
                                }
    
                        ).bind(
                                'originalContent.dot',
                                function( e, fn )
                                {
                                        e.preventDefault();
                                        e.stopPropagation();
    
                                        if ( typeof fn == 'function' )
                                        {
                                                fn.call( $dot[ 0 ], orgContent );
                                        }
                                        return orgContent;
                                }
    
                        ).bind(
                                'destroy.dot',
                                function( e )
                                {
                                        e.preventDefault();
                                        e.stopPropagation();
    
                                        $dot.unwatch()
                                                .unbind_events()
                                                .contents()
                                                .detach()
                                                .end()
                                                .append( orgContent )
                                                .attr( 'style', $dot.data( 'dotdotdot-style' ) || '' )
                                                .data( 'dotdotdot', false );
                                }
                        );
                        return $dot;
                };      //      /bind_events
    
                $dot.unbind_events = function()
                {
                        $dot.unbind('.dot');
                        return $dot;
                };      //      /unbind_events
    
                $dot.watch = function()
                {
                        $dot.unwatch();
                        if ( opts.watch == 'window' )
                        {
                                var $window = $(window),
                                        _wWidth = $window.width(),
                                        _wHeight = $window.height();
    
                                $window.bind(
                                        'resize.dot' + conf.dotId,
                                        function()
                                        {
                                                if ( _wWidth != $window.width() || _wHeight != $window.height() || !opts.windowResizeFix )
                                                {
                                                        _wWidth = $window.width();
                                                        _wHeight = $window.height();
    
                                                        if ( watchInt )
                                                        {
                                                                clearInterval( watchInt );
                                                        }
                                                        watchInt = setTimeout(
                                                                function()
                                                                {
                                                                        $dot.trigger( 'update.dot' );
                                                                }, 100
                                                        );
                                                }
                                        }
                                );
                        }
                        else
                        {
                                watchOrg = getSizes( $dot );
                                watchInt = setInterval(
                                        function()
                                        {
                                                if ( $dot.is( ':visible' ) )
                                                {
                                                        var watchNew = getSizes( $dot );
                                                        if ( watchOrg.width  != watchNew.width ||
                                                                 watchOrg.height != watchNew.height )
                                                        {
                                                                $dot.trigger( 'update.dot' );
                                                                watchOrg = watchNew;
                                                        }
                                                }
                                        }, 500
                                );
                        }
                        return $dot;
                };
                $dot.unwatch = function()
                {
                        $(window).unbind( 'resize.dot' + conf.dotId );
                        if ( watchInt )
                        {
                                clearInterval( watchInt );
                        }
                        return $dot;
                };
    
                var     orgContent      = $dot.contents(),
                        opts            = $.extend( true, {}, $.fn.dotdotdot.defaults, o ),
                        conf            = {},
                        watchOrg        = {},
                        watchInt        = null,
                        $inr            = null;
    
    
                if ( !( opts.lastCharacter.remove instanceof Array ) )
                {
                        opts.lastCharacter.remove = $.fn.dotdotdot.defaultArrays.lastCharacter.remove;
                }
                if ( !( opts.lastCharacter.noEllipsis instanceof Array ) )
                {
                        opts.lastCharacter.noEllipsis = $.fn.dotdotdot.defaultArrays.lastCharacter.noEllipsis;
                }
    
    
                conf.afterElement       = getElement( opts.after, $dot );
                conf.isTruncated        = false;
                conf.dotId                      = dotId++;
    
    
                $dot.data( 'dotdotdot', true )
                        .bind_events()
                        .trigger( 'update.dot' );
    
                if ( opts.watch )
                {
                        $dot.watch();
                }
    
                return $dot;
        };
    
    
        //      public
        $.fn.dotdotdot.defaults = {
                'ellipsis'                      : '... ',
                'wrap'                          : 'word',
                'fallbackToLetter'      : true,
                'lastCharacter'         : {},
                'tolerance'                     : 0,
                'callback'                      : null,
                'after'                         : null,
                'height'                        : null,
                'watch'                         : false,
                'windowResizeFix'       : true
        };
        $.fn.dotdotdot.defaultArrays = {
                'lastCharacter'         : {
                        'remove'                        : [ ' ', '\u3000', ',', ';', '.', '!', '?' ],
                        'noEllipsis'            : []
                }
        };
        $.fn.dotdotdot.debug = function( msg ) {};
    
    
        //      private
        var dotId = 1;
    
        function children( $elem, o, after )
        {
                var $elements   = $elem.children(),
                        isTruncated     = false;
    
                $elem.empty();
    
                for ( var a = 0, l = $elements.length; a < l; a++ )
                {
                        var $e = $elements.eq( a );
                        $elem.append( $e );
                        if ( after )
                        {
                                $elem.append( after );
                        }
                        if ( test( $elem, o ) )
                        {
                                $e.remove();
                                isTruncated = true;
                                break;
                        }
                        else
                        {
                                if ( after )
                                {
                                        after.detach();
                                }
                        }
                }
                return isTruncated;
        }
        function ellipsis( $elem, $d, $i, o, after )
        {
                var isTruncated = false;
    
                //      Don't put the ellipsis directly inside these elements
                var notx = 'table, thead, tbody, tfoot, tr, col, colgroup, object, embed, param, ol, ul, dl, blockquote, select, optgroup, option, textarea, script, style';
    
                //      Don't remove these elements even if they are after the ellipsis
                var noty = 'script, :hidden';
    
                $elem
                        .contents()
                        .detach()
                        .each(
                                function()
                                {
    
                                        var e   = this,
                                                $e      = $(e);
    
                                        if ( typeof e == 'undefined' || ( e.nodeType == 3 && $.trim( e.data ).length == 0 ) )
                                        {
                                                return true;
                                        }
                                        else if ( $e.is( noty ) )
                                        {
                                                $elem.append( $e );
                                        }
                                        else if ( isTruncated )
                                        {
                                                return true;
                                        }
                                        else
                                        {
                                                $elem.append( $e );
                                                if ( after )
                                                {
                                                        $elem[ $elem.is( notx ) ? 'after' : 'append' ]( after );
                                                }
                                                if ( test( $i, o ) )
                                                {
                                                        if ( e.nodeType == 3 ) // node is TEXT
                                                        {
                                                                isTruncated = ellipsisElement( $e, $d, $i, o, after );
                                                        }
                                                        else
                                                        {
                                                                isTruncated = ellipsis( $e, $d, $i, o, after );
                                                        }
    
                                                        if ( !isTruncated )
                                                        {
                                                                $e.detach();
                                                                isTruncated = true;
                                                        }
                                                }
    
                                                if ( !isTruncated )
                                                {
                                                        if ( after )
                                                        {
                                                                after.detach();
                                                        }
                                                }
                                        }
                                }
                        );
    
                return isTruncated;
        }
        function ellipsisElement( $e, $d, $i, o, after )
        {
                var e = $e[ 0 ];
    
                if ( !e )
                {
                        return false;
                }
    
                var txt                 = getTextContent( e ),
                        space           = ( txt.indexOf(' ') !== -1 ) ? ' ' : '\u3000',
                        separator       = ( o.wrap == 'letter' ) ? '' : space,
                        textArr         = txt.split( separator ),
                        position        = -1,
                        midPos          = -1,
                        startPos        = 0,
                        endPos          = textArr.length - 1;
    
    
                //      Only one word
                if ( o.fallbackToLetter && startPos == 0 && endPos == 0 )
                {
                        separator       = '';
                        textArr         = txt.split( separator );
                        endPos          = textArr.length - 1;
                }
    
                while ( startPos <= endPos && !( startPos == 0 && endPos == 0 ) )
                {
                        var m = Math.floor( ( startPos + endPos ) / 2 );
                        if ( m == midPos )
                        {
                                break;
                        }
                        midPos = m;
    
                        setTextContent( e, textArr.slice( 0, midPos + 1 ).join( separator ) + o.ellipsis );
    
                        if ( !test( $i, o ) )
                        {
                                position = midPos;
                                startPos = midPos;
                        }
                        else
                        {
                                endPos = midPos;
    
                                //      Fallback to letter
                                if (o.fallbackToLetter && startPos == 0 && endPos == 0 )
                                {
                                        separator       = '';
                                        textArr         = textArr[ 0 ].split( separator );
                                        position        = -1;
                                        midPos          = -1;
                                        startPos        = 0;
                                        endPos          = textArr.length - 1;
                                }
                        }
                }
    
                if ( position != -1 && !( textArr.length == 1 && textArr[ 0 ].length == 0 ) )
                {
                        txt = addEllipsis( textArr.slice( 0, position + 1 ).join( separator ), o );
                        setTextContent( e, txt );
                }
                else
                {
                        var $w = $e.parent();
                        $e.detach();
    
                        var afterLength = ( after && after.closest($w).length ) ? after.length : 0;
    
                        if ( $w.contents().length > afterLength )
                        {
                                e = findLastTextNode( $w.contents().eq( -1 - afterLength ), $d );
                        }
                        else
                        {
                                e = findLastTextNode( $w, $d, true );
                                if ( !afterLength )
                                {
                                        $w.detach();
                                }
                        }
                        if ( e )
                        {
                                txt = addEllipsis( getTextContent( e ), o );
                                setTextContent( e, txt );
                                if ( afterLength && after )
                                {
                                        $(e).parent().append( after );
                                }
                        }
                }
    
                return true;
        }
        function test( $i, o )
        {
                return $i.innerHeight() > o.maxHeight;
        }
        function addEllipsis( txt, o )
        {
                while( $.inArray( txt.slice( -1 ), o.lastCharacter.remove ) > -1 )
                {
                        txt = txt.slice( 0, -1 );
                }
                if ( $.inArray( txt.slice( -1 ), o.lastCharacter.noEllipsis ) < 0 )
                {
                        txt += o.ellipsis;
                }
                return txt;
        }
        function getSizes( $d )
        {
                return {
                        'width' : $d.innerWidth(),
                        'height': $d.innerHeight()
                };
        }
        function setTextContent( e, content )
        {
                if ( e.innerText )
                {
                        e.innerText = content;
                }
                else if ( e.nodeValue )
                {
                        e.nodeValue = content;
                }
                else if (e.textContent)
                {
                        e.textContent = content;
                }
    
        }
        function getTextContent( e )
        {
                if ( e.innerText )
                {
                        return e.innerText;
                }
                else if ( e.nodeValue )
                {
                        return e.nodeValue;
                }
                else if ( e.textContent )
                {
                        return e.textContent;
                }
                else
                {
                        return "";
                }
        }
        function getPrevNode( n )
        {
                do
                {
                        n = n.previousSibling;
                }
                while ( n && n.nodeType !== 1 && n.nodeType !== 3 );
    
                return n;
        }
        function findLastTextNode( $el, $top, excludeCurrent )
        {
                var e = $el && $el[ 0 ], p;
                if ( e )
                {
                        if ( !excludeCurrent )
                        {
                                if ( e.nodeType === 3 )
                                {
                                        return e;
                                }
                                if ( $.trim( $el.text() ) )
                                {
                                        return findLastTextNode( $el.contents().last(), $top );
                                }
                        }
                        p = getPrevNode( e );
                        while ( !p )
                        {
                                $el = $el.parent();
                                if ( $el.is( $top ) || !$el.length )
                                {
                                        return false;
                                }
                                p = getPrevNode( $el[0] );
                        }
                        if ( p )
                        {
                                return findLastTextNode( $(p), $top );
                        }
                }
                return false;
        }
        function getElement( e, $i )
        {
                if ( !e )
                {
                        return false;
                }
                if ( typeof e === 'string' )
                {
                        e = $(e, $i);
                        return ( e.length )
                                ? e
                                : false;
                }
                return !e.jquery
                        ? false
                        : e;
        }
        function getTrueInnerHeight( $el )
        {
                var h = $el.innerHeight(),
                        a = [ 'paddingTop', 'paddingBottom' ];
    
                for ( var z = 0, l = a.length; z < l; z++ )
                {
                        var m = parseInt( $el.css( a[ z ] ), 10 );
                        if ( isNaN( m ) )
                        {
                                m = 0;
                        }
                        h -= m;
                }
                return h;
        }
    
    
        //      override jQuery.html
        var _orgHtml = $.fn.html;
        $.fn.html = function( str )
        {
                if ( str != undef && !$.isFunction( str ) && this.data( 'dotdotdot' ) )
                {
                        return this.trigger( 'update', [ str ] );
                }
                return _orgHtml.apply( this, arguments );
        };
    
    
        //      override jQuery.text
        var _orgText = $.fn.text;
        $.fn.text = function( str )
        {
                if ( str != undef && !$.isFunction( str ) && this.data( 'dotdotdot' ) )
                {
                        str = $( '<div />' ).text( str ).html();
                        return this.trigger( 'update', [ str ] );
                }
                return _orgText.apply( this, arguments );
        };
    
    
    })( jQuery );
    
    (function (module, undefined) {
    
      /*
       *
       */
      function getStyle(element, property) {
        // IE8 and below does not support getComputedStyle. Use currentStyle instead.
        var styles = (window.getComputedStyle && window.getComputedStyle(element) || element.currentStyle);
        return parseFloat(styles[property]);
      }
    
      /*
       *
       */
      function height(element) {
        var total = getStyle(element, 'height');
        var boxModel = getStyle(element, 'boxSizing');
        if (boxModel === 'border-box') {
          return total - getStyle(element, 'paddingTop') - getStyle(element, 'paddingBottom');
        } else {
          // Assume content-box model
          return total;
        }
      }
    
      /* Trims leading and trailing whitespace. Regular expression taken from jQuery.
       * See https://github.com/jquery/jquery/blob/master/speed/jquery-basis.js
       *
       * str - The original string to trim.
       *
       * Returns trimmed string.
       */
      function trim(str) {
        return (str || '').replace(/^(\s|\u00A0)+|(\s|\u00A0)+$/g, '');
      }
    
      /*
       *
       */
      function getHTMLInRange(node, startIndex, endIndex) {
        var index, childNode;
        var childNodes = node.childNodes,
            length = childNodes.length,
            html = '';
    
        for (index = startIndex; index <= endIndex && index < length; index++) {
          childNode = childNodes[index];
          if (childNode.nodeType === childNode.COMMENT_NODE) {
            // Need the commend node HTML in order to reconstruct original DOM structure
            // This manual way is the only way to get a comment node's HTML
            html += '<!--' + childNode.nodeValue + '-->';
          } else {
            html += childNode.outerHTML || childNode.nodeValue;
          }
        }
        return html;
      }
    
      /* Truncates a text node using binary search.
       *
       * textNode - The text node to truncate.
       * rootNode - The root node (ancestor of the textNode) to measure the truncated height.
       * options  - An object containing:
       *            showMore  - The HTML string to append at the end of the string.
       *            maxHeight - The maximum height for the root node.
       *
       * Returns nothing.
       */
      function truncateTextNode(textNode, rootNode, options) {
        var originalHTML = textNode.nodeValue,
            mid,
            low = 0,
            high = originalHTML.length,
            chunk,
            maxChunk = '';
    
        // Binary Search
        while (low <= high) {
          mid = low + ((high - low) >> 1); // Integer division
    
          chunk = trim(originalHTML.substr(0, mid + 1)) + options.showMore;
          textNode.nodeValue = chunk;
    
          if (height(rootNode) > options.maxHeight) {
            high = mid - 1;
          } else {
            low = mid + 1;
            maxChunk = maxChunk.length > chunk.length ? maxChunk : chunk;
          }
        }
    
        textNode.nodeValue = maxChunk;
      }
    
      function truncateNestedNode(element, rootNode, options) {
    
        var childNodes = element.childNodes,
            length = childNodes.length;
    
        if (length === 0) {
    
          // Base case: single element remaining
    
          if (element.nodeType === element.TEXT_NODE) {
            // Truncate the text node
            truncateTextNode(element, rootNode, options);
          } else {
            // Remove the node itself
            element.parentNode.removeChild(element);
          }
    
          return;
    
        } else {
    
          // Recursive case: iterate backwards on children nodes until tipping node is found
    
          var index, node, chunk;
          var originalHTML = element.innerHTML;
    
          for (index = length - 1; index >= 0; index--) {
            node = childNodes[index];
    
            chunk = getHTMLInRange(element, 0, index);
            element.innerHTML = chunk;
    
            if (height(rootNode) <= options.maxHeight) {
    
              // Check if element is not the last child
              if (index + 1 <= length - 1) {
                // Reset HTML so original childNodes tree is available
                element.innerHTML = originalHTML;
    
                chunk += getHTMLInRange(element, index + 1, index + 1);
                element.innerHTML = chunk;
    
                index += 1;
              }
    
              return truncateNestedNode(childNodes[index], rootNode, options);
            }
          }
    
          return truncateNestedNode(childNodes[0], rootNode, options);
    
        }
      }
    
      /* Public: Creates an instance of Truncate.
       *
       * element - A DOM element to be truncated.
       * options - An Object literal containing setup options.
       *
       * Examples:
       *
       *   var element = document.createElement('span');
       *   element.innerHTML = 'This is<br>odd.';
       *   var truncated = new Truncate(element, {
       *     lines: 1,
       *     lineHeight: 16,
       *     showMore: '<a class="show-more">Show More</a>',
       *     showLess: '<a class="show-more">Show Less</a>'
       *   });
       *
       *   // Update HTML
       *   truncated.update('This is not very odd.');
       *
       *   // Undo truncation
       *   truncated.expand();
       *
       *   // Redo truncation
       *   truncated.collapse();
       */
      function Truncate(element, options) {
        this.options = options || {};
        options.showMore = typeof options.showMore !== 'undefined' ? options.showMore : '…';
        options.showLess = typeof options.showLess !== 'undefined' ? options.showLess : '';
    
        this.options.maxHeight = options.lines * options.lineHeight;
    
        this.element = element;
        this.originalHTML = element.innerHTML;
        this.cachedHTML = null;
    
        this.update();
      }
    
      /* Public: Updates the inner HTML of the element and re-truncates.
       *
       * newHTML - The new HTML.
       *
       * Returns nothing.
       */
      Truncate.prototype.update = function (newHTML) {
        // Update HTML if provided, otherwise default to current inner HTML.
        if (newHTML) {
          this.originalHTML = this.element.innerHTML = newHTML;
        }
    
        // Already meets height requirement
        if (height(this.element) <= this.options.maxHeight) {
          return;
        }
    
        var visibility = this.element.style.visibility;
        this.element.style.visibility = 'hidden';
    
        truncateNestedNode(this.element, this.element, this.options);
        this.cachedHTML = this.element.innerHTML;
    
        this.element.style.visibility = visibility;
      };
    
      /* Public: Expands the element to show content in full.
       *
       * Returns nothing.
       */
      Truncate.prototype.expand = function () {
        this.element.innerHTML = this.originalHTML + this.options.showLess;
      };
    
      /* Public: Collapses the element to the truncated state.
       * Uses the cached HTML from .update().
       *
       * Returns nothing.
       */
      Truncate.prototype.collapse = function () {
        this.element.innerHTML = this.cachedHTML;
      };
    
      module.Truncate = Truncate;
    
    })(window);

Test runner

Ready to run.

Testing in
TestOps/sec
dotdotdot
$('#test1').dotdotdot({
wrap: 'letter',
height: 200,
watch: 'window'
});
ready
truncate jquery
$('#test2').truncate({
lines: 10,
lineHeight: 20
});
ready
truncate old
new window.Truncate($('#test3')[0], {
lines: 10,
lineHeight: 20
});
ready

Revisions

You can edit these tests or add more tests to this page by appending /edit to the URL.