/*!
* jQuery Expander Plugin - v1.6.2 - 2016-01-26
* http://plugins.learningjquery.com/expander/
* Copyright (c) 2015 Karl Swedberg
* Licensed MIT (http://www.opensource.org/licenses/mit-license.php)
*/
(function(factory) {
if (typeof define === 'function' && define.amd) {
define(['jquery'], factory);
} else if (typeof module === 'object' && typeof module.exports === 'object') {
module.exports = factory;
} else {
factory(jQuery);
}
})(function($) {
$.expander = {
version: '1.6.2',
defaults: {
// the number of characters at which the contents will be sliced into two parts.
slicePoint: 100,
// a string of characters at which to slice the contents into two parts,
// but only if the string appears before slicePoint
// Useful for slicing at the first line break, e.g. {sliceOn: '
around "read-more" link and "read-less" link
moreClass: 'read-more',
lessClass: 'read-less',
// class names for around "read-more" link and "read-less" link
moreLinkClass: 'more-link',
lessLinkClass: 'less-link',
// number of milliseconds after text has been expanded at which to collapse the text again.
// when 0, no auto-collapsing
collapseTimer: 0,
// effects for expanding and collapsing
expandEffect: 'slideDown',
expandSpeed: 250,
collapseEffect: 'slideUp',
collapseSpeed: 200,
// allow the user to re-collapse the expanded text.
userCollapse: true,
// text to use for the link to re-collapse the text
userCollapseText: 'read less',
userCollapsePrefix: ' ',
// all callback functions have the this keyword mapped to the element in the jQuery set when .expander() is called
onSlice: null, // function() {}
beforeExpand: null, // function() {},
afterExpand: null, // function() {},
onCollapse: null, // function(byUser) {}
afterCollapse: null // function() {}
}
};
$.fn.expander = function(options) {
var meth = 'init';
if (typeof options === 'string') {
meth = options;
options = {};
}
var opts = $.extend({}, $.expander.defaults, options);
var rSelfClose = /^<(?:area|br|col|embed|hr|img|input|link|meta|param).*>$/i;
var rAmpWordEnd = opts.wordEnd;
var rOpenCloseTag = /<\/?(\w+)[^>]*>/g;
var rOpenTag = /<(\w+)[^>]*>/g;
var rCloseTag = /<\/(\w+)>/g;
var rLastCloseTag = /(<\/([^>]+)>)\s*$/;
var rTagPlus = /^(<[^>]+>)+.?/;
var rMultiSpace = /\s\s+/g;
var delayedCollapse;
var removeSpaces = function(str) {
return opts.normalizeWhitespace ? $.trim(str || '').replace(rMultiSpace, ' ') : str;
};
var methods = {
init: function() {
this.each(function() {
var i, l, tmp, newChar, summTagless, summOpens, summCloses,
lastCloseTag, detailText, detailTagless, html, expand;
var $thisDetails, $readMore;
var slicePointChanged;
var openTagsForDetails = [];
var closeTagsForsummaryText = [];
var strayChars = '';
var defined = {};
var thisEl = this;
var $this = $(this);
var $summEl = $([]);
var o = $.extend({}, opts, $this.data('expander') || $.meta && $this.data() || {});
var hasDetails = !!$this.find('.' + o.detailClass).length;
var hasBlocks = !!$this.find('*').filter(function() {
var display = $(this).css('display');
return (/^block|table|list/).test(display);
}).length;
var el = hasBlocks ? 'div' : 'span';
var detailSelector = el + '.' + o.detailClass;
var moreClass = o.moreClass + '';
var lessClass = o.lessClass + '';
var expandSpeed = o.expandSpeed || 0;
var allHtml = removeSpaces($this.html());
var summaryText = allHtml.slice(0, o.slicePoint);
// allow multiple classes for more/less links
o.moreSelector = 'span.' + moreClass.split(' ').join('.');
o.lessSelector = 'span.' + lessClass.split(' ').join('.');
// bail out if we've already set up the expander on this element
if ($.data(this, 'expanderInit')) {
return;
}
$.data(this, 'expanderInit', true);
$.data(this, 'expander', o);
// determine which callback functions are defined
$.each(['onSlice','beforeExpand', 'afterExpand', 'onCollapse', 'afterCollapse'], function(index, val) {
defined[val] = $.isFunction(o[val]);
});
// back up if we're in the middle of a tag or word
summaryText = backup(summaryText);
// summary text sans tags length
summTagless = summaryText.replace(rOpenCloseTag, '').length;
// add more characters to the summary, one for each character in the tags
while (summTagless < o.slicePoint) {
newChar = allHtml.charAt(summaryText.length);
if (newChar === '<') {
newChar = allHtml.slice(summaryText.length).match(rTagPlus)[0];
}
summaryText += newChar;
summTagless++;
}
// SliceOn script, Closes #16, resolves #59
// Original SliceEarlierAt code (since modfied): Sascha Peilicke @saschpe
if (o.sliceOn) {
slicePointChanged = changeSlicePoint({
sliceOn: o.sliceOn,
slicePoint: o.slicePoint,
allHtml: allHtml,
summaryText: summaryText
});
summaryText = slicePointChanged.summaryText;
}
summaryText = backup(summaryText, o.preserveWords && allHtml.slice(summaryText.length).length);
// separate open tags from close tags and clean up the lists
summOpens = summaryText.match(rOpenTag) || [];
summCloses = summaryText.match(rCloseTag) || [];
// filter out self-closing tags
tmp = [];
$.each(summOpens, function(index, val) {
if (!rSelfClose.test(val)) {
tmp.push(val);
}
});
summOpens = tmp;
// strip close tags to just the tag name
l = summCloses.length;
for (i = 0; i < l; i++) {
summCloses[i] = summCloses[i].replace(rCloseTag, '$1');
}
// tags that start in summary and end in detail need:
// a). close tag at end of summary
// b). open tag at beginning of detail
$.each(summOpens, function(index, val) {
var thisTagName = val.replace(rOpenTag, '$1');
var closePosition = $.inArray(thisTagName, summCloses);
if (closePosition === -1) {
openTagsForDetails.push(val);
closeTagsForsummaryText.push('' + thisTagName + '>');
} else {
summCloses.splice(closePosition, 1);
}
});
// reverse the order of the close tags for the summary so they line up right
closeTagsForsummaryText.reverse();
// create necessary summary and detail elements if they don't already exist
if (!hasDetails) {
// end script if there is no detail text or if detail has fewer words than widow option
detailText = allHtml.slice(summaryText.length);
detailTagless = $.trim(detailText.replace(rOpenCloseTag, ''));
if (detailTagless === '' || detailTagless.split(/\s+/).length < o.widow) {
return;
}
// otherwise, continue...
lastCloseTag = closeTagsForsummaryText.pop() || '';
summaryText += closeTagsForsummaryText.join('');
detailText = openTagsForDetails.join('') + detailText;
} else {
// assume that even if there are details, we still need readMore/readLess/summary elements
// (we already bailed out earlier when readMore el was found)
// but we need to create els differently
// remove the detail from the rest of the content
detailText = $this.find(detailSelector).remove().html();
// The summary is what's left
summaryText = $this.html();
// allHtml is the summary and detail combined (this is needed when content has block-level elements)
allHtml = summaryText + detailText;
lastCloseTag = '';
}
o.moreLabel = $this.find(o.moreSelector).length ? '' : buildMoreLabel(o, detailText);
if (hasBlocks) {
detailText = allHtml;
// Fixes issue #89; Tested by 'split html escapes'
} else if (summaryText.charAt(summaryText.length - 1) === '&') {
strayChars = /^[#\w\d\\]+;/.exec(detailText);
if (strayChars) {
detailText = detailText.slice(strayChars[0].length);
summaryText += strayChars[0];
}
}
summaryText += lastCloseTag;
// onSlice callback
o.summary = summaryText;
o.details = detailText;
o.lastCloseTag = lastCloseTag;
if (defined.onSlice) {
// user can choose to return a modified options object
// one last chance for user to change the options. sneaky, huh?
// but could be tricky so use at your own risk.
tmp = o.onSlice.call(thisEl, o);
// so, if the returned value from the onSlice function is an object with a details property, we'll use that!
o = tmp && tmp.details ? tmp : o;
}
// build the html with summary and detail and use it to replace old contents
html = buildHTML(o, hasBlocks);
$this.empty().append(html);
// set up details and summary for expanding/collapsing
$thisDetails = $this.find(detailSelector);
$readMore = $this.find(o.moreSelector);
// Hide details span using collapseEffect unless
// expandEffect is NOT slideDown and collapseEffect IS slideUp.
// The slideUp effect sets span's "default" display to
// inline-block. This is necessary for slideDown, but
// problematic for other "showing" animations.
// Fixes #46
if (o.collapseEffect === 'slideUp' && o.expandEffect !== 'slideDown' || $this.is(':hidden')) {
$thisDetails.css({display: 'none'});
} else {
$thisDetails[o.collapseEffect](0);
}
$summEl = $this.find('div.' + o.summaryClass);
expand = function(event) {
event.preventDefault();
$readMore.hide();
$summEl.hide();
if (defined.beforeExpand) {
o.beforeExpand.call(thisEl);
}
$thisDetails.stop(false, true)[o.expandEffect](expandSpeed, function() {
$thisDetails.css({zoom: ''});
if (defined.afterExpand) {
o.afterExpand.call(thisEl);
}
delayCollapse(o, $thisDetails, thisEl);
});
};
$readMore.find('a').unbind('click.expander').bind('click.expander', expand);
if (o.userCollapse && !$this.find(o.lessSelector).length) {
$this
.find(detailSelector)
.append('' + o.userCollapsePrefix + '' + o.userCollapseText + '');
}
$this
.find(o.lessSelector + ' a')
.unbind('click.expander')
.bind('click.expander', function(event) {
event.preventDefault();
clearTimeout(delayedCollapse);
var $detailsCollapsed = $(this).closest(detailSelector);
reCollapse(o, $detailsCollapsed);
if (defined.onCollapse) {
o.onCollapse.call(thisEl, true);
}
});
}); // this.each
},
destroy: function() {
this.each(function() {
var o, details;
var $this = $(this);
if (!$this.data('expanderInit')) {
return;
}
o = $.extend({}, $this.data('expander') || {}, opts);
details = $this.find('.' + o.detailClass).contents();
$this.removeData('expanderInit');
$this.removeData('expander');
$this.find(o.moreSelector).remove();
$this.find('.' + o.summaryClass).remove();
$this.find('.' + o.detailClass).after(details).remove();
$this.find(o.lessSelector).remove();
});
}
};
// run the methods (almost always "init")
if (methods[meth]) {
methods[ meth ].call(this);
}
// utility functions
function buildHTML(o, blocks) {
var el = 'span';
var summary = o.summary;
var closingTagParts = rLastCloseTag.exec(summary);
var closingTag = closingTagParts ? closingTagParts[2].toLowerCase() : '';
if (blocks) {
el = 'div';
// if summary ends with a close tag, tuck the moreLabel inside it
if (closingTagParts && closingTag !== 'a' && !o.expandAfterSummary) {
summary = summary.replace(rLastCloseTag, o.moreLabel + '$1');
} else {
// otherwise (e.g. if ends with self-closing tag) just add moreLabel after summary
// fixes #19
summary += o.moreLabel;
}
// and wrap it in a div
summary = '' + summary + '
';
} else {
summary += o.moreLabel;
}
return [
summary,
// after summary, add an optional prefix. Default single space prevents last word of summary
// and first word of detail from collapsing together into what looks like a single word.
// (could also be done with CSS, but this feels more natural)
// Prefix made optional to fix issue #106
o.detailPrefix || '',
'<',
el + ' class="' + o.detailClass + '"',
'>',
o.details,
'' + el + '>'
].join('');
}
function buildMoreLabel(o, detailText) {
var ret = '' + o.expandPrefix;
if (o.showWordCount) {
o.wordCountText = o.wordCountText.replace(/\{\{count\}\}/, detailText.replace(rOpenCloseTag, '').replace(/\&(?:amp|nbsp);/g, '').replace(/(?:^\s+|\s+$)/, '').match(/\w+/g).length);
} else {
o.wordCountText = '';
}
ret += '' + o.expandText + o.wordCountText + '';
return ret;
}
function backup(txt, preserveWords) {
if (txt.lastIndexOf('<') > txt.lastIndexOf('>')) {
txt = txt.slice(0, txt.lastIndexOf('<'));
}
if (preserveWords) {
txt = txt.replace(rAmpWordEnd, '');
}
return $.trim(txt);
}
function reCollapse(o, el) {
el.stop(true, true)[o.collapseEffect](o.collapseSpeed, function() {
var prevMore = el.prev('span.' + o.moreClass).show();
if (!prevMore.length) {
el.parent().children('div.' + o.summaryClass).show()
.find('span.' + o.moreClass).show();
}
if (o.afterCollapse) {
o.afterCollapse.call(el);
}
});
}
function delayCollapse(option, $collapseEl, thisEl) {
if (option.collapseTimer) {
delayedCollapse = setTimeout(function() {
reCollapse(option, $collapseEl);
if ($.isFunction(option.onCollapse)) {
option.onCollapse.call(thisEl, false);
}
}, option.collapseTimer);
}
}
function changeSlicePoint(info) {
// Create placeholder string text
var sliceOnTemp = 'ExpandMoreHere374216623';
// Replace sliceOn with placeholder unaffected by .text() cleaning
// (in case sliceOn contains html)
var summaryTextClean = info.summaryText.replace(info.sliceOn, sliceOnTemp);
summaryTextClean = $('' + summaryTextClean + '
').text();
// Find true location of sliceOn placeholder
var sliceOnIndexClean = summaryTextClean.indexOf(sliceOnTemp);
// Store location of html version too
var sliceOnIndexHtml = info.summaryText.indexOf(info.sliceOn);
// Base condition off of true sliceOn location...
if (sliceOnIndexClean !== -1 && sliceOnIndexClean < info.slicePoint) {
// ...but keep html in summaryText
info.summaryText = info.allHtml.slice(0, sliceOnIndexHtml);
}
return info;
}
return this;
};
// plugin defaults
$.fn.expander.defaults = $.expander.defaults;
});