react-sane-contenteditable
Advanced tools
Comparing version 1.4.0 to 1.4.1
@@ -1,4 +0,4 @@ | ||
import ReactDOM from "react-dom"; | ||
import React, { Component } from "react"; | ||
import ContentEditable from "../src/react-sane-contenteditable"; | ||
import ReactDOM from 'react-dom'; | ||
import React, { Component } from 'react'; | ||
import ContentEditable from '../src/react-sane-contenteditable'; | ||
@@ -10,3 +10,3 @@ class App extends Component { | ||
this.state = { | ||
title: "Title here" | ||
title: 'Title here', | ||
}; | ||
@@ -19,2 +19,6 @@ } | ||
handleKeyDown = (ev, value) => { | ||
this.setState({ title: value }); | ||
}; | ||
render() { | ||
@@ -24,2 +28,3 @@ return ( | ||
<ContentEditable | ||
focus | ||
tagName="h1" | ||
@@ -32,2 +37,5 @@ className="my-class" | ||
onChange={this.handleChange} | ||
onKeyDown={this.handleKeyDown} | ||
caretPosition="end" | ||
onKeyDown={this.handleKeyDown} | ||
/> | ||
@@ -39,4 +47,3 @@ | ||
fontSize: 14, | ||
fontFamily: | ||
"'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace" | ||
fontFamily: "'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace", | ||
}} | ||
@@ -51,2 +58,2 @@ > | ||
ReactDOM.render(<App />, document.getElementById("root")); | ||
ReactDOM.render(<App />, document.getElementById('root')); |
@@ -127,3 +127,3 @@ // For a detailed explanation regarding each configuration property, visit: | ||
// The test environment that will be used for testing | ||
// testEnvironment: "jest-environment-jsdom", | ||
testEnvironment: "jest-environment-jsdom", | ||
@@ -130,0 +130,0 @@ // Options that will be passed to the testEnvironment |
@@ -29,11 +29,13 @@ function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } | ||
import React, { Component } from "react"; | ||
import PropTypes from "prop-types"; | ||
import { omit, isEqual, pick, without } from "lodash"; | ||
import React, { Component } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { omit, isEqual, pick, without } from 'lodash'; | ||
var propTypes = { | ||
content: PropTypes.string, | ||
editable: PropTypes.bool, | ||
focus: PropTypes.bool, | ||
maxLength: PropTypes.number, | ||
multiLine: PropTypes.bool, | ||
sanitise: PropTypes.bool, | ||
caretPosition: PropTypes.oneOf(['start', 'end']), | ||
tagName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), | ||
@@ -50,8 +52,10 @@ // The element to make contenteditable. Takes an element string ('div', 'span', 'h1') or a styled component | ||
var defaultProps = { | ||
content: "", | ||
content: '', | ||
editable: true, | ||
focus: false, | ||
maxLength: Infinity, | ||
multiLine: false, | ||
sanitise: true, | ||
tagName: "div", | ||
caretPosition: null, | ||
tagName: 'div', | ||
innerRef: function innerRef() {}, | ||
@@ -77,2 +81,22 @@ onBlur: function onBlur() {}, | ||
_defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "setFocus", function () { | ||
if (_this.props.focus && _this._element) { | ||
_this._element.focus(); | ||
} | ||
}); | ||
_defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "setCaret", function () { | ||
var caretPosition = _this.props.caretPosition; | ||
if (caretPosition && _this._element) { | ||
var offset = caretPosition === 'end' ? 1 : 0; | ||
var range = document.createRange(); | ||
var selection = window.getSelection(); | ||
range.setStart(_this._element, offset); | ||
range.collapse(true); | ||
selection.removeAllRanges(); | ||
selection.addRange(range); | ||
} | ||
}); | ||
_defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "_onChange", function (ev) { | ||
@@ -95,4 +119,4 @@ var sanitise = _this.props.sanitise; | ||
ev.preventDefault(); | ||
var text = ev.clipboardData.getData("text").substr(0, maxLength); | ||
document.execCommand("insertText", false, text); | ||
var text = ev.clipboardData.getData('text').substr(0, maxLength); | ||
document.execCommand('insertText', false, text); | ||
@@ -132,3 +156,3 @@ _this.props.onPaste(ev); | ||
if (maxLength && !ev.metaKey && ev.which !== 8 && value.replace(/\s\s/g, " ").length >= maxLength) { | ||
if (maxLength && !ev.metaKey && ev.which !== 8 && value.replace(/\s\s/g, ' ').length >= maxLength) { | ||
ev.preventDefault(); // Call onKeyUp directly as ev.preventDefault() means that it will not be called | ||
@@ -155,2 +179,8 @@ | ||
_createClass(ContentEditable, [{ | ||
key: "componentDidMount", | ||
value: function componentDidMount() { | ||
this.setFocus(); | ||
this.setCaret(); | ||
} | ||
}, { | ||
key: "componentWillReceiveProps", | ||
@@ -161,3 +191,3 @@ value: function componentWillReceiveProps(nextProps) { | ||
value: nextProps.content | ||
}, this.forceUpdate); | ||
}); | ||
} | ||
@@ -168,6 +198,12 @@ } | ||
value: function shouldComponentUpdate(nextProps) { | ||
var propKeys = without(Object.keys(nextProps), "content"); | ||
var propKeys = without(Object.keys(nextProps), 'content'); | ||
return !isEqual(pick(nextProps, propKeys), pick(this.props, propKeys)); | ||
} | ||
}, { | ||
key: "componentDidUpdate", | ||
value: function componentDidUpdate() { | ||
this.setFocus(); | ||
this.setCaret(); | ||
} | ||
}, { | ||
key: "sanitiseValue", | ||
@@ -185,14 +221,14 @@ value: function sanitiseValue(val) { | ||
var value = val.replace(/ /g, " "); | ||
var value = val.replace(/ /, ' ').replace(/[\u00a0\u2000-\u200b\u2028-\u2029\u202e-\u202f\u3000]/g, ' '); | ||
if (multiLine) { | ||
// replace any 2+ character whitespace (other than new lines) with a single space | ||
value = value.replace(/[\t\v\f\r \u00a0\u2000-\u200b\u2028-\u2029\u3000]+/g, " "); | ||
value = value.replace(/[\t\v\f\r ]+/g, ' '); | ||
} else { | ||
value = value.replace(/\s+/g, " "); | ||
value = value.replace(/\s+/g, ' '); | ||
} | ||
return value.split("\n").map(function (line) { | ||
return value.split('\n').map(function (line) { | ||
return line.trim(); | ||
}).join("\n").replace(/\n{3,}/g, "\n\n") // replace 3+ line breaks with two | ||
}).join('\n').replace(/\n{3,}/g, '\n\n') // replace 3+ line breaks with two | ||
.trim().substr(0, maxLength); | ||
@@ -224,3 +260,3 @@ } | ||
style: _objectSpread({ | ||
whiteSpace: "pre-wrap" | ||
whiteSpace: 'pre-wrap' | ||
}, props.style), | ||
@@ -227,0 +263,0 @@ contentEditable: editable, |
{ | ||
"name": "react-sane-contenteditable", | ||
"version": "1.4.0", | ||
"version": "1.4.1", | ||
"description": "React component with sane defaults to make any element contentEditable", | ||
@@ -12,2 +12,3 @@ "main": "lib/index.js", | ||
"contributors": [ | ||
"Nick Aspinall", | ||
"Raphael Silva Cavalcanti", | ||
@@ -35,10 +36,5 @@ "Jess Telford" | ||
"enzyme-adapter-react-16": "^1.2.0", | ||
"eslint": "^5.3.0", | ||
"eslint-config-airbnb": "^17.0.0", | ||
"eslint-plugin-babel": "^5.1.0", | ||
"eslint-plugin-import": "^2.13.0", | ||
"eslint-plugin-jest": "^21.18.0", | ||
"eslint-plugin-jsx-a11y": "^6.1.1", | ||
"eslint-plugin-react": "^7.10.0", | ||
"jest": "^23.4.2", | ||
"jsdom": "^11.12.0", | ||
"prettier": "1.14.2", | ||
"react-dom": "^16.3.2", | ||
@@ -51,3 +47,4 @@ "rollup": "^0.58.2", | ||
"rollup-plugin-replace": "^2.0.0", | ||
"rollup-plugin-serve": "^0.4.2" | ||
"rollup-plugin-serve": "^0.4.2", | ||
"styled-components": "^3.4.2" | ||
}, | ||
@@ -60,3 +57,3 @@ "scripts": { | ||
"test": "jest --watch", | ||
"lint": "./node_modules/.bin/eslint src/*.js" | ||
"lint": "./node_modules/prettier/bin-prettier.js --write src/**/*" | ||
}, | ||
@@ -63,0 +60,0 @@ "bugs": { |
import React from 'react'; | ||
import { mount, shallow } from 'enzyme'; | ||
import { JSDOM } from 'jsdom'; | ||
import styled from 'styled-components'; | ||
// SuT | ||
import ContentEditable from '../react-sane-contenteditable'; | ||
// jsDOM | ||
const doc = new JSDOM('<!doctype html><html><body></body></html>'); | ||
global.document = doc; | ||
global.window = doc.defaultView; | ||
global.window.getSelection = jest.fn(() => ({ | ||
addRange: jest.fn(), | ||
removeAllRanges: jest.fn(), | ||
})); | ||
global.document.createRange = jest.fn(() => ({ | ||
collapse: jest.fn(), | ||
setStart: jest.fn(), | ||
})); | ||
// Helpers | ||
const focusThenBlur = (wrapper, element = 'div') => wrapper.find(element).simulate('focus').simulate('blur'); | ||
const focusThenBlur = (wrapper, element = 'div') => | ||
wrapper | ||
.find(element) | ||
.simulate('focus') | ||
.simulate('blur'); | ||
// Styled components | ||
const Wrapper = styled.div``; | ||
describe('Default behaviour', () => { | ||
@@ -19,3 +44,3 @@ it('renders a div by default', () => { | ||
it('sets contenteditable', () => { | ||
it('sets contentEditable', () => { | ||
const wrapper = shallow(<ContentEditable />); | ||
@@ -67,8 +92,42 @@ expect(wrapper.prop('contentEditable')).toBe(true); | ||
// @todo: I think Enzyme converts props.ref to props.innerRef when props.styled={false} | ||
it('props.styled={true} sets innerRef handler', () => { | ||
const wrapper = mount(<ContentEditable />); | ||
const wrapper = mount(<ContentEditable styled tagName={Wrapper} />); | ||
expect(wrapper.prop('innerRef')).toEqual(expect.any(Function)); | ||
}); | ||
it('props.content change calls setState', () => { | ||
const wrapper = mount(<ContentEditable content="" />); | ||
const instance = wrapper.instance(); | ||
jest.spyOn(instance, 'setState'); | ||
wrapper.setProps({ content: 'foo' }); | ||
expect(instance.setState).toHaveBeenCalled(); | ||
}); | ||
it('props.focus sets focus on update', () => { | ||
const wrapper = mount(<ContentEditable />); | ||
const instance = wrapper.instance(); | ||
jest.spyOn(instance, 'setFocus'); | ||
wrapper.setProps({ focus: true }); | ||
expect(instance.setFocus).toHaveBeenCalled(); | ||
}); | ||
it('props.caretPosition sets selection on mount', () => { | ||
mount(<ContentEditable caretPosition="start" />); | ||
expect(global.window.getSelection).toHaveBeenCalled(); | ||
}); | ||
it('props.caretPosition sets selection on update', () => { | ||
const wrapper = mount(<ContentEditable />); | ||
const instance = wrapper.instance(); | ||
jest.spyOn(instance, 'setCaret'); | ||
wrapper.setProps({ caretPosition: 'end' }); | ||
expect(instance.setCaret).toHaveBeenCalled(); | ||
}); | ||
it('shouldComponentUpdate returns false when props are the same', () => { | ||
@@ -172,3 +231,2 @@ const wrapper = mount(<ContentEditable multiLine />); | ||
// @todo: ASCII spaces and feeds should probably be replaced regardless of multiLine | ||
it('replaces ASCII spaces and feeds', () => { | ||
@@ -178,3 +236,3 @@ const mockHandler = jest.fn(); | ||
wrapper.instance()._element.innerText = `foo\f\f bar\r\r baz\t\t qux\v\v quux`; | ||
wrapper.instance()._element.innerText = 'foo\f\f bar\r\r baz\t\t qux\v\v quux'; | ||
focusThenBlur(wrapper); | ||
@@ -184,6 +242,5 @@ expect(wrapper.state('value')).toEqual('foo bar baz qux quux'); | ||
// @todo: Unicode spaces should probably be replaced regardless of multiLine | ||
it('replaces unicode spaces', () => { | ||
const mockHandler = jest.fn(); | ||
const wrapper = mount(<ContentEditable multiLine content="foo" onChange={mockHandler} />); | ||
const wrapper = mount(<ContentEditable content="foo" onChange={mockHandler} />); | ||
const unicodeChars = [ | ||
@@ -205,6 +262,8 @@ '\u00a0', | ||
'\u2029', | ||
'\u202e', | ||
'\u202f', | ||
'\u3000', | ||
].join(''); | ||
wrapper.instance()._element.innerText = `foo${unicodeChars}bar`; | ||
wrapper.instance()._element.innerText = `foo ${unicodeChars}bar`; | ||
focusThenBlur(wrapper); | ||
@@ -215,40 +274,2 @@ expect(wrapper.state('value')).toEqual('foo bar'); | ||
xdescribe('Failing tests to fix in component', () => { | ||
// @todo @fixme: This test should probably be fixed and merged into the previous test: 'replaces unicode spaces when props.multiLine' | ||
it('replaces unicode spaces', () => { | ||
const mockHandler = jest.fn(); | ||
const wrapper = mount(<ContentEditable multiLine content="foo" onChange={mockHandler} />); | ||
const unicodeChars = [ | ||
'\u200c', | ||
'\u200d', | ||
'\u200e', | ||
'\u200f', | ||
'\u202a', | ||
'\u202b', | ||
'\u202c', | ||
'\u202d', | ||
'\u202e', | ||
'\u202f', | ||
'\u2060', | ||
'\u2061', | ||
'\u2062', | ||
'\u2063', | ||
'\u2064', | ||
'\u2065', | ||
'\u2066', | ||
'\u2067', | ||
'\u2068', | ||
'\u2069', | ||
'\u206a', | ||
'\u206b', | ||
'\u206c', | ||
'\u206d', | ||
'\u206e', | ||
'\u206f', | ||
].join(''); | ||
wrapper.instance()._element.innerText = `foo${unicodeChars}bar`; | ||
focusThenBlur(wrapper); | ||
expect(wrapper.state('value')).toEqual('foo bar'); | ||
}); | ||
// @todo @fixme: Component should probably be fixed so that this test passes, given that it uses dangerouslySetInnerHTML | ||
@@ -261,3 +282,3 @@ // the naming of props.sanitise suggests that it will protect against this. | ||
wrapper.instance()._element.innerText = 'foo <script>console.log(\'XSS vulnerability\')</script>'; | ||
wrapper.instance()._element.innerText = "foo <script>console.log('XSS vulnerability')</script>"; | ||
focusThenBlur(wrapper); | ||
@@ -274,3 +295,2 @@ expect(wrapper.state('value')).toEqual(content); | ||
const wrapper = mount(<ContentEditable innerRef={mockHandler} />); | ||
wrapper.render(); | ||
expect(mockHandler).toHaveBeenCalled(); | ||
@@ -286,5 +306,7 @@ }); | ||
focusThenBlur(wrapper); | ||
expect(mockHandler).toHaveBeenCalledWith(expect.objectContaining({ | ||
type: 'blur', | ||
})); | ||
expect(mockHandler).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
type: 'blur', | ||
}), | ||
); | ||
}); | ||
@@ -360,7 +382,9 @@ | ||
wrapper.find('div').simulate('paste', { clipboardData: { getData: mockGetClipboardData } }); | ||
expect(mockOnPaste).toHaveBeenCalledWith(expect.objectContaining({ | ||
type: 'paste', | ||
nativeEvent: expect.any(Object), | ||
})); | ||
expect(mockOnPaste).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
type: 'paste', | ||
nativeEvent: expect.any(Object), | ||
}), | ||
); | ||
}); | ||
}); |
@@ -1,4 +0,4 @@ | ||
import React, { Component } from "react"; | ||
import PropTypes from "prop-types"; | ||
import { omit, isEqual, pick, without } from "lodash"; | ||
import React, { Component } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { omit, isEqual, pick, without } from 'lodash'; | ||
@@ -8,5 +8,7 @@ const propTypes = { | ||
editable: PropTypes.bool, | ||
focus: PropTypes.bool, | ||
maxLength: PropTypes.number, | ||
multiLine: PropTypes.bool, | ||
sanitise: PropTypes.bool, | ||
caretPosition: PropTypes.oneOf(['start', 'end']), | ||
tagName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), // The element to make contenteditable. Takes an element string ('div', 'span', 'h1') or a styled component | ||
@@ -18,12 +20,14 @@ innerRef: PropTypes.func, | ||
onChange: PropTypes.func, | ||
styled: PropTypes.bool // If element is a styled component (uses innerRef instead of ref) | ||
styled: PropTypes.bool, // If element is a styled component (uses innerRef instead of ref) | ||
}; | ||
const defaultProps = { | ||
content: "", | ||
content: '', | ||
editable: true, | ||
focus: false, | ||
maxLength: Infinity, | ||
multiLine: false, | ||
sanitise: true, | ||
tagName: "div", | ||
caretPosition: null, | ||
tagName: 'div', | ||
innerRef: () => {}, | ||
@@ -34,3 +38,3 @@ onBlur: () => {}, | ||
onChange: () => {}, | ||
styled: false | ||
styled: false, | ||
}; | ||
@@ -43,9 +47,14 @@ | ||
this.state = { | ||
value: props.content | ||
value: props.content, | ||
}; | ||
} | ||
componentDidMount() { | ||
this.setFocus(); | ||
this.setCaret(); | ||
} | ||
componentWillReceiveProps(nextProps) { | ||
if (nextProps.content !== this.sanitiseValue(this.state.value)) { | ||
this.setState({ value: nextProps.content }, this.forceUpdate); | ||
this.setState({ value: nextProps.content }); | ||
} | ||
@@ -55,6 +64,32 @@ } | ||
shouldComponentUpdate(nextProps) { | ||
const propKeys = without(Object.keys(nextProps), "content"); | ||
const propKeys = without(Object.keys(nextProps), 'content'); | ||
return !isEqual(pick(nextProps, propKeys), pick(this.props, propKeys)); | ||
} | ||
componentDidUpdate() { | ||
this.setFocus(); | ||
this.setCaret(); | ||
} | ||
setFocus = () => { | ||
if (this.props.focus && this._element) { | ||
this._element.focus(); | ||
} | ||
}; | ||
setCaret = () => { | ||
const { caretPosition } = this.props; | ||
if (caretPosition && this._element) { | ||
const offset = caretPosition === 'end' ? 1 : 0; | ||
const range = document.createRange(); | ||
const selection = window.getSelection(); | ||
range.setStart(this._element, offset); | ||
range.collapse(true); | ||
selection.removeAllRanges(); | ||
selection.addRange(range); | ||
} | ||
}; | ||
sanitiseValue(val) { | ||
@@ -68,19 +103,16 @@ const { maxLength, multiLine, sanitise } = this.props; | ||
// replace encoded spaces | ||
let value = val.replace(/ /g, " "); | ||
let value = val.replace(/ /, ' ').replace(/[\u00a0\u2000-\u200b\u2028-\u2029\u202e-\u202f\u3000]/g, ' '); | ||
if (multiLine) { | ||
// replace any 2+ character whitespace (other than new lines) with a single space | ||
value = value.replace( | ||
/[\t\v\f\r \u00a0\u2000-\u200b\u2028-\u2029\u3000]+/g, | ||
" " | ||
); | ||
value = value.replace(/[\t\v\f\r ]+/g, ' '); | ||
} else { | ||
value = value.replace(/\s+/g, " "); | ||
value = value.replace(/\s+/g, ' '); | ||
} | ||
return value | ||
.split("\n") | ||
.split('\n') | ||
.map(line => line.trim()) | ||
.join("\n") | ||
.replace(/\n{3,}/g, "\n\n") // replace 3+ line breaks with two | ||
.join('\n') | ||
.replace(/\n{3,}/g, '\n\n') // replace 3+ line breaks with two | ||
.trim() | ||
@@ -106,4 +138,4 @@ .substr(0, maxLength); | ||
ev.preventDefault(); | ||
const text = ev.clipboardData.getData("text").substr(0, maxLength); | ||
document.execCommand("insertText", false, text); | ||
const text = ev.clipboardData.getData('text').substr(0, maxLength); | ||
document.execCommand('insertText', false, text); | ||
@@ -140,8 +172,3 @@ this.props.onPaste(ev); | ||
// Ensure we don't exceed `maxLength` (keycode 8 === backspace) | ||
if ( | ||
maxLength && | ||
!ev.metaKey && | ||
ev.which !== 8 && | ||
value.replace(/\s\s/g, " ").length >= maxLength | ||
) { | ||
if (maxLength && !ev.metaKey && ev.which !== 8 && value.replace(/\s\s/g, ' ').length >= maxLength) { | ||
ev.preventDefault(); | ||
@@ -158,13 +185,7 @@ // Call onKeyUp directly as ev.preventDefault() means that it will not be called | ||
// this._onKeyDown can't be moved in it's entirety to onKeyUp as we lose the opportunity to preventDefault | ||
this.props.onKeyDown(ev, this._element.innerText) | ||
this.props.onKeyDown(ev, this._element.innerText); | ||
}; | ||
render() { | ||
const { | ||
tagName: Element, | ||
content, | ||
editable, | ||
styled, | ||
...props | ||
} = this.props; | ||
const { tagName: Element, content, editable, styled, ...props } = this.props; | ||
@@ -179,3 +200,3 @@ return ( | ||
props.innerRef(c); | ||
} | ||
}, | ||
} | ||
@@ -186,5 +207,5 @@ : { | ||
props.innerRef(c); | ||
} | ||
}, | ||
})} | ||
style={{ whiteSpace: "pre-wrap", ...props.style }} | ||
style={{ whiteSpace: 'pre-wrap', ...props.style }} | ||
contentEditable={editable} | ||
@@ -191,0 +212,0 @@ key={Date()} |
44962
24
950