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
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; |