diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..1e78999 --- /dev/null +++ b/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + "env", + "react", + "stage-0" + ], + "plugins": [ + "transform-class-properties", + "transform-decorators", + "transform-react-constant-elements", + "transform-react-inline-elements" + ] +} diff --git a/.eslintrc b/.eslintrc index 1dd7526..aecf1a6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,38 +1,65 @@ { - "parser": "babel-eslint", "env": { "browser": true, "node": true, "mocha": true }, - "plugins": [ - "react" - ], - "ecmaFeatures": { - "jsx": true + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "modules": true, + "forOf": true, + "jsx": true, + "es6": true, + "spread": true, + "experimentalObjectRestSpread": true, + "experimentalDecorators": true + } }, "rules": { - "strict": [0], - "quotes": [2, "single"], - "eol-last": [0], - "no-mixed-requires": [0], - "no-underscore-dangle": [0], - "no-alert": [0], - "key-spacing": [0], - "camelcase": [0], - "no-multi-spaces": [0], - "react/display-name": 1, - "react/jsx-boolean-value": 1, - "react/jsx-quotes": 1, - "react/jsx-no-undef": 1, - "react/jsx-uses-react": 1, - "react/jsx-uses-vars": 1, - "react/no-did-mount-set-state": 1, - "react/no-did-update-set-state": 1, - "react/no-unknown-property": 1, - "react/prop-types": 1, - "react/react-in-jsx-scope": 1, - "react/self-closing-comp": 1, - "react/wrap-multilines": 1 - } -} \ No newline at end of file + "padded-blocks": 0, + "react/no-multi-comp": 0, + "import/default": 0, + "import/no-duplicates": 0, + "import/named": 0, + "import/namespace": 0, + "import/no-unresolved": 0, + "import/no-named-as-default": 2, + "comma-dangle": 0, // not sure why airbnb turned this on. gross! + "indent": [2, 2, {"SwitchCase": 1}], + "no-console": 0, + "no-alert": 0, + "id-length": [2, {"min": 1, "properties": "never"}], + "object-curly-spacing": ["error", "never"], + "prefer-template": 0, + "max-len": ["error", 300, 2, {"ignoreUrls": true}], + "react/prefer-stateless-function": 0, + "consistent-return": 0, + "no-labels": 0, + "quote-props": 0, + "no-empty-label": 0, + "space-after-keywords": 0, + "space-return-throw-case": 0, + "no-return-assign": 0, + "react/jsx-indent-props": 0, + "react/jsx-space-before-closing": 0, + "arrow-body-style": 0, + "react/jsx-closing-bracket-location": 0, + "space-in-parens": 0, + "array-callback-return": 0, + "arrow-spacing": 0, + "no-param-reassign": 0, + "object-shorthand": 0, + "react/sort-comp": 0, + "array-bracket-spacing": 0, + "react/jsx-first-prop-new-line": 0, + "no-underscore-dangle": 0, + "global-require": 0, + "jsx-a11y/anchor-has-content": 0, + "react/forbid-prop-types": 0 + }, + "plugins": [ + "react" + ] +} diff --git a/dist/TinyMCEInput.js b/dist/TinyMCEInput.js new file mode 100644 index 0000000..c2daf9a --- /dev/null +++ b/dist/TinyMCEInput.js @@ -0,0 +1,386 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + +var _jsx = function () { var REACT_ELEMENT_TYPE = typeof Symbol === "function" && Symbol.for && Symbol.for("react.element") || 0xeac7; return function createRawReactElement(type, props, key, children) { var defaultProps = type && type.defaultProps; var childrenLength = arguments.length - 3; if (!props && childrenLength !== 0) { props = {}; } if (props && defaultProps) { for (var propName in defaultProps) { if (props[propName] === void 0) { props[propName] = defaultProps[propName]; } } } else if (!props) { props = defaultProps || {}; } if (childrenLength === 1) { props.children = children; } else if (childrenLength > 1) { var childArray = Array(childrenLength); for (var i = 0; i < childrenLength; i++) { childArray[i] = arguments[i + 3]; } props.children = childArray; } return { $$typeof: REACT_ELEMENT_TYPE, type: type, key: key === undefined ? null : '' + key, ref: null, props: props, _owner: null }; }; }(); + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +var _react = require('react'); + +var _react2 = _interopRequireDefault(_react); + +var _propTypes = require('prop-types'); + +var _propTypes2 = _interopRequireDefault(_propTypes); + +var _uuid = require('uuid'); + +var _uuid2 = _interopRequireDefault(_uuid); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } /* global tinymce */ + +// TinyMCE semi-controlled component. +// +// Limitations/Notes +// * `tinymce` be defined in the global scope. +// * `ignoreUpdatesWhenFocused` - sometimes TinyMCE has issues with cursor placement. This component tries very +// hard to avoid such issues, but if the come up, this prop might help. Set it to true and the component +// will only update the TinyMCE editor from new props when it does not have focus. +// * `onChange` - this is the main event you will want to handle. Note: unlike normal React onChange events, +// it does not use a SyntheticEvent based event. It simply passes up the changed content. +// * events - the component listens for several events and maps them to something more React-like (ex. blur +// => onBlur). Any event that changes the content should trigger both the original event plus onChange. +// The event handler will receive the original tinymce event as a param. +// [init, activate, deactivate, focus, blur, hide, remove reset, show, submit] +// * level of control - tinymce does not trigger an event on every character change. We could try binding to +// a keyboard event. However, we have found that, in practice, getting changes in TinyMCE time is good enoug. +// If you are trying to write a control that need per-character eventing, ex. a component that allows +// multiple editors to work on the input at the same time, tinymce may not be right for you. + +var DIRECT_PASSTHROUGH_EVENTS = ['Activate', 'Deactivate', 'Focus', 'Hide', 'Init', 'Remove', 'Reset', 'Show', 'Submit', 'Click']; +var PSEUDO_HIDDEN = { position: 'absolute', left: -200, top: -200, height: 0 }; + +var TinyMCEInput = function (_React$Component) { + _inherits(TinyMCEInput, _React$Component); + + function TinyMCEInput() { + _classCallCheck(this, TinyMCEInput); + + var _this = _possibleConstructorReturn(this, (TinyMCEInput.__proto__ || Object.getPrototypeOf(TinyMCEInput)).call(this)); + + _this.setupPassthroughEvents = _this.setupPassthroughEvents.bind(_this); + _this.setupEditor = _this.setupEditor.bind(_this); + _this.createMCEContextForComponent = _this.createMCEContextForComponent.bind(_this); + _this.initTinyMCE = _this.initTinyMCE.bind(_this); + _this.clearDropOverride = _this.clearDropOverride.bind(_this); + _this.flagDropOverride = _this.flagDropOverride.bind(_this); + _this.isDropOverrideFlagged = _this.isDropOverrideFlagged.bind(_this); + _this.syncChange = _this.syncChange.bind(_this); + _this.triggerEventHandler = _this.triggerEventHandler.bind(_this); + _this.checkForChanges = _this.checkForChanges.bind(_this); + _this.onTinyMCEChange = _this.onTinyMCEChange.bind(_this); + _this.onTinyMCEBlur = _this.onTinyMCEBlur.bind(_this); + _this.onTinyMCEUndo = _this.onTinyMCEUndo.bind(_this); + _this.onTinyMCERedo = _this.onTinyMCERedo.bind(_this); + _this.onTinyMCEDrop = _this.onTinyMCEDrop.bind(_this); + _this.onTextareaChange = _this.onTextareaChange.bind(_this); + _this.getContainerID = _this.getContainerID.bind(_this); + _this.state = { + id: (0, _uuid2.default)() + }; + _this.component = null; + _this.componentId = null; + return _this; + } + + _createClass(TinyMCEInput, [{ + key: 'getComponentID', + value: function getComponentID() { + return this.componentId || (this.componentId = this.component.getAttribute('id')); + } + }, { + key: 'getContainerID', + value: function getContainerID() { + return this.props.id || this.state.id; + } + }, { + key: 'componentWillMount', + value: function componentWillMount() { + this.setState({ value: this.props.value || '' }); + } + }, { + key: 'componentDidMount', + value: function componentDidMount() { + this.initStartTime = Date.now(); + if (typeof tinymce !== 'undefined') { + this.initTinyMCE(); + } else { + this.initTimeout = setTimeout(this.initTinyMCE, 100); + } + this.updateInterval = setInterval(this.checkForChanges, this.props.pollInterval); + } + }, { + key: 'componentDidUpdate', + value: function componentDidUpdate() { + if (this.props.focus) { + var editor = tinymce.get(this.getComponentID()); + if (editor) { + editor.focus(); + } + } + } + }, { + key: 'componentWillUnmount', + value: function componentWillUnmount() { + tinymce.remove(this.getComponentID()); + clearTimeout(this.initTimeout); + clearInterval(this.updateInterval); + this.initTimeout = undefined; + this.initStartTime = undefined; + } + }, { + key: 'componentWillReceiveProps', + value: function componentWillReceiveProps(nextProps) { + if (nextProps.value !== this.state.value) { + var editor = tinymce.get(this.getComponentID()); + if (editor) { + if (!this.props.ignoreUpdatesWhenFocused || tinymce.focusedEditor !== editor || this.isDropOverrideFlagged()) { + var bookmark = editor.selection.getBookmark(2, true); + editor.setContent(nextProps.value); + editor.selection.moveToBookmark(bookmark); + } + } + this.setState({ value: nextProps.value }); + } + } + }, { + key: 'setupPassthroughEvents', + value: function setupPassthroughEvents(editor) { + var _this2 = this; + + DIRECT_PASSTHROUGH_EVENTS.map(function (event) { + editor.on(event.toLowerCase(), function (tinyMCEEvent) { + var handler = _this2.props['on' + event]; + if (typeof handler === 'function') { + handler(tinyMCEEvent); + } + }); + }); + + var handlers = this.props.otherEventHandlers; + Object.keys(handlers).map(function (key, index) { + editor.on(index, key); + }); + } + }, { + key: 'setupEditor', + value: function setupEditor(editor) { + editor.on('change', this.onTinyMCEChange); + editor.on('blur', this.onTinyMCEBlur); + editor.on('drop', this.onTinyMCEDrop); + editor.on('undo', this.onTinyMCEUndo); + editor.on('redo', this.onTinyMCERedo); + this.setupPassthroughEvents(editor); + + if (this.props.onSetupEditor) { + this.props.onSetupEditor(editor); + } + + if (this.props.focus) { + editor.focus(); + } + this.initTimeout = undefined; + } + }, { + key: 'createMCEContextForComponent', + value: function createMCEContextForComponent() { + var tinymceConfig = Object.assign({}, this.props.tinymceConfig, { + selector: '#' + this.getContainerID(), + setup: this.setupEditor + }); + tinymce.init(tinymceConfig); + } + }, { + key: 'initTinyMCE', + value: function initTinyMCE() { + var currentTime = Date.now(); + if (!tinymce) { + if (currentTime - this.initStartTime > this.props.maxInitWaitTime) { + this.initTimeout = undefined; + } else { + this.initTimeout = setTimeout(this.initTinyMCE, 100); + } + } else { + this.createMCEContextForComponent(); + this.initTimeout = undefined; + } + } + }, { + key: 'clearDropOverride', + value: function clearDropOverride() { + this._tempDropOverride = undefined; + var editor = tinymce.get(this.getComponentID()); + if (editor) { + this.syncChange(editor.getContent()); + } + } + }, { + key: 'flagDropOverride', + value: function flagDropOverride() { + this._tempDropOverride = true; + if (this._tempDropOverrideTimeout) { + clearTimeout(this.clearDropOverride); + } + this._tempDropOverrideTimeout = setTimeout(this.clearDropOverride, 250); + } + }, { + key: 'isDropOverrideFlagged', + value: function isDropOverrideFlagged() { + return this._tempDropOverride; + } + }, { + key: 'syncChange', + value: function syncChange(newValue) { + if (newValue !== this.state.value) { + if (this.props.onChange) { + this.props.onChange(newValue); + } + this.setState({ value: newValue }); + } + } + }, { + key: 'triggerEventHandler', + value: function triggerEventHandler(handler, event) { + if (handler) { + handler(event); + } + } + }, { + key: 'checkForChanges', + value: function checkForChanges() { + var editor = tinymce.get(this.getComponentID()); + if (tinymce.focusedEditor === editor) { + var content = editor.getContent(); + if (content !== this.state.value) { + this.syncChange(content); + } + } + } + }, { + key: 'onTinyMCEChange', + value: function onTinyMCEChange(tinyMCEEvent) { + this.syncChange(tinyMCEEvent.target.getContent()); + } + }, { + key: 'onTinyMCEBlur', + value: function onTinyMCEBlur(tinyMCEEvent) { + this.triggerEventHandler(this.props.onBlur, tinyMCEEvent); + if (this.props.ignoreUpdatesWhenFocused) { + // if we have been ignoring updates while focused (to preserve cursor position) + // sync them now that we no longer have focus. + tinyMCEEvent.target.setContent(this.state.value); + } + } + }, { + key: 'onTinyMCEUndo', + value: function onTinyMCEUndo(tinyMCEEvent) { + this.triggerEventHandler(this.props.onUndo, tinyMCEEvent); + this.syncChange(tinyMCEEvent.target.getContent()); + } + }, { + key: 'onTinyMCERedo', + value: function onTinyMCERedo(tinyMCEEvent) { + this.triggerEventHandler(this.props.onRedo, tinyMCEEvent); + this.syncChange(tinyMCEEvent.target.getContent()); + } + }, { + key: 'onTinyMCEDrop', + value: function onTinyMCEDrop() { + // We want to process updates just after a drop, even if processUpdatesWhenFocused + // is false. The processUpdatesWhenFocused flag exists to keep the cursor from + // jumping around, and we do not cares so much if the cursor jumps after dropping + // an image because that is a mouse event. However, ignoring updates right after a + // drop means that anything that relies on knowing the content has changed is + // won't actually know. + this.flagDropOverride(); + } + }, { + key: 'onTextareaChange', + value: function onTextareaChange(e) { + // should only be called when tinymce failed to load and we are getting changes directly in the textarea (fallback mode?) + this.syncChange(e.target.value); + } + }, { + key: 'render', + value: function render() { + var _this3 = this; + + // the textarea is controlled by tinymce... and react, neither of which agree on the value + // solution: keep a separate input element, controlled by just react, that will actually be submitted + var Component = this.props.component; + return _jsx('div', { + className: this.props.className, + style: this.props.style + }, void 0, _jsx('input', { + type: 'hidden', + name: this.props.name, + value: this.state.value, + readOnly: true + }, 0), _react2.default.createElement(Component, _extends({ + key: 1, + id: this.getContainerID(), + defaultValue: this.state.value, + onChange: this.onTextareaChange, + rows: this.props.rows, + style: this.props.tinymceConfig.inline ? {} : PSEUDO_HIDDEN + }, this.props.textareaProps, { + ref: function ref(_ref) { + return _this3.component = _ref; + } + }))); + } + }]); + + return TinyMCEInput; +}(_react2.default.Component); + +TinyMCEInput.propTypes = { + id: _propTypes2.default.string, + className: _propTypes2.default.string, + tinymceConfig: _propTypes2.default.object.isRequired, + name: _propTypes2.default.string, // the form name for the input element + component: _propTypes2.default.string, + value: _propTypes2.default.string, + rows: _propTypes2.default.number, + focus: _propTypes2.default.bool, // focus the tinymce element if not already focused + maxInitWaitTime: _propTypes2.default.number, // [20000] maximum amount of time to wait, in ms, for tinymce to create an editor before giving up + style: _propTypes2.default.object, + ignoreUpdatesWhenFocused: _propTypes2.default.bool, // tinymce can sometimes have cursor position issues on updates, if you app does not need live updates from the backing model, then set the prop and it will only update when the editor does not have focus + + pollInterval: _propTypes2.default.number.isRequired, // [1000] inteval to wait between polling for changes in tinymce editor (since blur does not always work), changes are then synced if the editor is focused + + // intercepted events + onChange: _propTypes2.default.func.isRequired, // this is a controlled component, we require onChange + onBlur: _propTypes2.default.func, + onSetupEditor: _propTypes2.default.func, + + // direct pass through events + onActivate: _propTypes2.default.func, + onClick: _propTypes2.default.func, + onDeactivate: _propTypes2.default.func, + onFocus: _propTypes2.default.func, + onHide: _propTypes2.default.func, + onInit: _propTypes2.default.func, + onRedo: _propTypes2.default.func, + onRemove: _propTypes2.default.func, + onReset: _propTypes2.default.func, + onShow: _propTypes2.default.func, + onSubmit: _propTypes2.default.func, + onUndo: _propTypes2.default.func, + + textareaProps: _propTypes2.default.object.isRequired, // props passed through to the textarea + otherEventHandlers: _propTypes2.default.objectOf(_propTypes2.default.func.isRequired).isRequired + +}; +TinyMCEInput.defaultProps = { + tinymceConfig: {}, + maxInitWaitTime: 20000, + pollInterval: 1000, + textareaProps: {}, + otherEventHandlers: {}, + onChange: function onChange() {}, + component: 'textarea' + +}; +exports.default = TinyMCEInput; \ No newline at end of file diff --git a/examples/Tiny.js b/examples/Tiny.js index b2c3f05..3d6befb 100644 --- a/examples/Tiny.js +++ b/examples/Tiny.js @@ -1,17 +1,17 @@ -var React = require('react') - , ReactDOM = require('react-dom') - , createReactClass = require('create-react-class') - , TinyMCEInput = require('../src/TinyMCEInput'); +import React from 'react'; +import ReactDOM from 'react-dom'; +import {hot} from 'react-hot-loader' +import TinyMCEInput from '../src/TinyMCEInput'; const TINYMCE_CONFIG = { - 'language' : 'en', - 'theme' : 'modern', - 'toolbar' : 'bold italic underline strikethrough hr | bullist numlist | link unlink | undo redo | spellchecker code', - 'menubar' : false, - 'statusbar' : true, - 'resize' : true, - 'plugins' : 'link,spellchecker,paste', - 'theme_modern_toolbar_location' : 'top', + 'language': 'en', + 'theme': 'modern', + 'toolbar': 'bold italic underline strikethrough hr | bullist numlist | link unlink | undo redo | spellchecker code', + 'menubar': false, + 'statusbar': true, + 'resize': true, + 'plugins': 'link,spellchecker,paste', + 'theme_modern_toolbar_location': 'top', 'theme_modern_toolbar_align': 'left' }; @@ -19,31 +19,41 @@ const INLINE_TINYMCE_CONFIG = { inline: true, }; -var Tiny = createReactClass({ - displayName: 'TinyMCEExample', - getInitialState: function() { - return { +class Tiny extends React.Component { + constructor() { + super(); + this.state = { value: '', editMode: false }; - }, - onClick: function() { - this.setState({ editMode: !this.state.editMode }); - }, - onChange: function(newValue) { - this.setState({ value: newValue }); - }, - onTextAreaChange: function(e) { - this.setState({ value: e.target.value }); - }, - render: function() { + this.onClick = this.onClick.bind(this); + this.onChange = this.onChange.bind(this); + this.onTextAreaChange = this.onTextAreaChange.bind(this); + } + + onClick() { + this.setState({editMode: !this.state.editMode}); + } + + onChange(newValue) { + this.setState({value: newValue}); + } + + onTextAreaChange(e) { + this.setState({value: e.target.value}); + } + + render() { return (