triple-dots
Version:
Triple dots is a javascript plugin for truncating multiple line content with an ellipsis.
661 lines (550 loc) • 20.4 kB
text/typescript
import Timeout = NodeJS.Timeout;
/** An object with any value. */
interface dddLooseObject {
[key: string]: any;
}
/** An object with function values. */
interface dddFunctionObject {
[key: string]: Function;
}
/** Default options for the class. */
interface dddOptions {
/** The ellipsis to place after the truncated text. */
ellipsis?: string;
/** Function to invoke after the truncate process. */
callback?: Function;
/** How to truncate: 'node', 'word' (default) or 'letter'. */
truncate?: string;
/** Optional tolerance for the container height. */
tolerance?: number;
/** Selector for elements not to remove from the DOM. */
keep?: string | null;
/** Whether and when to update the ellipsis: null, true or 'window' (default) */
watch?: string;
/** The height for the container. If null, the max-height will be read from the CSS properties. */
height?: number | null;
}
/**
* Class for a multiline ellipsis.
*/
export default class TripleDots {
/** Plugin version. */
static version: string = '0.0.3';
/** Default options. */
static options: dddOptions = {
ellipsis: '\u2026 ',
callback: function () {
},
truncate: 'word',
tolerance: 0,
keep: null,
watch: 'window',
height: null,
};
/** Element to truncate */
container: HTMLElement;
/** Inner element, added for measuring. */
innerContainer: HTMLElement;
/** Options. */
options: dddOptions;
/** The max-height for the element. */
maxHeight: number | undefined;
/** The ellipsis to use for truncating. */
ellipsis: Text;
/** The API */
API: dddFunctionObject;
/** Storage for the watch timeout, oddly it has a number type. */
watchTimeout: Timeout | null;
/** Storage for the watch interval, oddly it has a number type. */
watchInterval: Timeout | null;
/** Storage for the original style attribute. */
originalStyle: string;
/** Storage for the original HTML. */
originalContent: Node[];
/** Function to invoke on window resize. Needs to be stored so it can be removed later on. */
resizeEvent: EventListener | null;
/** Check the element is clamped */
isClamped: boolean
expanded: boolean
/**
* Truncate a multiline element with an ellipsis.
*
* @param {HTMLElement} container The element to truncate.
* @param {object} [options=TripleDots.options] Options for the menu.
*/
constructor(
container: HTMLElement,
options: dddOptions = TripleDots.options
) {
this.container = container;
this.options = options || {};
// Set the watch timeout and -interval;
this.watchTimeout = null;
this.watchInterval = null;
// Set the resize event handler.
this.resizeEvent = null;
// Extend the specified options with the default options.
for (let option in TripleDots.options) {
if (!TripleDots.options.hasOwnProperty(option)) {
continue;
}
if (typeof this.options[option] == 'undefined') {
this.options[option] = TripleDots.options[option];
}
}
// If the element already is a tripleDots instance.
// -> Destroy the previous instance.
const oldAPI = this.container['tripleDots'];
if (oldAPI) {
oldAPI.destroy();
}
// Create the API.
this.API = {};
['truncate', 'restore', 'destroy', 'watch', 'unwatch'].forEach((fn) => {
this.API[fn] = () => {
return this[fn].call(this);
};
});
// Store the API.
this.container['tripleDots'] = this.API;
// Store the original style attribute;
this.originalStyle = this.container.getAttribute('style') || '';
// Collect the original contents.
this.originalContent = this._getOriginalContent();
// Create the ellipsis Text node.
this.ellipsis = document.createTextNode(this.options.ellipsis);
// Set CSS properties for the container.
const computedStyle = window.getComputedStyle(this.container);
if (computedStyle['word-wrap'] !== 'break-word') {
this.container.style['word-wrap'] = 'break-word';
}
if (computedStyle['white-space'] === 'pre') {
this.container.style['white-space'] = 'pre-wrap';
} else if (computedStyle['white-space'] === 'nowrap') {
this.container.style['white-space'] = 'normal';
}
// Set the max-height for the container.
if (this.options.height === null) {
this.options.height = this._getMaxHeight();
}
this.expanded = false;
this.isClamped = false;
// Truncate the text.
this.truncate();
// Set the watch.
if (this.options.watch) {
this.watch();
}
}
/**
* Restore the container to a pre-init state.
*/
restore() {
// Stop the watch.
this.unwatch();
// Restore the original style.
this.container.setAttribute('style', this.originalStyle);
// Restore the original classname.
this.container.classList.remove('ddd-truncated');
// Restore the original contents.
this.container.innerHTML = '';
this.originalContent.forEach((element) => {
this.container.append(element);
});
this.expanded = false;
}
/**
* Fully destroy the plugin.
*/
destroy() {
this.restore();
this.container['tripleDots'] = null;
}
/**
* Start a watch for the truncate process.
*/
watch() {
// Stop any previous watch.
this.unwatch();
/** The previously measure sizes. */
let oldSizes = {
width: null,
height: null,
};
/**
* Measure the sizes and start the truncate proces.
*/
let watchSizes = (
element: Window | HTMLElement,
width: string,
height: string
) => {
// Only if the container is visible.
if (
this.container.offsetWidth ||
this.container.offsetHeight ||
this.container.getClientRects().length
) {
let newSizes = {
width: element[width],
height: element[height],
};
if (
oldSizes.width != newSizes.width ||
oldSizes.height != newSizes.height
) {
this.truncate();
}
return newSizes;
}
return oldSizes;
};
// Update onWindowResize.
if (this.options.watch === 'window') {
this.resizeEvent = (evnt) => {
// Debounce the resize event to prevent it from being called very often.
if (this.watchTimeout) {
clearTimeout(this.watchTimeout);
}
this.watchTimeout = setTimeout(() => {
oldSizes = watchSizes(window, 'innerWidth', 'innerHeight');
}, 100);
};
window.addEventListener('resize', this.resizeEvent);
// Update in an interval.
} else {
this.watchInterval = setInterval(() => {
oldSizes = watchSizes(
this.container,
'clientWidth',
'clientHeight'
);
}, 1000);
}
}
/**
* Stop the watch.
*/
unwatch() {
// Stop the windowResize handler.
if (this.resizeEvent) {
window.removeEventListener('resize', this.resizeEvent);
this.resizeEvent = null;
}
// Stop the watch interval.
if (this.watchInterval) {
clearInterval(this.watchInterval);
}
// Stop the watch timeout.
if (this.watchTimeout) {
clearTimeout(this.watchTimeout);
}
}
/**
* Start the truncate process.
*/
truncate() {
let isTruncated = false;
this.expanded = false;
// Fill the container with all the original content.
this.container.innerHTML = '';
this.originalContent.forEach((element) => {
this.container.append(element.cloneNode(true));
});
// Get the max height.
this.maxHeight = this._getMaxHeight();
// Truncate the text.
if (!this._fits()) {
isTruncated = true;
this.expanded = true
this._truncateToNode(this.container);
}
// Add a class to the container to indicate whether or not it is truncated.
this.container.classList[isTruncated ? 'add' : 'remove']('ddd-truncated');
// Invoke the callback.
this.options.callback.call(this.container, isTruncated);
return isTruncated;
}
/**
* Truncate an element by removing elements from the end.
*
* @param {HTMLElement} element The element to truncate.
*/
_truncateToNode(element: HTMLElement) {
const _coms = [],
_elms = [];
// Empty the element
// -> replace all contents with comments
TripleDots.$.contents(element).forEach((element) => {
if (
element.nodeType != 1 ||
!(element as HTMLElement).matches('.ddd-keep')
) {
let comment = document.createComment('');
(element as HTMLElement).replaceWith(comment);
_elms.push(element);
_coms.push(comment);
}
});
if (!_elms.length) {
return;
}
// Re-fill the element
// -> replace comments with contents until it doesn't fit anymore.
for (var e = 0; e < _elms.length; e++) {
_coms[e].replaceWith(_elms[e]);
let ellipsis = this.ellipsis.cloneNode(true);
switch (_elms[e].nodeType) {
case 1:
_elms[e].append(ellipsis);
break;
case 3:
_elms[e].after(ellipsis);
break;
}
let fits = this._fits();
ellipsis.parentElement.removeChild(ellipsis);
if (!fits) {
if (this.options.truncate == 'node' && e > 1) {
_elms[e - 2].remove();
return;
}
break;
}
}
// Remove left over comments.
for (var c = e; c < _coms.length; c++) {
_coms[c].remove();
}
// Get last element
// -> the element that overflows.
let _last = _elms[Math.max(0, Math.min(e, _elms.length - 1))];
// Border case
// -> the last node with only an ellipsis in it...
if (_last.nodeType == 1) {
let element = document.createElement(_last.nodeName);
element.append(this.ellipsis);
this.isClamped = true
_last.replaceWith(element);
// ... fits
// -> Restore the full last element.
if (this._fits()) {
element.replaceWith(_last);
// ... doesn't fit
// -> remove it and go back one element.
} else {
element.remove();
_last = _elms[Math.max(0, e - 1)];
}
}
// Proceed inside last element.
if (_last.nodeType == 1) {
this._truncateToNode(_last);
} else {
this._truncateToWord(_last);
}
}
/**
* Truncate a sentence by removing words from the end.
*
* @param {HTMLElement} element The element to truncate.
*/
_truncateToWord(element: HTMLElement) {
const text = element.textContent,
separator = text.indexOf(' ') !== -1 ? ' ' : '\u3000',
words = text.split(separator);
for (let a = words.length; a >= 0; a--) {
element.textContent = this._addEllipsis(
words.slice(0, a).join(separator)
);
if (this._fits()) {
if (this.options.truncate == 'letter') {
element.textContent = words.slice(0, a + 1).join(separator);
this._truncateToLetter(element);
}
break;
}
}
}
/**
* Truncate a word by removing letters from the end.
*
* @param {HTMLElement} element The element to truncate.
*/
_truncateToLetter(element: HTMLElement) {
let letters = element.textContent.split(''),
text = '';
for (let a = letters.length; a >= 0; a--) {
text = letters.slice(0, a).join('');
if (!text.length) {
continue;
}
element.textContent = this._addEllipsis(text);
if (this._fits()) {
break;
}
}
}
/**
* Test if the content fits in the container.
*
* @return {boolean} Whether or not the content fits in the container.
*/
private _fits(): boolean {
const maxHeight = this.maxHeight + this.options.tolerance
return this.container.scrollHeight <= maxHeight;
}
/**
* Add the ellipsis to a text.
*
* @param {string} text The text to add the ellipsis to.
* @return {string} The text with the added ellipsis.
*/
private _addEllipsis(text: string): string {
const remove = [' ', '\u3000', ',', ';', '.', '!', '?'];
while (remove.indexOf(text.slice(-1)) > -1) {
text = text.slice(0, -1);
}
text += this.ellipsis.textContent;
return text;
}
/**
* Sanitize and collect the original contents.
*
* @return {array} The sanitizes HTML elements.
*/
_getOriginalContent(): HTMLElement[] {
let keep = 'script, style';
if (this.options.keep) {
keep += ', ' + this.options.keep;
}
// Add "keep" class to nodes to keep.
TripleDots.$.find(keep, this.container).forEach((elem) => {
elem.classList.add('ddd-keep');
});
/** Block level HTML tags. */
let _block_tags_ =
'div, section, article, header, footer, p, h1, h2, h3, h4, h5, h6, table, td, td, dt, dd, li';
/** HTML tags that only have block level children. */
let _block_parents_ =
'table, thead, tbody, tfoot, tr, dl, ul, ol, video';
[this.container, ...TripleDots.$.find('*', this.container)].forEach(
(element) => {
// Removes empty Text nodes and joins adjacent Text nodes.
element.normalize();
// Remove comments first
TripleDots.$.contents(element).forEach((text) => {
if (text.nodeType == 8) {
element.removeChild(text);
}
});
// Loop over all contents and remove nodes that can be removed.
TripleDots.$.contents(element).forEach((text) => {
// Remove Text nodes that do not take up space in the DOM.
// This kinda assumes a default display property for the elements in the container.
if (text.nodeType == 3) {
if (text.textContent.trim() == '') {
let prev = text.previousSibling as HTMLElement,
next = text.nextSibling as HTMLElement;
if (
text.parentElement.matches(_block_parents_) ||
!prev ||
(prev.nodeType == 1 &&
prev.matches(_block_tags_)) ||
!next ||
(next.nodeType == 1 &&
next.matches(_block_tags_))
) {
element.removeChild(text);
}
}
}
});
}
);
// Create a clone of all contents.
let content = [];
TripleDots.$.contents(this.container).forEach((element) => {
content.push(element.cloneNode(true));
});
return content;
}
/**
* Find the max-height for the container.
*
* @return {number} The max-height for the container.
*/
_getMaxHeight(): number {
if (typeof this.options.height == 'number') {
return this.options.height;
}
const style = window.getComputedStyle(this.container);
// Find smallest CSS height
let properties = ['maxHeight', 'height'],
height = 0;
for (let a = 0; a < properties.length; a++) {
let property = style[properties[a]];
if (property.slice(-2) == 'px') {
height = parseFloat(property);
break;
}
}
// Remove padding-top/bottom when needed.
if (style.boxSizing == 'border-box') {
properties = [
'borderTopWidth',
'borderBottomWidth',
'paddingTop',
'paddingBottom',
];
for (let a = 0; a < properties.length; a++) {
let property = style[properties[a]];
if (property.slice(-2) == 'px') {
height -= parseFloat(property);
}
}
}
// Sanitize
return Math.max(height, 0);
}
/** DOM traversing functions to uniform datatypes. */
static $ = {
/**
* Find elements by a query selector in an element.
*
* @param {string} selector The selector to search for.
* @param {HTMLElement} [element=document] The element to search in.
* @return {array} The found elements.
*/
find: (
selector: string,
element?: HTMLElement | Document
): HTMLElement[] => {
element = element || document;
return Array.prototype.slice.call(
element.querySelectorAll(selector)
);
},
/**
* Collect child nodes (HTML elements and TextNodes) in an element.
*
* @param {HTMLElement} [element=document] The element to search in.
* @return {array} The found nodes.
*/
contents: (element?: HTMLElement | Document): Node[] => {
element = element || document;
return Array.prototype.slice.call(element.childNodes);
},
};
}
// The jQuery plugin.
(function ($) {
if (typeof $ != 'undefined') {
$.fn.tripleDots = function (options) {
return this.each((e, element) => {
let dot = new TripleDots(element, options);
element['tripleDots'] = dot.API;
});
};
}
})(window['Zepto'] || window['jQuery']);