react-truncate-markup
Version:
React component for truncating JSX markup
512 lines (403 loc) • 18.8 kB
JavaScript
'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'];