UNPKG

react-truncate

Version:

React component for truncating multi-line spans and adding an ellipsis

413 lines (335 loc) 14.9 kB
var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } import React, { Component } from 'react'; import PropTypes from 'prop-types'; var Truncate = function (_Component) { _inherits(Truncate, _Component); function Truncate() { var _ref; _classCallCheck(this, Truncate); for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } var _this = _possibleConstructorReturn(this, (_ref = Truncate.__proto__ || Object.getPrototypeOf(Truncate)).call.apply(_ref, [this].concat(args))); _this.state = {}; _this.styles = { ellipsis: { position: 'fixed', visibility: 'hidden', top: 0, left: 0 } }; _this.elements = {}; _this.onResize = _this.onResize.bind(_this); _this.onTruncate = _this.onTruncate.bind(_this); _this.calcTargetWidth = _this.calcTargetWidth.bind(_this); _this.measureWidth = _this.measureWidth.bind(_this); _this.getLines = _this.getLines.bind(_this); _this.renderLine = _this.renderLine.bind(_this); return _this; } _createClass(Truncate, [{ key: 'componentDidMount', value: function componentDidMount() { var text = this.elements.text, calcTargetWidth = this.calcTargetWidth, onResize = this.onResize; var canvas = document.createElement('canvas'); this.canvasContext = canvas.getContext('2d'); calcTargetWidth(function () { // Node not needed in document tree to read its content if (text) { text.parentNode.removeChild(text); } }); window.addEventListener('resize', onResize); } }, { key: 'componentDidUpdate', value: function componentDidUpdate(prevProps) { // Render was based on outdated refs and needs to be rerun if (this.props.children !== prevProps.children) { this.forceUpdate(); } // If the width prop has changed, recalculate size of contents if (this.props.width !== prevProps.width) { this.calcTargetWidth(); } } }, { key: 'componentWillUnmount', value: function componentWillUnmount() { var ellipsis = this.elements.ellipsis, onResize = this.onResize, timeout = this.timeout; ellipsis.parentNode.removeChild(ellipsis); window.removeEventListener('resize', onResize); window.cancelAnimationFrame(timeout); } // Shim innerText to consistently break lines at <br/> but not at \n }, { key: 'innerText', value: function innerText(node) { var div = document.createElement('div'); var contentKey = 'innerText' in window.HTMLElement.prototype ? 'innerText' : 'textContent'; div.innerHTML = node.innerHTML.replace(/\r\n|\r|\n/g, ' '); var text = div[contentKey]; var test = document.createElement('div'); test.innerHTML = 'foo<br/>bar'; if (test[contentKey].replace(/\r\n|\r/g, '\n') !== 'foo\nbar') { div.innerHTML = div.innerHTML.replace(/<br.*?[\/]?>/gi, '\n'); text = div[contentKey]; } return text; } }, { key: 'onResize', value: function onResize() { this.calcTargetWidth(); } }, { key: 'onTruncate', value: function onTruncate(didTruncate) { var onTruncate = this.props.onTruncate; if (typeof onTruncate === 'function') { this.timeout = window.requestAnimationFrame(function () { onTruncate(didTruncate); }); } } }, { key: 'calcTargetWidth', value: function calcTargetWidth(callback) { var target = this.elements.target, calcTargetWidth = this.calcTargetWidth, canvasContext = this.canvasContext, width = this.props.width; // Calculation is no longer relevant, since node has been removed if (!target) { return; } var targetWidth = width || // Floor the result to deal with browser subpixel precision Math.floor(target.parentNode.getBoundingClientRect().width); // Delay calculation until parent node is inserted to the document // Mounting order in React is ChildComponent, ParentComponent if (!targetWidth) { return window.requestAnimationFrame(function () { return calcTargetWidth(callback); }); } var style = window.getComputedStyle(target); var font = [style['font-weight'], style['font-style'], style['font-size'], style['font-family']].join(' '); canvasContext.font = font; this.setState({ targetWidth: targetWidth }, callback); } }, { key: 'measureWidth', value: function measureWidth(text) { return this.canvasContext.measureText(text).width; } }, { key: 'ellipsisWidth', value: function ellipsisWidth(node) { return node.offsetWidth; } }, { key: 'trimRight', value: function trimRight(text) { return text.replace(/\s+$/, ''); } }, { key: 'getLines', value: function getLines() { var elements = this.elements, _props = this.props, numLines = _props.lines, ellipsis = _props.ellipsis, trimWhitespace = _props.trimWhitespace, targetWidth = this.state.targetWidth, innerText = this.innerText, measureWidth = this.measureWidth, onTruncate = this.onTruncate, trimRight = this.trimRight; var lines = []; var text = innerText(elements.text); var textLines = text.split('\n').map(function (line) { return line.split(' '); }); var didTruncate = true; var ellipsisWidth = this.ellipsisWidth(this.elements.ellipsis); for (var line = 1; line <= numLines; line++) { var textWords = textLines[0]; // Handle newline if (textWords.length === 0) { lines.push(); textLines.shift(); line--; continue; } var resultLine = textWords.join(' '); if (measureWidth(resultLine) <= targetWidth) { if (textLines.length === 1) { // Line is end of text and fits without truncating didTruncate = false; lines.push(resultLine); break; } } if (line === numLines) { // Binary search determining the longest possible line inluding truncate string var textRest = textWords.join(' '); var lower = 0; var upper = textRest.length - 1; while (lower <= upper) { var middle = Math.floor((lower + upper) / 2); var testLine = textRest.slice(0, middle + 1); if (measureWidth(testLine) + ellipsisWidth <= targetWidth) { lower = middle + 1; } else { upper = middle - 1; } } var lastLineText = textRest.slice(0, lower); if (trimWhitespace) { lastLineText = trimRight(lastLineText); // Remove blank lines from the end of text while (!lastLineText.length && lines.length) { var prevLine = lines.pop(); lastLineText = trimRight(prevLine); } } resultLine = React.createElement( 'span', null, lastLineText, ellipsis ); } else { // Binary search determining when the line breaks var _lower = 0; var _upper = textWords.length - 1; while (_lower <= _upper) { var _middle = Math.floor((_lower + _upper) / 2); var _testLine = textWords.slice(0, _middle + 1).join(' '); if (measureWidth(_testLine) <= targetWidth) { _lower = _middle + 1; } else { _upper = _middle - 1; } } // The first word of this line is too long to fit it if (_lower === 0) { // Jump to processing of last line line = numLines - 1; continue; } resultLine = textWords.slice(0, _lower).join(' '); textLines[0].splice(0, _lower); } lines.push(resultLine); } onTruncate(didTruncate); return lines; } }, { key: 'renderLine', value: function renderLine(line, i, arr) { if (i === arr.length - 1) { return React.createElement( 'span', { key: i }, line ); } else { var br = React.createElement('br', { key: i + 'br' }); if (line) { return [React.createElement( 'span', { key: i }, line ), br]; } else { return br; } } } }, { key: 'render', value: function render() { var _this2 = this; var target = this.elements.target, _props2 = this.props, children = _props2.children, ellipsis = _props2.ellipsis, lines = _props2.lines, spanProps = _objectWithoutProperties(_props2, ['children', 'ellipsis', 'lines']), targetWidth = this.state.targetWidth, getLines = this.getLines, renderLine = this.renderLine, onTruncate = this.onTruncate; var text = void 0; var mounted = !!(target && targetWidth); if (typeof window !== 'undefined' && mounted) { if (lines > 0) { text = getLines().map(renderLine); } else { text = children; onTruncate(false); } } delete spanProps.onTruncate; delete spanProps.trimWhitespace; return React.createElement( 'span', _extends({}, spanProps, { ref: function ref(targetEl) { _this2.elements.target = targetEl; } }), React.createElement( 'span', null, text ), React.createElement( 'span', { ref: function ref(textEl) { _this2.elements.text = textEl; } }, children ), React.createElement( 'span', { ref: function ref(ellipsisEl) { _this2.elements.ellipsis = ellipsisEl; }, style: this.styles.ellipsis }, ellipsis ) ); } }]); return Truncate; }(Component); Truncate.propTypes = { children: PropTypes.node, ellipsis: PropTypes.node, lines: PropTypes.oneOfType([PropTypes.oneOf([false]), PropTypes.number]), trimWhitespace: PropTypes.bool, width: PropTypes.number, onTruncate: PropTypes.func }; Truncate.defaultProps = { children: '', ellipsis: '…', lines: 1, trimWhitespace: false, width: 0 }; export default Truncate; ;