line-truncation
Version:
Line Truncation is a zero dependency tool that truncate text by user defined line number
295 lines (230 loc) • 11 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = global || self, factory(global.LineTruncation = {}));
}(this, (function (exports) { 'use strict';
/**
* Inspired by https://www.npmjs.com/package/line-clamp and https://www.npmjs.com/package/shave,
* adapted our own solution for better performance
*/
var NODE_TYPE_ELEMENT = 1; // Javascript ELEMENT_NODE constant
var NODE_TYPE_TEXT = 3; // Javascript TEXT_NODE constant
var TRAILING_WHITESPACE_AND_PUNCTUATION_REGEX = /[ .,;!?'‘’“”\-–—\n]+$/;
var ellipsisCharacter = "\u2026";
/**
* Line Truncation driver.
* @param rootElement The root element that needs to be truncated.
* @param truncateHeight The desired height.
* @param options The passed in options by the user.
*/
function truncate(rootElement, lines) {
var ellipsis = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : ellipsisCharacter;
var callback = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : function (val) {};
if (!lines || !rootElement) {
return;
}
var lineHeight = getLineHeight(rootElement);
ellipsisCharacter = ellipsis || ellipsisCharacter;
var truncateHeight = lines * lineHeight;
if (Math.floor(getContentHeight(rootElement) / 2) > truncateHeight) {
var childNodes = [];
while (rootElement.firstChild) {
childNodes.push(rootElement.removeChild(rootElement.firstChild));
}
callback(appendElementNode(childNodes, rootElement, lines, lineHeight));
} else {
callback(truncateElementNode(rootElement, rootElement, lines, lineHeight));
}
}
function truncateWhenNecessary(element) {
var _this = this;
var tries = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1;
var maxTries = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 10;
/**
* This CSS visibility change is for better truncating visual experience
*/
this.setVisibility('hidden');
if (tries <= maxTries) {
// Allows buffer period for DOM to be ready
setTimeout(function () {
/**
* Recursively call the truncate itself if Client Height is not ready
*/
if (element.clientHeight > 0) {
var contentHeight = getContentHeight(element);
var lineHeight = getLineHeight(element);
var targetHeight = _this.lines * lineHeight;
if (contentHeight > targetHeight) {
try {
truncate(element, _this.lines, _this.ellipsis, _this.handler.bind(_this));
} catch (error) {
console.info("lineTruncation: ".concat(error));
}
} else {
_this.setVisibility('visible');
}
} else {
console.info("LineTruncation: ".concat(tries, " time truncation try for element:"), {
context: element
});
_this.truncateWhenNecessary(element, ++tries);
}
}, 100);
} else {
this.setVisibility('visible');
console.info("[LineTruncation:truncateWhenNecessary()] Cannot retrieve item's clientHeight", {
context: element
});
}
}
function getContentHeight(element) {
var computedStyle = getComputedStyle(element);
if (!(computedStyle.paddingTop || computedStyle.paddingBottom)) {
return element.clientHeight;
}
var paddingY = parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom);
return element.clientHeight - paddingY;
}
function getLineHeight(element) {
var lineHeightComputedStyle = window.getComputedStyle(element).lineHeight;
if (lineHeightComputedStyle === 'normal') {
// Define a fallback for 'normal' value with 1.2 as a line-height
// https://www.w3.org/TR/CSS21/visudet.html#normal-block
return parseFloat(window.getComputedStyle(element).fontSize) * 1.2;
} else {
return parseFloat(lineHeightComputedStyle);
}
}
/**
* Append first level child nodes to empty rootElement, start truncating element when text overflowing desired height
* @param childNodes the set of original child nodes
* @param rootElement The root node.
* @param lines number of truncate lines
* @param lineHeight text line height
*/
function appendElementNode(childNodes, rootElement, lines, lineHeight) {
var truncateHeight = lines * lineHeight;
var i = 0;
while (i < childNodes.length) {
// Add child nodes until the height of the element is more than the desired height.
var childNode = childNodes[i++];
rootElement.appendChild(childNode); // if rootElement's height matches truncation height, add ellipsis and exit
if (getContentHeight(rootElement) === truncateHeight) {
addEllipsis(rootElement, truncateHeight);
return true;
} // If adding the root element's height exceeding the desired height, stop adding child element and start truncation from the the end of root element
if (getContentHeight(rootElement) > truncateHeight) {
var childNodeType = childNode.nodeType;
if (childNodeType === NODE_TYPE_ELEMENT && truncateElementNode(childNode, rootElement, lines, lineHeight) || childNodeType === NODE_TYPE_TEXT && truncateTextNode(childNode, rootElement, truncateHeight)) {
return true;
} // Remove the element if the node type is not ELEMENT_NODE or TEXT_NODE
rootElement.removeChild(childNode);
}
}
return false;
}
function truncateElementNode(element, rootElement, lines, lineHeight) {
var childNodes = element.childNodes;
var truncateHeight = lines * lineHeight;
var i = childNodes.length - 1;
while (i > -1) {
// start removing child nodes from the end until the height of the element is less than the desired height.
var childNode = childNodes[i--];
element.removeChild(childNode); // if rootElement's height matches truncation height, add ellipsis and exit
if (getContentHeight(rootElement) === truncateHeight) {
addEllipsis(rootElement, truncateHeight);
return true;
} // If removing the element decrease the height beyond the desired height then we know that we need to truncate in
// this element to achieve the desired height
if (getContentHeight(rootElement) < truncateHeight) {
var childNodeType = childNode.nodeType;
element.appendChild(childNode);
if (childNodeType === NODE_TYPE_ELEMENT && truncateElementNode(childNode, rootElement, lines, lineHeight) || childNodeType === NODE_TYPE_TEXT && truncateTextNode(childNode, rootElement, truncateHeight)) {
return true;
} // Remove the element if the node type is not ELEMENT_NODE or TEXT_NODE
element.removeChild(childNode);
}
}
return false;
}
/**
* Locate the position closest to our desired text content by using divide and conquer.
* @param textNode The child node in the root element.
* @param rootElement The root node.
* @param truncateHeight The desired height.
*/
function truncateTextNode(textNode, rootElement, truncateHeight) {
textNode.textContent = textNode.textContent.replace(TRAILING_WHITESPACE_AND_PUNCTUATION_REGEX, '');
if (textNode.textContent === '') {
return true;
}
var wholeTextContent = textNode.textContent; // A copy of text content before the truncation
var left = 0;
var right = wholeTextContent.length - 1; // Aggressively truncate text until truncating anymore would reduce our height beyond the desired height
while (left <= right) {
var mid = left + Math.floor((right - left) / 2);
textNode.textContent = wholeTextContent.substring(0, mid);
if (getContentHeight(rootElement) > truncateHeight) {
right = mid - 1;
} else {
left = mid + 1;
}
}
addEllipsis(rootElement, truncateHeight);
return true;
}
/**
* Truncate Text Node by remove the last character
* @param textNode The text node of last element of current root element.
* @param lastElement The last element of current root element.
* @param rootElement The root node.
* @param truncateHeight The desired height.
*/
function truncateTextNodeByCharacter(textNode, lastElement, rootElement, truncateHeight) {
var currentLength = textNode.textContent.length;
while (currentLength > 0) {
// Trim off one trailing character and any trailing punctuation and whitespace.
textNode.textContent = textNode.textContent.replace(TRAILING_WHITESPACE_AND_PUNCTUATION_REGEX, ''); // When text content is empty, exit
if (textNode.textContent === '') {
break;
}
textNode.textContent = textNode.textContent.substring(0, currentLength - 1); // Add ellipsis before comparing height
lastElement.insertAdjacentHTML('beforeend', "<span class=\"trunk-char\">".concat(ellipsisCharacter, "</span>"));
if (getContentHeight(rootElement) <= truncateHeight) {
break;
} // Take out ellipsis character for the next iteration
lastElement.removeChild(lastElement.lastChild);
currentLength = textNode.textContent.length;
}
return true;
}
/**
* Try Add ellipsis before the end of the last element that hold text content (so that the ellipsis stay with last text content
* , e.g. a paragraph), Adds the ellipsis character if the text overflows to the next line, remove characters to compensate.
* @param rootElement The root node.
* @param truncateHeight The desired height.
*/
function addEllipsis(rootElement, truncateHeight) {
var lastTextChildElement = getLastElementThatHasText(rootElement); // since our root element is a element, it has at least 1 child
lastTextChildElement.insertAdjacentHTML('beforeend', "<span class=\"trunk-char\">".concat(ellipsisCharacter, "</span>"));
if (getContentHeight(rootElement) > truncateHeight) {
lastTextChildElement.removeChild(lastTextChildElement.lastChild); // remove the ellipsis that we just inserted
truncateTextNodeByCharacter(lastTextChildElement.lastChild, lastTextChildElement, rootElement, truncateHeight);
}
}
/**
* Get last element that holds text content
* @param element
*/
function getLastElementThatHasText(element) {
if (!element.hasChildNodes()) {
throw Error('Must have child node');
}
return element.lastChild.nodeType === NODE_TYPE_TEXT ? element : getLastElementThatHasText(element.lastChild);
}
exports.getContentHeight = getContentHeight;
exports.getLineHeight = getLineHeight;
exports.truncate = truncate;
exports.truncateWhenNecessary = truncateWhenNecessary;
Object.defineProperty(exports, '__esModule', { value: true });
})));