var React = require('react') var ReactDOM = require('react-dom') var classNames = require('classnames') var escapeHTML = require('escape-html') var isServer = typeof window === 'undefined' if (!isServer) { var selectionRange = require('selection-range') } var noop = function(){} /** * Make a contenteditable element */ var ContentEditable = React.createClass({ propTypes: { editing: React.PropTypes.bool, html: React.PropTypes.string, onChange: React.PropTypes.func.isRequired, placeholder: React.PropTypes.bool, placeholderText: React.PropTypes.string, tagName: React.PropTypes.string, onEnterKey: React.PropTypes.func, onEscapeKey: React.PropTypes.func, preventStyling: React.PropTypes.bool, noLinebreaks: React.PropTypes.bool, onBlur: React.PropTypes.func, onFocus: React.PropTypes.func, onBold: React.PropTypes.func, onItalic: React.PropTypes.func, onKeyDown: React.PropTypes.func, onKeyPress: React.PropTypes.func, placeholderStyle: React.PropTypes.object }, getDefaultProps: function() { return { html: '', placeholder: false, placeholderText: '', onBold: noop, onItalic: noop, onKeyDown: noop, onKeyPress: noop }; }, getInitialState: function(){ return {}; }, shouldComponentUpdate: function(nextProps) { var el = ReactDOM.findDOMNode(this) if (nextProps.html !== el.innerHTML) { if (nextProps.html && document.activeElement === el) { this._range = selectionRange(el) } return true } if (nextProps.placeholder !== this.props.placeholder) { return true } if (nextProps.editing !== this.props.editing) { return true } return false }, componentWillReceiveProps: function (nextProps) { if (!this.props.editing && nextProps.editing) { if (this.contentIsEmpty(nextProps.html)) { this.props.onChange('', true) } } }, componentDidUpdate: function() { if (!this.props.editing && !this.props.html) { this.props.onChange('') } if (this._range) { selectionRange(ReactDOM.findDOMNode(this), this._range) delete this._range } }, autofocus: function(){ ReactDOM.findDOMNode(this).focus(); }, render: function() { // todo: use destructuring var editing = this.props.editing; var className = this.props.className; var tagName = this.props.tagName; // setup our classes var classes = { ContentEditable: true }; var placeholderStyle = this.props.placeholderStyle || { color: '#bbbbbb' } if (className) { classes[className] = true; } // set 'div' as our default tagname tagName = tagName || 'div'; var content = this.props.html; // return our newly created element return React.createElement(tagName, { tabIndex: 0, key: '0', className: classNames(classes), contentEditable: editing, onBlur: this.onBlur, onFocus: this.onFocus, onKeyDown: this.onKeyDown, onPaste: this.onPaste, onMouseDown: this.onMouseDown, 'aria-label': this.props.placeholderText, onTouchStart: this.onMouseDown, style: this.props.placeholder ? placeholderStyle : this.props.style || {}, onKeyPress: this.onKeyPress, onInput: this.onInput, onKeyUp: this.onKeyUp, dangerouslySetInnerHTML: { __html : this.props.placeholder ? this.props.placeholderText : content } }); }, unsetPlaceholder: function(){ this.props.onChange('', false, '') }, setCursorToStart: function(){ ReactDOM.findDOMNode(this).focus(); var sel = window.getSelection(); var range = document.createRange(); range.setStart(ReactDOM.findDOMNode(this), 0); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); }, contentIsEmpty: function (content) { if (this.state.placeholder) { return true } if (!content) { return true } if (content === '
') { return true } if (!content.trim().length) { return true } return false }, onMouseDown: function(e) { // prevent cursor placement if placeholder is set if (this.contentIsEmpty(this.props.html)) { this.setCursorToStart() e.preventDefault() } }, onKeyDown: function(e) { var self = this this.props.onKeyDown(e) function prev () { e.preventDefault(); e.stopPropagation(); self.stop = true } var key = e.keyCode; // bold & italic styling if (e.metaKey) { // bold if (key === 66) { this.props.onBold(e) if (this.props.preventStyling) { return prev() } // italic } else if (key === 73) { this.props.onItalic(e) if (this.props.preventStyling) { return prev() } //paste } else if (key === 86) { return; } } // prevent linebreaks if (this.props.noLinebreaks && (key === 13)) { return prev() } // placeholder behaviour if (this.contentIsEmpty(this.props.html)) { // If no text if (e.metaKey || (e.shiftKey && (key === 16))) { return prev() } switch (key) { case 46: // 'Delete' key case 8: // 'Backspace' key case 9: // 'Tab' key case 39: // 'Arrow right' key case 37: // 'Arrow left' key case 40: // 'Arrow left' key case 38: // 'Arrow left' key prev(); break; case 13: // 'Enter' key prev(); if (this.props.onEnterKey) { this.props.onEnterKey(); } break; case 27: // 'Escape' key prev(); if (this.props.onEscapeKey) { this.props.onEscapeKey(); } break; default: this.unsetPlaceholder(); break; } } }, _replaceCurrentSelection: function(data) { var selection = window.getSelection(); var range = selection.getRangeAt(0); range.deleteContents(); var fragment = range.createContextualFragment(''); fragment.textContent = data; var replacementEnd = fragment.lastChild; range.insertNode(fragment); // Set cursor at the end of the replaced content, just like browsers do. range.setStartAfter(replacementEnd); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); }, onPaste: function(e){ // handle paste manually to ensure we unset our placeholder e.preventDefault(); var data = e.clipboardData.getData('text/plain') this._replaceCurrentSelection(data); var target = ReactDOM.findDOMNode(this) this.props.onChange(target.textContent, false, target.innerHTML) }, onKeyPress: function(e){ this.props.onKeyPress(e) }, onKeyUp: function(e) { if (this._supportsInput) return if (this.stop) { this.stop = false return } var target = ReactDOM.findDOMNode(this) var self = this if (!target.textContent.trim().length) { this.props.onChange('', true, '') setTimeout(function(){ self.setCursorToStart() }, 1) } else { this.props.onChange(target.textContent, false, target.innerHTML) } }, onInput: function(e) { this._supportsInput = true var val = e.target.innerHTML var text = e.target.textContent.trim() if (!text) { this.props.onChange('', true, '') return } this.props.onChange(escapeHTML(e.target.textContent), false, e.target.innerHTML) }, onBlur: function(e) { if (this.props.onBlur) { this.props.onBlur(e); } }, onFocus: function(e) { if (this.props.onFocus) { this.props.onFocus(e); } } }); module.exports = ContentEditable;