UNPKG

react-truncate-markup

Version:
512 lines (403 loc) 18.8 kB
'use strict'; exports.__esModule = true; exports.default = undefined; 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; }; }(); var _class, _temp; var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 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 _react = require('react'); var _react2 = _interopRequireDefault(_react); var _memoizeOne = require('memoize-one'); var _memoizeOne2 = _interopRequireDefault(_memoizeOne); var _propTypes = require('prop-types'); var _propTypes2 = _interopRequireDefault(_propTypes); var _lineHeight = require('line-height'); var _lineHeight2 = _interopRequireDefault(_lineHeight); var _resizeObserverPolyfill = require('resize-observer-polyfill'); var _resizeObserverPolyfill2 = _interopRequireDefault(_resizeObserverPolyfill); var _tokenizeRules = require('./tokenize-rules'); var _tokenizeRules2 = _interopRequireDefault(_tokenizeRules); var _atom = require('./atom'); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 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; } var SPLIT = { LEFT: true, RIGHT: false }; var toString = function toString(node) { var string = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; if (!node) { return string; } else if (typeof node === 'string') { return string + node; } else if ((0, _atom.isAtomComponent)(node)) { return string + _atom.ATOM_STRING_ID; } var children = Array.isArray(node) ? node : node.props.children || ''; return string + _react2.default.Children.map(children, function (child) { return toString(child); }).join(''); }; var cloneWithChildren = function cloneWithChildren(node, children, isRootEl, level) { var getDisplayStyle = function getDisplayStyle() { if (isRootEl) { return { // root element cannot be an inline element because of the line calculation display: (node.props.style || {}).display || 'block' }; } else if (level === 2) { return { // level 2 elements (direct children of the root element) need to be inline because of the ellipsis. // if level 2 element was a block element, ellipsis would get rendered on a new line, breaking the max number of lines display: (node.props.style || {}).display || 'inline-block' }; } else return {}; }; return _extends({}, node, { props: _extends({}, node.props, { style: _extends({}, node.props.style, getDisplayStyle()), children: children }) }); }; var validateTree = function validateTree(node) { if (node == null || ['string', 'number'].includes(typeof node === 'undefined' ? 'undefined' : _typeof(node)) || (0, _atom.isAtomComponent)(node)) { return true; } else if (typeof node.type === 'function') { if (process.env.NODE_ENV !== 'production') { /* eslint-disable no-console */ console.error('ReactTruncateMarkup tried to render <' + node.type.name + ' />, but truncating React components is not supported, the full content is rendered instead. Only DOM elements are supported. Alternatively, you can take advantage of the <TruncateMarkup.Atom /> component (see more in the docs https://github.com/patrik-piskay/react-truncate-markup/blob/master/README.md#truncatemarkupatom-).'); /* eslint-enable */ } return false; } if (node.props && node.props.children) { return _react2.default.Children.toArray(node.props.children).reduce(function (isValid, child) { return isValid && validateTree(child); }, true); } return true; }; var TruncateMarkup = (_temp = _class = function (_React$Component) { _inherits(TruncateMarkup, _React$Component); function TruncateMarkup(props) { _classCallCheck(this, TruncateMarkup); var _this = _possibleConstructorReturn(this, _React$Component.call(this, props)); _this.lineHeight = null; _this.splitDirectionSeq = []; _this.shouldTruncate = true; _this.wasLastCharTested = false; _this.endFound = false; _this.latestThatFits = null; _this.onTruncateCalled = false; _this.toStringMemo = (0, _memoizeOne2.default)(toString); _this.childrenWithRefMemo = (0, _memoizeOne2.default)(_this.childrenElementWithRef); _this.validateTreeMemo = (0, _memoizeOne2.default)(validateTree); _this.onTruncate = function (wasTruncated) { if (!_this.onTruncateCalled) { _this.onTruncateCalled = true; _this.props.onTruncate(wasTruncated); } }; _this.handleResize = function (el, prevResizeObserver) { // clean up previous observer if (prevResizeObserver) { prevResizeObserver.disconnect(); } // unmounting or just unsetting the element to be replaced with a new one later if (!el) return null; /* Wrapper element resize handing */ var initialRender = true; var resizeCallback = function resizeCallback() { if (initialRender) { // ResizeObserer cb is called on initial render too so we are skipping here initialRender = false; } else { // wrapper element has been resized, recalculating with the original text _this.shouldTruncate = false; _this.latestThatFits = null; _this.setState({ text: _this.origText }, function () { _this.shouldTruncate = true; _this.onTruncateCalled = false; _this.truncate(); }); } }; var resizeObserver = prevResizeObserver || new _resizeObserverPolyfill2.default(resizeCallback); resizeObserver.observe(el); return resizeObserver; }; _this.setRef = function (el) { var isNewEl = _this.el !== el; _this.el = el; // whenever we obtain a new element, attach resize handler if (isNewEl) { _this.resizeObserver = _this.handleResize(el, _this.resizeObserver); } }; _this.state = { text: _this.childrenWithRefMemo(_this.props.children) }; return _this; } TruncateMarkup.prototype.componentDidMount = function componentDidMount() { if (!this.isValid) { return; } // get the computed line-height of the parent element // it'll be used for determining whether the text fits the container or not this.lineHeight = this.props.lineHeight || (0, _lineHeight2.default)(this.el); this.truncate(); }; TruncateMarkup.prototype.UNSAFE_componentWillReceiveProps = function UNSAFE_componentWillReceiveProps(nextProps) { var _this2 = this; this.shouldTruncate = false; this.latestThatFits = null; this.setState({ text: this.childrenWithRefMemo(nextProps.children) }, function () { if (!_this2.isValid) { return; } _this2.lineHeight = nextProps.lineHeight || (0, _lineHeight2.default)(_this2.el); _this2.shouldTruncate = true; _this2.truncate(); }); }; TruncateMarkup.prototype.componentDidUpdate = function componentDidUpdate() { if (this.shouldTruncate === false || this.isValid === false) { return; } if (this.endFound) { // we've found the end where we cannot split the text further // that means we've already found the max subtree that fits the container // so we are rendering that if (this.latestThatFits !== null && this.state.text !== this.latestThatFits) { /* eslint-disable react/no-did-update-set-state */ this.setState({ text: this.latestThatFits }); return; /* eslint-enable */ } this.onTruncate( /* wasTruncated */true); return; } if (this.splitDirectionSeq.length) { if (this.fits()) { this.latestThatFits = this.state.text; // we've found a subtree that fits the container // but we need to check if we didn't cut too much of it off // so we are changing the last splitting decision from splitting and going left // to splitting and going right this.splitDirectionSeq.splice(this.splitDirectionSeq.length - 1, 1, SPLIT.RIGHT, SPLIT.LEFT); } else { this.splitDirectionSeq.push(SPLIT.LEFT); } this.tryToFit(this.origText, this.splitDirectionSeq); } }; TruncateMarkup.prototype.componentWillUnmount = function componentWillUnmount() { this.lineHeight = null; this.latestThatFits = null; this.splitDirectionSeq = []; }; TruncateMarkup.prototype.truncate = function truncate() { if (this.fits()) { // the whole text fits on the first try, no need to do anything else this.shouldTruncate = false; this.onTruncate( /* wasTruncated */false); return; } this.truncateOriginalText(); }; TruncateMarkup.prototype.childrenElementWithRef = function childrenElementWithRef(children) { var child = _react2.default.Children.only(children); return _react2.default.cloneElement(child, { ref: this.setRef, style: _extends({ wordWrap: 'break-word' }, child.props.style) }); }; TruncateMarkup.prototype.truncateOriginalText = function truncateOriginalText() { this.endFound = false; this.splitDirectionSeq = [SPLIT.LEFT]; this.wasLastCharTested = false; this.tryToFit(this.origText, this.splitDirectionSeq); }; /** * Splits rootEl based on instructions and updates React's state with the returned element * After React rerenders the new text, we'll check if the new text fits in componentDidUpdate * @param {ReactElement} rootEl - the original children element * @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions */ TruncateMarkup.prototype.tryToFit = function tryToFit(rootEl, splitDirections) { if (!rootEl.props.children) { // no markup in container return; } var newRootEl = this.split(rootEl, splitDirections, /* isRootEl */true); var ellipsis = typeof this.props.ellipsis === 'function' ? this.props.ellipsis(newRootEl) : this.props.ellipsis; ellipsis = (typeof ellipsis === 'undefined' ? 'undefined' : _typeof(ellipsis)) === 'object' ? _react2.default.cloneElement(ellipsis, { key: 'ellipsis' }) : ellipsis; var newChildren = newRootEl.props.children; var newChildrenWithEllipsis = [].concat(newChildren, ellipsis); // edge case tradeoff EC#1 - on initial render it doesn't fit in the requested number of lines (1) so it starts truncating // - because of truncating and the ellipsis position, div#lvl2 will have display set to 'inline-block', // causing the whole body to fit in 1 line again // - if that happens, ellipsis is not needed anymore as the whole body is rendered // - NOTE this could be fixed by checking for this exact case and handling it separately so it renders <div>foo {ellipsis}</div> // // Example: // <TruncateMarkup lines={1}> // <div> // foo // <div id="lvl2">bar</div> // </div> // </TruncateMarkup> var shouldRenderEllipsis = toString(newChildren) !== this.toStringMemo(this.props.children); this.setState({ text: _extends({}, newRootEl, { props: _extends({}, newRootEl.props, { children: shouldRenderEllipsis ? newChildrenWithEllipsis : newChildren }) }) }); }; /** * Splits JSX node based on its type * @param {null|string|Array|Object} node - JSX node * @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions * @return {null|string|Array|Object} - split JSX node */ TruncateMarkup.prototype.split = function split(node, splitDirections) { var isRoot = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; var level = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 1; if (!node || (0, _atom.isAtomComponent)(node)) { this.endFound = true; return node; } else if (typeof node === 'string') { return this.splitString(node, splitDirections, level); } else if (Array.isArray(node)) { return this.splitArray(node, splitDirections, level); } var newChildren = this.split(node.props.children, splitDirections, /* isRoot */false, level + 1); return cloneWithChildren(node, newChildren, isRoot, level); }; TruncateMarkup.prototype.splitString = function splitString(string) { var splitDirections = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; var level = arguments[2]; if (!splitDirections.length) { return string; } if (splitDirections.length && this.policy.isAtomic(string)) { // allow for an extra render test with the current character included // in most cases this variation was already tested, but some edge cases require this check // NOTE could be removed once EC#1 is taken care of if (!this.wasLastCharTested) { this.wasLastCharTested = true; } else { // we are trying to split further but we have nowhere to go now // that means we've already found the max subtree that fits the container this.endFound = true; } return string; } if (this.policy.tokenizeString) { var wordsArray = this.splitArray(this.policy.tokenizeString(string), splitDirections, level); // in order to preserve the input structure return wordsArray.join(''); } var splitDirection = splitDirections[0], restSplitDirections = splitDirections.slice(1); var pivotIndex = Math.ceil(string.length / 2); var beforeString = string.substring(0, pivotIndex); if (splitDirection === SPLIT.LEFT) { return this.splitString(beforeString, restSplitDirections, level); } var afterString = string.substring(pivotIndex); return beforeString + this.splitString(afterString, restSplitDirections, level); }; TruncateMarkup.prototype.splitArray = function splitArray(array) { var splitDirections = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; var level = arguments[2]; if (!splitDirections.length) { return array; } if (array.length === 1) { return [this.split(array[0], splitDirections, /* isRoot */false, level)]; } var splitDirection = splitDirections[0], restSplitDirections = splitDirections.slice(1); var pivotIndex = Math.ceil(array.length / 2); var beforeArray = array.slice(0, pivotIndex); if (splitDirection === SPLIT.LEFT) { return this.splitArray(beforeArray, restSplitDirections, level); } var afterArray = array.slice(pivotIndex); return beforeArray.concat(this.splitArray(afterArray, restSplitDirections, level)); }; TruncateMarkup.prototype.fits = function fits() { var maxLines = this.props.lines; var _el$getBoundingClient = this.el.getBoundingClientRect(), height = _el$getBoundingClient.height; var computedLines = Math.round(height / parseFloat(this.lineHeight)); return maxLines >= computedLines; }; TruncateMarkup.prototype.render = function render() { return this.state.text; }; _createClass(TruncateMarkup, [{ key: 'isValid', get: function get() { return this.validateTreeMemo(this.props.children); } }, { key: 'origText', get: function get() { return this.childrenWithRefMemo(this.props.children); } }, { key: 'policy', get: function get() { return _tokenizeRules2.default[this.props.tokenize] || _tokenizeRules2.default.characters; } }]); return TruncateMarkup; }(_react2.default.Component), _class.Atom = _atom.Atom, _class.defaultProps = { lines: 1, ellipsis: '...', lineHeight: '', onTruncate: function onTruncate() {}, tokenize: 'characters' }, _temp); exports.default = TruncateMarkup; TruncateMarkup.propTypes = process.env.NODE_ENV !== "production" ? { children: _propTypes2.default.element.isRequired, lines: _propTypes2.default.number, ellipsis: _propTypes2.default.oneOfType([_propTypes2.default.element, _propTypes2.default.string, _propTypes2.default.func]), lineHeight: _propTypes2.default.oneOfType([_propTypes2.default.string, _propTypes2.default.number]), onTruncate: _propTypes2.default.func, // eslint-disable-next-line onAfterTruncate: function onAfterTruncate(props, propName, componentName) { if (props[propName]) { return new Error(componentName + ': Setting `onAfterTruncate` prop is deprecated, use `onTruncate` instead.'); } }, tokenize: function tokenize(props, propName, componentName) { var tokenizeValue = props[propName]; if (typeof tokenizeValue !== 'undefined') { if (!_tokenizeRules2.default[tokenizeValue]) { /* eslint-disable no-console */ return new Error(componentName + ': Unknown option for prop \'tokenize\': \'' + tokenizeValue + '\'. Option \'characters\' will be used instead.'); /* eslint-enable */ } } } } : {}; module.exports = exports['default'];