]*>
]*><\/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
--------------------------------------------------------------------------------