├── .gitignore ├── .jshintrc ├── .npmignore ├── .travis.yml ├── CHANGES.md ├── LICENSE ├── README.md ├── demo └── index.html ├── dist ├── react-plain-editable.js └── react-plain-editable.min.js ├── gulpfile.js ├── package.json └── src └── index.jsx /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "node": true, 4 | 5 | "curly": true, 6 | "devel": true, 7 | "globals": { 8 | }, 9 | "noempty": true, 10 | "newcap": false, 11 | "undef": true, 12 | "unused": "vars", 13 | 14 | "asi": true, 15 | "boss": true, 16 | "eqnull": true, 17 | "expr": true, 18 | "funcscope": true, 19 | "globalstrict": true, 20 | "laxbreak": true, 21 | "laxcomma": true, 22 | "loopfunc": true, 23 | "sub": true 24 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo 2 | dist 3 | src 4 | test 5 | .gitignore 6 | .jshintrc 7 | .travis.yml 8 | gulpfile.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 2.0.0 / 2015-02-26 2 | 3 | **Breaking change:** replaced `html` prop with `value` prop. 4 | 5 | Plain text should be passed for the new `value` prop. 6 | 7 | The component will handle creating an HTML representation of the text for 8 | display in the `contentEditable`. 9 | 10 | **Breaking change:** the value passed to `onBlur()` and `onChange()` callbacks 11 | is now converted to plain text with linebreaks and non-breaking space characters 12 | where appropriate to preserve whitespace. Any HTML present in the value will no 13 | longer be escaped. 14 | 15 | Value returned pre-2.0.0: 16 | 17 | ``` 18 | 1

  <2>

3 19 | ``` 20 | 21 | Value returned in 2.0.0: 22 | 23 | ``` 24 | 1 25 | 26 | <2> 27 | 28 | 3 29 | ``` 30 | 31 | Added a `singleLine` boolean prop to prevent linebreaks being added to the 32 | `contentEditable` by pressing Enter. They can still be pasted in, but will be 33 | replaced with spaces in text passed to the `onBlur` and `onChange` callbacks. 34 | 35 | Added a `noTrim` boolean prop to disable trimming of leading and trailing 36 | whitespace in text passed to the `onBlur` and `onChange` callbacks. 37 | 38 | ## 1.1.0 / 2015-02-23 39 | 40 | Added an `autoFocus` prop to give focus to the `contentEditable` when the 41 | component first mounts. 42 | 43 | Added a `focus()` method to give focus to the `contentEditable` DOM node. 44 | 45 | ## 1.0.1 / 2015-02-20 / Like a frightened turtle 46 | 47 | Fixed shrinkage when empty in FF and IE - now ensures default `innerHTML` is set 48 | if the `contentEditable` becomes empty. 49 | 50 | ## 1.0.0 / 2015-02-19 / Ye release 51 | 52 | Initial release. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ## 2 | 3 | Copyright (c) , 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [DEPRECATED] This component is unnecessary, use an `` or something like [react-textarea-autosize](https://github.com/andreypopp/react-textarea-autosize) with `rows="1"` instead 2 | 3 | # react-plain-editable 4 | 5 | A `PlainEditable` [React](http://facebook.github.io/react) component which uses 6 | `contentEditable` to edit plain text. 7 | 8 | **Note:** `contentEditable` seems like an inconsistent mess across browsers and 9 | this has only been tested in the latest stable Firefox (35), Chrome (40) and 10 | Internet Explorer (11) with a `
` container with an unaltered CSS `display` 11 | style. 12 | 13 | Pull requests and compatilbility issue reports to help improve this component 14 | are welcome! 15 | 16 | ## [Live Demo](http://insin.github.io/react-plain-editable/) 17 | 18 | You can also see `PlainEditable` in action in 19 | [ideas-md](http://insin.github.io/ideas-md), a float-to-the-top ideas log app. 20 | 21 | ## Install 22 | 23 | ### npm 24 | 25 | `PlainEditable` can be used on the server, or bundled for the client using an 26 | npm-compatible packaging system such as [Browserify](http://browserify.org/) or 27 | [webpack](http://webpack.github.io/). 28 | 29 | ``` 30 | npm install react-plain-editable --save 31 | ``` 32 | 33 | ### Browser bundle 34 | 35 | The browser bundle exposes a global `PlainEditable` variable and expects to find a 36 | global `React` variable to work with. 37 | 38 | You can find it in the [/dist directory](https://github.com/insin/react-plain-editable/tree/master/dist). 39 | 40 | ## Usage 41 | 42 | Provide `PlainEditable` with at least an `onBlur` or an `onChange` callback 43 | function to get input data back at the desired time, and provide any initial 44 | value as a `value` prop. 45 | 46 | ```html 47 | var Editor = React.createClass({ 48 | _onBlur(e, value) { 49 | this.props.onChange(value) 50 | }, 51 | 52 | render() { 53 | 54 | } 55 | }) 56 | ``` 57 | 58 | For Internet Explorer (and any other browser which generates `

` elements in a 59 | `contentEditable`), you must set up a CSS rule to make `

` elements have the 60 | same visual effect as a `
`: 61 | 62 | ```css 63 | .PlainEditable p { 64 | margin: 0; 65 | } 66 | ``` 67 | 68 | ## API 69 | 70 | ### `PlainEditable` component 71 | 72 | `PlainEditable` is implemented as an "uncontrolled" component which uses 73 | `contentEditable` to edit a given value - i.e. changes to the initial `value` 74 | prop passed to it will not be reflected. 75 | 76 | It expects to be given a plain text value and will provide edited input back as 77 | plain text via its `onBlur` and `onChange` callbacks. 78 | 79 | Leading & trailing whitespance is trimmed in the returned text by default. This 80 | can be disabled by using the `noTrim` prop. 81 | 82 | Multi-line editing is enabled by default. You can restrict editing to a single 83 | line by using the `singleLine` prop. 84 | 85 | The component attempts to work around the most obvious `contentEditable` quirks, 86 | but bugs are likely due to the nature of how `contentEditable` has been 87 | implemented across various browsers. 88 | 89 | #### Props 90 | 91 | *Note: any props passed in addition to those documented below will be passed to 92 | the component created in `PlainEditable`'s `render()` method - if you need to 93 | give your `contentEditable` an `id`, `data-`, or any other additional props, 94 | just pass them as you normally would.* 95 | 96 | ##### `value: String` 97 | 98 | Initial value to be displayed in the `contentEditable`. 99 | 100 | `PlainEditable` is currently implemented as an "uncontrolled" component - i.e. 101 | changes to the initial `value` prop given to it will not be reflected in the 102 | `contentEditable`. 103 | 104 | ##### `onBlur: Function(event: SyntheticEvent, value: String)` 105 | ##### `onChange: Function(event: SyntheticEvent, value: String)` 106 | 107 | These callback props are used to receive edited values from the 108 | `contentEditable` via the `value` argument when the appropriate event fires. 109 | 110 | If `onChange` is given, the `input` event is used to trigger the callback on 111 | every change. 112 | 113 | Since Internet Explorer doesn't currently support `input` on `contentEditable`s, 114 | the `keydown` and `keyup` events are used to trigger the `onChange` callback for 115 | it instead. 116 | 117 | ##### `autoFocus: Boolean` 118 | 119 | If `true` when the component mounts, the `contentEditable` will be given focus. 120 | 121 | ##### `className: String` 122 | 123 | An additional CSS class to append to the default `PlainEditable` CSS class. 124 | 125 | ##### `component: String|ReactCompositeComponent` (default: `'div'`) 126 | 127 | The HTML tag name or React component to be created for use as a 128 | `contentEditable` in `PlainEditable`'s `render()` method. 129 | 130 | ##### `noTrim: Boolean` 131 | 132 | Pass this prop to disable trimming of leading and trailing whitespace in text 133 | passed to the `onBlur` and `onChange` callbacks. 134 | 135 | ```html 136 | 137 | ``` 138 | 139 | ##### `onFocus: Function(event: SyntheticEvent, selecting: Boolean)` 140 | 141 | This callback prop is accepted because this event is already used with the 142 | `contentEditable` to implement selection of `placeholder` content. 143 | 144 | The `selecting` argument will be `true` if the `contentEditable`'s contents will 145 | be selected after giving the browser a chance to complete other operations. 146 | 147 | ##### `onKeyDown: Function(event: SyntheticEvent)` 148 | ##### `onKeyUp: Function(event: SyntheticEvent)` 149 | 150 | These callback props are accepted because these events are already used with the 151 | `contentEditable` to make `onChange` work in IE. 152 | 153 | If you're using IE and you prevent the evant's default action using 154 | `event.preventDefault()`, `onChange` will not be triggered. 155 | 156 | ##### `placeholder: String` 157 | 158 | If provided, the contents of the `contentEditable` will be selected if they 159 | match this prop when it gains focus. 160 | 161 | This can be used to make it more convenient for users to edit an initial value 162 | you provide as a placeholder. 163 | 164 | ##### `singleLine: Boolean` 165 | 166 | Pass this prop to disable entry of linebreaks into the `contentEditable` by 167 | pressing the Enter key, which will force a `blur()`. 168 | 169 | Linebreaks can still be pasted in, but will be replaced with spaces in text 170 | passed to the `onBlur` and `onChange` callbacks. 171 | 172 | ```html 173 | 174 | ``` 175 | 176 | ##### `spellcheck: String` (default: `'false'`) 177 | 178 | Coinfig for the `contentEditable`'s `spellcheck` prop, which is disabled by 179 | default. 180 | 181 | #### Methods 182 | 183 | ##### `focus()` 184 | 185 | Gives focus to the `contentEditable` DOM node. 186 | 187 | ## MIT Licensed 188 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-plain-editable 5 | 6 | 7 | 8 | 9 | 76 | 77 | 78 |

79 | 282 | 283 | -------------------------------------------------------------------------------- /dist/react-plain-editable.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * react-plain-editable 2.0.0 - https://github.com/insin/react-plain-editable 3 | * MIT Licensed 4 | */ 5 | !function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.PlainEditable=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o/g 17 | var linebreaksRE = /\r\n|\r|\n/g 18 | 19 | var escapeHTML = (function() { 20 | var escapeRE = /[&><\u00A0]/g 21 | var escapes = {'&': '&', '>': '>', '<': '<', '\u00A0': ' '} 22 | var escaper = function(match) {return escapes[match];} 23 | return function(text) {return text.replace(escapeRE, escaper);} 24 | })() 25 | 26 | var unescapeHTML = (function() { 27 | var unescapeRE = /&(?:amp|gt|lt|nbsp);/g 28 | var unescapes = {'&': '&', '>': '>', '<': '<', ' ': '\u00A0'} 29 | var unescaper = function(match) {return unescapes[match];} 30 | return function(text) {return text.replace(unescapeRE, unescaper);} 31 | })() 32 | 33 | var linebreaksToBr = (function() { 34 | return function(text) {return text.replace(linebreaksRE, '
');} 35 | })() 36 | 37 | var brsToLinebreak = (function() { 38 | return function(text) {return text.replace(brRE, '\n');} 39 | })() 40 | 41 | function selectElementText(el) { 42 | setTimeout(function() { 43 | var range 44 | if (window.getSelection && document.createRange) { 45 | range = document.createRange() 46 | range.selectNodeContents(el) 47 | var selection = window.getSelection() 48 | selection.removeAllRanges() 49 | selection.addRange(range) 50 | } 51 | else if (document.body.createTextRange) { 52 | range = document.body.createTextRange() 53 | range.moveToElementText(el) 54 | range.select() 55 | } 56 | }, 1) 57 | } 58 | 59 | // ====================================================== HTML normalisation === 60 | 61 | function htmlToText(html) { 62 | if (html == DEFAULT_CONTENTEDITABLE_HTML) { 63 | return '' 64 | } 65 | return unescapeHTML(brsToLinebreak(html)) 66 | } 67 | 68 | function textToHTML(text, singleLine) { 69 | if (singleLine && linebreaksRE.test(text)) { 70 | text = text.replace(linebreaksRE, ' ') 71 | } 72 | return linebreaksToBr(escapeHTML(text)) 73 | } 74 | 75 | // Chrome 40 not wrapping first line when wrapping with block elements 76 | var initialBreaks = /^([^<]+)(?:]*>]*><\/div>]*>|]*>]*><\/p>]*>)/ 77 | var initialBreak = /^([^<]+)(?:]*>|]*>)/ 78 | 79 | var wrappedBreaks = /]*>]*><\/p>|]*>]*><\/div>/g 80 | var openBreaks = /<(?:p|div)[^>]*>/g 81 | var breaks = /]*><\/(?:p|div)>|]*>|<\/(?:p|div)>/g 82 | var allTags = /<\/?[^>]+>/g 83 | var newlines = /\r\n|\n|\r/g 84 | 85 | // Leading and trailing whitespace,
s &  s 86 | var trimWhitespace = /^(?:\s| |]*>)*|(?:\s| |]*>)*$/g 87 | 88 | /** 89 | * Normalises contentEditable innerHTML, stripping all tags except
and 90 | * trimming leading and trailing whitespace and causes of whitespace. The 91 | * resulting normalised HTML uses
for linebreaks. 92 | */ 93 | function normaliseContentEditableHTML(html, trim) { 94 | html = html.replace(initialBreaks, '$1\n\n') 95 | .replace(initialBreak, '$1\n') 96 | .replace(wrappedBreaks, '\n') 97 | .replace(openBreaks, '') 98 | .replace(breaks, '\n') 99 | .replace(allTags, '') 100 | .replace(newlines, '
') 101 | 102 | if (trim) { 103 | html = html.replace(trimWhitespace, '') 104 | } 105 | 106 | return html 107 | } 108 | 109 | // =============================================================== Component === 110 | 111 | var PlainEditable = React.createClass({displayName: "PlainEditable", 112 | propTypes: { 113 | autoFocus: React.PropTypes.bool, 114 | className: React.PropTypes.string, 115 | component: React.PropTypes.any, 116 | noTrim: React.PropTypes.bool, 117 | onBlur: React.PropTypes.func, 118 | onChange: React.PropTypes.func, 119 | onFocus: React.PropTypes.func, 120 | onKeyDown: React.PropTypes.func, 121 | onKeyUp: React.PropTypes.func, 122 | placeholder: React.PropTypes.string, 123 | singleLine: React.PropTypes.bool, 124 | value: React.PropTypes.string 125 | }, 126 | 127 | getDefaultProps:function() { 128 | return { 129 | component: 'div', 130 | noTrim: false, 131 | placeholder: '', 132 | singleLine: false, 133 | spellCheck: 'false', 134 | value: '' 135 | } 136 | }, 137 | 138 | componentDidMount:function() { 139 | if (this.props.autoFocus) { 140 | this.focus() 141 | } 142 | }, 143 | 144 | focus:function() { 145 | this.getDOMNode().focus() 146 | }, 147 | 148 | _onBlur:function(e) { 149 | var html = normaliseContentEditableHTML(e.target.innerHTML, !this.props.noTrim) 150 | this.props.onBlur(e, htmlToText(html)) 151 | }, 152 | 153 | _onInput:function(e) { 154 | var html = e.target.innerHTML 155 | 156 | // Don't allow innerHTML to become completely empty - causes shrinkage in FF 157 | if (!html) { 158 | e.target.innerHTML = DEFAULT_CONTENTEDITABLE_HTML 159 | } 160 | 161 | if (html && (this.props.singleLine || this.props.onChange)) { 162 | html = normaliseContentEditableHTML(html, !this.props.noTrim) 163 | } 164 | 165 | // If we're in single-line mode, replace any linebreaks which were pasted in 166 | // with spaces. 167 | if (html && this.props.singleLine && brRE.test(html)) { 168 | html = html.replace(brRE, ' ') 169 | } 170 | 171 | if (this.props.onChange) { 172 | this.props.onChange(e, htmlToText(html)) 173 | } 174 | }, 175 | 176 | _onKeyDown:function(e) { 177 | if (this.props.singleLine && e.key == 'Enter') { 178 | e.preventDefault() 179 | e.target.blur() 180 | return 181 | } 182 | 183 | if (this.props.onKeyDown) { 184 | this.props.onKeyDown(e) 185 | } 186 | if (e.defaultPrevented === true) { 187 | return 188 | } 189 | if (isIE) { 190 | this._onInput(e) 191 | } 192 | }, 193 | 194 | _onKeyUp:function(e) { 195 | if (this.props.onKeyUp) { 196 | this.props.onKeyUp(e) 197 | } 198 | if (e.defaultPrevented === true) { 199 | return 200 | } 201 | if (isIE) { 202 | this._onInput(e) 203 | } 204 | }, 205 | 206 | _onFocus:function(e) { 207 | var $__0= e,target=$__0.target 208 | var selecting = false 209 | if (this.props.placeholder && target.innerHTML == this.props.placeholder) { 210 | selectElementText(target) 211 | selecting = true 212 | } 213 | if (this.props.onFocus) { 214 | this.props.onFocus(e, selecting) 215 | } 216 | }, 217 | 218 | render:function() { 219 | var $__0= 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | this.props,autoFocus=$__0.autoFocus,className=$__0.className,component=$__0.component,noTrim=$__0.noTrim,onBlur=$__0.onBlur,onChange=$__0.onChange,onFocus=$__0.onFocus,onKeyDown=$__0.onKeyDown,onKeyUp=$__0.onKeyUp,placeholder=$__0.placeholder,singleLine=$__0.singleLine,spellCheck=$__0.spellCheck,value=$__0.value,props=(function(source, exclusion) {var rest = {};var hasOwn = Object.prototype.hasOwnProperty;if (source == null) {throw new TypeError();}for (var key in source) {if (hasOwn.call(source, key) && !hasOwn.call(exclusion, key)) {rest[key] = source[key];}}return rest;})($__0,{autoFocus:1,className:1,component:1,noTrim:1,onBlur:1,onChange:1,onFocus:1,onKeyDown:1,onKeyUp:1,placeholder:1,singleLine:1,spellCheck:1,value:1}) 229 | 230 | var html = value ? textToHTML(value, singleLine) : DEFAULT_CONTENTEDITABLE_HTML 231 | 232 | return React.createElement(this.props.component, React.__spread({}, 233 | props, 234 | {className: 'PlainEditable' + (className ? ' ' + className : ''), 235 | contentEditable: true, 236 | dangerouslySetInnerHTML: {__html: html}, 237 | onBlur: onBlur && this._onBlur, 238 | onInput: this._onInput, 239 | onFocus: (onFocus || placeholder) && this._onFocus, 240 | onKeyDown: (onKeyDown || singleLine || isIE) && this._onKeyDown, 241 | onKeyUp: (onKeyUp || isIE) && this._onKeyUp, 242 | spellCheck: spellCheck, 243 | style: {minHeight: '1em'}}) 244 | ) 245 | } 246 | }) 247 | 248 | module.exports = PlainEditable 249 | },{}]},{},[1])(1) 250 | }); -------------------------------------------------------------------------------- /dist/react-plain-editable.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * react-plain-editable 2.0.0 - https://github.com/insin/react-plain-editable 3 | * MIT Licensed 4 | */ 5 | !function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var n;"undefined"!=typeof window?n=window:"undefined"!=typeof global?n=global:"undefined"!=typeof self&&(n=self),n.PlainEditable=e()}}(function(){return function e(n,o,r){function t(p,s){if(!o[p]){if(!n[p]){var u="function"==typeof require&&require;if(!s&&u)return u(p,!0);if(i)return i(p,!0);var l=new Error("Cannot find module '"+p+"'");throw l.code="MODULE_NOT_FOUND",l}var c=o[p]={exports:{}};n[p][0].call(c.exports,function(e){var o=n[p][1][e];return t(o?o:e)},c,c.exports,e,n,o,r)}return o[p].exports}for(var i="function"==typeof require&&require,p=0;p"),n&&(e=e.replace(P,"")),e}var p="undefined"!=typeof window?window.React:"undefined"!=typeof global?global.React:null,s="

",u="undefined"!=typeof window&&"ActiveXObject"in window,l=/
/g,c=/\r\n|\r|\n/g,a=function(){var e=/[&><\u00A0]/g,n={"&":"&",">":">","<":"<"," ":" "},o=function(e){return n[e]};return function(n){return n.replace(e,o)}}(),f=function(){var e=/&(?:amp|gt|lt|nbsp);/g,n={"&":"&",">":">","<":"<"," ":" "},o=function(e){return n[e]};return function(n){return n.replace(e,o)}}(),d=function(){return function(e){return e.replace(c,"
")}}(),g=function(){return function(e){return e.replace(l,"\n")}}(),h=/^([^<]+)(?:]*>]*><\/div>]*>|]*>]*><\/p>]*>)/,y=/^([^<]+)(?:]*>|]*>)/,v=/]*>]*><\/p>|]*>]*><\/div>/g,m=/<(?:p|div)[^>]*>/g,b=/]*><\/(?:p|div)>|]*>|<\/(?:p|div)>/g,T=/<\/?[^>]+>/g,w=/\r\n|\n|\r/g,P=/^(?:\s| |]*>)*|(?:\s| |]*>)*$/g,_=p.createClass({displayName:"PlainEditable",propTypes:{autoFocus:p.PropTypes.bool,className:p.PropTypes.string,component:p.PropTypes.any,noTrim:p.PropTypes.bool,onBlur:p.PropTypes.func,onChange:p.PropTypes.func,onFocus:p.PropTypes.func,onKeyDown:p.PropTypes.func,onKeyUp:p.PropTypes.func,placeholder:p.PropTypes.string,singleLine:p.PropTypes.bool,value:p.PropTypes.string},getDefaultProps:function(){return{component:"div",noTrim:!1,placeholder:"",singleLine:!1,spellCheck:"false",value:""}},componentDidMount:function(){this.props.autoFocus&&this.focus()},focus:function(){this.getDOMNode().focus()},_onBlur:function(e){var n=i(e.target.innerHTML,!this.props.noTrim);this.props.onBlur(e,r(n))},_onInput:function(e){var n=e.target.innerHTML;n||(e.target.innerHTML=s),n&&(this.props.singleLine||this.props.onChange)&&(n=i(n,!this.props.noTrim)),n&&this.props.singleLine&&l.test(n)&&(n=n.replace(l," ")),this.props.onChange&&this.props.onChange(e,r(n))},_onKeyDown:function(e){return this.props.singleLine&&"Enter"==e.key?(e.preventDefault(),void e.target.blur()):(this.props.onKeyDown&&this.props.onKeyDown(e),void(e.defaultPrevented!==!0&&u&&this._onInput(e)))},_onKeyUp:function(e){this.props.onKeyUp&&this.props.onKeyUp(e),e.defaultPrevented!==!0&&u&&this._onInput(e)},_onFocus:function(e){var n=e,r=n.target,t=!1;this.props.placeholder&&r.innerHTML==this.props.placeholder&&(o(r),t=!0),this.props.onFocus&&this.props.onFocus(e,t)},render:function(){var e=this.props,n=(e.autoFocus,e.className),o=(e.component,e.noTrim,e.onBlur),r=(e.onChange,e.onFocus),i=e.onKeyDown,l=e.onKeyUp,c=e.placeholder,a=e.singleLine,f=e.spellCheck,d=e.value,g=function(e,n){var o={},r=Object.prototype.hasOwnProperty;if(null==e)throw new TypeError;for(var t in e)r.call(e,t)&&!r.call(n,t)&&(o[t]=e[t]);return o}(e,{autoFocus:1,className:1,component:1,noTrim:1,onBlur:1,onChange:1,onFocus:1,onKeyDown:1,onKeyUp:1,placeholder:1,singleLine:1,spellCheck:1,value:1}),h=d?t(d,a):s;return p.createElement(this.props.component,p.__spread({},g,{className:"PlainEditable"+(n?" "+n:""),contentEditable:!0,dangerouslySetInnerHTML:{__html:h},onBlur:o&&this._onBlur,onInput:this._onInput,onFocus:(r||c)&&this._onFocus,onKeyDown:(i||a||u)&&this._onKeyDown,onKeyUp:(l||u)&&this._onKeyUp,spellCheck:f,style:{minHeight:"1em"}}))}});n.exports=_},{}]},{},[1])(1)}); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var browserify = require('browserify') 2 | var del = require('del') 3 | var gulp = require('gulp') 4 | var source = require('vinyl-source-stream') 5 | 6 | var header = require('gulp-header') 7 | var jshint = require('gulp-jshint') 8 | var rename = require('gulp-rename') 9 | var plumber = require('gulp-plumber') 10 | var react = require('gulp-react') 11 | var streamify = require('gulp-streamify') 12 | var uglify = require('gulp-uglify') 13 | var gutil = require('gulp-util') 14 | 15 | var pkg = require('./package.json') 16 | var devBuild = gutil.env.release ? '' : ' (dev build at ' + (new Date()).toUTCString() + ')' 17 | var distHeader = '/*!\n\ 18 | * <%= pkg.name %> <%= pkg.version %><%= devBuild %> - <%= pkg.homepage %>\n\ 19 | * <%= pkg.license %> Licensed\n\ 20 | */\n' 21 | 22 | var jsSrcPaths = './src/**/*.js*' 23 | var jsLibPaths = './lib/**/*.js' 24 | 25 | gulp.task('clean-dist', function(cb) { 26 | del('./dist/*.js', cb) 27 | }) 28 | 29 | gulp.task('clean-lib', function(cb) { 30 | del(jsLibPaths, cb) 31 | }) 32 | 33 | gulp.task('transpile-js', ['clean-lib'], function() { 34 | return gulp.src(jsSrcPaths) 35 | .pipe(plumber()) 36 | .pipe(react({harmony: true})) 37 | .pipe(gulp.dest('./lib')) 38 | }) 39 | 40 | gulp.task('lint-js', ['transpile-js'], function() { 41 | return gulp.src(jsLibPaths) 42 | .pipe(jshint('./.jshintrc')) 43 | .pipe(jshint.reporter('jshint-stylish')) 44 | }) 45 | 46 | gulp.task('bundle-js', ['clean-dist', 'lint-js'], function() { 47 | var b = browserify(pkg.main, { 48 | debug: !!gutil.env.debug 49 | , standalone: pkg.standalone 50 | , detectGlobals: false 51 | }) 52 | b.transform('browserify-shim') 53 | 54 | var stream = b.bundle() 55 | .pipe(source(pkg.name + '.js')) 56 | .pipe(streamify(header(distHeader, {pkg: pkg, devBuild: devBuild}))) 57 | .pipe(gulp.dest('./dist')) 58 | 59 | if (gutil.env.production) { 60 | stream = stream 61 | .pipe(rename(pkg.name + '.min.js')) 62 | .pipe(streamify(uglify())) 63 | .pipe(streamify(header(distHeader, {pkg: pkg, devBuild: devBuild}))) 64 | .pipe(gulp.dest('./dist')) 65 | } 66 | 67 | return stream 68 | }) 69 | 70 | gulp.task('watch', function() { 71 | gulp.watch(jsSrcPaths, ['bundle-js']) 72 | }) 73 | 74 | gulp.task('default', ['bundle-js', 'watch']) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-plain-editable", 3 | "description": "React component for editing plain(ish) text via contentEditable", 4 | "version": "2.0.0", 5 | "main": "./lib/index.js", 6 | "standalone": "PlainEditable", 7 | "homepage": "https://github.com/insin/react-plain-editable", 8 | "license": "MIT", 9 | "author": "Jonny Buchanan ", 10 | "keywords": [ 11 | "react", 12 | "react-component", 13 | "contentEditable" 14 | ], 15 | "dependencies": {}, 16 | "peerDependencies": { 17 | "react": ">=0.12.0" 18 | }, 19 | "devDependencies": { 20 | "browserify": "^8.1.3", 21 | "browserify-shim": "^3.8.2", 22 | "del": "^1.1.1", 23 | "gulp": "^3.8.10", 24 | "gulp-header": "^1.2.2", 25 | "gulp-jshint": "^1.9.2", 26 | "gulp-plumber": "^0.6.6", 27 | "gulp-react": "^2.0.0", 28 | "gulp-rename": "^1.2.0", 29 | "gulp-streamify": "0.0.5", 30 | "gulp-uglify": "^1.1.0", 31 | "gulp-util": "^3.0.3", 32 | "jshint-stylish": "^1.0.0", 33 | "vinyl-source-stream": "^1.0.0" 34 | }, 35 | "scripts": { 36 | "debug": "gulp --debug", 37 | "dist": "gulp bundle-js --production --release", 38 | "watch": "gulp" 39 | }, 40 | "browserify-shim": { 41 | "react": "global:React", 42 | "react/addons": "global:React" 43 | }, 44 | "repository": { 45 | "type": "git", 46 | "url": "http://github.com/insin/react-plain-editable.git" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react') 4 | 5 | var DEFAULT_CONTENTEDITABLE_HTML = '

' 6 | 7 | var isIE = (typeof window !== 'undefined' && 'ActiveXObject' in window) 8 | 9 | // =================================================================== Utils === 10 | 11 | var brRE = /
/g 12 | var linebreaksRE = /\r\n|\r|\n/g 13 | 14 | var escapeHTML = (() => { 15 | var escapeRE = /[&><\u00A0]/g 16 | var escapes = {'&': '&', '>': '>', '<': '<', '\u00A0': ' '} 17 | var escaper = (match) => escapes[match] 18 | return (text) => text.replace(escapeRE, escaper) 19 | })() 20 | 21 | var unescapeHTML = (() => { 22 | var unescapeRE = /&(?:amp|gt|lt|nbsp);/g 23 | var unescapes = {'&': '&', '>': '>', '<': '<', ' ': '\u00A0'} 24 | var unescaper = (match) => unescapes[match] 25 | return (text) => text.replace(unescapeRE, unescaper) 26 | })() 27 | 28 | var linebreaksToBr = (() => { 29 | return (text) => text.replace(linebreaksRE, '
') 30 | })() 31 | 32 | var brsToLinebreak = (() => { 33 | return (text) => text.replace(brRE, '\n') 34 | })() 35 | 36 | function selectElementText(el) { 37 | setTimeout(function() { 38 | var range 39 | if (window.getSelection && document.createRange) { 40 | range = document.createRange() 41 | range.selectNodeContents(el) 42 | var selection = window.getSelection() 43 | selection.removeAllRanges() 44 | selection.addRange(range) 45 | } 46 | else if (document.body.createTextRange) { 47 | range = document.body.createTextRange() 48 | range.moveToElementText(el) 49 | range.select() 50 | } 51 | }, 1) 52 | } 53 | 54 | // ====================================================== HTML normalisation === 55 | 56 | function htmlToText(html) { 57 | if (html == DEFAULT_CONTENTEDITABLE_HTML) { 58 | return '' 59 | } 60 | return unescapeHTML(brsToLinebreak(html)) 61 | } 62 | 63 | function textToHTML(text, singleLine) { 64 | if (singleLine && linebreaksRE.test(text)) { 65 | text = text.replace(linebreaksRE, ' ') 66 | } 67 | return linebreaksToBr(escapeHTML(text)) 68 | } 69 | 70 | // Chrome 40 not wrapping first line when wrapping with block elements 71 | var initialBreaks = /^([^<]+)(?:]*>]*><\/div>]*>|]*>]*><\/p>]*>)/ 72 | var initialBreak = /^([^<]+)(?:]*>|]*>)/ 73 | 74 | var wrappedBreaks = /]*>]*><\/p>|]*>]*><\/div>/g 75 | var openBreaks = /<(?:p|div)[^>]*>/g 76 | var breaks = /]*><\/(?:p|div)>|]*>|<\/(?:p|div)>/g 77 | var allTags = /<\/?[^>]+>/g 78 | var newlines = /\r\n|\n|\r/g 79 | 80 | // Leading and trailing whitespace,
s &  s 81 | var trimWhitespace = /^(?:\s| |]*>)*|(?:\s| |]*>)*$/g 82 | 83 | /** 84 | * Normalises contentEditable innerHTML, stripping all tags except
and 85 | * trimming leading and trailing whitespace and causes of whitespace. The 86 | * resulting normalised HTML uses
for linebreaks. 87 | */ 88 | function normaliseContentEditableHTML(html, trim) { 89 | html = html.replace(initialBreaks, '$1\n\n') 90 | .replace(initialBreak, '$1\n') 91 | .replace(wrappedBreaks, '\n') 92 | .replace(openBreaks, '') 93 | .replace(breaks, '\n') 94 | .replace(allTags, '') 95 | .replace(newlines, '
') 96 | 97 | if (trim) { 98 | html = html.replace(trimWhitespace, '') 99 | } 100 | 101 | return html 102 | } 103 | 104 | // =============================================================== Component === 105 | 106 | var PlainEditable = React.createClass({ 107 | propTypes: { 108 | autoFocus: React.PropTypes.bool, 109 | className: React.PropTypes.string, 110 | component: React.PropTypes.any, 111 | noTrim: React.PropTypes.bool, 112 | onBlur: React.PropTypes.func, 113 | onChange: React.PropTypes.func, 114 | onFocus: React.PropTypes.func, 115 | onKeyDown: React.PropTypes.func, 116 | onKeyUp: React.PropTypes.func, 117 | placeholder: React.PropTypes.string, 118 | singleLine: React.PropTypes.bool, 119 | value: React.PropTypes.string 120 | }, 121 | 122 | getDefaultProps() { 123 | return { 124 | component: 'div', 125 | noTrim: false, 126 | placeholder: '', 127 | singleLine: false, 128 | spellCheck: 'false', 129 | value: '' 130 | } 131 | }, 132 | 133 | componentDidMount() { 134 | if (this.props.autoFocus) { 135 | this.focus() 136 | } 137 | }, 138 | 139 | focus() { 140 | this.getDOMNode().focus() 141 | }, 142 | 143 | _onBlur(e) { 144 | var html = normaliseContentEditableHTML(e.target.innerHTML, !this.props.noTrim) 145 | this.props.onBlur(e, htmlToText(html)) 146 | }, 147 | 148 | _onInput(e) { 149 | var html = e.target.innerHTML 150 | 151 | // Don't allow innerHTML to become completely empty - causes shrinkage in FF 152 | if (!html) { 153 | e.target.innerHTML = DEFAULT_CONTENTEDITABLE_HTML 154 | } 155 | 156 | if (html && (this.props.singleLine || this.props.onChange)) { 157 | html = normaliseContentEditableHTML(html, !this.props.noTrim) 158 | } 159 | 160 | // If we're in single-line mode, replace any linebreaks which were pasted in 161 | // with spaces. 162 | if (html && this.props.singleLine && brRE.test(html)) { 163 | html = html.replace(brRE, ' ') 164 | } 165 | 166 | if (this.props.onChange) { 167 | this.props.onChange(e, htmlToText(html)) 168 | } 169 | }, 170 | 171 | _onKeyDown(e) { 172 | if (this.props.singleLine && e.key == 'Enter') { 173 | e.preventDefault() 174 | e.target.blur() 175 | return 176 | } 177 | 178 | if (this.props.onKeyDown) { 179 | this.props.onKeyDown(e) 180 | } 181 | if (e.defaultPrevented === true) { 182 | return 183 | } 184 | if (isIE) { 185 | this._onInput(e) 186 | } 187 | }, 188 | 189 | _onKeyUp(e) { 190 | if (this.props.onKeyUp) { 191 | this.props.onKeyUp(e) 192 | } 193 | if (e.defaultPrevented === true) { 194 | return 195 | } 196 | if (isIE) { 197 | this._onInput(e) 198 | } 199 | }, 200 | 201 | _onFocus(e) { 202 | var {target} = e 203 | var selecting = false 204 | if (this.props.placeholder && target.innerHTML == this.props.placeholder) { 205 | selectElementText(target) 206 | selecting = true 207 | } 208 | if (this.props.onFocus) { 209 | this.props.onFocus(e, selecting) 210 | } 211 | }, 212 | 213 | render() { 214 | var { 215 | autoFocus, 216 | className, component, 217 | noTrim, 218 | onBlur, onChange, onFocus, onKeyDown, onKeyUp, 219 | placeholder, 220 | singleLine, spellCheck, 221 | value, 222 | ...props 223 | } = this.props 224 | 225 | var html = value ? textToHTML(value, singleLine) : DEFAULT_CONTENTEDITABLE_HTML 226 | 227 | return 240 | } 241 | }) 242 | 243 | module.exports = PlainEditable --------------------------------------------------------------------------------