angular-ellipsis-copy
Version:
Angular directive to truncate multi-line text
269 lines (234 loc) • 8.54 kB
JavaScript
/**
* Angular directive to truncate multi-line text to visible height
*
* @param bind (angular bound value to append) REQUIRED
* @param ellipsisAppend (string) string to append at end of truncated text after ellipsis, can be HTML OPTIONAL
* @param ellipsisAppendClick (function) function to call if ellipsisAppend is clicked (ellipsisAppend must be clicked) OPTIONAL
* @param ellipsisSymbol (string) string to use as ellipsis, replaces default '...' OPTIONAL
* @param ellipsisSeparator (string) separator to split string, replaces default ' ' OPTIONAL
*
* @example <p data-ellipsis data-ng-bind="boundData"></p>
* @example <p data-ellipsis data-ng-bind="boundData" data-ellipsis-symbol="---"></p>
* @example <p data-ellipsis data-ng-bind="boundData" data-ellipsis-append="read more"></p>
* @example <p data-ellipsis data-ng-bind="boundData" data-ellipsis-append="read more" data-ellipsis-append-click="displayFull()"></p>
*
*/
angular.module('dibari.angular-ellipsis', [])
.directive('ellipsis', ['$timeout', '$window', '$sce', function($timeout, $window, $sce) {
var AsyncDigest = function(delay) {
var timeout = null;
var queue = [];
this.remove = function(fn) {
if (queue.indexOf(fn) !== -1) {
queue.splice(queue.indexOf(fn), 1);
if (queue.length === 0) {
$timeout.cancel(timeout);
timeout = null;
}
}
};
this.add = function(fn) {
if (queue.indexOf(fn) === -1) {
queue.push(fn);
}
if (!timeout) {
timeout = $timeout(function() {
var copy = queue.slice();
timeout = null;
// reset scheduled array first in case one of the functions throws an error
queue.length = 0;
copy.forEach(function(fn) {
fn();
});
}, delay);
}
};
};
var asyncDigestImmediate = new AsyncDigest(0);
var asyncDigestDebounced = new AsyncDigest(75);
return {
restrict: 'A',
scope: {
ngShow: '=',
ngBind: '=',
ngBindHtml: '=',
ellipsisAppend: '@',
ellipsisAppendClick: '&',
ellipsisSymbol: '@',
ellipsisSeparator: '@',
useParent: "@",
ellipsisSeparatorReg: '=',
ellipsisFallbackFontSize:'@'
},
compile: function(elem, attr, linker) {
return function(scope, element, attributes) {
/* Window Resize Variables */
attributes.lastWindowResizeTime = 0;
attributes.lastWindowResizeWidth = 0;
attributes.lastWindowResizeHeight = 0;
attributes.lastWindowTimeoutEvent = null;
/* State Variables */
attributes.isTruncated = false;
function _isDefined(value) {
return typeof(value) !== 'undefined';
}
function getParentHeight(element) {
var heightOfChildren = 0;
angular.forEach(element.parent().children(), function(child) {
if (child != element[0]) {
heightOfChildren += child.clientHeight;
}
});
return element.parent()[0].clientHeight - heightOfChildren;
}
function buildEllipsis() {
var binding = scope.ngBind || scope.ngBindHtml;
var isTrustedHTML = false;
if($sce.isEnabled() && angular.isObject(binding) && $sce.getTrustedHtml(binding)) {
isTrustedHTML = true;
binding = $sce.getTrustedHtml(binding);
}
if (binding) {
var isHtml = (!(!!scope.ngBind) && !!(scope.ngBindHtml));
var i = 0,
ellipsisSymbol = (typeof(attributes.ellipsisSymbol) !== 'undefined') ? attributes.ellipsisSymbol : '…',
ellipsisSeparator = (typeof(scope.ellipsisSeparator) !== 'undefined') ? attributes.ellipsisSeparator : ' ',
ellipsisSeparatorReg = (typeof(scope.ellipsisSeparatorReg) !== 'undefined') ? scope.ellipsisSeparatorReg : false,
appendString = (typeof(scope.ellipsisAppend) !== 'undefined' && scope.ellipsisAppend !== '') ? ellipsisSymbol + "<span class='angular-ellipsis-append'>" + scope.ellipsisAppend + '</span>' : ellipsisSymbol,
bindArray = ellipsisSeparatorReg ? binding.match(ellipsisSeparatorReg) : binding.split(ellipsisSeparator);
attributes.isTruncated = false;
if (isHtml) {
element.html(binding);
} else {
element.text(binding);
}
if (_isDefined(attributes.ellipsisFallbackFontSize) && isOverflowed(element)) {
element.css('font-size',attributes.ellipsisFallbackFontSize);
}
// If text has overflow
if (isOverflowed(element, scope.useParent)) {
var bindArrayStartingLength = bindArray.length,
initialMaxHeight = scope.useParent ? getParentHeight(element) : element[0].clientHeight;
if (isHtml) {
element.html(binding + appendString);
} else {
element.text(binding).html(element.html() + appendString);
}
//Set data-overflow on element for targeting
element.attr('data-overflowed', 'true');
// Set complete text and remove one word at a time, until there is no overflow
for (; i < bindArrayStartingLength; i++) {
var current = bindArray.pop();
//if the last string still overflowed, then truncate the last string
if (bindArray.length === 0) {
bindArray[0] = current.substring(0, Math.min(current.length, 5));
}
if (isHtml) {
element.html(bindArray.join(ellipsisSeparator) + appendString);
} else {
element.text(bindArray.join(ellipsisSeparator)).html(element.html() + appendString);
}
if ((scope.useParent ? element.parent()[0] : element[0]).scrollHeight < initialMaxHeight || isOverflowed(element, scope.useParent) === false) {
attributes.isTruncated = true;
break;
}
}
// If append string was passed and append click function included
if (ellipsisSymbol != appendString && typeof(scope.ellipsisAppendClick) !== 'undefined' && scope.ellipsisAppendClick !== '') {
element.find('span.angular-ellipsis-append').bind("click", function(e) {
scope.$apply(function() {
scope.ellipsisAppendClick.call(scope, {
event: e
});
});
});
}
if(!isTrustedHTML && $sce.isEnabled())
{
$sce.trustAsHtml(binding);
}
}
else{
element.attr('data-overflowed', 'false');
}
}
}
/**
* Test if element has overflow of text beyond height or max-height
*
* @param element (DOM object)
*
* @return bool
*
*/
function isOverflowed(thisElement, useParent) {
thisElement = useParent ? thisElement.parent() : thisElement;
return thisElement[0].scrollHeight > thisElement[0].clientHeight;
}
/**
* Watchers
*/
/**
* Execute ellipsis truncate on ngShow update
*/
scope.$watch('ngShow', function() {
asyncDigestImmediate.add(buildEllipsis);
});
/**
* Execute ellipsis truncate on ngBind update
*/
scope.$watch('ngBind', function() {
asyncDigestImmediate.add(buildEllipsis);
});
/**
* Execute ellipsis truncate on ngBindHtml update
*/
scope.$watch('ngBindHtml', function() {
asyncDigestImmediate.add(buildEllipsis);
});
/**
* Execute ellipsis truncate on ngBind update
*/
scope.$watch('ellipsisAppend', function() {
buildEllipsis();
});
/**
* Execute ellipsis truncate when element becomes visible
*/
scope.$watch(function() { return element[0].offsetWidth != 0 && element[0].offsetHeight != 0 }, function() {
asyncDigestDebounced.add(buildEllipsis);
});
function checkWindowForRebuild() {
if (attributes.lastWindowResizeWidth != window.innerWidth || attributes.lastWindowResizeHeight != window.innerHeight) {
buildEllipsis();
}
attributes.lastWindowResizeWidth = window.innerWidth;
attributes.lastWindowResizeHeight = window.innerHeight;
}
var unbindRefreshEllipsis = scope.$on('dibari:refresh-ellipsis', function() {
asyncDigestImmediate.add(buildEllipsis);
});
/**
* When window width or height changes - re-init truncation
*/
function onResize() {
asyncDigestDebounced.add(checkWindowForRebuild);
}
var $win = angular.element($window);
$win.bind('resize', onResize);
/**
* Clean up after ourselves
*/
scope.$on('$destroy', function() {
$win.unbind('resize', onResize);
asyncDigestImmediate.remove(buildEllipsis);
asyncDigestDebounced.remove(checkWindowForRebuild);
if (unbindRefreshEllipsis) {
unbindRefreshEllipsis();
unbindRefreshEllipsis = null;
}
});
};
}
};
}]);