UNPKG

line-truncation

Version:

Line Truncation is a zero dependency tool that truncate text by user defined line number

282 lines (248 loc) 9.87 kB
/** * Inspired by https://www.npmjs.com/package/line-clamp and https://www.npmjs.com/package/shave, * adapted our own solution for better performance */ const NODE_TYPE_ELEMENT = 1; // Javascript ELEMENT_NODE constant const NODE_TYPE_TEXT = 3; // Javascript TEXT_NODE constant const TRAILING_WHITESPACE_AND_PUNCTUATION_REGEX = /[ .,;!?'‘’“”\-–—\n]+$/; let 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. */ export function truncate(rootElement, lines, ellipsis = ellipsisCharacter, callback = val => { }) { if (!lines || !rootElement) { return; } const lineHeight = getLineHeight(rootElement); ellipsisCharacter = ellipsis || ellipsisCharacter; const truncateHeight = lines * lineHeight; if (Math.floor(getContentHeight(rootElement) / 2) > truncateHeight) { const childNodes = []; while (rootElement.firstChild) { childNodes.push(rootElement.removeChild(rootElement.firstChild)); } callback(appendElementNode(childNodes, rootElement, lines, lineHeight)); } else { callback(truncateElementNode(rootElement, rootElement, lines, lineHeight)); } } export function truncateWhenNecessary(element, tries = 1, maxTries = 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(() => { /** * Recursively call the truncate itself if Client Height is not ready */ if (element.clientHeight > 0) { const contentHeight = getContentHeight(element); const lineHeight = getLineHeight(element); const targetHeight = this.lines * lineHeight; if (contentHeight > targetHeight) { try { truncate(element, this.lines, this.ellipsis, this.handler.bind(this)); } catch (error) { console.info(`lineTruncation: ${error}`); } } else { this.setVisibility('visible'); } } else { console.info(`LineTruncation: ${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, }); } } export function getContentHeight(element) { const computedStyle = getComputedStyle(element); if (!(computedStyle.paddingTop || computedStyle.paddingBottom)) { return element.clientHeight; } const paddingY = parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom); return element.clientHeight - paddingY; } export function getLineHeight(element) { const 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) { const truncateHeight = lines * lineHeight; let i = 0; while (i < childNodes.length) { // Add child nodes until the height of the element is more than the desired height. const 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) { const 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) { const childNodes = element.childNodes; const truncateHeight = lines * lineHeight; let 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. const 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) { const 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; } const wholeTextContent = textNode.textContent; // A copy of text content before the truncation let left = 0; let right = wholeTextContent.length - 1; // Aggressively truncate text until truncating anymore would reduce our height beyond the desired height while (left <= right) { const 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) { let 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">${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) { const lastTextChildElement = getLastElementThatHasText(rootElement); // since our root element is a element, it has at least 1 child lastTextChildElement.insertAdjacentHTML( 'beforeend', `<span class="trunk-char">${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); }