UNPKG

react-mentions

Version:
762 lines (596 loc) 23.5 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports._getTriggerRegex = undefined; var _extends2 = require('babel-runtime/helpers/extends'); var _extends3 = _interopRequireDefault(_extends2); var _getPrototypeOf = require('babel-runtime/core-js/object/get-prototype-of'); var _getPrototypeOf2 = _interopRequireDefault(_getPrototypeOf); var _classCallCheck2 = require('babel-runtime/helpers/classCallCheck'); var _classCallCheck3 = _interopRequireDefault(_classCallCheck2); var _createClass2 = require('babel-runtime/helpers/createClass'); var _createClass3 = _interopRequireDefault(_createClass2); var _possibleConstructorReturn2 = require('babel-runtime/helpers/possibleConstructorReturn'); var _possibleConstructorReturn3 = _interopRequireDefault(_possibleConstructorReturn2); var _inherits2 = require('babel-runtime/helpers/inherits'); var _inherits3 = _interopRequireDefault(_inherits2); var _react = require('react'); var _react2 = _interopRequireDefault(_react); var _propTypes = require('prop-types'); var _propTypes2 = _interopRequireDefault(_propTypes); var _reactDom = require('react-dom'); var _reactDom2 = _interopRequireDefault(_reactDom); var _keys = require('lodash/keys'); var _keys2 = _interopRequireDefault(_keys); var _values = require('lodash/values'); var _values2 = _interopRequireDefault(_values); var _omit = require('lodash/omit'); var _omit2 = _interopRequireDefault(_omit); var _isEqual = require('lodash/isEqual'); var _isEqual2 = _interopRequireDefault(_isEqual); var _substyle = require('substyle'); var _utils = require('./utils'); var _utils2 = _interopRequireDefault(_utils); var _SuggestionsOverlay = require('./SuggestionsOverlay'); var _SuggestionsOverlay2 = _interopRequireDefault(_SuggestionsOverlay); var _Highlighter = require('./Highlighter'); var _Highlighter2 = _interopRequireDefault(_Highlighter); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } var _getTriggerRegex = exports._getTriggerRegex = function _getTriggerRegex(trigger) { var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; if (trigger instanceof RegExp) { return trigger; } else { var allowSpaceInQuery = options.allowSpaceInQuery; var escapedTriggerChar = _utils2.default.escapeRegex(trigger); // first capture group is the part to be replaced on completion // second capture group is for extracting the search query return new RegExp('(?:^|\\s)(' + escapedTriggerChar + '([^' + (allowSpaceInQuery ? '' : '\\s') + escapedTriggerChar + ']*))$'); } }; var _getDataProvider = function _getDataProvider(data) { if (data instanceof Array) { // if data is an array, create a function to query that return function (query, callback) { var results = []; for (var i = 0, l = data.length; i < l; ++i) { var display = data[i].display || data[i].id; if (display.toLowerCase().indexOf(query.toLowerCase()) >= 0) { results.push(data[i]); } } return results; }; } else { // expect data to be a query function return data; } }; var KEY = { TAB: 9, RETURN: 13, ESC: 27, UP: 38, DOWN: 40 }; var isComposing = false; var MentionsInput = function (_React$Component) { (0, _inherits3.default)(MentionsInput, _React$Component); function MentionsInput(props) { (0, _classCallCheck3.default)(this, MentionsInput); var _this = (0, _possibleConstructorReturn3.default)(this, (MentionsInput.__proto__ || (0, _getPrototypeOf2.default)(MentionsInput)).call(this, props)); _initialiseProps.call(_this); _this.suggestions = {}; _this.state = { focusIndex: 0, selectionStart: null, selectionEnd: null, suggestions: {}, caretPosition: null, suggestionsPosition: null }; return _this; } (0, _createClass3.default)(MentionsInput, [{ key: 'render', value: function render() { return _react2.default.createElement( 'div', (0, _extends3.default)({ ref: 'container' }, this.props.style), this.renderControl(), this.renderSuggestionsOverlay() ); } // Returns the text to set as the value of the textarea with all markups removed // Handle input element's change event // Handle input element's select event }, { key: 'componentDidMount', value: function componentDidMount() { this.updateSuggestionsPosition(); } }, { key: 'componentDidUpdate', value: function componentDidUpdate() { this.updateSuggestionsPosition(); // maintain selection in case a mention is added/removed causing // the cursor to jump to the end if (this.state.setSelectionAfterMentionChange) { this.setState({ setSelectionAfterMentionChange: false }); this.setSelection(this.state.selectionStart, this.state.selectionEnd); } } }]); return MentionsInput; }(_react2.default.Component); MentionsInput.propTypes = { /** * If set to `true` a regular text input element will be rendered * instead of a textarea */ singleLine: _propTypes2.default.bool, /** * If set to `true` spaces will not interrupt matching suggestions */ allowSpaceInQuery: _propTypes2.default.bool, markup: _propTypes2.default.string, value: _propTypes2.default.string, displayTransform: _propTypes2.default.func, onKeyDown: _propTypes2.default.func, onSelect: _propTypes2.default.func, onBlur: _propTypes2.default.func, onChange: _propTypes2.default.func, children: _propTypes2.default.oneOfType([_propTypes2.default.element, _propTypes2.default.arrayOf(_propTypes2.default.element)]).isRequired }; MentionsInput.defaultProps = { markup: "@[__display__](__id__)", singleLine: false, displayTransform: function displayTransform(id, display, type) { return display; }, onKeyDown: function onKeyDown() { return null; }, onSelect: function onSelect() { return null; }, onBlur: function onBlur() { return null; } }; var _initialiseProps = function _initialiseProps() { var _this2 = this; this.getInputProps = function (isTextarea) { var _props = _this2.props, readOnly = _props.readOnly, disabled = _props.disabled, style = _props.style; // pass all props that we don't use through to the input control var props = (0, _omit2.default)(_this2.props, 'style', (0, _keys2.default)(MentionsInput.propTypes)); return (0, _extends3.default)({}, props, style("input"), { value: _this2.getPlainText() }, !readOnly && !disabled && { onChange: _this2.handleChange, onSelect: _this2.handleSelect, onKeyDown: _this2.handleKeyDown, onBlur: _this2.handleBlur, onCompositionStart: _this2.handleCompositionStart, onCompositionEnd: _this2.handleCompositionEnd }); }; this.renderControl = function () { var _props2 = _this2.props, singleLine = _props2.singleLine, style = _props2.style; var inputProps = _this2.getInputProps(!singleLine); return _react2.default.createElement( 'div', style("control"), _this2.renderHighlighter(inputProps.style), singleLine ? _this2.renderInput(inputProps) : _this2.renderTextarea(inputProps) ); }; this.renderInput = function (props) { return _react2.default.createElement('input', (0, _extends3.default)({ type: 'text', ref: 'input' }, props)); }; this.renderTextarea = function (props) { return _react2.default.createElement('textarea', (0, _extends3.default)({ ref: 'input' }, props)); }; this.renderSuggestionsOverlay = function () { if (!_utils2.default.isNumber(_this2.state.selectionStart)) { // do not show suggestions when the input does not have the focus return null; } return _react2.default.createElement(_SuggestionsOverlay2.default, { style: _this2.props.style("suggestions"), position: _this2.state.suggestionsPosition, focusIndex: _this2.state.focusIndex, scrollFocusedIntoView: _this2.state.scrollFocusedIntoView, ref: 'suggestions', suggestions: _this2.state.suggestions, onSelect: _this2.addMention, onMouseDown: _this2.handleSuggestionsMouseDown, onMouseEnter: function onMouseEnter(focusIndex) { return _this2.setState({ focusIndex: focusIndex, scrollFocusedIntoView: false }); }, isLoading: _this2.isLoading() }); }; this.renderHighlighter = function (inputStyle) { var _state = _this2.state, selectionStart = _state.selectionStart, selectionEnd = _state.selectionEnd; var _props3 = _this2.props, markup = _props3.markup, displayTransform = _props3.displayTransform, singleLine = _props3.singleLine, children = _props3.children, value = _props3.value, style = _props3.style; return _react2.default.createElement( _Highlighter2.default, { ref: 'highlighter', style: style("highlighter"), inputStyle: inputStyle, value: value, markup: markup, displayTransform: displayTransform, singleLine: singleLine, selection: { start: selectionStart, end: selectionEnd }, onCaretPositionChange: function onCaretPositionChange(position) { return _this2.setState({ caretPosition: position }); } }, children ); }; this.getPlainText = function () { return _utils2.default.getPlainText(_this2.props.value || "", _this2.props.markup, _this2.props.displayTransform); }; this.executeOnChange = function (event) { for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { args[_key - 1] = arguments[_key]; } if (_this2.props.onChange) { var _props4; return (_props4 = _this2.props).onChange.apply(_props4, [event].concat(args)); } if (_this2.props.valueLink) { var _props$valueLink; return (_props$valueLink = _this2.props.valueLink).requestChange.apply(_props$valueLink, [event.target.value].concat(args)); } }; this.handleChange = function (ev) { if (document.activeElement !== ev.target) { // fix an IE bug (blur from empty input element with placeholder attribute trigger "input" event) return; } var value = _this2.props.value || ""; var newPlainTextValue = ev.target.value; // Derive the new value to set by applying the local change in the textarea's plain text var newValue = _utils2.default.applyChangeToValue(value, _this2.props.markup, newPlainTextValue, _this2.state.selectionStart, _this2.state.selectionEnd, ev.target.selectionEnd, _this2.props.displayTransform); // In case a mention is deleted, also adjust the new plain text value newPlainTextValue = _utils2.default.getPlainText(newValue, _this2.props.markup, _this2.props.displayTransform); // Save current selection after change to be able to restore caret position after rerendering var selectionStart = ev.target.selectionStart; var selectionEnd = ev.target.selectionEnd; var setSelectionAfterMentionChange = false; // Adjust selection range in case a mention will be deleted by the characters outside of the // selection range that are automatically deleted var startOfMention = _utils2.default.findStartOfMentionInPlainText(value, _this2.props.markup, selectionStart, _this2.props.displayTransform); if (startOfMention !== undefined && _this2.state.selectionEnd > startOfMention) { // only if a deletion has taken place selectionStart = startOfMention; selectionEnd = selectionStart; setSelectionAfterMentionChange = true; } _this2.setState({ selectionStart: selectionStart, selectionEnd: selectionEnd, setSelectionAfterMentionChange: setSelectionAfterMentionChange }); var mentions = _utils2.default.getMentions(newValue, _this2.props.markup); // Propagate change // let handleChange = this.getOnChange(this.props) || emptyFunction; var eventMock = { target: { value: newValue } }; // this.props.onChange.call(this, eventMock, newValue, newPlainTextValue, mentions); _this2.executeOnChange(eventMock, newValue, newPlainTextValue, mentions); }; this.handleSelect = function (ev) { // do nothing while a IME composition session is active if (isComposing) return; // keep track of selection range / caret position _this2.setState({ selectionStart: ev.target.selectionStart, selectionEnd: ev.target.selectionEnd }); // refresh suggestions queries var el = _this2.refs.input; if (ev.target.selectionStart === ev.target.selectionEnd) { _this2.updateMentionsQueries(el.value, ev.target.selectionStart); } else { _this2.clearSuggestions(); } // sync highlighters scroll position _this2.updateHighlighterScroll(); _this2.props.onSelect(ev); }; this.handleKeyDown = function (ev) { // do not intercept key events if the suggestions overlay is not shown var suggestionsCount = _utils2.default.countSuggestions(_this2.state.suggestions); var suggestionsComp = _this2.refs.suggestions; if (suggestionsCount === 0 || !suggestionsComp) { _this2.props.onKeyDown(ev); return; } if ((0, _values2.default)(KEY).indexOf(ev.keyCode) >= 0) { ev.preventDefault(); } switch (ev.keyCode) { case KEY.ESC: { _this2.clearSuggestions(); return; } case KEY.DOWN: { _this2.shiftFocus(+1); return; } case KEY.UP: { _this2.shiftFocus(-1); return; } case KEY.RETURN: { _this2.selectFocused(); return; } case KEY.TAB: { _this2.selectFocused(); return; } } }; this.shiftFocus = function (delta) { var suggestionsCount = _utils2.default.countSuggestions(_this2.state.suggestions); _this2.setState({ focusIndex: (suggestionsCount + _this2.state.focusIndex + delta) % suggestionsCount, scrollFocusedIntoView: true }); }; this.selectFocused = function () { var _state2 = _this2.state, suggestions = _state2.suggestions, focusIndex = _state2.focusIndex; var _utils$getSuggestion = _utils2.default.getSuggestion(suggestions, focusIndex), suggestion = _utils$getSuggestion.suggestion, descriptor = _utils$getSuggestion.descriptor; _this2.addMention(suggestion, descriptor); _this2.setState({ focusIndex: 0 }); }; this.handleBlur = function (ev) { var clickedSuggestion = _this2._suggestionsMouseDown; _this2._suggestionsMouseDown = false; // only reset selection if the mousedown happened on an element // other than the suggestions overlay if (!clickedSuggestion) { _this2.setState({ selectionStart: null, selectionEnd: null }); }; window.setTimeout(function () { _this2.updateHighlighterScroll(); }, 1); _this2.props.onBlur(ev, clickedSuggestion); }; this.handleSuggestionsMouseDown = function (ev) { _this2._suggestionsMouseDown = true; }; this.updateSuggestionsPosition = function () { var caretPosition = _this2.state.caretPosition; if (!caretPosition || !_this2.refs.suggestions) { return; } var container = _this2.refs.container; var suggestions = _reactDom2.default.findDOMNode(_this2.refs.suggestions); var highlighter = _reactDom2.default.findDOMNode(_this2.refs.highlighter); if (!suggestions) { return; } var left = caretPosition.left - highlighter.scrollLeft; var position = {}; // guard for mentions suggestions list clipped by right edge of window if (left + suggestions.offsetWidth > container.offsetWidth) { position.right = 0; } else { position.left = left; } position.top = caretPosition.top - highlighter.scrollTop; if ((0, _isEqual2.default)(position, _this2.state.suggestionsPosition)) { return; } _this2.setState({ suggestionsPosition: position }); }; this.updateHighlighterScroll = function () { if (!_this2.refs.input || !_this2.refs.highlighter) { // since the invocation of this function is deferred, // the whole component may have been unmounted in the meanwhile return; } var input = _this2.refs.input; var highlighter = _reactDom2.default.findDOMNode(_this2.refs.highlighter); highlighter.scrollLeft = input.scrollLeft; }; this.handleCompositionStart = function () { isComposing = true; }; this.handleCompositionEnd = function () { isComposing = false; }; this.setSelection = function (selectionStart, selectionEnd) { if (selectionStart === null || selectionEnd === null) return; var el = _this2.refs.input; if (el.setSelectionRange) { el.setSelectionRange(selectionStart, selectionEnd); } else if (el.createTextRange) { var range = el.createTextRange(); range.collapse(true); range.moveEnd('character', selectionEnd); range.moveStart('character', selectionStart); range.select(); } }; this.updateMentionsQueries = function (plainTextValue, caretPosition) { // Invalidate previous queries. Async results for previous queries will be neglected. _this2._queryId++; _this2.suggestions = {}; _this2.setState({ suggestions: {} }); // If caret is inside of or directly behind of mention, do not query var value = _this2.props.value || ""; if (_utils2.default.isInsideOfMention(value, _this2.props.markup, caretPosition, _this2.props.displayTransform) || _utils2.default.isInsideOfMention(value, _this2.props.markup, caretPosition - 1, _this2.props.displayTransform)) { return; } // Check if suggestions have to be shown: // Match the trigger patterns of all Mention children the new plain text substring up to the current caret position var substring = plainTextValue.substring(0, caretPosition); _react2.default.Children.forEach(_this2.props.children, function (child) { if (!child) { return; } var regex = _getTriggerRegex(child.props.trigger, _this2.props); var match = substring.match(regex); if (match) { var querySequenceStart = substring.indexOf(match[1], match.index); _this2.queryData(match[2], child, querySequenceStart, querySequenceStart + match[1].length, plainTextValue); } }); }; this.clearSuggestions = function () { // Invalidate previous queries. Async results for previous queries will be neglected. _this2._queryId++; _this2.suggestions = {}; _this2.setState({ suggestions: {}, focusIndex: 0 }); }; this.queryData = function (query, mentionDescriptor, querySequenceStart, querySequenceEnd, plainTextValue) { var provideData = _getDataProvider(mentionDescriptor.props.data); var snycResult = provideData(query, _this2.updateSuggestions.bind(null, _this2._queryId, mentionDescriptor, query, querySequenceStart, querySequenceEnd, plainTextValue)); if (snycResult instanceof Array) { _this2.updateSuggestions(_this2._queryId, mentionDescriptor, query, querySequenceStart, querySequenceEnd, plainTextValue, snycResult); } }; this.updateSuggestions = function (queryId, mentionDescriptor, query, querySequenceStart, querySequenceEnd, plainTextValue, suggestions) { // neglect async results from previous queries if (queryId !== _this2._queryId) return; var update = {}; update[mentionDescriptor.props.type] = { query: query, mentionDescriptor: mentionDescriptor, querySequenceStart: querySequenceStart, querySequenceEnd: querySequenceEnd, results: suggestions, plainTextValue: plainTextValue }; // save in property so that multiple sync state updates from different mentions sources // won't overwrite each other _this2.suggestions = _utils2.default.extend({}, _this2.suggestions, update); _this2.setState({ suggestions: _this2.suggestions }); }; this.addMention = function (suggestion, _ref2) { var mentionDescriptor = _ref2.mentionDescriptor, querySequenceStart = _ref2.querySequenceStart, querySequenceEnd = _ref2.querySequenceEnd, plainTextValue = _ref2.plainTextValue; // Insert mention in the marked up value at the correct position var value = _this2.props.value || ""; var start = _utils2.default.mapPlainTextIndex(value, _this2.props.markup, querySequenceStart, 'START', _this2.props.displayTransform); var end = start + querySequenceEnd - querySequenceStart; var insert = _utils2.default.makeMentionsMarkup(_this2.props.markup, suggestion.id, suggestion.display, mentionDescriptor.props.type); if (mentionDescriptor.props.appendSpaceOnAdd) { insert = insert + ' '; } var newValue = _utils2.default.spliceString(value, start, end, insert); // Refocus input and set caret position to end of mention _this2.refs.input.focus(); var displayValue = _this2.props.displayTransform(suggestion.id, suggestion.display, mentionDescriptor.props.type); if (mentionDescriptor.props.appendSpaceOnAdd) { displayValue = displayValue + ' '; } var newCaretPosition = querySequenceStart + displayValue.length; _this2.setState({ selectionStart: newCaretPosition, selectionEnd: newCaretPosition, setSelectionAfterMentionChange: true }); // Propagate change var eventMock = { target: { value: newValue } }; var mentions = _utils2.default.getMentions(newValue, _this2.props.markup); var newPlainTextValue = _utils2.default.spliceString(plainTextValue, querySequenceStart, querySequenceEnd, displayValue); _this2.executeOnChange(eventMock, newValue, newPlainTextValue, mentions); var onAdd = mentionDescriptor.props.onAdd; if (onAdd) { onAdd(suggestion.id, suggestion.display); } // Make sure the suggestions overlay is closed _this2.clearSuggestions(); }; this.isLoading = function () { var isLoading = false; _react2.default.Children.forEach(_this2.props.children, function (child) { isLoading = isLoading || child && child.props.isLoading; }); return isLoading; }; this._queryId = 0; }; var isMobileSafari = typeof navigator !== 'undefined' && /iPhone|iPad|iPod/i.test(navigator.userAgent); var styled = (0, _substyle.defaultStyle)({ position: "relative", overflowY: "visible", input: { display: "block", position: "absolute", top: 0, boxSizing: "border-box", backgroundColor: "transparent", width: "inherit" }, '&multiLine': { input: (0, _extends3.default)({ width: "100%", height: "100%", bottom: 0, overflow: "hidden", resize: "none" }, isMobileSafari ? { marginTop: 1, marginLeft: -3 } : null) } }, function (_ref) { var singleLine = _ref.singleLine; return { "&singleLine": singleLine, "&multiLine": !singleLine }; }); exports.default = styled(MentionsInput);