You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
345 lines
7.8 KiB
345 lines
7.8 KiB
9 years ago
|
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 === '<br />') {
|
||
|
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;
|