ember-truncate
Version:
A generic component used to truncate text to a specified number of lines.
278 lines (259 loc) • 8.37 kB
JavaScript
/**
* A heavily modified version of TextOverflowClamp.js (http://codepen.io/Merri/pen/Dsuim)
*
* @module Utilities
* @method clamp
* @param {Element} el - The element containing the content to be truncated.
* @param {Number} lineClamp - The number of lines at which to truncate.
* @param {Function} cb - A callback function that is invoked after truncation. It is
* passed a single argument that indicates whether or not truncation was necessary.
* @param {String} cssClass - A CSS class applied to the last line instead of inline CSS.
*/
let measure,
text,
lineWidth,
pos,
lineStart,
lineCount,
wordStart,
line,
lineText,
wasNewLine,
nodeStack,
seedQueue,
pendingQueue,
textNode,
measureWidth,
thisNode,
nextQueue,
ce,
ctn;
function appendNodeAndQueueToElement(element, node, queue) {
let queueLength = queue && queue.length,
i,
aNode,
bNode;
// add nodes waiting to be finalized
for (i = 0; i < queueLength; ++i) {
element.appendChild(queue[i]);
}
if (nodeStack.length) {
// add nodes from the stack
i = nodeStack.length - 1;
// add the text to the last node on the stack
nodeStack[i].appendChild(node);
// ensure nodes from the stack are appended to each other
for (
;
i > 0 && (aNode = nodeStack[i]).parentNode !== (bNode = nodeStack[i - 1]);
--i
) {
bNode.appendChild(aNode);
}
// ensure root node from stack is added to measurement node
if ((aNode = nodeStack[0]).parentNode !== element) {
element.appendChild(aNode);
}
} else {
// add the text directly to the measurement node
element.appendChild(node);
}
} // function appendNodeAndQueueToElement
function cleanup() {
thisNode = null;
textNode = null;
measure = null;
line = null;
} // function cleanup
function createMeasureElement() {
// measurement element is made a child of the clamped element to get it's style
measure = ce('span');
measure.style.position = 'absolute'; // prevent page reflow
measure.style.whiteSpace = 'pre'; // cross-browser width results
measure.style.visibility = 'hidden'; // prevent drawing
} // function createMeasureElement
export default function clamp(el, lineClamp, cb, cssClass, doc) {
// make sure the element belongs to the document
if (!el.ownerDocument || el.ownerDocument !== doc) {
return;
}
ce = doc.createElement.bind(doc);
ctn = doc.createTextNode.bind(doc);
// reset to safe starting values
lineCount = 1;
wasNewLine = false;
lineWidth = el.clientWidth;
nodeStack = [];
seedQueue = [];
pendingQueue = [];
// get all nodes and remove them
while (el.firstChild !== null) {
// convert BR tag to space
if (el.firstChild.tagName === 'BR') {
seedQueue.push(ctn(' '));
el.removeChild(el.firstChild);
// remove remaining BR tags in a sequence
while (el.firstChild !== null && el.firstChild.tagName === 'BR') {
el.removeChild(el.firstChild);
}
} else {
seedQueue.push(el.firstChild);
el.removeChild(el.firstChild);
}
}
// add measurement element within so it inherits styles
createMeasureElement();
el.appendChild(measure);
function clampNodeRecurse(nodeQueue) {
function nextWord() {
// remember last word start position
wordStart = pos + 1;
// move to the next word
if (pos >= text.length) {
pos = text.length + 1;
} else {
pos = text.indexOf(' ', pos + 1);
if (pos < 0) {
pos = text.length;
}
}
} // function nextWord
function calculateFit() {
// ignore any further processing if we have total lines
if (lineCount > lineClamp) {
// move to the next word
nextWord();
return;
}
// create a text node to measure
textNode = ctn(text.substr(lineStart, pos - lineStart));
// place relevant nodes into the measurement element
appendNodeAndQueueToElement(measure, textNode, pendingQueue);
// take the measurement
measureWidth = measure.clientWidth;
// remove text node from node stack
if (nodeStack.length) {
nodeStack[nodeStack.length - 1].removeChild(textNode);
}
// have we exceeded allowed line width?
if (lineWidth <= measureWidth) {
if (wasNewLine) {
// we have a long word so it gets a line of it's own
lineText = text.substr(
lineStart,
Math.min(pos + 1, text.length) - lineStart
);
// next line start position
lineStart = Math.min(pos + 1, text.length);
// move to the next word
nextWord();
} else {
// grab the text until this word
lineText = text.substr(lineStart, wordStart - lineStart);
// next line start position
lineStart = wordStart;
}
// create a line element
line = ce('span');
// add text to the line element
appendNodeAndQueueToElement(line, ctn(lineText), pendingQueue);
// add the line element to the container
el.appendChild(line);
// flush the queue
pendingQueue = [];
// refresh the stack
nodeStack = nodeStack.map((node) => node.cloneNode(false));
// yes, we created a new line
wasNewLine = true;
++lineCount;
} else {
// did not create a new line
wasNewLine = false;
// move to the next word
nextWord();
}
// clear measurement element
while (measure.firstChild !== null) {
measure.removeChild(measure.firstChild);
}
} // function calculateFit
while (nodeQueue.length) {
thisNode = nodeQueue.shift();
if (thisNode.nodeType === 3 && thisNode.nodeValue) {
// text node
// get all the text, remove any line changes
text = thisNode.nodeValue.replace(/\n/g, ' ');
// reset to safe starting values
lineStart = wordStart = 0;
pos = text.indexOf(' ');
// step through the words
while (pos <= text.length) {
calculateFit();
}
if (lineStart < text.length) {
// there is text that hasn't been appended
if (nodeStack.length) {
// add the text to the last node on the stack
appendNodeAndQueueToElement(null, ctn(text.substr(lineStart)));
// push the root from the node stack into the queue if it's not already
if (pendingQueue.indexOf(nodeStack[0]) < 0) {
pendingQueue.push(nodeStack[0]);
}
} else {
// add the text directly to the pending queue
pendingQueue.push(ctn(text.substr(lineStart)));
}
}
} else {
// element node
nextQueue = [];
while (thisNode.firstChild !== null) {
nextQueue.push(thisNode.firstChild);
thisNode.removeChild(thisNode.firstChild);
}
nodeStack.push(thisNode);
clampNodeRecurse(nextQueue);
nodeStack.pop();
}
}
} // function clampNodeRecurse
// Recurse through all nodes
clampNodeRecurse(seedQueue);
// remove the measurement element from the container
el.removeChild(measure);
// give styles required for text-overflow to kick in
if (lineCount > lineClamp) {
if ('string' === typeof cssClass) {
el.lastChild.classList.add(cssClass);
} else {
(function (s) {
s.display = 'block';
s.overflow = 'hidden';
s.textOverflow = 'ellipsis';
s.whiteSpace = 'nowrap';
s.width = '100%';
})(el.lastChild.style);
}
}
// flush nodes waiting to be appended
if (pendingQueue.length) {
if (lineCount > lineClamp) {
// flush them into the last span
while (pendingQueue.length) {
el.lastChild.appendChild(pendingQueue.shift());
}
} else {
// create the last line element
line = ce('span');
// flush them into the new span
while (pendingQueue.length) {
line.appendChild(pendingQueue.shift());
}
// add the line element to the container
el.appendChild(line);
}
}
// call the callback with whether or not the text was truncated
cb(lineCount > lineClamp);
cleanup();
} // function clamp