import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import classnames from 'classnames'; import autobind from 'autobind-decorator'; import CodeEditor from './code-editor'; import Input from '../base/debounced-input'; const MODE_INPUT = 'input'; const MODE_EDITOR = 'editor'; const TYPE_TEXT = 'text'; const NUNJUCKS_REGEX = /({%|%}|{{|}})/; @autobind class OneLineEditor extends PureComponent { constructor (props) { super(props); let mode; if (props.forceInput) { mode = MODE_INPUT; } else if (props.forceEditor) { mode = MODE_EDITOR; } else if (this._mayContainNunjucks(props.defaultValue)) { mode = MODE_EDITOR; } else { mode = MODE_INPUT; } this.state = { mode }; } focus (setToEnd = false) { if (this.state.mode === MODE_EDITOR) { if (this._editor && !this._editor.hasFocus()) { setToEnd ? this._editor.focusEnd() : this._editor.focus(); } } else { if (this._input && !this._input.hasFocus()) { setToEnd ? this._input.focusEnd() : this._input.focus(); } } } focusEnd () { this.focus(true); } selectAll () { if (this.state.mode === MODE_EDITOR) { this._editor.selectAll(); } else { this._input.select(); } } getValue () { if (this.state.mode === MODE_EDITOR) { return this._editor.getValue(); } else { return this._input.getValue(); } } getSelectionStart () { if (this._editor) { return this._editor.getSelectionStart(); } else { console.warn('Tried to get selection start of one-line-editor when '); return this._input.value.length; } } getSelectionEnd () { if (this._editor) { return this._editor.getSelectionEnd(); } else { console.warn('Tried to get selection end of one-line-editor when '); return this._input.value.length; } } componentDidMount () { document.body.addEventListener('mousedown', this._handleDocumentMousedown); } componentWillUnmount () { document.body.removeEventListener('mousedown', this._handleDocumentMousedown); } _handleDocumentMousedown (e) { if (!this._editor) { return; } // Clear the selection if mousedown happens outside the input so we act like // a regular // NOTE: Must be "mousedown", not "click" because "click" triggers on selection drags const node = ReactDOM.findDOMNode(this._editor); const clickWasOutsideOfComponent = !node.contains(e.target); if (clickWasOutsideOfComponent) { this._editor.clearSelection(); } } _handleInputDragEnter () { this._convertToEditorPreserveFocus(); } _handleInputMouseEnter () { // Convert to editor when user hovers mouse over input /* * NOTE: we're doing it in a timeout because we don't want to convert if the * mouse goes in an out right away. */ this._mouseEnterTimeout = setTimeout(this._convertToEditorPreserveFocus, 100); } _handleInputMouseLeave () { clearTimeout(this._mouseEnterTimeout); } _handleEditorMouseLeave () { this._convertToInputIfNotFocused(); } _handleEditorFocus (e) { const focusedFromTabEvent = !!e.sourceCapabilities; if (focusedFromTabEvent) { this._editor.focusEnd(); } if (!this._editor) { console.warn('Tried to focus editor when it was not mounted', this); return; } // Set focused state this._editor.setAttribute('data-focused', 'on'); this.props.onFocus && this.props.onFocus(e); } _handleInputFocus (e) { // If we're focusing the whole thing, blur the input. This happens when // the user tabs to the field. const start = this._input.getSelectionStart(); const end = this._input.getSelectionEnd(); const focusedFromTabEvent = start === 0 && end === e.target.value.length; if (focusedFromTabEvent) { this._input.focusEnd(); // Also convert to editor if we tabbed to it. Just in case the user // needs an editor this._convertToEditorPreserveFocus(); } // Set focused state this._input.setAttribute('data-focused', 'on'); // Also call the regular callback this.props.onFocus && this.props.onFocus(e); } _handleInputChange (value) { this._convertToEditorPreserveFocus(); this.props.onChange && this.props.onChange(value); } _handleInputKeyDown (e) { if (this.props.onKeyDown) { this.props.onKeyDown(e, e.target.value); } } _handleInputBlur () { // Set focused state this._input.removeAttribute('data-focused'); this.props.onBlur && this.props.onBlur(); } _handleEditorBlur () { // Editor was already removed from the DOM, so do nothing if (!this._editor) { return; } // Set focused state this._editor.removeAttribute('data-focused'); if (!this.props.forceEditor) { // Convert back to input sometime in the future. // NOTE: this was originally added because the input would disappear if // the user tabbed away very shortly after typing, but it's actually a pretty // good feature. setTimeout(() => { this._convertToInputIfNotFocused(); }, 2000); } this.props.onBlur && this.props.onBlur(); } _handleKeyDown (e) { // submit form if needed if (e.keyCode === 13) { let node = e.target; for (let i = 0; i < 20 && node; i++) { if (node.tagName === 'FORM') { node.dispatchEvent(new window.Event('submit')); e.preventDefault(); e.stopPropagation(); break; } node = node.parentNode; } } this.props.onKeyDown && this.props.onKeyDown(e, this.getValue()); } _convertToEditorPreserveFocus () { if (this.state.mode !== MODE_INPUT || this.props.forceInput) { return; } if (!this._input) { return; } if (this._input.hasFocus()) { const start = this._input.getSelectionStart(); const end = this._input.getSelectionEnd(); // Wait for the editor to swap and restore cursor position const check = () => { if (this._editor) { this._editor.focus(); this._editor.setSelection(start, end); } else { setTimeout(check, 40); } }; // Tell the component to show the editor setTimeout(check); } this.setState({mode: MODE_EDITOR}); } _convertToInputIfNotFocused () { if (this.state.mode === MODE_INPUT || this.props.forceEditor) { return; } if (!this._editor || this._editor.hasFocus()) { return; } if (this._mayContainNunjucks(this.getValue())) { return; } this.setState({mode: MODE_INPUT}); } _setEditorRef (n) { this._editor = n; } _setInputRef (n) { this._input = n; } _mayContainNunjucks (text) { return !!(text && text.match(NUNJUCKS_REGEX)); } render () { const { id, defaultValue, className, onChange, placeholder, render, onPaste, getRenderContext, nunjucksPowerUserMode, getAutocompleteConstants, mode: syntaxMode, type: originalType } = this.props; const {mode} = this.state; const type = originalType || TYPE_TEXT; const showEditor = mode === MODE_EDITOR; if (showEditor) { return ( ); } else { return ( ); } } } OneLineEditor.propTypes = { defaultValue: PropTypes.string.isRequired, // Optional id: PropTypes.string, type: PropTypes.string, mode: PropTypes.string, onBlur: PropTypes.func, onKeyDown: PropTypes.func, onFocus: PropTypes.func, onChange: PropTypes.func, onPaste: PropTypes.func, render: PropTypes.func, getRenderContext: PropTypes.func, nunjucksPowerUserMode: PropTypes.bool, getAutocompleteConstants: PropTypes.func, placeholder: PropTypes.string, className: PropTypes.string, forceEditor: PropTypes.bool, forceInput: PropTypes.bool }; export default OneLineEditor;