├── .babelrc ├── .eslintignore ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── CNAME ├── css │ ├── demo.css │ └── normalize.css ├── demo.html ├── index.html └── react.ico ├── flow-typed ├── classnames.js └── globals.js ├── package.json ├── src ├── Draft.global.css ├── EditorDemo.js ├── RichTextEditor.css ├── RichTextEditor.js ├── SimpleRichTextEditor.js ├── __tests__ │ └── RichTextEditor-test.js ├── demo.js ├── lib │ ├── EditorToolbar.css │ ├── EditorToolbar.js │ ├── EditorToolbarConfig.js │ ├── EditorValue.js │ ├── ImageDecorator.js │ ├── LinkDecorator.js │ ├── StyleButton.js │ ├── __tests__ │ │ └── composite-test.js │ ├── blockStyleFunctions.js │ ├── changeBlockDepth.js │ ├── changeBlockType.js │ ├── clearEntityForRange.js │ ├── composite.js │ ├── getBlocksInSelection.js │ ├── getEntityAtCursor.js │ ├── insertBlockAfter.js │ └── isListItem.js └── ui │ ├── Button.css │ ├── Button.js │ ├── ButtonGroup.css │ ├── ButtonGroup.js │ ├── ButtonWrap.css │ ├── ButtonWrap.js │ ├── Dropdown.css │ ├── Dropdown.js │ ├── IconButton.css │ ├── IconButton.js │ ├── ImageSpan.css │ ├── ImageSpan.js │ ├── InputPopover.css │ ├── InputPopover.js │ └── PopoverIconButton.js ├── test ├── init.js └── mocha.opts ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-runtime" 4 | ], 5 | "presets": [ 6 | ["babel-preset-es2015", { "modules": false }], 7 | "babel-preset-react", 8 | "babel-preset-stage-2" 9 | ], 10 | "env": { 11 | "test": { 12 | "plugins": [ 13 | "transform-es2015-modules-commonjs" 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | _* 2 | !__tests__ 3 | /assets/dist 4 | /dist 5 | /lib 6 | /node_modules 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "plugins": [ 4 | "babel", 5 | "flow-vars", 6 | "react" 7 | ], 8 | "env": { 9 | "browser": true, 10 | "node": true 11 | }, 12 | "globals": { 13 | "Map": false, 14 | "Set": false, 15 | // Flow globals 16 | "ReactElement": false, 17 | "ReactNode": false 18 | }, 19 | "rules": { 20 | "no-cond-assign": 1, // disallow assignment in conditional expressions 21 | "no-console": 0, // disallow use of console: should use nuclide-logging instead 22 | "no-constant-condition": 1, // disallow use of constant expressions in conditions 23 | "comma-dangle": [ // disallow trailing commas in object and array literals 24 | 1, "always-multiline" 25 | ], 26 | "no-control-regex": 1, // disallow control characters in regular expressions 27 | "no-debugger": 1, // disallow use of debugger 28 | "no-dupe-keys": 1, // disallow duplicate keys when creating object literals 29 | "no-dupe-args": 1, // disallow duplicate arguments in functions 30 | "no-duplicate-case": 1, // disallow a duplicate case label 31 | "no-empty": 0, // disallow empty statements 32 | "no-empty-character-class": 1, // disallow the use of empty character classes in regular expressions 33 | "no-ex-assign": 1, // disallow assigning to the exception in a catch block 34 | "no-extra-boolean-cast": 1, // disallow double-negation boolean casts in a boolean context 35 | "no-extra-semi": 1, // disallow unnecessary semicolons 36 | "no-func-assign": 1, // disallow overwriting functions written as function declarations 37 | "no-inner-declarations": 0, // disallow function or variable declarations in nested blocks 38 | "no-invalid-regexp": 1, // disallow invalid regular expression strings in the RegExp constructor 39 | "no-negated-in-lhs": 1, // disallow negation of the left operand of an in expression 40 | "no-obj-calls": 1, // disallow the use of object properties of the global object (Math and JSON) as functions 41 | "no-regex-spaces": 1, // disallow multiple spaces in a regular expression literal 42 | "no-reserved-keys": 0, // disallow reserved words being used as object literal keys 43 | "no-sparse-arrays": 1, // disallow sparse arrays 44 | "no-unreachable": 1, // disallow unreachable statements after a return, throw, continue, or break statement 45 | "use-isnan": 1, // disallow comparisons with the value NaN 46 | "valid-jsdoc": 0, // Ensure JSDoc comments are valid 47 | "valid-typeof": 1, // Ensure that the results of typeof are compared against a valid string 48 | 49 | // Best Practices (designed to prevent you from making mistakes) 50 | 51 | "block-scoped-var": 0, // treat var statements as if they were block scoped 52 | "complexity": 0, // specify the maximum cyclomatic complexity allowed in a program 53 | "consistent-return": 0, // require return statements to either always or never specify values 54 | "curly": 1, // specify curly brace conventions for all control statements 55 | "default-case": 0, // require default case in switch statements 56 | "dot-notation": 0, // dot notation encouraged except for foreign properties that cannot be renamed (i.e., Closure Compiler rules) 57 | "eqeqeq": [1, "allow-null"], // require the use of === and !== 58 | "guard-for-in": 1, // make sure for-in loops have an if statement 59 | "no-alert": 1, // disallow the use of alert, confirm, and prompt 60 | "no-caller": 1, // disallow use of arguments.caller or arguments.callee 61 | "no-div-regex": 1, // disallow division operators explicitly at beginning of regular expression 62 | "no-else-return": 0, // disallow else after a return in an if 63 | "no-eq-null": 0, // disallow comparisons to null without a type-checking operator 64 | "no-eval": 1, // disallow use of eval() 65 | "no-extend-native": 1, // disallow adding to native types 66 | "no-extra-bind": 1, // disallow unnecessary function binding 67 | "no-fallthrough": 1, // disallow fallthrough of case statements 68 | "no-floating-decimal": 1, // disallow the use of leading or trailing decimal points in numeric literals 69 | "no-implied-eval": 1, // disallow use of eval()-like methods 70 | "no-labels": 1, // disallow use of labeled statements 71 | "no-iterator": 1, // disallow usage of __iterator__ property 72 | "no-lone-blocks": 1, // disallow unnecessary nested blocks 73 | "no-loop-func": 0, // disallow creation of functions within loops 74 | "no-multi-str": 0, // disallow use of multiline strings 75 | "no-native-reassign": 0, // disallow reassignments of native objects 76 | "no-new": 1, // disallow use of new operator when not part of the assignment or comparison 77 | "no-new-func": 1, // disallow use of new operator for Function object 78 | "no-new-wrappers": 1, // disallows creating new instances of String,Number, and Boolean 79 | "no-octal": 1, // disallow use of octal literals 80 | "no-octal-escape": 1, // disallow use of octal escape sequences in string literals, such as var foo = "Copyright \251"; 81 | "no-proto": 1, // disallow usage of __proto__ property 82 | "no-redeclare": 1, // disallow declaring the same variable more then once 83 | "no-return-assign": 1, // disallow use of assignment in return statement 84 | "no-script-url": 1, // disallow use of javascript: urls. 85 | "no-self-compare": 1, // disallow comparisons where both sides are exactly the same 86 | "no-sequences": 1, // disallow use of comma operator 87 | "no-unused-expressions": 0, // disallow usage of expressions in statement position 88 | "no-void": 1, // disallow use of void operator 89 | "no-warning-comments": 0, // disallow usage of configurable warning terms in comments e.g. TODO or FIXME 90 | "no-with": 1, // disallow use of the with statement 91 | "radix": 1, // require use of the second argument for parseInt() 92 | "vars-on-top": 0, // requires to declare all vars on top of their containing scope 93 | "wrap-iife": 0, // require immediate function invocation to be wrapped in parentheses 94 | "yoda": 1, // require or disallow Yoda conditions 95 | "strict": 0, // this rule conflicts with 'use-babel' so we'll just disable it 96 | 97 | // Variables 98 | 99 | "no-catch-shadow": 1, // disallow the catch clause parameter name being the same as a variable in the outer scope (off by default in the node environment) 100 | "no-delete-var": 1, // disallow deletion of variables 101 | "no-label-var": 1, // disallow labels that share a name with a variable 102 | "no-shadow": 0, // disallow declaration of variables already declared in the outer scope 103 | "no-shadow-restricted-names": 1, // disallow shadowing of names such as arguments 104 | "no-undef": 1, // disallow undeclared variables 105 | "no-undefined": 0, // disallow use of undefined variable 106 | "no-undef-init": 0, // disallow use of undefined when initializing variables 107 | "no-unused-vars": 1, // disallow declaration of variables that are not used in the code 108 | "no-use-before-define": 0, // disallow use of variables before they are defined 109 | 110 | // Node.js 111 | 112 | "handle-callback-err": 1, // enforces error handling in callbacks 113 | "no-mixed-requires": 1, // disallow mixing regular variable and require declarations 114 | "no-new-require": 1, // disallow use of new operator with the require function 115 | "no-path-concat": 1, // disallow string concatenation with __dirname and __filename 116 | "no-process-exit": 0, // disallow process.exit() 117 | "no-restricted-modules": 1, // restrict usage of specified node modules 118 | "no-sync": 0, // disallow use of synchronous methods 119 | 120 | // React (eslint-plugin-react) 121 | 122 | "jsx-quotes": [1, "prefer-double"], // <Foo bar="x" /> not <Foo bar='x' /> 123 | "react/jsx-curly-spacing": [ // Enforce or disallow spaces inside of curly braces in JSX attributes 124 | 1, "never" 125 | ], 126 | "react/jsx-no-undef": 1, // Disallow undeclared variables in JSX 127 | "react/jsx-uses-react": 1, // Prevent React to be incorrectly marked as unused 128 | "react/jsx-uses-vars": 1, // Prevent variables used in JSX to be incorrectly marked as unused 129 | "react/no-unknown-property": 1, // Prevent usage of unknown DOM property 130 | "react/prop-types": 1, // Prevent missing props validation in a React component definition 131 | "react/react-in-jsx-scope": 2, // Prevent missing React when using JSX 132 | 133 | // Stylistic (these rules are purely matters of style and are quite subjective) 134 | 135 | "key-spacing": 1, // require space after colon `{a: 1}` 136 | "comma-spacing": 1, // require space after comma `var a, b;` 137 | "no-multi-spaces": 1, // don't allow more spaces than necessary 138 | "brace-style": [ // enforce one true brace style 139 | 1, "1tbs", { 140 | "allowSingleLine": false 141 | } 142 | ], 143 | "camelcase": [ // require camel case names 144 | 1, {"properties": "never"} 145 | ], 146 | "consistent-this": [1, "self"], // enforces consistent naming when capturing the current execution context 147 | "eol-last": 1, // enforce newline at the end of file, with no multiple empty lines 148 | "func-names": 0, // require function expressions to have a name 149 | "func-style": 0, // enforces use of function declarations or expressions 150 | "new-cap": 0, // require a capital letter for constructors 151 | "new-parens": 1, // disallow the omission of parentheses when invoking a constructor with no arguments 152 | "no-nested-ternary": 0, // disallow nested ternary expressions 153 | "no-array-constructor": 1, // disallow use of the Array constructor 154 | "no-lonely-if": 0, // disallow if as the only statement in an else block 155 | "no-new-object": 1, // disallow use of the Object constructor 156 | "no-spaced-func": 1, // disallow space between function identifier and application 157 | "semi-spacing": 1, // disallow space before semicolon 158 | "no-ternary": 0, // disallow the use of ternary operators 159 | "no-trailing-spaces": 1, // disallow trailing whitespace at the end of lines 160 | "no-underscore-dangle": 0, // disallow dangling underscores in identifiers 161 | "no-extra-parens": [1, "functions"], // disallow wrapping of non-IIFE statements in parens 162 | "no-mixed-spaces-and-tabs": 1, // disallow mixed spaces and tabs for indentation 163 | "indent": [1, 2, {"SwitchCase": 1}], // indentation should be two spaces 164 | "quotes": [ // enforce single quotes, allow double to avoid escaping ("don't escape" instead of 'don\'t escape') 165 | 1, "single", "avoid-escape" 166 | ], 167 | "quote-props": [1, "as-needed"], // require quotes around object literal property names 168 | "semi": 1, // require or disallow use of semicolons instead of ASI 169 | "sort-vars": 0, // sort variables within the same declaration block 170 | "keyword-spacing": 1, // require a space around certain keywords 171 | "space-before-blocks": 1, // require a space before blocks 172 | "space-before-function-paren": [ // disallow a space before function parenthesis 173 | 1, "never" 174 | ], 175 | "object-curly-spacing": [ // disallow spaces inside of curly braces in object literals 176 | 1, "never" 177 | ], 178 | "array-bracket-spacing": [ // disallow spaces inside of curly braces in array literals 179 | 1, "never" 180 | ], 181 | "space-in-parens": 1, // require or disallow spaces inside parentheses 182 | "space-infix-ops": 1, // require spaces around operators 183 | "space-unary-ops": 1, // require a space around word operators such as typeof 184 | "max-nested-callbacks": 0, // specify the maximum depth callbacks can be nested 185 | "one-var": [1, "never"], // allow just one var statement per function 186 | "wrap-regex": 0, // require regex literals to be wrapped in parentheses 187 | 188 | // ECMAScript 6/7 (2015 and above) 189 | "arrow-parens": 1, // require parens in arrow function arguments 190 | "arrow-spacing": 1, // require space before/after arrow function's arrow (fixable) 191 | "constructor-super": 1, // verify calls of super() in constructors 192 | "generator-star-spacing": 0, // enforce spacing around the * in generator functions (fixable) 193 | "no-class-assign": 1, // disallow modifying variables of class declarations 194 | "no-const-assign": 1, // disallow modifying variables that are declared using const 195 | "no-dupe-class-members": 1, // disallow duplicate name in class members 196 | "no-this-before-super": 1, // disallow use of this/super before calling super() in constructors. 197 | "no-var": 0, // require let or const instead of var 198 | "object-shorthand": 0, // require method and property shorthand syntax for object literals 199 | "prefer-arrow-callback": 1, // suggest using arrow functions as callbacks 200 | "prefer-const": 0, // suggest using const declaration for variables that are never modified after declared 201 | "prefer-reflect": 0, // suggest using Reflect methods where applicable 202 | "prefer-spread": 0, // suggest using the spread operator instead of .apply(). 203 | "prefer-template": 0, // suggest using template literals instead of strings concatenation 204 | "require-yield": 0, // disallow generator functions that do not have yield 205 | "babel/no-await-in-loop": 1, // async inside a loop will run operations in serial, when often the desired behavior is to do do in parallel 206 | 207 | // Legacy (included for compatibility with JSHint and JSLint. While the names of the rules may not match up with the JSHint/JSLint counterpart, the functionality is the same) 208 | 209 | "max-depth": 0, // specify the maximum depth that blocks can be nested 210 | //"max-len": [ // specify the maximum length of a line in your program [warning level, max line length, number of characters to treat a tab as] 211 | // 1, 100, 2, { 212 | // "ignoreUrls": true, 213 | // "ignorePattern": "^\\s*(import\\s[^{]+from|(var|const|let)\\s[^{]+=\\s*require\\s*\\()" 214 | // } 215 | //], 216 | "max-params": 0, // limits the number of parameters that can be used in the function declaration. 217 | "max-statements": 0, // specify the maximum number of statement allowed in a function 218 | "no-bitwise": 0, // disallow use of bitwise operators 219 | "no-plusplus": 0 // disallow use of unary operators, ++ and -- 220 | 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.*/node_modules/fbjs/.* 3 | 4 | [include] 5 | 6 | [libs] 7 | 8 | [options] 9 | esproposal.class_static_fields=enable 10 | module.system=haste 11 | 12 | suppress_type=$FlowIssue 13 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _* 2 | !__tests__ 3 | /assets/dist 4 | /lib 5 | /dist 6 | /node_modules 7 | .idea 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | _* 2 | /assets 3 | /flow-typed 4 | /src 5 | /test 6 | .babelrc 7 | .eslintignore 8 | .eslintrc 9 | .flowconfig 10 | .surgeignore 11 | .travis.yml 12 | CNAME 13 | demo.html 14 | index.html 15 | webpack.config.js 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v0.1.17 2 | 3 | - Remove hard-coded height from RichTextEditor.css 4 | 5 | v0.1.16 6 | 7 | - Update peerDependencies to allow React 0.14 8 | - Pass extra props to <Editor /> 9 | 10 | v0.1.15 11 | 12 | - Update Flow and fix some Flow errors 13 | 14 | v0.1.14 15 | 16 | - Update README about building demo 17 | 18 | v0.1.13 19 | 20 | - Updates to demo including better display on mobile 21 | - Remove console.error hack 22 | - Minor style fixes to dropdown component 23 | 24 | v0.1.12 25 | 26 | - Updates to README 27 | - CSS fix for transparent dropdown options. Fixes #26 28 | - Fix placeholder text that was hard-coded in component 29 | - Update contributors 30 | 31 | v0.1.11 32 | 33 | - Add onTab handler for list indent 34 | - Refactor to use CSS Modules 35 | - Update build system to work with CSS Modules 36 | 37 | v0.1.10 38 | 39 | - [return handling] In non-list block, pressing return at end should always create a normal block 40 | - [fix] Allow soft-linebreak when text is selected 41 | - [fix] [parse-html] Correctly parse entities with inline style 42 | 43 | v0.1.9 44 | 45 | - [demo] Add "Log State" button for debugging 46 | - [deployment] Add travis-ci for continuous integration 47 | - [tests] Better tests 48 | - [docs] Update readme 49 | 50 | v0.1.8 51 | 52 | - Break out import/export logic (and related deps) into separate npm packages 53 | - Move RichTextEditor.js up one dir 54 | - [fix] Form submit issue with InputPopover 55 | - [docs] Update readme 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2016, Simon Sturmer <sstur@me.com> 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | This repository is long ourdated and even [Draft.js](https://github.com/facebookarchive/draft-js) (on which this editor is built) is long outdated and has been superceded by [Lexical](https://github.com/facebook/lexical). 4 | 5 | I don't recommend you use this in your project. There are many great alternatives such as [TipTap](https://tiptap.dev/). 6 | 7 | # React Rich Text Editor 8 | 9 | This is a UI component built completely in React that is meant to be a full-featured textarea replacement similar to [CKEditor][ckeditor], [TinyMCE][tinymce] and other [rich text "WYSIWYG" editors][rte]. It's based on the excellent, open source [Draft.js][draft-js] from Facebook which is performant and production-tested. 10 | 11 | ## Demo 12 | 13 | Try the editor here: [react-rte.org/demo][react-rte-demo] 14 | 15 | [][react-rte-demo] 16 | 17 | 18 | ## Getting Started 19 | 20 | $ npm install --save react-rte 21 | 22 | `RichTextEditor` is the main editor component. It is comprised of the Draft.js `<Editor>`, some UI components (e.g. toolbar) and some helpful abstractions around getting and setting content with HTML/Markdown. 23 | 24 | `RichTextEditor` is designed to be used like a `textarea` except that instead of `value` being a string, it is an object with `toString` on it. Creating a `value` from a string is also easy using `createValueFromString(markup, 'html')`. 25 | 26 | ### Browser Compatibility 27 | 28 | The scripts are transpiled by Babel to ES6. Additionally, at least one of this package's dependencies does not support IE. So, for IE and back-plat support you will need to include some polyfill in your HTML (#74, #196, #203): `<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=String.prototype.startsWith,Array.from,Array.prototype.fill,Array.prototype.keys,Array.prototype.findIndex,Number.isInteger&flags=gated"></script>` 29 | 30 | ### Required Webpack configuration 31 | 32 | If you are not using Webpack, you can skip this section. Webpack is required for isomorphic/server-side rendering support in a Node.js environment. 33 | 34 | `'react-rte'` contains a bundle that is already built (with CSS) using webpack and is not intended to be consumed again by webpack. So, if you are using webpack you must import RichTextEditor from `react-rte/lib/RichTextEditor` in order to get the un-bundled script which webpack can bundle with your app. 35 | 36 | If you are using webpack you must add a css loader or else your webpack build will fail. For example: 37 | 38 | ```js 39 | { 40 | test: /\.css$/, 41 | loaders: [ 42 | 'style-loader', 43 | 'css-loader?modules' 44 | ] 45 | }, 46 | ``` 47 | 48 | ### Example Usage: 49 | 50 | This example uses newer JavaScript and JSX. For an example in old JavaScript, [see below](#example-with-es5-and-no-jsx). 51 | 52 | ```javascript 53 | import React, {Component, PropTypes} from 'react'; 54 | import RichTextEditor from 'react-rte'; 55 | 56 | class MyStatefulEditor extends Component { 57 | static propTypes = { 58 | onChange: PropTypes.func 59 | }; 60 | 61 | state = { 62 | value: RichTextEditor.createEmptyValue() 63 | } 64 | 65 | onChange = (value) => { 66 | this.setState({value}); 67 | if (this.props.onChange) { 68 | // Send the changes up to the parent component as an HTML string. 69 | // This is here to demonstrate using `.toString()` but in a real app it 70 | // would be better to avoid generating a string on each change. 71 | this.props.onChange( 72 | value.toString('html') 73 | ); 74 | } 75 | }; 76 | 77 | render () { 78 | return ( 79 | <RichTextEditor 80 | value={this.state.value} 81 | onChange={this.onChange} 82 | /> 83 | ); 84 | } 85 | } 86 | ``` 87 | 88 | ### Toolbar Customization 89 | 90 | ```javascript 91 | 92 | render() { 93 | // The toolbarConfig object allows you to specify custom buttons, reorder buttons and to add custom css classes. 94 | // Supported inline styles: https://github.com/facebook/draft-js/blob/master/docs/Advanced-Topics-Inline-Styles.md 95 | // Supported block types: https://github.com/facebook/draft-js/blob/master/docs/Advanced-Topics-Custom-Block-Render.md#draft-default-block-render-map 96 | const toolbarConfig = { 97 | // Optionally specify the groups to display (displayed in the order listed). 98 | display: ['INLINE_STYLE_BUTTONS', 'BLOCK_TYPE_BUTTONS', 'LINK_BUTTONS', 'BLOCK_TYPE_DROPDOWN', 'HISTORY_BUTTONS'], 99 | INLINE_STYLE_BUTTONS: [ 100 | {label: 'Bold', style: 'BOLD', className: 'custom-css-class'}, 101 | {label: 'Italic', style: 'ITALIC'}, 102 | {label: 'Underline', style: 'UNDERLINE'} 103 | ], 104 | BLOCK_TYPE_DROPDOWN: [ 105 | {label: 'Normal', style: 'unstyled'}, 106 | {label: 'Heading Large', style: 'header-one'}, 107 | {label: 'Heading Medium', style: 'header-two'}, 108 | {label: 'Heading Small', style: 'header-three'} 109 | ], 110 | BLOCK_TYPE_BUTTONS: [ 111 | {label: 'UL', style: 'unordered-list-item'}, 112 | {label: 'OL', style: 'ordered-list-item'} 113 | ] 114 | }; 115 | return ( 116 | <RichTextEditor toolbarConfig={toolbarConfig} /> 117 | ); 118 | } 119 | 120 | ``` 121 | 122 | ## Motivation 123 | 124 | In short, this is a modern approach to rich text editing built on a battle-hardened open-source framework and, importantly, we do not store document state in the DOM, eliminating entire classes of common "WYSIWYG" problems. 125 | 126 | This editor is built on [Draft.js][draft-js] from Facebook. Draft.js is more of a low-level framework (`contentEditable` abstraction), however this component is intended to be a fully polished UI component that you can reach for when you need to replace a `<textarea/>` in your application to support bold, italic, links, lists, etc. 127 | 128 | The data model in Draft.js allows us to represent the document in a way that is mostly agnostic to the view/render layer or the textual representation (html/markdown) you choose. This data model encapsulates the content/state of the editor and is based on [Immutable.js][immutablejs] to be both performant and easy to reason about. 129 | 130 | ## Features 131 | 132 | * Pure React and fully declarative 133 | * Supported formats: HTML and Markdown (coming soon: extensible support for custom formats) 134 | * Document Model represents your document in a sane way that will [deterministically convert to clean markup](#deterministic-output) regardless of your format choice 135 | * Takes full advantage of Immutable.js and the excellent performance characteristics that come with it. 136 | * Reliable undo/redo without a large memory footprint 137 | * Modern browser support 138 | 139 | ## Deterministic Output 140 | 141 | Unlike typical rich text editors (such as [CKEditor][ckeditor] and [TinyMCE][tinymce]) we keep our content state in a well-architected data model instead of in the view. One important advantage of separating our data model from our view is deterministic output. 142 | 143 | Say, for instance, you select some text and add bold style. Then you add italic style. Or what if you add italic first and then bold. The result should be the same either way: the text range has both bold and italic style. But in the browser's view (Document Object Model) is this represented with a `<strong>` inside of an `<em>` or vice versa? Does it depend on the order in which you added the styles? In many web-based editors the HTML output *does* depend on the order of your actions. That means your output is non-deterministic. Two documents that look exactly the same in the editor will have different, sometimes unpredictable, HTML representations. 144 | 145 | In this editor we use a pure, deterministic function to convert document state to HTML output. No matter how you *arrived at* the state, the output will be predictable. This makes everything easier to reason about. In our case, the `<strong>` will go inside the `<em>` every time. 146 | 147 | ## API 148 | 149 | ### Required Props 150 | * `value`: Used to represent the content/state of the editor. Initially you will probably want to create an instance using a provided helper such as `RichTextEditor.createEmptyValue()` or `RichTextEditor.createValueFromString(markup, 'html')`. 151 | * `onChange`: A function that will be called with the "value" of the editor whenever it is changed. The value has a `toString` method which accepts a single `format` argument (either 'html' or 'markdown'). 152 | 153 | ### Other Props 154 | All the props you can pass to Draft.js `Editor` can be passed to `RichTextEditor` (with the exception of `editorState` which will be generated internally based on the `value` prop). 155 | 156 | * `autoFocus`: Setting this to true will automatically focus input into the editor when the component is mounted 157 | * `placeholder`: A string to use as placeholder text for the `RichTextEditor`. 158 | * `readOnly`: A boolean that determines if the `RichTextEditor` should render static html. 159 | * `enableSoftNewLineByDefault`: Set this to `true` if you wish to use soft line breaks when only pressing the return key. By default, if you press the return/enter key it will create a new text block. If you don't set this value to `true`, the user may use one of several [designated keys](https://github.com/facebook/draft-js/blob/cf9be24e8b14419143f1f01aabc68ed1be2f95e4/src/component/utils/isSoftNewlineEvent.js#L16) while pressing the return key to create a soft new line. 160 | 161 | ### EditorValue Class 162 | In Draft.js `EditorState` contains not only the document contents but the entire state of the editor including cursor position and selection. This is helpful for many reasons including undo/redo. To make things easier for you, we have wrapped the state of the editor in an `EditorValue` instance with helpful methods to convert to/from a HTML or Markdown. An instance of this class should be passed to `RichTextEditor` in the `value` prop. 163 | 164 | The `EditorValue` class has certain optimizations built in. So let's say you are showing the HTML of the editor contents in your view. If you change your cursor position, that will trigger an `onChange` event (because, remember, cursor position is part of `EditorState`) and you will need to call `toString()` to render your view. However, `EditorValue` is smart enough to know that the *content* didn't actually change since last `toString()` so it will return a cached version of the HTML. 165 | 166 | Optimization tip: Try to call `editorValue.toString()` only when you actually need to convert it to a string. If you can keep passing around the `editorValue` without calling `toString` it will be very performant. 167 | 168 | ### Example with ES5 and no JSX 169 | ```javascript 170 | var React = require('react'); 171 | var RichTextEditor = require('react-rte'); 172 | 173 | React.createClass({ 174 | propTypes: { 175 | onChange: React.PropTypes.func 176 | }, 177 | 178 | getInitialState: function() { 179 | return { 180 | value: RichTextEditor.createEmptyValue() 181 | }; 182 | }, 183 | 184 | render: function() { 185 | return React.createElement(RichTextEditor, { 186 | value: this.state.value, 187 | onChange: this.onChange 188 | }); 189 | }, 190 | 191 | onChange: function(value) { 192 | this.setState({value: value}); 193 | if (this.props.onChange) { 194 | // Send the changes up to the parent component as an HTML string. 195 | // This is here to demonstrate using `.toString()` but in a real app it 196 | // would be better to avoid generating a string on each change. 197 | this.props.onChange( 198 | value.toString('html') 199 | ); 200 | } 201 | } 202 | 203 | }); 204 | ``` 205 | 206 | ## TODO 207 | 208 | - Support images 209 | - Better test coverage 210 | - Documentation for using this editor in your projects 211 | - Fix some issues with Markdown parsing (migrate to `remark` parser) 212 | - Internationalization 213 | - Better icons and overall design 214 | 215 | ## Known Limitations 216 | 217 | Currently the biggest limitation is that images are not supported. There is a plan to support inline images (using decorators) and eventually Medium-style block-level images (using a custom block renderer). 218 | 219 | Other limitations include missing features such as: text-alignment and text color. These are coming soon. 220 | 221 | React prior v15 will log the following superfluous warning: 222 | 223 | > A component is contentEditable and contains children managed by 224 | > React. It is now your responsibility to guarantee that none of 225 | > those nodes are unexpectedly modified or duplicated. This is 226 | > probably not intentional. 227 | 228 | As all nodes are managed internally by Draft, this is not a problem and this [warning can be safely ignored](https://github.com/facebook/draft-js/issues/53). You can suppress this warning's display completely by duck-punching `console.error` before instantiating your component: 229 | 230 | ```javascript 231 | console.error = (function(_error) { 232 | return function(message) { 233 | if (typeof message !== 'string' || message.indexOf('component is `contentEditable`') === -1) { 234 | _error.apply(console, arguments); 235 | } 236 | }; 237 | })(console.error); 238 | ``` 239 | 240 | ## Contribute 241 | 242 | I'm happy to take pull requests for bug-fixes and improvements (and tests). If you have a feature you want to implement it's probably a good idea to open an issue first to see if it's already being worked on. Please match the code style of the rest of the project (ESLint should enforce this) and please include tests. Thanks! 243 | 244 | ## Run the Demo 245 | Clone this project. Run `npm install`. Run `npm run build-dist` then point the server of your choice (like [serv][serv]) to `/demo.html`. 246 | 247 | ## License 248 | 249 | This software is [ISC Licensed](/LICENSE). 250 | 251 | 252 | [ckeditor]: http://ckeditor.com/ 253 | [draft-js]: https://facebook.github.io/draft-js/ 254 | [immutablejs]: https://facebook.github.io/immutable-js/ 255 | [react-rte-demo]: https://react-rte.org/demo 256 | [rte]: https://www.google.com/search?q=web+based+rich+text+editor 257 | [serv]: https://www.npmjs.com/package/serv 258 | [tinymce]: https://www.tinymce.com/ 259 | -------------------------------------------------------------------------------- /assets/CNAME: -------------------------------------------------------------------------------- 1 | react-rte.org 2 | -------------------------------------------------------------------------------- /assets/css/demo.css: -------------------------------------------------------------------------------- 1 | @import "/css/normalize.css"; 2 | 3 | html { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | position: relative; 9 | box-sizing: border-box; 10 | min-height: 100%; 11 | } 12 | 13 | body::before, body::after { 14 | content: ""; 15 | display: table; 16 | } 17 | 18 | a { 19 | color: #00e; 20 | } 21 | 22 | .btn-row { 23 | font-size: 14px; 24 | text-align: right; 25 | } 26 | 27 | .label { 28 | display: inline-block; 29 | line-height: 18px; 30 | height: 30px; 31 | padding: 5px 15px; 32 | margin: 0 5px 0 0; 33 | box-sizing: border-box; 34 | } 35 | 36 | .btn { 37 | display: inline-block; 38 | line-height: 18px; 39 | height: 30px; 40 | padding: 5px 15px; 41 | margin: 0 5px 0 0; 42 | cursor: pointer; 43 | box-sizing: border-box; 44 | border-radius: 3px; 45 | background: linear-gradient(to bottom, #329ced 0, #168eea 100%); 46 | border: 1px solid #137dcf; 47 | color: white; 48 | text-shadow: 0 0 1px rgba(0, 0, 0, 0.3); 49 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07); 50 | font-family: inherit; 51 | font-size: inherit; 52 | font-weight: inherit; 53 | text-decoration: none; 54 | } 55 | 56 | .btn:last-child { 57 | margin: 0; 58 | } 59 | 60 | .editor-demo { 61 | width: 560px; 62 | margin: 10px auto; 63 | } 64 | 65 | .editor-demo .row { 66 | margin: 10px 0; 67 | } 68 | 69 | .editor-demo .radio-item { 70 | display: inline-block; 71 | margin-right: 10px; 72 | } 73 | 74 | .editor-demo .radio-item input { 75 | margin-right: 5px; 76 | } 77 | 78 | .editor-demo .source { 79 | display: block; 80 | margin: 0; 81 | padding: 10px; 82 | border: 1px solid #ddd; 83 | border-radius: 3px; 84 | width: 100%; 85 | height: 130px; 86 | box-sizing: border-box; 87 | font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace; 88 | font-size: 13px; 89 | line-height: 18px; 90 | resize: none; 91 | white-space: pre; 92 | word-wrap: normal; 93 | } 94 | 95 | .editor-demo .react-rte-demo .public-DraftEditor-content { 96 | min-height: 110px; 97 | } 98 | -------------------------------------------------------------------------------- /assets/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS and IE text size adjust after device orientation change, 6 | * without disabling user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability of focused elements when they are also in an 95 | * active/hover state. 96 | */ 97 | 98 | a:active, 99 | a:hover { 100 | outline: 0; 101 | } 102 | 103 | /* Text-level semantics 104 | ========================================================================== */ 105 | 106 | /** 107 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 108 | */ 109 | 110 | abbr[title] { 111 | border-bottom: 1px dotted; 112 | } 113 | 114 | /** 115 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bold; 121 | } 122 | 123 | /** 124 | * Address styling not present in Safari and Chrome. 125 | */ 126 | 127 | dfn { 128 | font-style: italic; 129 | } 130 | 131 | /** 132 | * Address variable `h1` font-size and margin within `section` and `article` 133 | * contexts in Firefox 4+, Safari, and Chrome. 134 | */ 135 | 136 | h1 { 137 | font-size: 2em; 138 | margin: 0.67em 0; 139 | } 140 | 141 | /** 142 | * Address styling not present in IE 8/9. 143 | */ 144 | 145 | mark { 146 | background: #ff0; 147 | color: #000; 148 | } 149 | 150 | /** 151 | * Address inconsistent and variable font size in all browsers. 152 | */ 153 | 154 | small { 155 | font-size: 80%; 156 | } 157 | 158 | /** 159 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 160 | */ 161 | 162 | sub, 163 | sup { 164 | font-size: 75%; 165 | line-height: 0; 166 | position: relative; 167 | vertical-align: baseline; 168 | } 169 | 170 | sup { 171 | top: -0.5em; 172 | } 173 | 174 | sub { 175 | bottom: -0.25em; 176 | } 177 | 178 | /* Embedded content 179 | ========================================================================== */ 180 | 181 | /** 182 | * Remove border when inside `a` element in IE 8/9/10. 183 | */ 184 | 185 | img { 186 | border: 0; 187 | } 188 | 189 | /** 190 | * Correct overflow not hidden in IE 9/10/11. 191 | */ 192 | 193 | svg:not(:root) { 194 | overflow: hidden; 195 | } 196 | 197 | /* Grouping content 198 | ========================================================================== */ 199 | 200 | /** 201 | * Address margin not present in IE 8/9 and Safari. 202 | */ 203 | 204 | figure { 205 | margin: 1em 40px; 206 | } 207 | 208 | /** 209 | * Address differences between Firefox and other browsers. 210 | */ 211 | 212 | hr { 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome. 354 | */ 355 | 356 | input[type="search"] { 357 | -webkit-appearance: textfield; /* 1 */ 358 | box-sizing: content-box; /* 2 */ 359 | } 360 | 361 | /** 362 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 363 | * Safari (but not Chrome) clips the cancel button when the search input has 364 | * padding (and `textfield` appearance). 365 | */ 366 | 367 | input[type="search"]::-webkit-search-cancel-button, 368 | input[type="search"]::-webkit-search-decoration { 369 | -webkit-appearance: none; 370 | } 371 | 372 | /** 373 | * Define consistent border, margin, and padding. 374 | */ 375 | 376 | fieldset { 377 | border: 1px solid #c0c0c0; 378 | margin: 0 2px; 379 | padding: 0.35em 0.625em 0.75em; 380 | } 381 | 382 | /** 383 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 384 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 385 | */ 386 | 387 | legend { 388 | border: 0; /* 1 */ 389 | padding: 0; /* 2 */ 390 | } 391 | 392 | /** 393 | * Remove default vertical scrollbar in IE 8/9/10/11. 394 | */ 395 | 396 | textarea { 397 | overflow: auto; 398 | } 399 | 400 | /** 401 | * Don't inherit the `font-weight` (applied by a rule above). 402 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 403 | */ 404 | 405 | optgroup { 406 | font-weight: bold; 407 | } 408 | 409 | /* Tables 410 | ========================================================================== */ 411 | 412 | /** 413 | * Remove most spacing between table cells. 414 | */ 415 | 416 | table { 417 | border-collapse: collapse; 418 | border-spacing: 0; 419 | } 420 | 421 | td, 422 | th { 423 | padding: 0; 424 | } 425 | -------------------------------------------------------------------------------- /assets/demo.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html> 3 | <head> 4 | <title>React Rich Text Editor Example</title> 5 | <meta charset="utf-8" /> 6 | <meta name="viewport" content="width=600, initial-scale=1" /> 7 | <link rel="icon" type="image/x-icon" href="/react.ico" /> 8 | <link rel="stylesheet" href="/css/demo.css" /> 9 | <script src="/dist/demo.js"></script> 10 | </head> 11 | <body></body> 12 | </html> 13 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html> 3 | <head> 4 | <title>Redirecting...</title> 5 | <meta http-equiv="Cache-Control" content="private, no-cache, must-revalidate, max-age=0" /> 6 | <script> 7 | function onload() { 8 | var hostname = document.location.hostname; 9 | var destination = (hostname === 'react-rte.org') ? '/demo' : '/demo.html'; 10 | location.replace(destination); 11 | } 12 | </script> 13 | </head> 14 | <body onload="onload()"> 15 | <noscript><p>Redirecting...</p></noscript> 16 | </body> 17 | </html> 18 | -------------------------------------------------------------------------------- /assets/react.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sstur/react-rte/8e00bf68d7a9b84e2ee2f4ed6a17f6de107fb108/assets/react.ico -------------------------------------------------------------------------------- /flow-typed/classnames.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | declare module 'classnames' { 4 | declare function exports(...args: Array<any>): string; 5 | } 6 | -------------------------------------------------------------------------------- /flow-typed/globals.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* eslint-disable no-unused-vars, no-undef */ 3 | declare var __DEV__: bool; 4 | 5 | declare type ReactElement = React$Element<*>; 6 | declare type ReactNode = null | string | number | ReactElement | Array<string | number | ReactElement>; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-rte", 3 | "version": "0.16.5", 4 | "description": "React Rich Text Editor", 5 | "main": "dist/react-rte.js", 6 | "files": [ 7 | "lib", 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "npm run build-lib && npm run build-dist", 12 | "build-dist": "rimraf dist && webpack -p", 13 | "build-lib": "rimraf lib && babel src --ignore \"_*\" --out-dir lib --copy-files", 14 | "lint": "eslint --max-warnings 0 .", 15 | "typecheck": "flow", 16 | "prepare": "npm run build", 17 | "start": "webpack-dev-server --content-base assets/", 18 | "test": "npm run lint && npm run typecheck && npm run test-src", 19 | "test-src": "BABEL_ENV=test mocha \"src/**/__tests__/*.js\"" 20 | }, 21 | "dependencies": { 22 | "babel-runtime": "^6.23.0", 23 | "class-autobind": "^0.1.4", 24 | "classnames": "^2.2.5", 25 | "draft-js": ">=0.10.0", 26 | "draft-js-export-html": ">=0.6.0", 27 | "draft-js-export-markdown": ">=0.3.0", 28 | "draft-js-import-html": ">=0.4.0", 29 | "draft-js-import-markdown": ">=0.3.0", 30 | "draft-js-utils": ">=1.4.0", 31 | "draft-js-import-element": ">=1.4.0", 32 | "immutable": "^3.8.1" 33 | }, 34 | "peerDependencies": { 35 | "react": "0.14.x || 15.x.x || 16.x.x || 17.x.x", 36 | "react-dom": "0.14.x || 15.x.x || 16.x.x || 17.x.x" 37 | }, 38 | "devDependencies": { 39 | "babel-cli": "^6.18.0", 40 | "babel-core": "^6.18.2", 41 | "babel-eslint": "^7.1.0", 42 | "babel-loader": "^7.1.1", 43 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 44 | "babel-plugin-transform-runtime": "^6.23.0", 45 | "babel-preset-es2015": "^6.18.0", 46 | "babel-preset-react": "^6.11.1", 47 | "babel-preset-stage-2": "^6.18.0", 48 | "css-loader": "^0.28.4", 49 | "css-modules-require-hook": "^4.0.0", 50 | "eslint": "^3.9.1", 51 | "eslint-plugin-babel": "^3.3.0", 52 | "eslint-plugin-flow-vars": "^0.5.0", 53 | "eslint-plugin-react": "^6.5.0", 54 | "expect": "^1.20.2", 55 | "flow-bin": "^0.32.0", 56 | "mocha": "^3.1.2", 57 | "raw-loader": "^0.5.1", 58 | "react": "16.x.x", 59 | "react-dom": "16.x.x", 60 | "react-test-renderer": "^16.4.0", 61 | "rimraf": "^2.5.4", 62 | "style-loader": "^0.18.2", 63 | "webpack": "^3.4.0", 64 | "webpack-dev-server": "^2.6.1" 65 | }, 66 | "repository": { 67 | "type": "git", 68 | "url": "git+https://github.com/sstur/react-rte.git" 69 | }, 70 | "keywords": [ 71 | "reactjs", 72 | "draftjs", 73 | "contenteditable", 74 | "wysiwyg", 75 | "richtext", 76 | "editor" 77 | ], 78 | "author": "sstur@me.com", 79 | "contributors": [ 80 | { 81 | "name": "Adam J. McIntyre", 82 | "url": "https://github.com/amcintyre-cs" 83 | }, 84 | { 85 | "name": "André Schmidt", 86 | "url": "https://github.com/andschdk" 87 | }, 88 | { 89 | "name": "Ben Herila", 90 | "url": "https://github.com/bherila" 91 | }, 92 | { 93 | "name": "Harris Brakmic", 94 | "url": "https://github.com/brakmic" 95 | }, 96 | { 97 | "name": "Forbes Lindesay", 98 | "url": "https://github.com/ForbesLindesay" 99 | }, 100 | { 101 | "name": "Kristopher Craw", 102 | "url": "https://github.com/KCraw" 103 | }, 104 | { 105 | "name": "Rory Hunter", 106 | "url": "https://github.com/pugnascotia" 107 | }, 108 | { 109 | "name": "Ralph Schindler", 110 | "url": "https://github.com/ralphschindler" 111 | }, 112 | { 113 | "name": "RaoHai", 114 | "url": "https://github.com/RaoHai" 115 | }, 116 | { 117 | "name": "Jordan Kohl", 118 | "url": "https://github.com/simpixelated" 119 | }, 120 | { 121 | "name": "Steffen Kolmer", 122 | "url": "https://github.com/skolmer" 123 | }, 124 | { 125 | "name": "Simon Sturmer", 126 | "url": "https://github.com/sstur" 127 | }, 128 | { 129 | "name": "Waldir Pimenta", 130 | "url": "https://github.com/waldyrious" 131 | }, 132 | { 133 | "name": "Zach Silveira", 134 | "url": "https://github.com/zackify" 135 | }, 136 | { 137 | "name": "Hyunyoung Cho", 138 | "url": "https://github.com/ZeroCho" 139 | } 140 | ], 141 | "license": "ISC", 142 | "bugs": { 143 | "url": "https://github.com/sstur/react-rte/issues" 144 | }, 145 | "homepage": "https://github.com/sstur/react-rte#readme" 146 | } 147 | -------------------------------------------------------------------------------- /src/Draft.global.css: -------------------------------------------------------------------------------- 1 | /** 2 | * We inherit the height of the container by default 3 | */ 4 | 5 | .DraftEditor-root, 6 | .DraftEditor-editorContainer, 7 | .public-DraftEditor-content { 8 | height: inherit; 9 | text-align: initial; 10 | } 11 | 12 | .DraftEditor-root { 13 | position: relative; 14 | } 15 | 16 | /** 17 | * Zero-opacity background used to allow focus in IE. Otherwise, clicks 18 | * fall through to the placeholder. 19 | */ 20 | 21 | .DraftEditor-editorContainer { 22 | background-color: rgba(255, 255, 255, 0); 23 | /* Repair mysterious missing Safari cursor */ 24 | border: 1px solid transparent; 25 | position: relative; 26 | z-index: 1; 27 | } 28 | 29 | .public-DraftEditor-content { 30 | outline: none; 31 | white-space: pre-wrap; 32 | } 33 | 34 | .public-DraftEditor-block { 35 | position: relative; 36 | } 37 | 38 | .DraftEditor-alignLeft .public-DraftEditor-block { 39 | text-align: left; 40 | } 41 | 42 | .DraftEditor-alignLeft .public-DraftEditorPlaceholder-root { 43 | left: 0; 44 | text-align: left; 45 | } 46 | 47 | .DraftEditor-alignCenter .public-DraftEditor-block { 48 | text-align: center; 49 | } 50 | 51 | .DraftEditor-alignCenter .public-DraftEditorPlaceholder-root { 52 | margin: 0 auto; 53 | text-align: center; 54 | width: 100%; 55 | } 56 | 57 | .DraftEditor-alignRight .public-DraftEditor-block { 58 | text-align: right; 59 | } 60 | 61 | .DraftEditor-alignRight .public-DraftEditorPlaceholder-root { 62 | right: 0; 63 | text-align: right; 64 | } 65 | /** 66 | * @providesModule DraftEditorPlaceholder 67 | */ 68 | 69 | .public-DraftEditorPlaceholder-root { 70 | color: #9197a3; 71 | position: absolute; 72 | z-index: 0; 73 | } 74 | 75 | .public-DraftEditorPlaceholder-hasFocus { 76 | color: #bdc1c9; 77 | } 78 | 79 | .DraftEditorPlaceholder-hidden { 80 | display: none; 81 | } 82 | /** 83 | * @providesModule DraftStyleDefault 84 | */ 85 | 86 | .public-DraftStyleDefault-block { 87 | position: relative; 88 | white-space: pre-wrap; 89 | } 90 | 91 | /* @noflip */ 92 | 93 | .public-DraftStyleDefault-ltr { 94 | direction: ltr; 95 | text-align: left; 96 | } 97 | 98 | /* @noflip */ 99 | 100 | .public-DraftStyleDefault-rtl { 101 | direction: rtl; 102 | text-align: right; 103 | } 104 | 105 | /** 106 | * These rules provide appropriate text direction for counter pseudo-elements. 107 | */ 108 | 109 | /* @noflip */ 110 | 111 | .public-DraftStyleDefault-listLTR { 112 | direction: ltr; 113 | } 114 | 115 | /* @noflip */ 116 | 117 | .public-DraftStyleDefault-listRTL { 118 | direction: rtl; 119 | } 120 | 121 | /** 122 | * Default spacing for list container elements. Override with CSS as needed. 123 | */ 124 | 125 | .public-DraftStyleDefault-ul, 126 | .public-DraftStyleDefault-ol { 127 | margin: 16px 0; 128 | padding: 0; 129 | } 130 | 131 | /** 132 | * Default counters and styles are provided for five levels of nesting. 133 | * If you require nesting beyond that level, you should use your own CSS 134 | * classes to do so. If you care about handling RTL languages, the rules you 135 | * create should look a lot like these. 136 | */ 137 | 138 | /* @noflip */ 139 | 140 | .public-DraftStyleDefault-depth0.public-DraftStyleDefault-listLTR { 141 | margin-left: 1.5em; 142 | } 143 | 144 | /* @noflip */ 145 | 146 | .public-DraftStyleDefault-depth0.public-DraftStyleDefault-listRTL { 147 | margin-right: 1.5em; 148 | } 149 | 150 | /* @noflip */ 151 | 152 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-listLTR { 153 | margin-left: 3em; 154 | } 155 | 156 | /* @noflip */ 157 | 158 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-listRTL { 159 | margin-right: 3em; 160 | } 161 | 162 | /* @noflip */ 163 | 164 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-listLTR { 165 | margin-left: 4.5em; 166 | } 167 | 168 | /* @noflip */ 169 | 170 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-listRTL { 171 | margin-right: 4.5em; 172 | } 173 | 174 | /* @noflip */ 175 | 176 | .public-DraftStyleDefault-depth3.public-DraftStyleDefault-listLTR { 177 | margin-left: 6em; 178 | } 179 | 180 | /* @noflip */ 181 | 182 | .public-DraftStyleDefault-depth3.public-DraftStyleDefault-listRTL { 183 | margin-right: 6em; 184 | } 185 | 186 | /* @noflip */ 187 | 188 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-listLTR { 189 | margin-left: 7.5em; 190 | } 191 | 192 | /* @noflip */ 193 | 194 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-listRTL { 195 | margin-right: 7.5em; 196 | } 197 | 198 | /** 199 | * Only use `square` list-style after the first two levels. 200 | */ 201 | 202 | .public-DraftStyleDefault-unorderedListItem { 203 | list-style-type: square; 204 | position: relative; 205 | } 206 | 207 | .public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth0 { 208 | list-style-type: disc; 209 | } 210 | 211 | .public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth1 { 212 | list-style-type: circle; 213 | } 214 | 215 | /** 216 | * Ordered list item counters are managed with CSS, since all list nesting is 217 | * purely visual. 218 | */ 219 | 220 | .public-DraftStyleDefault-orderedListItem { 221 | list-style-type: none; 222 | position: relative; 223 | } 224 | 225 | /* @noflip */ 226 | 227 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listLTR:before { 228 | left: -36px; 229 | position: absolute; 230 | text-align: right; 231 | width: 30px; 232 | } 233 | 234 | /* @noflip */ 235 | 236 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listRTL:before { 237 | position: absolute; 238 | right: -36px; 239 | text-align: left; 240 | width: 30px; 241 | } 242 | 243 | /** 244 | * Counters are reset in JavaScript. If you need different counter styles, 245 | * override these rules. If you need more nesting, create your own rules to 246 | * do so. 247 | */ 248 | 249 | .public-DraftStyleDefault-orderedListItem:before { 250 | content: counter(ol0) ". "; 251 | counter-increment: ol0; 252 | } 253 | 254 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth1:before { 255 | content: counter(ol1) ". "; 256 | counter-increment: ol1; 257 | } 258 | 259 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth2:before { 260 | content: counter(ol2) ". "; 261 | counter-increment: ol2; 262 | } 263 | 264 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth3:before { 265 | content: counter(ol3) ". "; 266 | counter-increment: ol3; 267 | } 268 | 269 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth4:before { 270 | content: counter(ol4) ". "; 271 | counter-increment: ol4; 272 | } 273 | 274 | .public-DraftStyleDefault-depth0.public-DraftStyleDefault-reset { 275 | counter-reset: ol0; 276 | } 277 | 278 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-reset { 279 | counter-reset: ol1; 280 | } 281 | 282 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-reset { 283 | counter-reset: ol2; 284 | } 285 | 286 | .public-DraftStyleDefault-depth3.public-DraftStyleDefault-reset { 287 | counter-reset: ol3; 288 | } 289 | 290 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-reset { 291 | counter-reset: ol4; 292 | } 293 | -------------------------------------------------------------------------------- /src/EditorDemo.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, {Component} from 'react'; 3 | import RichTextEditor, {createEmptyValue} from './RichTextEditor'; 4 | import {convertToRaw} from 'draft-js'; 5 | import autobind from 'class-autobind'; 6 | 7 | import {getTextAlignBlockMetadata, getTextAlignClassName, getTextAlignStyles} from './lib/blockStyleFunctions'; 8 | import ButtonGroup from './ui/ButtonGroup'; 9 | import Dropdown from './ui/Dropdown'; 10 | import IconButton from './ui/IconButton'; 11 | 12 | import type {EditorValue} from './RichTextEditor'; 13 | 14 | type Props = {}; 15 | type State = { 16 | value: EditorValue; 17 | format: string; 18 | readOnly: boolean; 19 | }; 20 | 21 | export default class EditorDemo extends Component { 22 | props: Props; 23 | state: State; 24 | 25 | constructor() { 26 | super(...arguments); 27 | autobind(this); 28 | this.state = { 29 | value: createEmptyValue(), 30 | format: 'html', 31 | readOnly: false, 32 | }; 33 | } 34 | 35 | render() { 36 | let {value, format} = this.state; 37 | 38 | return ( 39 | <div className="editor-demo"> 40 | <div className="row"> 41 | <p>This is a demo of the <a href="https://github.com/sstur/react-rte" target="top">react-rte</a> editor.</p> 42 | </div> 43 | <div className="row"> 44 | <RichTextEditor 45 | value={value} 46 | onChange={this._onChange} 47 | className="react-rte-demo" 48 | placeholder="Tell a story" 49 | toolbarClassName="demo-toolbar" 50 | editorClassName="demo-editor" 51 | readOnly={this.state.readOnly} 52 | blockStyleFn={getTextAlignClassName} 53 | customControls={[ 54 | // eslint-disable-next-line no-unused-vars 55 | (setValue, getValue, editorState) => { 56 | let choices = new Map([ 57 | ['1', {label: '1'}], 58 | ['2', {label: '2'}], 59 | ['3', {label: '3'}], 60 | ]); 61 | return ( 62 | <ButtonGroup key={1}> 63 | <Dropdown 64 | choices={choices} 65 | selectedKey={getValue('my-control-name')} 66 | onChange={(value) => setValue('my-control-name', value)} 67 | /> 68 | </ButtonGroup> 69 | ); 70 | }, 71 | <ButtonGroup key={2}> 72 | <IconButton 73 | label="Remove Link" 74 | iconName="remove-link" 75 | focusOnClick={false} 76 | onClick={() => console.log('You pressed a button')} 77 | /> 78 | </ButtonGroup>, 79 | ]} 80 | /> 81 | </div> 82 | <div className="row"> 83 | <label className="radio-item"> 84 | <input 85 | type="radio" 86 | name="format" 87 | value="html" 88 | checked={format === 'html'} 89 | onChange={this._onChangeFormat} 90 | /> 91 | <span>HTML</span> 92 | </label> 93 | <label className="radio-item"> 94 | <input 95 | type="radio" 96 | name="format" 97 | value="markdown" 98 | checked={format === 'markdown'} 99 | onChange={this._onChangeFormat} 100 | /> 101 | <span>Markdown</span> 102 | </label> 103 | <label className="radio-item"> 104 | <input 105 | type="checkbox" 106 | onChange={this._onChangeReadOnly} 107 | checked={this.state.readOnly} 108 | /> 109 | <span>Editor is read-only</span> 110 | </label> 111 | </div> 112 | <div className="row"> 113 | <textarea 114 | className="source" 115 | placeholder="Editor Source" 116 | value={value.toString(format, {blockStyleFn: getTextAlignStyles})} 117 | onChange={this._onChangeSource} 118 | /> 119 | </div> 120 | <div className="row btn-row"> 121 | <span className="label">Debugging:</span> 122 | <button className="btn" onClick={this._logState}>Log Content State</button> 123 | <button className="btn" onClick={this._logStateRaw}>Log Raw</button> 124 | </div> 125 | </div> 126 | ); 127 | } 128 | 129 | _logState() { 130 | let editorState = this.state.value.getEditorState(); 131 | let contentState = window.contentState = editorState.getCurrentContent().toJS(); 132 | console.log(contentState); 133 | } 134 | 135 | _logStateRaw() { 136 | let editorState = this.state.value.getEditorState(); 137 | let contentState = editorState.getCurrentContent(); 138 | let rawContentState = window.rawContentState = convertToRaw(contentState); 139 | console.log(JSON.stringify(rawContentState)); 140 | } 141 | 142 | _onChange(value: EditorValue) { 143 | this.setState({value}); 144 | } 145 | 146 | _onChangeSource(event: Object) { 147 | let source = event.target.value; 148 | let oldValue = this.state.value; 149 | this.setState({ 150 | value: oldValue.setContentFromString(source, this.state.format, {customBlockFn: getTextAlignBlockMetadata}), 151 | }); 152 | } 153 | 154 | _onChangeFormat(event: Object) { 155 | this.setState({format: event.target.value}); 156 | } 157 | 158 | _onChangeReadOnly(event: Object) { 159 | this.setState({readOnly: event.target.checked}); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/RichTextEditor.css: -------------------------------------------------------------------------------- 1 | .root { 2 | background: #fff; 3 | border: 1px solid #ddd; 4 | border-radius: 3px; 5 | font-family: 'Georgia', serif; 6 | font-size: 14px; 7 | } 8 | 9 | .editor { 10 | cursor: text; 11 | font-size: 16px; 12 | } 13 | 14 | :global(.text-align--center .public-DraftStyleDefault-ltr) { 15 | text-align: center; 16 | } 17 | 18 | :global(.text-align--right .public-DraftStyleDefault-ltr) { 19 | text-align: right; 20 | } 21 | 22 | :global(.text-align--justify .public-DraftStyleDefault-ltr) { 23 | text-align: justify; 24 | } 25 | 26 | .editor :global(.public-DraftEditorPlaceholder-root), 27 | .editor :global(.public-DraftEditor-content) { 28 | margin: 0; 29 | /* 1px is added as transparent border on .DraftEditor-editorContainer */ 30 | padding: 9px; 31 | } 32 | 33 | .editor :global(.public-DraftEditor-content) { 34 | overflow: auto; 35 | } 36 | 37 | .hidePlaceholder :global(.public-DraftEditorPlaceholder-root) { 38 | display: none; 39 | } 40 | 41 | .editor .paragraph { 42 | margin: 14px 0; 43 | } 44 | 45 | /* Consecutive code blocks are nested inside a single parent <pre> (like <li> 46 | inside <ul>). Unstyle the parent and style the children. */ 47 | .editor pre { 48 | margin: 14px 0; 49 | } 50 | 51 | .editor .codeBlock { 52 | background-color: #f3f3f3; 53 | font-family: "Inconsolata", "Menlo", "Consolas", monospace; 54 | font-size: 16px; 55 | /* This should collapse with the margin around the parent <pre>. */ 56 | margin: 14px 0; 57 | padding: 20px; 58 | } 59 | 60 | /* Hacky: Remove padding from inline <code> within code block. */ 61 | .editor .codeBlock span[style] { 62 | padding: 0 !important; 63 | } 64 | 65 | .editor .blockquote { 66 | border-left: 5px solid #eee; 67 | color: #666; 68 | font-family: 'Hoefler Text', 'Georgia', serif; 69 | font-style: italic; 70 | margin: 16px 0; 71 | padding: 10px 20px; 72 | } 73 | 74 | /* There shouldn't be margin outside the first/last blocks */ 75 | .editor .block:first-child, 76 | .editor pre:first-child, 77 | .editor ul:first-child, 78 | .editor ol:first-child { 79 | margin-top: 0; 80 | } 81 | .editor .block:last-child, 82 | .editor pre:last-child, 83 | .editor ul:last-child, 84 | .editor ol:last-child { 85 | margin-bottom: 0; 86 | } 87 | -------------------------------------------------------------------------------- /src/RichTextEditor.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, {Component} from 'react'; 3 | import {CompositeDecorator, Editor, EditorState, Modifier, RichUtils, Entity} from 'draft-js'; 4 | import getDefaultKeyBinding from 'draft-js/lib/getDefaultKeyBinding'; 5 | import {getTextAlignBlockMetadata, getTextAlignClassName, getTextAlignStyles} from './lib/blockStyleFunctions'; 6 | import changeBlockDepth from './lib/changeBlockDepth'; 7 | import changeBlockType from './lib/changeBlockType'; 8 | import getBlocksInSelection from './lib/getBlocksInSelection'; 9 | import insertBlockAfter from './lib/insertBlockAfter'; 10 | import isListItem from './lib/isListItem'; 11 | import isSoftNewlineEvent from 'draft-js/lib/isSoftNewlineEvent'; 12 | import EditorToolbar from './lib/EditorToolbar'; 13 | import EditorValue from './lib/EditorValue'; 14 | import LinkDecorator from './lib/LinkDecorator'; 15 | import ImageDecorator from './lib/ImageDecorator'; 16 | import composite from './lib/composite'; 17 | import cx from 'classnames'; 18 | import autobind from 'class-autobind'; 19 | import EventEmitter from 'events'; 20 | import {BLOCK_TYPE} from 'draft-js-utils'; 21 | 22 | import './Draft.global.css'; 23 | import styles from './RichTextEditor.css'; 24 | 25 | import type {ContentBlock} from 'draft-js'; 26 | import type {ToolbarConfig, CustomControl} from './lib/EditorToolbarConfig'; 27 | import type {ImportOptions} from './lib/EditorValue'; 28 | 29 | import ButtonGroup from './ui/ButtonGroup'; 30 | import Button from './ui/Button'; 31 | import Dropdown from './ui/Dropdown'; 32 | 33 | const MAX_LIST_DEPTH = 2; 34 | 35 | // Custom overrides for "code" style. 36 | const styleMap = { 37 | CODE: { 38 | backgroundColor: '#f3f3f3', 39 | fontFamily: '"Inconsolata", "Menlo", "Consolas", monospace', 40 | fontSize: 16, 41 | padding: 2, 42 | }, 43 | }; 44 | 45 | type ChangeHandler = (value: EditorValue) => any; 46 | 47 | type Props = { 48 | className?: string; 49 | toolbarClassName?: string; 50 | editorClassName?: string; 51 | value: EditorValue; 52 | onChange?: ChangeHandler; 53 | placeholder?: string; 54 | customStyleMap?: {[style: string]: {[key: string]: any}}; 55 | handleReturn?: (event: Object) => boolean; 56 | customControls?: Array<CustomControl>; 57 | readOnly?: boolean; 58 | toolbarHidden?: boolean; 59 | disabled?: boolean; // Alias of readOnly 60 | toolbarConfig?: ToolbarConfig; 61 | toolbarOnBottom?: boolean; 62 | blockStyleFn?: (block: ContentBlock) => ?string; 63 | autoFocus?: boolean; 64 | keyBindingFn?: (event: Object) => ?string; 65 | rootStyle?: Object; 66 | editorStyle?: Object; 67 | toolbarStyle?: Object; 68 | onBlur?: (event: Object) => void; 69 | }; 70 | 71 | export default class RichTextEditor extends Component { 72 | props: Props; 73 | _keyEmitter: EventEmitter; 74 | editor: HTMLDivElement; 75 | 76 | constructor() { 77 | super(...arguments); 78 | this._keyEmitter = new EventEmitter(); 79 | autobind(this); 80 | } 81 | 82 | componentDidMount() { 83 | const {autoFocus} = this.props; 84 | 85 | if (!autoFocus) { 86 | return; 87 | } 88 | 89 | this._focus(); 90 | } 91 | 92 | render() { 93 | let { 94 | value, 95 | className, 96 | toolbarClassName, 97 | editorClassName, 98 | placeholder, 99 | customStyleMap, 100 | readOnly, 101 | toolbarHidden, 102 | disabled, 103 | toolbarConfig, 104 | toolbarOnBottom, 105 | blockStyleFn, 106 | customControls, 107 | keyBindingFn, 108 | rootStyle, 109 | toolbarStyle, 110 | editorStyle, 111 | ...otherProps // eslint-disable-line comma-dangle 112 | } = this.props; 113 | let editorState = value.getEditorState(); 114 | customStyleMap = customStyleMap ? {...styleMap, ...customStyleMap} : styleMap; 115 | 116 | // If the user changes block type before entering any text, we can either 117 | // style the placeholder or hide it. Let's just hide it for now. 118 | let combinedEditorClassName = cx({ 119 | [styles.editor]: true, 120 | [styles.hidePlaceholder]: this._shouldHidePlaceholder(), 121 | }, editorClassName); 122 | if (readOnly == null) { 123 | readOnly = disabled; 124 | } 125 | let editorToolbar; 126 | 127 | if (!readOnly && !toolbarHidden) { 128 | editorToolbar = ( 129 | <EditorToolbar 130 | rootStyle={toolbarStyle} 131 | isOnBottom={toolbarOnBottom} 132 | className={toolbarClassName} 133 | keyEmitter={this._keyEmitter} 134 | editorState={editorState} 135 | onChange={this._onChange} 136 | focusEditor={this._focus} 137 | toolbarConfig={toolbarConfig} 138 | customControls={customControls} 139 | /> 140 | ); 141 | } 142 | return ( 143 | <div className={cx(styles.root, className)} style={rootStyle}> 144 | { !toolbarOnBottom && editorToolbar } 145 | <div className={combinedEditorClassName} style={editorStyle}> 146 | <Editor 147 | {...otherProps} 148 | blockStyleFn={composite(defaultBlockStyleFn, blockStyleFn)} 149 | customStyleMap={customStyleMap} 150 | editorState={editorState} 151 | handleReturn={this._handleReturn} 152 | keyBindingFn={keyBindingFn || this._customKeyHandler} 153 | handleKeyCommand={this._handleKeyCommand} 154 | onTab={this._onTab} 155 | onChange={this._onChange} 156 | placeholder={placeholder} 157 | ariaLabel={placeholder || 'Edit text'} 158 | ref={(el) => { 159 | this.editor = el; 160 | }} 161 | spellCheck={true} 162 | readOnly={readOnly} 163 | /> 164 | </div> 165 | { toolbarOnBottom && editorToolbar } 166 | </div> 167 | ); 168 | } 169 | 170 | _shouldHidePlaceholder(): boolean { 171 | let editorState = this.props.value.getEditorState(); 172 | let contentState = editorState.getCurrentContent(); 173 | if (!contentState.hasText()) { 174 | if (contentState.getBlockMap().first().getType() !== 'unstyled') { 175 | return true; 176 | } 177 | } 178 | return false; 179 | } 180 | 181 | _handleReturn(event: Object): boolean { 182 | let {handleReturn} = this.props; 183 | if (handleReturn != null && handleReturn(event)) { 184 | return true; 185 | } 186 | if (this._handleReturnSoftNewline(event)) { 187 | return true; 188 | } 189 | if (this._handleReturnEmptyListItem()) { 190 | return true; 191 | } 192 | if (this._handleReturnSpecialBlock()) { 193 | return true; 194 | } 195 | return false; 196 | } 197 | 198 | // `shift + return` should insert a soft newline. 199 | _handleReturnSoftNewline(event: Object): boolean { 200 | let editorState = this.props.value.getEditorState(); 201 | if (isSoftNewlineEvent(event)) { 202 | let selection = editorState.getSelection(); 203 | if (selection.isCollapsed()) { 204 | this._onChange(RichUtils.insertSoftNewline(editorState)); 205 | } else { 206 | let content = editorState.getCurrentContent(); 207 | let newContent = Modifier.removeRange(content, selection, 'forward'); 208 | let newSelection = newContent.getSelectionAfter(); 209 | let block = newContent.getBlockForKey(newSelection.getStartKey()); 210 | newContent = Modifier.insertText( 211 | newContent, 212 | newSelection, 213 | '\n', 214 | block.getInlineStyleAt(newSelection.getStartOffset()), 215 | null, 216 | ); 217 | this._onChange( 218 | EditorState.push(editorState, newContent, 'insert-fragment') 219 | ); 220 | } 221 | return true; 222 | } 223 | return false; 224 | } 225 | 226 | // If the cursor is in an empty list item when return is pressed, then the 227 | // block type should change to normal (end the list). 228 | _handleReturnEmptyListItem(): boolean { 229 | let editorState = this.props.value.getEditorState(); 230 | let selection = editorState.getSelection(); 231 | if (selection.isCollapsed()) { 232 | let contentState = editorState.getCurrentContent(); 233 | let blockKey = selection.getStartKey(); 234 | let block = contentState.getBlockForKey(blockKey); 235 | if (isListItem(block) && block.getLength() === 0) { 236 | let depth = block.getDepth(); 237 | let newState = (depth === 0) ? 238 | changeBlockType(editorState, blockKey, BLOCK_TYPE.UNSTYLED) : 239 | changeBlockDepth(editorState, blockKey, depth - 1); 240 | this._onChange(newState); 241 | return true; 242 | } 243 | } 244 | return false; 245 | } 246 | 247 | // If the cursor is at the end of a special block (any block type other than 248 | // normal or list item) when return is pressed, new block should be normal. 249 | _handleReturnSpecialBlock(): boolean { 250 | let editorState = this.props.value.getEditorState(); 251 | let selection = editorState.getSelection(); 252 | if (selection.isCollapsed()) { 253 | let contentState = editorState.getCurrentContent(); 254 | let blockKey = selection.getStartKey(); 255 | let block = contentState.getBlockForKey(blockKey); 256 | if (!isListItem(block) && block.getType() !== BLOCK_TYPE.UNSTYLED) { 257 | // If cursor is at end. 258 | if (block.getLength() === selection.getStartOffset()) { 259 | let newEditorState = insertBlockAfter( 260 | editorState, 261 | blockKey, 262 | BLOCK_TYPE.UNSTYLED 263 | ); 264 | this._onChange(newEditorState); 265 | return true; 266 | } 267 | } 268 | } 269 | return false; 270 | } 271 | 272 | _onTab(event: Object): ?string { 273 | let editorState = this.props.value.getEditorState(); 274 | let newEditorState = RichUtils.onTab(event, editorState, MAX_LIST_DEPTH); 275 | if (newEditorState !== editorState) { 276 | this._onChange(newEditorState); 277 | } 278 | } 279 | 280 | _customKeyHandler(event: Object): ?string { 281 | // Allow toolbar to catch key combinations. 282 | let eventFlags = {}; 283 | this._keyEmitter.emit('keypress', event, eventFlags); 284 | if (eventFlags.wasHandled) { 285 | return null; 286 | } else { 287 | return getDefaultKeyBinding(event); 288 | } 289 | } 290 | 291 | _handleKeyCommand(command: string): boolean { 292 | let editorState = this.props.value.getEditorState(); 293 | let newEditorState = RichUtils.handleKeyCommand(editorState, command); 294 | if (newEditorState) { 295 | this._onChange(newEditorState); 296 | return true; 297 | } else { 298 | return false; 299 | } 300 | } 301 | 302 | _onChange(editorState: EditorState) { 303 | let {onChange, value} = this.props; 304 | if (onChange == null) { 305 | return; 306 | } 307 | let newValue = value.setEditorState(editorState); 308 | let newEditorState = newValue.getEditorState(); 309 | this._handleInlineImageSelection(newEditorState); 310 | onChange(newValue); 311 | } 312 | 313 | _handleInlineImageSelection(editorState: EditorState) { 314 | let selection = editorState.getSelection(); 315 | let blocks = getBlocksInSelection(editorState); 316 | 317 | const selectImage = (block, offset) => { 318 | const imageKey = block.getEntityAt(offset); 319 | Entity.mergeData(imageKey, {selected: true}); 320 | }; 321 | 322 | let isInMiddleBlock = (index) => index > 0 && index < blocks.size - 1; 323 | let isWithinStartBlockSelection = (offset, index) => ( 324 | index === 0 && offset > selection.getStartOffset() 325 | ); 326 | let isWithinEndBlockSelection = (offset, index) => ( 327 | index === blocks.size - 1 && offset < selection.getEndOffset() 328 | ); 329 | 330 | blocks.toIndexedSeq().forEach((block, index) => { 331 | ImageDecorator.strategy( 332 | block, 333 | (offset) => { 334 | if (isWithinStartBlockSelection(offset, index) || 335 | isInMiddleBlock(index) || 336 | isWithinEndBlockSelection(offset, index)) { 337 | selectImage(block, offset); 338 | } 339 | }); 340 | }); 341 | } 342 | 343 | _focus() { 344 | this.editor.focus(); 345 | } 346 | } 347 | 348 | function defaultBlockStyleFn(block: ContentBlock): string { 349 | let result = styles.block; 350 | switch (block.getType()) { 351 | case 'unstyled': 352 | return cx(result, styles.paragraph); 353 | case 'blockquote': 354 | return cx(result, styles.blockquote); 355 | case 'code-block': 356 | return cx(result, styles.codeBlock); 357 | default: 358 | return result; 359 | } 360 | } 361 | 362 | const decorator = new CompositeDecorator([LinkDecorator, ImageDecorator]); 363 | 364 | function createEmptyValue(): EditorValue { 365 | return EditorValue.createEmpty(decorator); 366 | } 367 | 368 | function createValueFromString(markup: string, format: string, options?: ImportOptions): EditorValue { 369 | return EditorValue.createFromString(markup, format, decorator, options); 370 | } 371 | 372 | // $FlowIssue - This should probably not be done this way. 373 | Object.assign(RichTextEditor, { 374 | EditorValue, 375 | decorator, 376 | createEmptyValue, 377 | createValueFromString, 378 | ButtonGroup, 379 | Button, 380 | Dropdown, 381 | }); 382 | 383 | export { 384 | EditorValue, 385 | decorator, 386 | createEmptyValue, 387 | createValueFromString, 388 | getTextAlignBlockMetadata, 389 | getTextAlignClassName, 390 | getTextAlignStyles, 391 | ButtonGroup, 392 | Button, 393 | Dropdown, 394 | }; 395 | -------------------------------------------------------------------------------- /src/SimpleRichTextEditor.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, {Component} from 'react'; 3 | import RichTextEditor, {createEmptyValue} from './RichTextEditor'; 4 | import autobind from 'class-autobind'; 5 | 6 | import type {EditorValue} from './RichTextEditor'; 7 | 8 | type Props = { 9 | value: string; 10 | format: string; 11 | onChange: (value: string) => any; 12 | }; 13 | type State = { 14 | editorValue: EditorValue; 15 | }; 16 | 17 | export default class SimpleRichTextEditor extends Component { 18 | props: Props; 19 | state: State; 20 | // The [format, value] of what's currently displayed in the <RichTextEditor /> 21 | _currentValue: ?[string, string]; 22 | 23 | constructor() { 24 | super(...arguments); 25 | autobind(this); 26 | this.state = { 27 | editorValue: createEmptyValue(), 28 | }; 29 | } 30 | 31 | // eslint-disable-next-line 32 | UNSAFE_componentWillMount() { 33 | this._updateStateFromProps(this.props); 34 | } 35 | 36 | // eslint-disable-next-line 37 | UNSAFE_componentWillReceiveProps(newProps: Props) { 38 | this._updateStateFromProps(newProps); 39 | } 40 | 41 | _updateStateFromProps(newProps: Props) { 42 | let {value, format} = newProps; 43 | if (this._currentValue != null) { 44 | let [currentFormat, currentValue] = this._currentValue; 45 | if (format === currentFormat && value === currentValue) { 46 | return; 47 | } 48 | } 49 | let {editorValue} = this.state; 50 | this.setState({ 51 | editorValue: editorValue.setContentFromString(value, format), 52 | }); 53 | this._currentValue = [format, value]; 54 | } 55 | 56 | render() { 57 | let {value, format, onChange, ...otherProps} = this.props; // eslint-disable-line no-unused-vars 58 | return ( 59 | <RichTextEditor 60 | {...otherProps} 61 | value={this.state.editorValue} 62 | onChange={this._onChange} 63 | /> 64 | ); 65 | } 66 | 67 | _onChange(editorValue: EditorValue) { 68 | let {format, onChange} = this.props; 69 | let oldEditorValue = this.state.editorValue; 70 | this.setState({editorValue}); 71 | let oldContentState = oldEditorValue ? oldEditorValue.getEditorState().getCurrentContent() : null; 72 | let newContentState = editorValue.getEditorState().getCurrentContent(); 73 | if (oldContentState !== newContentState) { 74 | let stringValue = editorValue.toString(format); 75 | // Optimization so if we receive new props we don't need 76 | // to parse anything unnecessarily. 77 | this._currentValue = [format, stringValue]; 78 | if (onChange && stringValue !== this.props.value) { 79 | onChange(stringValue); 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/__tests__/RichTextEditor-test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | const {describe, it} = global; 3 | import React from 'react'; 4 | import ShallowRenderer from 'react-test-renderer/shallow'; 5 | import expect from 'expect'; 6 | import RichTextEditor, {createEmptyValue} from '../RichTextEditor'; 7 | 8 | describe('RichTextEditor', () => { 9 | it('should render', () => { 10 | let renderer = new ShallowRenderer(); 11 | let value = createEmptyValue(); 12 | renderer.render(<RichTextEditor value={value} />); 13 | let output = renderer.getRenderOutput(); 14 | expect(output.type).toEqual('div'); 15 | expect(output.props.className).toBeA('string'); 16 | expect(output.props.className).toInclude('RichTextEditor__root'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/demo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import EditorDemo from './EditorDemo'; 4 | 5 | document.addEventListener('DOMContentLoaded', () => { 6 | let rootNode = document.createElement('div'); 7 | document.body.appendChild(rootNode); 8 | ReactDOM.render( 9 | <EditorDemo />, 10 | rootNode, 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /src/lib/EditorToolbar.css: -------------------------------------------------------------------------------- 1 | .root { 2 | font-family: 'Helvetica', sans-serif; 3 | font-size: 14px; 4 | margin: 0 10px; 5 | padding: 10px 0 5px; 6 | border-bottom: 1px solid #ddd; 7 | -webkit-user-select: none; 8 | -moz-user-select: none; 9 | -ms-user-select: none; 10 | user-select: none; 11 | } 12 | 13 | .onBottom { 14 | border-top: 1px solid #ddd; 15 | border-bottom: none; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/EditorToolbar.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {hasCommandModifier} from 'draft-js/lib/KeyBindingUtil'; 3 | 4 | import React, {Component} from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import {EditorState, Entity, EntityDescription, RichUtils, Modifier} from 'draft-js'; 7 | import {ENTITY_TYPE} from 'draft-js-utils'; 8 | import DefaultToolbarConfig from './EditorToolbarConfig'; 9 | import StyleButton from './StyleButton'; 10 | import PopoverIconButton from '../ui/PopoverIconButton'; 11 | import ButtonGroup from '../ui/ButtonGroup'; 12 | import Dropdown from '../ui/Dropdown'; 13 | import IconButton from '../ui/IconButton'; 14 | import getEntityAtCursor from './getEntityAtCursor'; 15 | import clearEntityForRange from './clearEntityForRange'; 16 | import autobind from 'class-autobind'; 17 | import cx from 'classnames'; 18 | 19 | import styles from './EditorToolbar.css'; 20 | 21 | import type EventEmitter from 'events'; 22 | import type {ToolbarConfig, CustomControl} from './EditorToolbarConfig'; 23 | 24 | type ChangeHandler = (state: EditorState) => any; 25 | 26 | type Props = { 27 | className?: string; 28 | editorState: EditorState; 29 | keyEmitter: EventEmitter; 30 | onChange: ChangeHandler; 31 | focusEditor: Function; 32 | toolbarConfig: ToolbarConfig; 33 | customControls: Array<CustomControl>; 34 | rootStyle?: Object; 35 | isOnBottom?: boolean; 36 | }; 37 | 38 | type State = { 39 | showLinkInput: boolean; 40 | showImageInput: boolean; 41 | customControlState: {[key: string]: string}; 42 | }; 43 | 44 | 45 | export default class EditorToolbar extends Component { 46 | props: Props; 47 | state: State; 48 | 49 | constructor() { 50 | super(...arguments); 51 | autobind(this); 52 | this.state = { 53 | showLinkInput: false, 54 | showImageInput: false, 55 | customControlState: {}, 56 | }; 57 | } 58 | 59 | // eslint-disable-next-line 60 | UNSAFE_componentWillMount() { 61 | // Technically, we should also attach/detach event listeners when the 62 | // `keyEmitter` prop changes. 63 | this.props.keyEmitter.on('keypress', this._onKeypress); 64 | } 65 | 66 | componentWillUnmount() { 67 | this.props.keyEmitter.removeListener('keypress', this._onKeypress); 68 | } 69 | 70 | render() { 71 | let {className, toolbarConfig, rootStyle, isOnBottom} = this.props; 72 | if (toolbarConfig == null) { 73 | toolbarConfig = DefaultToolbarConfig; 74 | } 75 | let display = toolbarConfig.display || DefaultToolbarConfig.display; 76 | let buttonGroups = display.map((groupName) => { 77 | switch (groupName) { 78 | case 'INLINE_STYLE_BUTTONS': { 79 | return this._renderInlineStyleButtons(groupName, toolbarConfig); 80 | } 81 | case 'BLOCK_ALIGNMENT_BUTTONS': { 82 | return this._renderBlockAlignmentButtons(groupName, toolbarConfig); 83 | } 84 | case 'BLOCK_TYPE_DROPDOWN': { 85 | return this._renderBlockTypeDropdown(groupName, toolbarConfig); 86 | } 87 | case 'LINK_BUTTONS': { 88 | return this._renderLinkButtons(groupName, toolbarConfig); 89 | } 90 | case 'IMAGE_BUTTON': { 91 | return this._renderImageButton(groupName, toolbarConfig); 92 | } 93 | case 'BLOCK_TYPE_BUTTONS': { 94 | return this._renderBlockTypeButtons(groupName, toolbarConfig); 95 | } 96 | case 'HISTORY_BUTTONS': { 97 | return this._renderUndoRedo(groupName, toolbarConfig); 98 | } 99 | } 100 | }); 101 | return ( 102 | <div className={cx(styles.root, (isOnBottom && styles.onBottom), className)} style={rootStyle}> 103 | {buttonGroups} 104 | {this._renderCustomControls()} 105 | </div> 106 | ); 107 | } 108 | 109 | _renderCustomControls() { 110 | let {customControls, editorState} = this.props; 111 | if (customControls == null) { 112 | return; 113 | } 114 | return customControls.map((f) => { 115 | switch (typeof f) { 116 | case 'function': { 117 | return f( 118 | this._setCustomControlState, 119 | this._getCustomControlState, 120 | editorState 121 | ); 122 | } 123 | default: { 124 | return f; 125 | } 126 | } 127 | }); 128 | } 129 | 130 | _setCustomControlState(key: string, value: string) { 131 | this.setState(({customControlState}) => ({ 132 | customControlState: {...customControlState, [key]: value}, 133 | })); 134 | } 135 | 136 | _getCustomControlState(key: string) { 137 | return this.state.customControlState[key]; 138 | } 139 | 140 | _renderBlockTypeDropdown(name: string, toolbarConfig: ToolbarConfig) { 141 | let blockType = this._getCurrentBlockType(); 142 | let choices = new Map( 143 | (toolbarConfig.BLOCK_TYPE_DROPDOWN || []).map((type) => [type.style, {label: type.label, className: type.className}]) 144 | ); 145 | if (!choices.has(blockType)) { 146 | blockType = Array.from(choices.keys())[0]; 147 | } 148 | return ( 149 | <ButtonGroup key={name}> 150 | <Dropdown 151 | {...toolbarConfig.extraProps} 152 | choices={choices} 153 | selectedKey={blockType} 154 | onChange={this._selectBlockType} 155 | aria-label={'Block type'} 156 | /> 157 | </ButtonGroup> 158 | ); 159 | } 160 | 161 | _renderBlockTypeButtons(name: string, toolbarConfig: ToolbarConfig) { 162 | let blockType = this._getCurrentBlockType(); 163 | let buttons = (toolbarConfig.BLOCK_TYPE_BUTTONS || []).map((type, index) => ( 164 | <StyleButton 165 | {...toolbarConfig.extraProps} 166 | key={String(index)} 167 | isActive={type.style === blockType} 168 | label={type.label} 169 | onToggle={this._toggleBlockType} 170 | style={type.style} 171 | className={type.className} 172 | /> 173 | )); 174 | return ( 175 | <ButtonGroup key={name}>{buttons}</ButtonGroup> 176 | ); 177 | } 178 | 179 | _renderInlineStyleButtons(name: string, toolbarConfig: ToolbarConfig) { 180 | let {editorState} = this.props; 181 | let currentStyle = editorState.getCurrentInlineStyle(); 182 | let buttons = (toolbarConfig.INLINE_STYLE_BUTTONS || []).map((type, index) => ( 183 | <StyleButton 184 | {...toolbarConfig.extraProps} 185 | key={String(index)} 186 | isActive={currentStyle.has(type.style)} 187 | label={type.label} 188 | onToggle={this._toggleInlineStyle} 189 | style={type.style} 190 | className={type.className} 191 | /> 192 | )); 193 | return ( 194 | <ButtonGroup key={name}>{buttons}</ButtonGroup> 195 | ); 196 | } 197 | 198 | _renderBlockAlignmentButtons(name: string, toolbarConfig: ToolbarConfig) { 199 | let {editorState} = this.props; 200 | let content = editorState.getCurrentContent(); 201 | let selection = editorState.getSelection(); 202 | let blockKey = selection.getStartKey(); 203 | let block = content.getBlockForKey(blockKey); 204 | let blockAlignment = block.getData().get('textAlign'); 205 | 206 | let buttons = (toolbarConfig.BLOCK_ALIGNMENT_BUTTONS || []).map((type, index) => ( 207 | <StyleButton 208 | {...toolbarConfig.extraProps} 209 | key={String(index)} 210 | isActive={blockAlignment === type.style} 211 | label={type.label} 212 | onToggle={this._toggleAlignment} 213 | style={type.style} 214 | className={type.className} 215 | /> 216 | )); 217 | return ( 218 | <ButtonGroup key={name}>{buttons}</ButtonGroup> 219 | ); 220 | } 221 | 222 | _renderLinkButtons(name: string, toolbarConfig: ToolbarConfig) { 223 | let {editorState} = this.props; 224 | let selection = editorState.getSelection(); 225 | let entity = this._getEntityAtCursor(); 226 | let hasSelection = !selection.isCollapsed(); 227 | let isCursorOnLink = (entity != null && entity.type === ENTITY_TYPE.LINK); 228 | let shouldShowLinkButton = hasSelection || isCursorOnLink; 229 | let defaultValue = (entity && isCursorOnLink) ? entity.getData().url : ''; 230 | let config = toolbarConfig.LINK_BUTTONS || {}; 231 | let linkConfig = config.link || {}; 232 | let removeLinkConfig = config.removeLink || {}; 233 | let linkLabel = linkConfig.label || 'Link'; 234 | let removeLinkLabel = removeLinkConfig.label || 'Remove Link'; 235 | let targetBlank = (entity && isCursorOnLink) ? entity.getData().target === '_blank' : false; 236 | let noFollow = (entity && isCursorOnLink) ? entity.getData().rel === 'nofollow' : false; 237 | 238 | return ( 239 | <ButtonGroup key={name}> 240 | <PopoverIconButton 241 | label={linkLabel} 242 | iconName="link" 243 | isDisabled={!shouldShowLinkButton} 244 | showPopover={this.state.showLinkInput} 245 | onTogglePopover={this._toggleShowLinkInput} 246 | defaultValue={defaultValue} 247 | onSubmit={this._setLink} 248 | checkOptions={{ 249 | targetBlank: {label: 'Open link in new tab', defaultValue: targetBlank}, 250 | noFollow: {label: 'No follow', defaultValue: noFollow}, 251 | }} 252 | /> 253 | <IconButton 254 | {...toolbarConfig.extraProps} 255 | label={removeLinkLabel} 256 | iconName="remove-link" 257 | isDisabled={!isCursorOnLink} 258 | onClick={this._removeLink} 259 | focusOnClick={false} 260 | /> 261 | </ButtonGroup> 262 | ); 263 | } 264 | 265 | _renderImageButton(name: string, toolbarConfig: ToolbarConfig) { 266 | const config = (toolbarConfig.IMAGE_BUTTON || {}); 267 | const label = config.label || 'Image'; 268 | return ( 269 | <ButtonGroup key={name}> 270 | <PopoverIconButton 271 | label={label} 272 | iconName="image" 273 | showPopover={this.state.showImageInput} 274 | onTogglePopover={this._toggleShowImageInput} 275 | onSubmit={this._setImage} 276 | /> 277 | </ButtonGroup> 278 | ); 279 | } 280 | 281 | _renderUndoRedo(name: string, toolbarConfig: ToolbarConfig) { 282 | let {editorState} = this.props; 283 | let canUndo = editorState.getUndoStack().size !== 0; 284 | let canRedo = editorState.getRedoStack().size !== 0; 285 | const config = toolbarConfig.HISTORY_BUTTONS || {}; 286 | const undoConfig = config.undo || {}; 287 | const redoConfig = config.redo || {}; 288 | const undoLabel = undoConfig.label || 'Undo'; 289 | const redoLabel = redoConfig.label || 'Redo'; 290 | return ( 291 | <ButtonGroup key={name}> 292 | <IconButton 293 | {...toolbarConfig.extraProps} 294 | label={undoLabel} 295 | iconName="undo" 296 | isDisabled={!canUndo} 297 | onClick={this._undo} 298 | focusOnClick={false} 299 | /> 300 | <IconButton 301 | {...toolbarConfig.extraProps} 302 | label={redoLabel} 303 | iconName="redo" 304 | isDisabled={!canRedo} 305 | onClick={this._redo} 306 | focusOnClick={false} 307 | /> 308 | </ButtonGroup> 309 | ); 310 | } 311 | 312 | _onKeypress(event: Object, eventFlags: Object) { 313 | // Catch cmd+k for use with link insertion. 314 | if (hasCommandModifier(event) && event.keyCode === 75) { 315 | let {editorState} = this.props; 316 | if (!editorState.getSelection().isCollapsed()) { 317 | this.setState({showLinkInput: true}); 318 | eventFlags.wasHandled = true; 319 | } 320 | } 321 | } 322 | 323 | _toggleShowLinkInput(event: ?Object) { 324 | let isShowing = this.state.showLinkInput; 325 | // If this is a hide request, decide if we should focus the editor. 326 | if (isShowing) { 327 | let shouldFocusEditor = true; 328 | if (event && event.type === 'click') { 329 | // TODO: Use a better way to get the editor root node. 330 | let editorRoot = ReactDOM.findDOMNode(this).parentNode; 331 | let {activeElement} = document; 332 | let wasClickAway = (activeElement == null || activeElement === document.body); 333 | if (!wasClickAway && !editorRoot.contains(activeElement)) { 334 | shouldFocusEditor = false; 335 | } 336 | } 337 | if (shouldFocusEditor) { 338 | this.props.focusEditor(); 339 | } 340 | } 341 | this.setState({showLinkInput: !isShowing}); 342 | } 343 | 344 | _toggleShowImageInput(event: ?Object) { 345 | let isShowing = this.state.showImageInput; 346 | // If this is a hide request, decide if we should focus the editor. 347 | if (isShowing) { 348 | let shouldFocusEditor = true; 349 | if (event && event.type === 'click') { 350 | // TODO: Use a better way to get the editor root node. 351 | let editorRoot = ReactDOM.findDOMNode(this).parentNode; 352 | let {activeElement} = document; 353 | let wasClickAway = (activeElement == null || activeElement === document.body); 354 | if (!wasClickAway && !editorRoot.contains(activeElement)) { 355 | shouldFocusEditor = false; 356 | } 357 | } 358 | if (shouldFocusEditor) { 359 | this.props.focusEditor(); 360 | } 361 | } 362 | this.setState({showImageInput: !isShowing}); 363 | } 364 | 365 | _setImage(src: string) { 366 | let {editorState} = this.props; 367 | let contentState = editorState.getCurrentContent(); 368 | let selection = editorState.getSelection(); 369 | contentState = contentState.createEntity(ENTITY_TYPE.IMAGE, 'IMMUTABLE', {src}); 370 | let entityKey = contentState.getLastCreatedEntityKey(); 371 | let newContentState = Modifier.insertText(contentState, selection, ' ', null, entityKey); 372 | this.setState({showImageInput: false}); 373 | this.props.onChange( 374 | EditorState.push(editorState, newContentState) 375 | ); 376 | this._focusEditor(); 377 | } 378 | 379 | _setLink(url: string, checkOptions: {[key: string]: boolean}) { 380 | let {editorState} = this.props; 381 | let contentState = editorState.getCurrentContent(); 382 | let selection = editorState.getSelection(); 383 | let origSelection = selection; 384 | let canApplyLink = false; 385 | 386 | if (selection.isCollapsed()) { 387 | let entity = this._getEntityDescriptionAtCursor(); 388 | if (entity) { 389 | canApplyLink = true; 390 | selection = selection.merge({ 391 | anchorOffset: entity.startOffset, 392 | focusOffset: entity.endOffset, 393 | isBackward: false, 394 | }); 395 | } 396 | } else { 397 | canApplyLink = true; 398 | } 399 | 400 | this.setState({showLinkInput: false}); 401 | if (canApplyLink) { 402 | let target = checkOptions.targetBlank ? '_blank' : undefined; 403 | let rel = checkOptions.noFollow ? 'nofollow' : undefined; 404 | contentState = contentState.createEntity(ENTITY_TYPE.LINK, 'MUTABLE', {url, target, rel}); 405 | let entityKey = contentState.getLastCreatedEntityKey(); 406 | 407 | editorState = EditorState.push(editorState, contentState); 408 | editorState = RichUtils.toggleLink(editorState, selection, entityKey); 409 | editorState = EditorState.acceptSelection(editorState, origSelection); 410 | 411 | this.props.onChange(editorState); 412 | } 413 | this._focusEditor(); 414 | } 415 | 416 | _removeLink() { 417 | let {editorState} = this.props; 418 | let entity = getEntityAtCursor(editorState); 419 | if (entity != null) { 420 | let {blockKey, startOffset, endOffset} = entity; 421 | this.props.onChange( 422 | clearEntityForRange(editorState, blockKey, startOffset, endOffset) 423 | ); 424 | } 425 | } 426 | 427 | _getEntityDescriptionAtCursor(): ?EntityDescription { 428 | let {editorState} = this.props; 429 | return getEntityAtCursor(editorState); 430 | } 431 | 432 | _getEntityAtCursor(): ?Entity { 433 | let {editorState} = this.props; 434 | let contentState = editorState.getCurrentContent(); 435 | let entity = getEntityAtCursor(editorState); 436 | return (entity == null) ? null : contentState.getEntity(entity.entityKey); 437 | } 438 | 439 | _getCurrentBlockType(): string { 440 | let {editorState} = this.props; 441 | let selection = editorState.getSelection(); 442 | return editorState 443 | .getCurrentContent() 444 | .getBlockForKey(selection.getStartKey()) 445 | .getType(); 446 | } 447 | 448 | _selectBlockType() { 449 | this._toggleBlockType(...arguments); 450 | this._focusEditor(); 451 | } 452 | 453 | _toggleBlockType(blockType: string) { 454 | this.props.onChange( 455 | RichUtils.toggleBlockType( 456 | this.props.editorState, 457 | blockType 458 | ) 459 | ); 460 | } 461 | 462 | _toggleInlineStyle(inlineStyle: string) { 463 | this.props.onChange( 464 | RichUtils.toggleInlineStyle( 465 | this.props.editorState, 466 | inlineStyle 467 | ) 468 | ); 469 | } 470 | 471 | _toggleAlignment(textAlign: string) { 472 | let {editorState} = this.props; 473 | let selection = editorState.getSelection(); 474 | 475 | let content = editorState.getCurrentContent(); 476 | let blockKey = selection.getStartKey(); 477 | let block = content.getBlockForKey(blockKey); 478 | let blockData = block.getData(); 479 | 480 | let newBlockData; 481 | if (blockData.get('textAlign') === textAlign) { 482 | newBlockData = blockData.remove('textAlign'); 483 | } else { 484 | newBlockData = blockData.set('textAlign', textAlign); 485 | } 486 | 487 | let newBlock = block.set('data', newBlockData); 488 | 489 | let newContent = content.merge({ 490 | blockMap: content.getBlockMap().set(blockKey, newBlock), 491 | }); 492 | let newState = EditorState.push( 493 | editorState, 494 | newContent, 495 | 'change-block-data' 496 | ); 497 | this.props.onChange(newState); 498 | } 499 | 500 | _undo() { 501 | let {editorState} = this.props; 502 | this.props.onChange( 503 | EditorState.undo(editorState) 504 | ); 505 | } 506 | 507 | _redo() { 508 | let {editorState} = this.props; 509 | this.props.onChange( 510 | EditorState.redo(editorState) 511 | ); 512 | } 513 | 514 | _focusEditor() { 515 | // Hacky: Wait to focus the editor so we don't lose selection. 516 | setTimeout(() => { 517 | this.props.focusEditor(); 518 | }, 50); 519 | } 520 | } 521 | -------------------------------------------------------------------------------- /src/lib/EditorToolbarConfig.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type {EditorState} from 'draft-js'; 3 | 4 | export type StyleConfig = { 5 | label: string; 6 | style: string; 7 | className?: string; 8 | }; 9 | 10 | type GetControlState = (key: string) => ?string; 11 | type SetControlState = (key: string, value: string) => void; 12 | 13 | export type CustomControl = ReactNode | (set: SetControlState, get: GetControlState, state: EditorState) => ReactNode; 14 | export type CustomControlList = Array<CustomControl>; 15 | 16 | export type StyleConfigList = Array<StyleConfig>; 17 | 18 | export type GroupName = 'INLINE_STYLE_BUTTONS' | 'BLOCK_ALIGNMENT_BUTTONS' | 'BLOCK_TYPE_BUTTONS' | 'LINK_BUTTONS' | 'BLOCK_TYPE_DROPDOWN' | 'HISTORY_BUTTONS' | 'IMAGE_BUTTON'; 19 | 20 | export type ToolbarConfig = { 21 | display: Array<GroupName>; 22 | extraProps?: Object; 23 | INLINE_STYLE_BUTTONS: StyleConfigList; 24 | BLOCK_ALIGNMENT_BUTTONS: StyleConfigList; 25 | BLOCK_TYPE_DROPDOWN: StyleConfigList; 26 | BLOCK_TYPE_BUTTONS: StyleConfigList; 27 | }; 28 | 29 | export const INLINE_STYLE_BUTTONS: StyleConfigList = [ 30 | {label: 'Bold', style: 'BOLD'}, 31 | {label: 'Italic', style: 'ITALIC'}, 32 | {label: 'Strikethrough', style: 'STRIKETHROUGH'}, 33 | {label: 'Monospace', style: 'CODE'}, 34 | {label: 'Underline', style: 'UNDERLINE'}, 35 | ]; 36 | 37 | export const BLOCK_ALIGNMENT_BUTTONS: StyleConfigList = [ 38 | {label: 'Align Left', style: 'ALIGN_LEFT'}, 39 | {label: 'Align Center', style: 'ALIGN_CENTER'}, 40 | {label: 'Align Right', style: 'ALIGN_RIGHT'}, 41 | {label: 'Align Justify', style: 'ALIGN_JUSTIFY'}, 42 | ]; 43 | 44 | export const BLOCK_TYPE_DROPDOWN: StyleConfigList = [ 45 | {label: 'Normal', style: 'unstyled'}, 46 | {label: 'Heading Large', style: 'header-one'}, 47 | {label: 'Heading Medium', style: 'header-two'}, 48 | {label: 'Heading Small', style: 'header-three'}, 49 | {label: 'Code Block', style: 'code-block'}, 50 | ]; 51 | export const BLOCK_TYPE_BUTTONS: StyleConfigList = [ 52 | {label: 'UL', style: 'unordered-list-item'}, 53 | {label: 'OL', style: 'ordered-list-item'}, 54 | {label: 'Blockquote', style: 'blockquote'}, 55 | ]; 56 | 57 | let EditorToolbarConfig: ToolbarConfig = { 58 | display: ['INLINE_STYLE_BUTTONS', 'BLOCK_ALIGNMENT_BUTTONS', 'BLOCK_TYPE_BUTTONS', 'LINK_BUTTONS', 'IMAGE_BUTTON', 'BLOCK_TYPE_DROPDOWN', 'HISTORY_BUTTONS'], 59 | INLINE_STYLE_BUTTONS, 60 | BLOCK_ALIGNMENT_BUTTONS, 61 | BLOCK_TYPE_DROPDOWN, 62 | BLOCK_TYPE_BUTTONS, 63 | }; 64 | 65 | export default EditorToolbarConfig; 66 | -------------------------------------------------------------------------------- /src/lib/EditorValue.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {ContentState, EditorState, convertToRaw, convertFromRaw} from 'draft-js'; 3 | import {stateToHTML} from 'draft-js-export-html'; 4 | import {stateFromHTML} from 'draft-js-import-html'; 5 | import {stateToMarkdown} from 'draft-js-export-markdown'; 6 | import {stateFromMarkdown} from 'draft-js-import-markdown'; 7 | 8 | import type {DraftDecoratorType as Decorator} from 'draft-js/lib/DraftDecoratorType'; 9 | import type {Options as ImportOptions} from 'draft-js-import-html'; 10 | import type {Options as ExportOptions} from 'draft-js-export-html'; 11 | export type {ImportOptions, ExportOptions}; 12 | 13 | type StringMap = {[key: string]: string}; 14 | 15 | export default class EditorValue { 16 | _editorState: EditorState; 17 | _cache: StringMap; 18 | 19 | constructor(editorState: EditorState, cache: StringMap = {}) { 20 | this._cache = cache; 21 | this._editorState = editorState; 22 | } 23 | 24 | getEditorState(): EditorState { 25 | return this._editorState; 26 | } 27 | 28 | setEditorState(editorState: EditorState): EditorValue { 29 | return (this._editorState === editorState) ? 30 | this : 31 | new EditorValue(editorState); 32 | } 33 | 34 | toString(format: string, options?: ExportOptions): string { 35 | let fromCache = this._cache[format]; 36 | if (fromCache != null) { 37 | return fromCache; 38 | } 39 | return (this._cache[format] = toString(this.getEditorState(), format, options)); 40 | } 41 | 42 | setContentFromString(markup: string, format: string, options?: ImportOptions): EditorValue { 43 | let editorState = EditorState.push( 44 | this._editorState, 45 | fromString(markup, format, options), 46 | 'secondary-paste' 47 | ); 48 | return new EditorValue(editorState, {[format]: markup}); 49 | } 50 | 51 | static createEmpty(decorator: ?Decorator): EditorValue { 52 | let editorState = EditorState.createEmpty(decorator); 53 | return new EditorValue(editorState); 54 | } 55 | 56 | static createFromState(editorState: EditorState): EditorValue { 57 | return new EditorValue(editorState); 58 | } 59 | 60 | static createFromString(markup: string, format: string, decorator: ?Decorator, options?: ImportOptions): EditorValue { 61 | let contentState = fromString(markup, format, options); 62 | let editorState = EditorState.createWithContent(contentState, decorator); 63 | return new EditorValue(editorState, {[format]: markup}); 64 | } 65 | } 66 | 67 | function toString(editorState: EditorState, format: string, options?: ExportOptions): string { 68 | let contentState = editorState.getCurrentContent(); 69 | switch (format) { 70 | case 'html': { 71 | return stateToHTML(contentState, options); 72 | } 73 | case 'markdown': { 74 | return stateToMarkdown(contentState); 75 | } 76 | case 'raw': { 77 | return JSON.stringify(convertToRaw(contentState)); 78 | } 79 | default: { 80 | throw new Error('Format not supported: ' + format); 81 | } 82 | } 83 | } 84 | 85 | function fromString(markup: string, format: string, options?: ImportOptions): ContentState { 86 | switch (format) { 87 | case 'html': { 88 | return stateFromHTML(markup, options); 89 | } 90 | case 'markdown': { 91 | return stateFromMarkdown(markup, options); 92 | } 93 | case 'raw': { 94 | return convertFromRaw(JSON.parse(markup)); 95 | } 96 | default: { 97 | throw new Error('Format not supported: ' + format); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/lib/ImageDecorator.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import ImageSpan from '../ui/ImageSpan'; 3 | import {ENTITY_TYPE} from 'draft-js-utils'; 4 | 5 | import type {ContentBlock, ContentState} from 'draft-js'; 6 | 7 | type EntityRangeCallback = (start: number, end: number) => void; 8 | 9 | function findImageEntities(contentBlock: ContentBlock, callback: EntityRangeCallback, contentState: ?ContentState) { 10 | contentBlock.findEntityRanges((character) => { 11 | const entityKey = character.getEntity(); 12 | if (entityKey != null) { 13 | let entity = contentState ? contentState.getEntity(entityKey) : null; 14 | return entity != null && entity.getType() === ENTITY_TYPE.IMAGE; 15 | } 16 | return false; 17 | }, callback); 18 | } 19 | 20 | export default { 21 | strategy: findImageEntities, 22 | component: ImageSpan, 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/LinkDecorator.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import {ENTITY_TYPE} from 'draft-js-utils'; 4 | 5 | import type {ContentBlock, ContentState} from 'draft-js'; 6 | 7 | type Props = { 8 | children: ReactNode, 9 | entityKey: string, 10 | contentState: ContentState, 11 | }; 12 | 13 | type EntityRangeCallback = (start: number, end: number) => void; 14 | 15 | function Link(props: Props) { 16 | const {url} = props.contentState.getEntity(props.entityKey).getData(); 17 | return ( 18 | <a href={url}>{props.children}</a> 19 | ); 20 | } 21 | 22 | function findLinkEntities(contentBlock: ContentBlock, callback: EntityRangeCallback, contentState: ?ContentState) { 23 | contentBlock.findEntityRanges((character) => { 24 | const entityKey = character.getEntity(); 25 | if (entityKey != null) { 26 | let entity = contentState ? contentState.getEntity(entityKey) : null; 27 | return entity != null && entity.getType() === ENTITY_TYPE.LINK; 28 | } 29 | return false; 30 | }, callback); 31 | } 32 | 33 | export default { 34 | strategy: findLinkEntities, 35 | component: Link, 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/StyleButton.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, {Component} from 'react'; 3 | import IconButton from '../ui/IconButton'; 4 | import autobind from 'class-autobind'; 5 | 6 | type Props = { 7 | style: string; 8 | onToggle: (style: string) => any; 9 | }; 10 | 11 | export default class StyleButton extends Component { 12 | props: Props; 13 | 14 | constructor() { 15 | super(...arguments); 16 | autobind(this); 17 | } 18 | 19 | render() { 20 | let {style, onToggle, ...otherProps} = this.props; // eslint-disable-line no-unused-vars 21 | let iconName = style.toLowerCase(); 22 | // `focusOnClick` will prevent the editor from losing focus when a control 23 | // button is clicked. 24 | return ( 25 | <IconButton 26 | {...otherProps} 27 | iconName={iconName} 28 | onClick={this._onClick} 29 | focusOnClick={false} 30 | isSwitch={true} 31 | /> 32 | ); 33 | } 34 | 35 | _onClick() { 36 | this.props.onToggle(this.props.style); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/__tests__/composite-test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | const {describe, it} = global; 3 | 4 | import composite from '../composite'; 5 | import expect from 'expect'; 6 | 7 | describe('composite', () => { 8 | it('should return the composite of two functions', () => { 9 | let addOne = (x) => x + 1; 10 | let addTwo = (x) => x + 2; 11 | expect( 12 | composite(addOne, addTwo)(5) 13 | ).toBe(7); 14 | expect( 15 | composite(addOne, undefined)(5) 16 | ).toBe(6); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/blockStyleFunctions.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type {BlockStyleFn, CustomBlockFn} from 'draft-js-import-html'; 3 | 4 | export const getTextAlignClassName: BlockStyleFn = (contentBlock) => { 5 | switch (contentBlock.getData().get('textAlign')) { 6 | case 'ALIGN_LEFT': 7 | return 'text-align--left'; 8 | 9 | case 'ALIGN_CENTER': 10 | return 'text-align--center'; 11 | 12 | case 'ALIGN_RIGHT': 13 | return 'text-align--right'; 14 | 15 | case 'ALIGN_JUSTIFY': 16 | return 'text-align--justify'; 17 | 18 | default: 19 | return ''; 20 | } 21 | }; 22 | 23 | export const getTextAlignStyles: BlockStyleFn = (contentBlock) => { 24 | switch (contentBlock.getData().get('textAlign')) { 25 | case 'ALIGN_LEFT': 26 | return { 27 | style: { 28 | textAlign: 'left', 29 | }, 30 | }; 31 | 32 | case 'ALIGN_CENTER': 33 | return { 34 | style: { 35 | textAlign: 'center', 36 | }, 37 | }; 38 | 39 | case 'ALIGN_RIGHT': 40 | return { 41 | style: { 42 | textAlign: 'right', 43 | }, 44 | }; 45 | 46 | case 'ALIGN_JUSTIFY': 47 | return { 48 | style: { 49 | textAlign: 'justify', 50 | }, 51 | }; 52 | 53 | default: 54 | return {}; 55 | } 56 | }; 57 | 58 | export const getTextAlignBlockMetadata: CustomBlockFn = (element) => { 59 | switch (element.style.textAlign) { 60 | case 'right': 61 | return { 62 | data: { 63 | textAlign: 'ALIGN_RIGHT', 64 | }, 65 | }; 66 | 67 | case 'center': 68 | return { 69 | data: { 70 | textAlign: 'ALIGN_CENTER', 71 | }, 72 | }; 73 | 74 | case 'justify': 75 | return { 76 | data: { 77 | textAlign: 'ALIGN_JUSTIFY', 78 | }, 79 | }; 80 | 81 | case 'left': 82 | return { 83 | data: { 84 | textAlign: 'ALIGN_LEFT', 85 | }, 86 | }; 87 | 88 | default: 89 | return {}; 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/lib/changeBlockDepth.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import {EditorState} from 'draft-js'; 4 | 5 | export default function changeBlockDepth( 6 | editorState: EditorState, 7 | blockKey: string, 8 | newDepth: number, 9 | ): EditorState { 10 | let content = editorState.getCurrentContent(); 11 | let block = content.getBlockForKey(blockKey); 12 | let depth = block.getDepth(); 13 | if (depth === newDepth) { 14 | return editorState; 15 | } 16 | let newBlock = block.set('depth', newDepth); 17 | let newContent = content.merge({ 18 | blockMap: content.getBlockMap().set(blockKey, newBlock), 19 | }); 20 | return EditorState.push( 21 | editorState, 22 | newContent, 23 | 'adjust-depth' 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/changeBlockType.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import {EditorState} from 'draft-js'; 4 | 5 | export default function changeBlockType( 6 | editorState: EditorState, 7 | blockKey: string, 8 | newType: string, 9 | ): EditorState { 10 | let content = editorState.getCurrentContent(); 11 | let block = content.getBlockForKey(blockKey); 12 | let type = block.getType(); 13 | if (type === newType) { 14 | return editorState; 15 | } 16 | let newBlock = block.set('type', newType); 17 | let newContent = content.merge({ 18 | blockMap: content.getBlockMap().set(blockKey, newBlock), 19 | }); 20 | return EditorState.push( 21 | editorState, 22 | newContent, 23 | 'change-block-type' 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/clearEntityForRange.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {CharacterMetadata, EditorState} from 'draft-js'; 3 | 4 | export default function clearEntityForRange( 5 | editorState: EditorState, 6 | blockKey: string, 7 | startOffset: number, 8 | endOffset: number, 9 | ): EditorState { 10 | let contentState = editorState.getCurrentContent(); 11 | let blockMap = contentState.getBlockMap(); 12 | let block = blockMap.get(blockKey); 13 | let charList = block.getCharacterList(); 14 | let newCharList = charList.map((char: CharacterMetadata, i) => { 15 | if (i >= startOffset && i < endOffset) { 16 | return CharacterMetadata.applyEntity(char, null); 17 | } else { 18 | return char; 19 | } 20 | }); 21 | let newBlock = block.set('characterList', newCharList); 22 | let newBlockMap = blockMap.set(blockKey, newBlock); 23 | let newContentState = contentState.set('blockMap', newBlockMap); 24 | return EditorState.push(editorState, newContentState, 'apply-entity'); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/composite.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | function composite<T, U>( 4 | defaultFunc: (input: T) => U, 5 | customFunc?: (input: T) => ?U, 6 | ): (input: T) => U { 7 | return (input: T) => { 8 | if (customFunc) { 9 | let result = customFunc(input); 10 | if (result != null) { 11 | return result; 12 | } 13 | } 14 | return defaultFunc(input); 15 | }; 16 | } 17 | 18 | export default composite; 19 | -------------------------------------------------------------------------------- /src/lib/getBlocksInSelection.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {EditorState} from 'draft-js'; 3 | import {OrderedMap} from 'immutable'; 4 | 5 | export default function getBlocksInSelection( 6 | editorState: EditorState, 7 | ): EditorState { 8 | let contentState = editorState.getCurrentContent(); 9 | let blockMap = contentState.getBlockMap(); 10 | let selection = editorState.getSelection(); 11 | if (selection.isCollapsed()) { 12 | return new OrderedMap(); 13 | } 14 | 15 | let startKey = selection.getStartKey(); 16 | let endKey = selection.getEndKey(); 17 | if (startKey === endKey) { 18 | return new OrderedMap({startKey: contentState.getBlockForKey(startKey)}); 19 | } 20 | let blocksUntilEnd = blockMap.takeUntil((block, key) => key === endKey); 21 | return blocksUntilEnd.skipUntil((block, key) => key === startKey); 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/getEntityAtCursor.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type {EditorState, ContentBlock} from 'draft-js'; 3 | 4 | export type EntityDescription = { 5 | entityKey: string; 6 | blockKey: string; 7 | startOffset: number; 8 | endOffset: number; 9 | }; 10 | 11 | function getEntityAtOffset(block: ContentBlock, offset: number): ?EntityDescription { 12 | let entityKey = block.getEntityAt(offset); 13 | if (entityKey == null) { 14 | return null; 15 | } 16 | let startOffset = offset; 17 | while (startOffset > 0 && block.getEntityAt(startOffset - 1) === entityKey) { 18 | startOffset -= 1; 19 | } 20 | let endOffset = startOffset; 21 | let blockLength = block.getLength(); 22 | while (endOffset < blockLength && block.getEntityAt(endOffset + 1) === entityKey) { 23 | endOffset += 1; 24 | } 25 | return { 26 | entityKey, 27 | blockKey: block.getKey(), 28 | startOffset, 29 | endOffset: endOffset + 1, 30 | }; 31 | } 32 | 33 | export default function getEntityAtCursor(editorState: EditorState): ?EntityDescription { 34 | let selection = editorState.getSelection(); 35 | let startKey = selection.getStartKey(); 36 | let startBlock = editorState.getCurrentContent().getBlockForKey(startKey); 37 | let startOffset = selection.getStartOffset(); 38 | if (selection.isCollapsed()) { 39 | // Get the entity before the cursor (unless the cursor is at the start). 40 | return getEntityAtOffset(startBlock, startOffset === 0 ? startOffset : startOffset - 1); 41 | } 42 | if (startKey !== selection.getEndKey()) { 43 | return null; 44 | } 45 | let endOffset = selection.getEndOffset(); 46 | let startEntityKey = startBlock.getEntityAt(startOffset); 47 | for (let i = startOffset; i < endOffset; i++) { 48 | let entityKey = startBlock.getEntityAt(i); 49 | if (entityKey == null || entityKey !== startEntityKey) { 50 | return null; 51 | } 52 | } 53 | return { 54 | entityKey: startEntityKey, 55 | blockKey: startBlock.getKey(), 56 | startOffset: startOffset, 57 | endOffset: endOffset, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/insertBlockAfter.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import {ContentBlock, EditorState, genKey} from 'draft-js'; 4 | 5 | export default function insertBlockAfter( 6 | editorState: EditorState, 7 | blockKey: string, 8 | newType: string, 9 | ): EditorState { 10 | let content = editorState.getCurrentContent(); 11 | let blockMap = content.getBlockMap(); 12 | let block = blockMap.get(blockKey); 13 | let blocksBefore = blockMap.toSeq().takeUntil((v) => (v === block)); 14 | let blocksAfter = blockMap.toSeq().skipUntil((v) => (v === block)).rest(); 15 | let newBlockKey = genKey(); 16 | let newBlock = new ContentBlock({ 17 | key: newBlockKey, 18 | type: newType, 19 | text: '', 20 | characterList: block.getCharacterList().slice(0, 0), 21 | depth: 0, 22 | }); 23 | let newBlockMap = blocksBefore.concat( 24 | [[blockKey, block], [newBlockKey, newBlock]], 25 | blocksAfter, 26 | ).toOrderedMap(); 27 | let selection = editorState.getSelection(); 28 | let newContent = content.merge({ 29 | blockMap: newBlockMap, 30 | selectionBefore: selection, 31 | selectionAfter: selection.merge({ 32 | anchorKey: newBlockKey, 33 | anchorOffset: 0, 34 | focusKey: newBlockKey, 35 | focusOffset: 0, 36 | isBackward: false, 37 | }), 38 | }); 39 | return EditorState.push(editorState, newContent, 'split-block'); 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/isListItem.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import {BLOCK_TYPE} from 'draft-js-utils'; 4 | 5 | import type {ContentBlock} from 'draft-js'; 6 | 7 | 8 | export default function isListItem(block: ContentBlock): boolean { 9 | let blockType = block.getType(); 10 | return ( 11 | blockType === BLOCK_TYPE.UNORDERED_LIST_ITEM || 12 | blockType === BLOCK_TYPE.ORDERED_LIST_ITEM 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/Button.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: inline-block; 3 | margin: 0 5px 0 0; 4 | padding: 3px 8px; 5 | height: 30px; 6 | line-height: 22px; 7 | box-sizing: border-box; 8 | background: none #fdfdfd; 9 | background: linear-gradient(to bottom, #fdfdfd 0%,#f6f7f8 100%); 10 | border: 1px solid #999; 11 | border-radius: 2px; 12 | color: #333; 13 | text-decoration: none; 14 | font-size: inherit; 15 | font-family: inherit; 16 | cursor: pointer; 17 | white-space: nowrap; 18 | } 19 | 20 | .root:disabled { 21 | cursor: not-allowed; 22 | background: none transparent; 23 | } 24 | 25 | .root:disabled > * { 26 | opacity: .5; 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/Button.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React, {Component} from 'react'; 4 | import cx from 'classnames'; 5 | import autobind from 'class-autobind'; 6 | 7 | import styles from './Button.css'; 8 | 9 | type EventHandler = (event: Event) => any; 10 | 11 | type Props = { 12 | children?: ReactNode; 13 | className?: string; 14 | focusOnClick?: boolean; 15 | formSubmit?: boolean; 16 | isDisabled?: boolean; 17 | onMouseDown?: EventHandler; 18 | }; 19 | 20 | export default class Button extends Component { 21 | props: Props; 22 | 23 | constructor() { 24 | super(...arguments); 25 | autobind(this); 26 | } 27 | 28 | render() { 29 | let {props} = this; 30 | let {className, isDisabled, focusOnClick, formSubmit, ...otherProps} = props; 31 | className = cx(className, styles.root); 32 | let onMouseDown = (focusOnClick === false) ? this._onMouseDownPreventDefault : props.onMouseDown; 33 | let type = formSubmit ? 'submit' : 'button'; 34 | return ( 35 | <button type={type} {...otherProps} onMouseDown={onMouseDown} className={className} disabled={isDisabled}> 36 | {props.children} 37 | </button> 38 | ); 39 | } 40 | 41 | _onMouseDownPreventDefault(event: Event) { 42 | event.preventDefault(); 43 | let {onMouseDown} = this.props; 44 | if (onMouseDown != null) { 45 | onMouseDown(event); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/ButtonGroup.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: inline-block; 3 | vertical-align: top; 4 | margin: 0 5px 5px 0; 5 | white-space: nowrap; 6 | } 7 | 8 | .root:last-child { 9 | margin-right: 0; 10 | } 11 | 12 | /* TODO: remove all this child selector and tag selector stuff. */ 13 | 14 | .root > div > button { 15 | margin-right: 0; 16 | border-radius: 0; 17 | } 18 | 19 | .root > div > button:focus { 20 | position: relative; 21 | z-index: 1; 22 | } 23 | 24 | .root > div:first-child > button { 25 | border-top-left-radius: 2px; 26 | border-bottom-left-radius: 2px; 27 | } 28 | 29 | .root > div + div > button { 30 | border-left-width: 0; 31 | } 32 | 33 | .root > div:last-child > button { 34 | border-top-right-radius: 2px; 35 | border-bottom-right-radius: 2px; 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/ButtonGroup.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from 'react'; 4 | import cx from 'classnames'; 5 | 6 | import styles from './ButtonGroup.css'; 7 | 8 | type Props = { 9 | className?: string; 10 | }; 11 | 12 | export default function ButtonGroup(props: Props) { 13 | let className = cx(props.className, styles.root); 14 | return ( 15 | <div {...props} className={className} /> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/ButtonWrap.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: inline-block; 3 | position: relative; 4 | /* This ensures the popover will show on top of the editor. */ 5 | z-index: 10; 6 | } 7 | -------------------------------------------------------------------------------- /src/ui/ButtonWrap.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from 'react'; 4 | import cx from 'classnames'; 5 | 6 | import styles from './ButtonWrap.css'; 7 | 8 | type Props = { 9 | className?: string; 10 | }; 11 | 12 | export default function ButtonWrap(props: Props) { 13 | let className = cx(props.className, styles.root); 14 | return <div {...props} className={className} />; 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/Dropdown.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: inline-block; 3 | position: relative; 4 | line-height: 22px; 5 | vertical-align: top; 6 | -webkit-user-select: none; 7 | -moz-user-select: none; 8 | -ms-user-select: none; 9 | user-select: none; 10 | } 11 | 12 | .root select { 13 | position: relative; 14 | z-index: 2; 15 | display: inline-block; 16 | box-sizing: border-box; 17 | height: 30px; 18 | line-height: inherit; 19 | font-family: inherit; 20 | font-size: inherit; 21 | color: inherit; 22 | margin: 0; 23 | padding: 0; 24 | border: 4px solid transparent; 25 | border-right-width: 10px; 26 | border-left-width: 5px; 27 | background: none transparent; 28 | opacity: 0; 29 | cursor: pointer; 30 | } 31 | 32 | .root .value { 33 | display: block; 34 | position: absolute; 35 | z-index: 1; 36 | left: 0; 37 | top: 0; 38 | right: 0; 39 | bottom: 0; 40 | line-height: 23px; 41 | border: 1px solid #999; 42 | border-radius: 2px; 43 | padding: 3px; 44 | padding-right: 33px; 45 | padding-left: 12px; 46 | white-space: nowrap; 47 | text-overflow: ellipsis; 48 | } 49 | 50 | .root .value::before, 51 | .root .value::after { 52 | display: block; 53 | content: ""; 54 | position: absolute; 55 | top: 50%; 56 | right: 10px; 57 | width: 0; 58 | height: 0; 59 | border: 4px solid transparent; 60 | } 61 | 62 | .root .value::before { 63 | margin-top: -10px; 64 | border-bottom-color: #555; 65 | } 66 | 67 | .root .value::after { 68 | margin-top: 1px; 69 | border-top-color: #555; 70 | } 71 | 72 | .root select:focus + .value { 73 | border-color: #66afe9; 74 | } 75 | 76 | /* On Webkit we can style <select> to be transparant without turning off the 77 | default focus styles. This is better for accessibility. */ 78 | @media screen and (-webkit-min-device-pixel-ratio:0) { 79 | .root select { 80 | opacity: 1; 81 | color: inherit; 82 | -webkit-appearance: none; 83 | border-left-width: 12px; 84 | border-right-width: 35px; 85 | } 86 | 87 | .root select + .value { 88 | color: transparent; 89 | } 90 | 91 | .root select:focus + .value { 92 | border-color: #999; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/ui/Dropdown.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, {Component} from 'react'; 3 | import autobind from 'class-autobind'; 4 | import cx from 'classnames'; 5 | 6 | import styles from './Dropdown.css'; 7 | 8 | type Choice = { 9 | label: string; 10 | className?: string; 11 | }; 12 | 13 | type Props = { 14 | choices: Map<string, Choice>; 15 | selectedKey: ?string; 16 | onChange: (selectedKey: string) => any; 17 | className?: string; 18 | }; 19 | 20 | export default class Dropdown extends Component { 21 | props: Props; 22 | 23 | constructor() { 24 | super(...arguments); 25 | autobind(this); 26 | } 27 | 28 | render() { 29 | let {choices, selectedKey, className, ...otherProps} = this.props; 30 | className = cx(className, styles.root); 31 | let selectedItem = (selectedKey == null) ? null : choices.get(selectedKey); 32 | let selectedValue = selectedItem && selectedItem.label || ''; 33 | return ( 34 | <span className={className} title={selectedValue}> 35 | <select {...otherProps} value={selectedKey} onChange={this._onChange}> 36 | {this._renderChoices()} 37 | </select> 38 | <span className={styles.value}>{selectedValue}</span> 39 | </span> 40 | ); 41 | } 42 | 43 | _onChange(event: Object) { 44 | let value: string = event.target.value; 45 | this.props.onChange(value); 46 | } 47 | 48 | _renderChoices() { 49 | let {choices} = this.props; 50 | let entries = Array.from(choices.entries()); 51 | return entries.map(([key, {label, className}]) => ( 52 | <option key={key} value={key} className={className}>{label}</option> 53 | )); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ui/IconButton.css: -------------------------------------------------------------------------------- 1 | .root { 2 | padding-left: 3px; 3 | padding-right: 3px; 4 | } 5 | 6 | .icon { 7 | display: inline-block; 8 | width: 22px; 9 | height: 22px; 10 | background-position: center center; 11 | background-repeat: no-repeat; 12 | background-size: 18px; 13 | } 14 | 15 | .isActive { 16 | background: none #d8d8d8; 17 | } 18 | 19 | .icon-undo { 20 | composes: icon; 21 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTcuODU2IDI0YzIuNjY1LTQuODMgMy4xMTUtMTIuMTk1LTcuMzU2LTExLjk1VjE4bC05LTkgOS05djUuODJDMjMuMDM4IDUuNDk1IDI0LjQzNSAxNi44OSAxNy44NTYgMjR6Ii8+PC9zdmc+"); 22 | background-size: 14px; 23 | } 24 | .icon-redo { 25 | composes: icon; 26 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTMuNSA1LjgyVjBsOSA5LTkgOXYtNS45NUMzLjAzIDExLjgwNiAzLjQ3OCAxOS4xNyA2LjE0NCAyNC0uNDM2IDE2Ljg5Ljk2MiA1LjQ5NCAxMy41IDUuODJ6Ii8+PC9zdmc+"); 27 | background-size: 14px; 28 | } 29 | 30 | .icon-unordered-list-item { 31 | composes: icon; 32 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBkPSJNNC42NTYgMy4zNDRIMTR2MS4zMTNINC42NTZWMy4zNDR6bTAgNS4zMTJWNy4zNDNIMTR2MS4zMTNINC42NTZ6bTAgNHYtMS4zMTNIMTR2MS4zMTNINC42NTZ6bS0yLTEuNTNxLjM3NSAwIC42NC4yNXQuMjY3LjYyNC0uMjY2LjYyNS0uNjQuMjUtLjYyNi0uMjVUMS43OCAxMnQuMjUtLjYyNS42MjYtLjI1em0wLTguMTI2cS40MDYgMCAuNzAzLjI4dC4yOTYuNzItLjI5Ny43Mi0uNzA0LjI4LS43MDMtLjI4VDEuNjU2IDR0LjI5Ny0uNzIuNzAzLS4yOHptMCA0cS40MDYgMCAuNzAzLjI4dC4yOTYuNzItLjI5Ny43Mi0uNzA0LjI4LS43MDMtLjI4VDEuNjU2IDh0LjI5Ny0uNzIuNzAzLS4yOHoiLz48L3N2Zz4="); 33 | } 34 | .icon-ordered-list-item { 35 | composes: icon; 36 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBkPSJNNC42NTYgOC42NTZWNy4zNDNIMTR2MS4zMTNINC42NTZ6bTAgNHYtMS4zMTNIMTR2MS4zMTNINC42NTZ6bTAtOS4zMTJIMTR2MS4zMTNINC42NTZWMy4zNDR6bS0zLjMxMiA0di0uNjg4aDJ2LjYyNWwtMS4yMiAxLjM3NmgxLjIydi42ODhoLTJWOC43MmwxLjE4OC0xLjM3NkgxLjM0NHptLjY1Ni0ydi0yaC0uNjU2di0uNjg4aDEuMzEzdjIuNjg4SDJ6bS0uNjU2IDZ2LS42ODhoMnYyLjY4OGgtMnYtLjY4OGgxLjMxM3YtLjMxM0gydi0uNjg4aC42NTd2LS4zMTNIMS4zNDR6Ii8+PC9zdmc+"); 37 | } 38 | .icon-blockquote { 39 | composes: icon; 40 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBkPSJNOS4zNDQgMTEuMzQ0bDEuMzEzLTIuNjg4aC0ydi00aDR2NGwtMS4zMTMgMi42ODhoLTJ6bS01LjM0NCAwbDEuMzQ0LTIuNjg4aC0ydi00aDR2NEw2IDExLjM0NEg0eiIvPjwvc3ZnPg=="); 41 | } 42 | 43 | .icon-bold { 44 | composes: icon; 45 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBkPSJNOSAxMC4zNDRxLjQzOCAwIC43Mi0uMjk3dC4yOC0uNzAzLS4yOC0uNzAzVDkgOC4zNDVINi42NTZ2Mkg5em0tMi4zNDQtNnYyaDJxLjQwNiAwIC43MDMtLjI5N3QuMjk2LS43MDMtLjI5Ny0uNzAzLS43MDQtLjI5NmgtMnptMy43NSAyLjg0NHExLjQzOC42NTYgMS40MzggMi4yOCAwIDEuMDY0LS43MDMgMS43OThUOS4zNzYgMTJoLTQuNzJWMi42NTZoNC4xOXExLjEyNCAwIDEuODkuNzh0Ljc2NiAxLjkwNy0xLjA5MyAxLjg0NHoiLz48L3N2Zz4="); 46 | } 47 | .icon-italic { 48 | composes: icon; 49 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBkPSJNNi42NTYgMi42NTZIMTJ2MmgtMS44NzVMNy44NzUgMTBoMS40N3YySDR2LTJoMS44NzVsMi4yNS01LjM0NGgtMS40N3YtMnoiLz48L3N2Zz4="); 50 | } 51 | .icon-underline { 52 | composes: icon; 53 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBkPSJNMy4zNDQgMTIuNjU2aDkuMzEzVjE0SDMuMzQ0di0xLjM0NHpNOCAxMS4zNDRxLTEuNjU2IDAtMi44MjgtMS4xNzJUNCA3LjM0NFYyaDEuNjU2djUuMzQ0cTAgLjk3LjY4OCAxLjY0VDggOS42NTh0MS42NTYtLjY3Mi42ODgtMS42NFYySDEydjUuMzQ0UTEyIDkgMTAuODI4IDEwLjE3MlQ4IDExLjM0NHoiLz48L3N2Zz4="); 54 | } 55 | .icon-strikethrough { 56 | composes: icon; 57 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMjMuNTcgMTJxLjE5IDAgLjMxLjEydC4xMi4zMXYuODU2cTAgLjE4OC0uMTIuMzA4dC0uMzEuMTJILjQzcS0uMTg4IDAtLjMwOC0uMTJUMCAxMy4yODZ2LS44NTdxMC0uMTkuMTItLjMxVC40MjggMTJIMjMuNTd6bS0xNy4xLS44NTdxLS4zNzYtLjQ3LS42ODQtMS4wNy0uNjQzLTEuMy0uNjQzLTIuNTIgMC0yLjQyMyAxLjc5NS00LjEzNyAxLjc4LTEuNyA1LjI2My0xLjcuNjcgMCAyLjIzOC4yNTMuODg0LjE2IDIuMzcuNjQyLjEzNS41MS4yODIgMS41OC4xODggMS42NDcuMTg4IDIuNDUgMCAuMjQyLS4wNjcuNjA0bC0uMTYuMDQtMS4xMjUtLjA4LS4xODgtLjAyN3EtLjY3LTEuOTk3LTEuMzgtMi43NDctMS4xNzgtMS4yMi0yLjgxMi0xLjIyLTEuNTI3IDAtMi40MzguNzktLjg5Ny43NzgtLjg5NyAxLjk1NiAwIC45NzcuODg0IDEuODc0dDMuNzM3IDEuNzI4cS45MjUuMjY4IDIuMzE4Ljg4NC43NzcuMzc1IDEuMjcyLjY5Nkg2LjQ3em02Ljc5IDMuNDI4aDUuNTAzcS4wOTQuNTIzLjA5NCAxLjIzMyAwIDEuNDg3LS41NSAyLjg0LS4zMDcuNzM2LS45NSAxLjM5Mi0uNDk2LjQ3LTEuNDYgMS4wODUtMS4wNy42NDMtMi4wNS44ODQtMS4wNy4yOC0yLjcxOC4yOC0xLjUyOCAwLTIuNjEzLS4zMDdsLTEuODc1LS41MzZxLS43NjMtLjIxMy0uOTY0LS4zNzQtLjEwNy0uMTA3LS4xMDctLjI5NXYtLjE3M3EwLTEuNDQ2LS4wMjYtMi4wOS0uMDEzLS40IDAtLjkxbC4wMjctLjQ5NnYtLjU4OGwxLjM2Ny0uMDI3cS4yLjQ1NS40MDIuOTV0LjMuNzUuMTY3LjM2M3EuNDcuNzYzIDEuMDcgMS4yNi41NzcuNDggMS40MDcuNzYyLjc5LjI5NSAxLjc2OC4yOTUuODU3IDAgMS44NjItLjM2MiAxLjAzLS4zNDggMS42MzQtMS4xNTIuNjMtLjgxNi42My0xLjcyNyAwLTEuMTI1LTEuMDg2LTIuMTAzLS40NTUtLjM4OC0xLjgzNS0uOTV6Ii8+PC9zdmc+"); 58 | background-size: 14px; 59 | } 60 | .icon-code { 61 | composes: icon; 62 | background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTE2IDExNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEuNDE0Ij48ZyBmaWxsLXJ1bGU9Im5vbnplcm8iPjxwYXRoIGQ9Ik0yMi40NjQgMjguNDhjMCAyLjg5NS4zNDQgNS45MDUuODA2IDkuMDIuMzQyIDMuMDEuNjkgNi4wMi42OSA4LjkxNyAwIDMuNTYyLS45MTcgNy43OS04Ljk1NSA3LjkxMnY3LjIzNmM4LjAzNi4xMTUgOC45NTYgNC42NzIgOC45NTYgNy45MTIgMCAyLjg4Ni0uMzQ4IDUuNzgzLS42OSA4Ljc4Ny0uNDYyIDMuMDEzLS44MDYgNi4xMzQtLjgwNiA4LjkyIDAgMTEuMjM4IDcuMTA2IDE1LjI1MiAxNy4wODcgMTUuMjUyaDMuMzJ2LTcuOTEyaC0yLjA2MmMtNS43MjYgMC04LjAyNS0zLjIzMy04LjAyNS04Ljc5NiAwLTIuMjM2LjM0NC00LjU3LjgwNi03LjAyMy4yMjctMi40MzguNjg0LTUuMTIuNjg0LTguMTIuMTE1LTcuNzkyLTMuMzItMTEuMjUzLTkuMTc0LTEyLjU4NnYtLjIyNWM1Ljg1NC0xLjMzMiA5LjI5My00LjY3NiA5LjE3LTEyLjQ3IDAtMi44OTUtLjQ1Ny01LjU2NS0uNjg0LTguMDI0LS40NjItMi40NC0uODA3LTQuNzc3LS44MDctNy4wMTIgMC01LjQ1IDIuMDU4LTguNjg4IDguMDI0LTguNjg4aDIuMDY2di04LjAxNGgtMy4zMmMtMTAuMjA1LS4wMDMtMTcuMDg2IDQuNDQ0LTE3LjA4NiAxNC45MTV6TTkyLjA2IDQ2LjQxN2MwLTIuODkzLjQ1My01LjkwMy44MDMtOC45MTguMzQzLTMuMTE0Ljc5Ny02LjEyLjc5Ny05LjAyIDAtMTAuNDctNi44NzUtMTQuOTE3LTE3LjA4LTE0LjkxN2gtMy4zMjd2OC4wMTdoMi4wNmM1Ljg1Mi4xMTQgNy45MSAzLjIzMyA3LjkxIDguNjg4IDAgMi4yMy0uMzQyIDQuNTY1LS42ODUgNy4wMTItLjM1IDIuNDU1LS42OTIgNS4xMjYtLjY5MiA4LjAyNC0uMTA1IDcuNzk3IDMuMzI3IDExLjEzNiA5LjA1NiAxMi40N3YuMjIyYy01LjcyIDEuMzMzLTkuMTYgNC43OTYtOS4wNTYgMTIuNTg3IDAgMyAuMzQyIDUuNjg2LjY5MiA4LjEyLjM0MyAyLjQ1NS42ODYgNC43OS42ODYgNy4wMjUgMCA1LjU1NC0yLjE4IDguNjgtNy45MTIgOC43ODhoLTIuMDZ2Ny45MTJoMy4zMjVjOS45NzUgMCAxNy4wNzYtNC4wMSAxNy4wNzYtMTUuMjUgMC0yLjc4My0uNDU0LTUuOS0uNzk2LTguOTE0LS4zNDctMy4wMS0uODA1LTUuOS0uODA1LTguNzk1IDAtMy4yMzMgMS4wMzUtNy43OSA4Ljk0My03LjkxM1Y1NC4zMmMtNy45MDQtLjExMi04LjkzNS00LjM0LTguOTM1LTcuOTAzeiIvPjwvZz48L3N2Zz4="); 63 | } 64 | 65 | .icon-link { 66 | composes: icon; 67 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMiIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDIyIDI0Ij48cGF0aCBkPSJNMTkuNSAxNi4yODZxMC0uNTM2LS4zNzUtLjkxbC0yLjc4Ni0yLjc4N3EtLjM3Ni0uMzc2LS45MTItLjM3Ni0uNTYzIDAtLjk2NC40M2wuMjU0LjI0N3EuMjE0LjIwOC4yODguMjl0LjIuMjUzLjE3NS4zNDIuMDQ4LjM2OHEwIC41MzYtLjM3NS45MXQtLjkxLjM3NnEtLjIwMiAwLS4zNy0uMDQ4dC0uMzQtLjE3NC0uMjU1LS4yLS4yODgtLjI5LS4yNDgtLjI1M3EtLjQ0Mi40MTUtLjQ0Mi45NzggMCAuNTM2LjM3NS45MWwyLjc2IDIuNzczcS4zNi4zNjIuOTEuMzYyLjUzNiAwIC45MS0uMzQ4bDEuOTctMS45NTVxLjM3NS0uMzc1LjM3NS0uODk3em0tOS40MTUtOS40NDJxMC0uNTM2LS4zNzUtLjkxTDYuOTUgMy4xNnEtLjM3NC0uMzc0LS45MS0uMzc0LS41MjIgMC0uOTEuMzYyTDMuMTYgNS4xMDNxLS4zNzUuMzc1LS4zNzUuODk3IDAgLjUzNi4zNzUuOTFsMi43ODYgMi43ODdxLjM2Mi4zNjIuOTEuMzYyLjU2NCAwIC45NjUtLjQxNmwtLjI1My0uMjQ4cS0uMjEzLS4yMDgtLjI4OC0uMjg4dC0uMjAyLS4yNTQtLjE3NC0uMzQyLS4wNDctLjM2OHEwLS41MzYuMzc1LS45MXQuOTEtLjM3NnEuMjAyIDAgLjM3LjA0N3QuMzQuMTc0LjI1NS4yLjI4OC4yODguMjQ4LjI1NHEuNDQyLS40MTUuNDQyLS45Nzh6bTExLjk4NiA5LjQ0MnEwIDEuNjA3LTEuMTM3IDIuNzJsLTEuOTcgMS45NTRxLTEuMTEgMS4xMTItMi43MTggMS4xMTItMS42MiAwLTIuNzMyLTEuMTM4bC0yLjc2LTIuNzcycS0xLjExLTEuMTEyLTEuMTEtMi43MiAwLTEuNjQ2IDEuMTc4LTIuNzk4bC0xLjE3OC0xLjE4cS0xLjE1MiAxLjE4LTIuNzg2IDEuMTgtMS42MDcgMC0yLjczMi0xLjEyNUwxLjMzOCA4LjczMlEuMjEzIDcuNjA4LjIxMyA2VDEuMzUgMy4yODNsMS45Ny0xLjk1NVE0LjQzMi4yMTUgNi4wNC4yMTVxMS42MiAwIDIuNzMgMS4xMzhsMi43NiAyLjc3MnExLjExMiAxLjExMiAxLjExMiAyLjcyIDAgMS42NDYtMS4xOCAyLjc5OGwxLjE4IDEuMThxMS4xNTItMS4xOCAyLjc4Ni0xLjE4IDEuNjA3IDAgMi43MzIgMS4xMjVsMi43ODYgMi43ODZxMS4xMjUgMS4xMjUgMS4xMjUgMi43MzJ6Ii8+PC9zdmc+"); 68 | background-size: 14px; 69 | } 70 | 71 | .icon-remove-link { 72 | composes: icon; 73 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMiIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDIyIDI0Ij48cGF0aCBkPSJNNS44OCAxNy4wMjJsLTMuNDMgMy40M3EtLjEzNC4xMi0uMzA4LjEyLS4xNiAwLS4zMDgtLjEyLS4xMi0uMTM1LS4xMi0uMzF0LjEyLS4zMDdsMy40My0zLjQzcS4xMzMtLjEyLjMwNy0uMTJ0LjMxLjEycS4xMi4xMzUuMTIuMzF0LS4xMi4zMDd6bTIuMjYzLjU1djQuMjg1cTAgLjE4OC0uMTIuMzA4dC0uMzEuMTItLjMwNy0uMTItLjEyLS4zMDhWMTcuNTdxMC0uMTg3LjEyLS4zMDd0LjMwOC0uMTIuMzA4LjEyLjEyLjMwOHptLTMtM3EwIC4xODctLjEyLjMwN3QtLjMxLjEySC40M3EtLjE4OCAwLS4zMDgtLjEyVDAgMTQuNTd0LjEyLS4zMDcuMzA4LS4xMmg0LjI4NnEuMTg4IDAgLjMwOC4xMnQuMTIuMzA4em0xNi45MjggMS43MTRxMCAxLjYwNy0xLjEzNyAyLjcybC0xLjk3IDEuOTU0cS0xLjExIDEuMTEyLTIuNzE4IDEuMTEyLTEuNjIgMC0yLjczMi0xLjEzOEw5LjA0IDE2LjQ0N3EtLjI4LS4yOC0uNTYzLS43NWwzLjItLjI0IDMuNjU3IDMuNjdxLjM2Mi4zNi45MS4zNjd0LjkxMi0uMzU1bDEuOTctMS45NTZxLjM3NC0uMzc1LjM3NC0uODk3IDAtLjUzNi0uMzc1LS45MWwtMy42Ny0zLjY4NC4yNC0zLjJxLjQ3LjI4Ljc1LjU2Mmw0LjUgNC41cTEuMTI2IDEuMTUyIDEuMTI2IDIuNzMyek0xMy44MSA2LjU5bC0zLjIuMjRMNi45NSAzLjE2cS0uMzc0LS4zNzUtLjkxLS4zNzUtLjUyMiAwLS45MS4zNjJMMy4xNiA1LjEwMnEtLjM3NS4zNzUtLjM3NS44OTcgMCAuNTM1LjM3NS45MWwzLjY3IDMuNjctLjI0IDMuMjE0cS0uNDctLjI4LS43NS0uNTYzbC00LjUtNC41US4yMTMgNy41OC4yMTMgNnEwLTEuNjA4IDEuMTM4LTIuNzJsMS45Ny0xLjk1NVE0LjQzLjIxMyA2LjA0LjIxM3ExLjYyIDAgMi43MzIgMS4xMzhsNC40NzMgNC40ODhxLjI4LjI4LjU2My43NXptOC40NzggMS4xMjRxMCAuMTg4LS4xMi4zMDh0LS4zMS4xMmgtNC4yODVxLS4xODcgMC0uMzA3LS4xMnQtLjEyLS4zMDguMTItLjMwOC4zMDgtLjEyaDQuMjg3cS4xODggMCAuMzA4LjEydC4xMi4zMDh6TTE1IC40M3Y0LjI4NXEwIC4xODgtLjEyLjMwOHQtLjMxLjEyLS4zMDctLjEyLS4xMi0uMzA4Vi40M3EwLS4xOS4xMi0uMzFUMTQuNTcgMHQuMzEuMTIuMTIuMzF6bTUuNDUgMi4wMmwtMy40MjggMy40M3EtLjE0Ny4xMi0uMzA4LjEydC0uMzA4LS4xMnEtLjEyLS4xMzQtLjEyLS4zMDh0LjEyLS4zMDhsMy40My0zLjQzcS4xMzMtLjEyLjMwNy0uMTJ0LjMwOC4xMnEuMTIyLjEzNS4xMjIuMzF0LS4xMi4zMDd6Ii8+PC9zdmc+"); 74 | background-size: 14px; 75 | } 76 | 77 | .icon-image { 78 | composes: icon; 79 | background-image: url("data:image/svg+xml;utf8;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pgo8IS0tIEdlbmVyYXRvcjogQWRvYmUgSWxsdXN0cmF0b3IgMTYuMC4wLCBTVkcgRXhwb3J0IFBsdWctSW4gLiBTVkcgVmVyc2lvbjogNi4wMCBCdWlsZCAwKSAgLS0+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0iQ2FwYV8xIiB4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjUxMnB4IiBoZWlnaHQ9IjUxMnB4IiB2aWV3Qm94PSIwIDAgNTMzLjMzMyA1MzMuMzM0IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1MzMuMzMzIDUzMy4zMzQ7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8cGF0aCBkPSJNNDY2LjY2NywxMDBoLTQwMHYzMzMuMzMzaDQwMFYxMDB6IE01MzMuMzMzLDMzLjMzM0w1MzMuMzMzLDMzLjMzM1Y1MDBIMFYzMy4zMzNINTMzLjMzM3ogTTQzMy4zMzMsNDAwSDEwMHYtNjYuNjY3ICAgbDEwMC0xNjYuNjY3bDEzNi45NzksMTY2LjY2N2w5Ni4zNTQtNjYuNjY2VjMwMFY0MDB6IE0zMzMuMzMzLDE4My4zMzNjMCwyNy42MTQsMjIuMzg2LDUwLDUwLDUwczUwLTIyLjM4Niw1MC01MHMtMjIuMzg2LTUwLTUwLTUwICAgUzMzMy4zMzMsMTU1LjcxOSwzMzMuMzMzLDE4My4zMzN6IiBmaWxsPSIjMDAwMDAwIi8+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPC9zdmc+Cg=="); 80 | background-size: 14px; 81 | } 82 | 83 | .icon-cancel { 84 | composes: icon; 85 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMjMuNzggMTkuMjhMMTYuNSAxMmw3LjI4LTcuMjhhLjc0OC43NDggMCAwIDAgMC0xLjA2TDIwLjM0LjIxOGEuNzUuNzUgMCAwIDAtMS4wNi0uMDAyTDEyIDcuNDk4IDQuNzE3LjIyYS43NDguNzQ4IDAgMCAwLTEuMDYgMEwuMjE3IDMuNjZhLjc1Ljc1IDAgMCAwIDAgMS4wNkw3LjQ5NyAxMmwtNy4yOCA3LjI4YS43NDguNzQ4IDAgMCAwIDAgMS4wNmwzLjQ0IDMuNDRhLjc1Ljc1IDAgMCAwIDEuMDYuMDAybDcuMjgtNy4yOCA3LjI4MiA3LjI4Yy4wNzguMDc4LjE3LjEzNS4yNjguMTcuMjY3LjEuNTguMDQ0Ljc5My0uMTdsMy40NC0zLjQ0YS43NS43NSAwIDAgMCAwLTEuMDZ6Ii8+PC9zdmc+"); 86 | background-size: 13px; 87 | } 88 | 89 | .icon-accept { 90 | composes: icon; 91 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMjAuMjUgM0w5IDE0LjI1IDMuNzUgOSAwIDEyLjc1bDkgOSAxNS0xNXoiLz48L3N2Zz4="); 92 | background-size: 13px; 93 | } 94 | 95 | .icon-align_left { 96 | composes: icon; 97 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij4KPHBhdGggZD0iTTkuOSwxMGgtOFY4LjdoOFYxMHogTTkuOSwzLjNoLTh2MS4zaDhWMy4zeiBNMS44LDEyLjdoMTIuM3YtMS4zSDEuOFYxMi43eiBNMS44LDZ2MS4zaDEyLjNWNkgxLjh6Ii8+Cjwvc3ZnPgo="); 98 | } 99 | 100 | .icon-align_center { 101 | composes: icon; 102 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij4KPHBhdGggZD0iTTEyLDQuN0g0VjMuM2g4VjQuN3ogTTEyLDguN0g0VjEwaDhWOC43eiBNMS44LDZ2MS4zaDEyLjNWNkgxLjh6IE0xLjgsMTIuN2gxMi4zdi0xLjNIMS44VjEyLjd6Ii8+Cjwvc3ZnPgo="); 103 | } 104 | 105 | .icon-align_right { 106 | composes: icon; 107 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij4KPHBhdGggZD0iTTEuOCwxMS4zaDEyLjN2MS4zSDEuOFYxMS4zeiBNMTQuMiwzLjNoLTh2MS4zaDhWMy4zeiBNNi4xLDguN1YxMGg4VjguN0g2LjF6IE0xLjgsNnYxLjNoMTIuM1Y2SDEuOHoiLz4KPC9zdmc+Cg=="); 108 | } 109 | 110 | .icon-align_justify { 111 | composes: icon; 112 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij4KPHBhdGggZD0iTTEuOCw4LjdoMTIuM1YxMEgxLjhWOC43eiBNMS44LDEyLjdoMTIuM3YtMS4zSDEuOFYxMi43eiBNMS44LDcuM2gxMi4zVjZIMS44VjcuM3ogTTEuOCwzLjN2MS4zaDEyLjNWMy4zSDEuOHoiLz4KPC9zdmc+Cg=="); 113 | } -------------------------------------------------------------------------------- /src/ui/IconButton.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React, {Component} from 'react'; 4 | import cx from 'classnames'; 5 | import Button from './Button'; 6 | import ButtonWrap from './ButtonWrap'; 7 | 8 | import styles from './IconButton.css'; 9 | 10 | type Props = { 11 | iconName: string; 12 | isActive?: boolean; 13 | children?: ReactNode; 14 | className?: string; 15 | label?: string; 16 | isSwitch?: boolean; 17 | }; 18 | 19 | export default class IconButton extends Component { 20 | props: Props; 21 | 22 | render() { 23 | let {props} = this; 24 | let {className, iconName, label, children, isActive, isSwitch, ...otherProps} = props; 25 | className = cx(className, { 26 | [styles.root]: true, 27 | [styles.isActive]: isActive, 28 | }); 29 | return ( 30 | <ButtonWrap> 31 | <Button {...otherProps} title={label} className={className} role={isSwitch && 'switch'} aria-checked={isActive}> 32 | <span className={styles['icon-' + iconName]} /> 33 | {/* TODO: add text label here with aria-hidden */} 34 | </Button> 35 | {children} 36 | </ButtonWrap> 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/ImageSpan.css: -------------------------------------------------------------------------------- 1 | .root { 2 | background-repeat: no-repeat; 3 | display: inline-block; 4 | overflow: hidden; 5 | cursor: pointer; 6 | } 7 | 8 | .resize { 9 | border: 1px dashed #78a300; 10 | position: relative; 11 | max-width: 100%; 12 | display: inline-block; 13 | line-height: 0; 14 | top: -1px; 15 | left: -1px; 16 | } 17 | 18 | .resizeHandle { 19 | cursor: nwse-resize; 20 | position: absolute; 21 | z-index: 2; 22 | line-height: 1; 23 | bottom: -4px; 24 | right: -5px; 25 | border: 1px solid white; 26 | background-color: #78a300; 27 | width: 8px; 28 | height: 8px; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/ImageSpan.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import autobind from 'class-autobind'; 4 | import cx from 'classnames'; 5 | import React, {Component} from 'react'; 6 | import {Entity} from 'draft-js'; 7 | 8 | import styles from './ImageSpan.css'; 9 | 10 | import type {ContentState} from 'draft-js'; 11 | 12 | // TODO: Use a more specific type here. 13 | type ReactNode = any; 14 | 15 | type Props = { 16 | children: ReactNode; 17 | entityKey: string; 18 | contentState: ContentState, 19 | className?: string; 20 | }; 21 | 22 | type State = { 23 | width: string; 24 | height: string; 25 | }; 26 | 27 | export default class ImageSpan extends Component { 28 | props: Props; 29 | state: State; 30 | 31 | constructor(props: Props) { 32 | super(props); 33 | autobind(this); 34 | const entity = props.contentState.getEntity(props.entityKey); 35 | const {width, height} = entity.getData(); 36 | this._setSize(width, height); 37 | } 38 | 39 | componentDidMount() { 40 | const {width, height} = this.state; 41 | const entity = this.props.contentState.getEntity(this.props.entityKey); 42 | const image = new Image(); 43 | const {src} = entity.getData(); 44 | image.src = src; 45 | image.onload = () => { 46 | if (width == null || height == null) { 47 | // TODO: isMounted? 48 | this._setSize({width: image.width, height: image.height}); 49 | Entity.mergeData( 50 | this.props.entityKey, 51 | { 52 | width: image.width, 53 | height: image.height, 54 | originalWidth: image.width, 55 | originalHeight: image.height, 56 | } 57 | ); 58 | } 59 | }; 60 | } 61 | 62 | render() { 63 | const {width, height} = this.state; 64 | let {className} = this.props; 65 | const entity = this.props.contentState.getEntity(this.props.entityKey); 66 | const {src} = entity.getData(); 67 | 68 | className = cx(className, styles.root); 69 | const imageStyle = { 70 | verticalAlign: 'bottom', 71 | backgroundImage: `url("${src}")`, 72 | backgroundSize: `${width} ${height}`, 73 | lineHeight: `${height}`, 74 | fontSize: `${height}`, 75 | width, 76 | height, 77 | letterSpacing: width, 78 | }; 79 | 80 | return ( 81 | <span 82 | className={className} 83 | style={imageStyle} 84 | onClick={this._onClick} 85 | > 86 | {this.props.children} 87 | </span> 88 | ); 89 | } 90 | 91 | _onClick() { 92 | console.log('image clicked'); 93 | } 94 | 95 | _handleResize(event: Object, data: Object) { 96 | const {width, height} = data.size; 97 | this._setSize(width, height); 98 | Entity.mergeData( 99 | this.props.entityKey, 100 | {width, height} 101 | ); 102 | } 103 | 104 | _setSize(width: string | number, height: string | number) { 105 | if (isFinite(width)) { 106 | width = `${width}px`; 107 | } 108 | if (isFinite(height)) { 109 | height = `${height}px`; 110 | } 111 | this.setState({width, height}); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/ui/InputPopover.css: -------------------------------------------------------------------------------- 1 | .root { 2 | position: absolute; 3 | top: calc(100% + 5px); 4 | left: 0; 5 | width: 260px; 6 | background: none #fdfdfd; 7 | background: linear-gradient(to bottom, #fdfdfd 0%,#f6f7f8 100%); 8 | border: 1px solid #999; 9 | border-radius: 2px; 10 | box-sizing: border-box; 11 | padding: 4px; 12 | } 13 | 14 | .root:before { 15 | content: ""; 16 | display: block; 17 | position: absolute; 18 | width: 0; 19 | height: 0; 20 | top: -10px; 21 | left: 10px; 22 | border: 5px solid transparent; 23 | border-bottom-color: #999; 24 | } 25 | 26 | .root:after { 27 | content: ""; 28 | display: block; 29 | position: absolute; 30 | width: 0; 31 | height: 0; 32 | top: -9px; 33 | left: 10px; 34 | border: 5px solid transparent; 35 | border-bottom-color: #fdfdfd; 36 | } 37 | 38 | .inner { 39 | display: flex; 40 | } 41 | 42 | .input { 43 | display: block; 44 | flex: 1 0 auto; 45 | height: 30px; 46 | background: none white; 47 | border: 1px solid #999; 48 | border-radius: 2px; 49 | box-sizing: border-box; 50 | padding: 2px 6px; 51 | font-family: inherit; 52 | font-size: inherit; 53 | line-height: 24px; 54 | } 55 | 56 | .inner .buttonGroup { 57 | flex: 0 1 auto; 58 | margin-left: 4px; 59 | margin-bottom: 0; 60 | } 61 | 62 | .checkOption { 63 | margin: 8px 2px; 64 | } 65 | 66 | .checkOption input { 67 | margin-right: 8px; 68 | } 69 | 70 | .checkOption input { 71 | cursor: pointer; 72 | } 73 | -------------------------------------------------------------------------------- /src/ui/InputPopover.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, {Component} from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import IconButton from './IconButton'; 5 | import ButtonGroup from './ButtonGroup'; 6 | import autobind from 'class-autobind'; 7 | import cx from 'classnames'; 8 | 9 | import styles from './InputPopover.css'; 10 | 11 | type CheckOptionValues = { 12 | [key: string]: boolean; 13 | }; 14 | 15 | type Props = { 16 | className?: string; 17 | defaultValue?: string; 18 | checkOptions?: { 19 | [key: string]: { label: string, defaultValue: boolean }; 20 | }; 21 | onCancel: () => any; 22 | onSubmit: (value: string, checkOptionValues: CheckOptionValues) => any; 23 | }; 24 | 25 | type State = { 26 | checkOptionValues: CheckOptionValues 27 | }; 28 | 29 | export default class InputPopover extends Component { 30 | props: Props; 31 | state: State; 32 | _inputRef: ?Object; 33 | 34 | constructor() { 35 | super(...arguments); 36 | autobind(this); 37 | let {checkOptions} = this.props; 38 | let checkOptionValues: CheckOptionValues = {}; 39 | if (checkOptions) { 40 | for (let key of Object.keys(checkOptions)) { 41 | let {defaultValue} = checkOptions[key]; 42 | checkOptionValues[key] = defaultValue; 43 | } 44 | } 45 | this.state = { 46 | checkOptionValues, 47 | }; 48 | } 49 | 50 | componentDidMount() { 51 | document.addEventListener('click', this._onDocumentClick, true); 52 | document.addEventListener('keydown', this._onDocumentKeydown); 53 | if (this._inputRef) { 54 | this._inputRef.focus(); 55 | } 56 | } 57 | 58 | componentWillUnmount() { 59 | document.removeEventListener('click', this._onDocumentClick, true); 60 | document.removeEventListener('keydown', this._onDocumentKeydown); 61 | } 62 | 63 | render() { 64 | let {props} = this; 65 | let className = cx(props.className, styles.root); 66 | return ( 67 | <div className={className}> 68 | <div className={styles.inner}> 69 | <input 70 | ref={this._setInputRef} 71 | defaultValue={props.defaultValue} 72 | type="text" 73 | placeholder="https://example.com/" 74 | className={styles.input} 75 | onKeyPress={this._onInputKeyPress} 76 | /> 77 | <ButtonGroup className={styles.buttonGroup}> 78 | <IconButton 79 | label="Cancel" 80 | iconName="cancel" 81 | onClick={props.onCancel} 82 | /> 83 | <IconButton 84 | label="Submit" 85 | iconName="accept" 86 | onClick={this._onSubmit} 87 | /> 88 | </ButtonGroup> 89 | </div> 90 | {this._renderCheckOptions()} 91 | </div> 92 | ); 93 | } 94 | 95 | _renderCheckOptions() { 96 | if (!this.props.checkOptions) { 97 | return null; 98 | } 99 | let {checkOptions} = this.props; 100 | return Object.keys(checkOptions).map((key) => { 101 | let label = checkOptions && checkOptions[key] ? checkOptions[key].label : ''; 102 | return ( 103 | <div key={key} className={styles.checkOption}> 104 | <label> 105 | <input 106 | type="checkbox" 107 | checked={this.state.checkOptionValues[key]} 108 | onChange={() => this._onCheckOptionPress(key)} 109 | /> 110 | <span>{label}</span> 111 | </label> 112 | </div> 113 | ); 114 | }); 115 | } 116 | 117 | _setInputRef(inputElement: Object) { 118 | this._inputRef = inputElement; 119 | } 120 | 121 | _onCheckOptionPress(key: string) { 122 | let {checkOptionValues} = this.state; 123 | let oldValue = Boolean(checkOptionValues[key]); 124 | let newCheckOptionValues = {...checkOptionValues, [key]: !oldValue}; 125 | this.setState({ 126 | checkOptionValues: newCheckOptionValues, 127 | }); 128 | } 129 | 130 | _onInputKeyPress(event: Object) { 131 | if (event.which === 13) { 132 | // Avoid submitting a <form> somewhere up the element tree. 133 | event.preventDefault(); 134 | this._onSubmit(); 135 | } 136 | } 137 | 138 | _onSubmit() { 139 | let value = this._inputRef ? this._inputRef.value : ''; 140 | this.props.onSubmit(value, this.state.checkOptionValues); 141 | } 142 | 143 | _onDocumentClick(event: Object) { 144 | let rootNode = ReactDOM.findDOMNode(this); 145 | if (!rootNode.contains(event.target)) { 146 | // Here we pass the event so the parent can manage focus. 147 | this.props.onCancel(event); 148 | } 149 | } 150 | 151 | _onDocumentKeydown(event: Object) { 152 | if (event.keyCode === 27) { 153 | this.props.onCancel(); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/ui/PopoverIconButton.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React, {Component} from 'react'; 4 | import IconButton from './IconButton'; 5 | import InputPopover from './InputPopover'; 6 | import autobind from 'class-autobind'; 7 | 8 | type Props = { 9 | iconName: string; 10 | showPopover: boolean, 11 | defaultValue?: string, 12 | checkOptions?: { 13 | [key: string]: { label: string, defaultValue: boolean }; 14 | }; 15 | onTogglePopover: Function, 16 | onSubmit: Function; 17 | }; 18 | 19 | export default class PopoverIconButton extends Component { 20 | props: Props; 21 | 22 | constructor() { 23 | super(...arguments); 24 | autobind(this); 25 | } 26 | 27 | render() { 28 | let {onTogglePopover, showPopover, checkOptions, ...props} = this.props; // eslint-disable-line no-unused-vars 29 | return ( 30 | <IconButton {...props} onClick={onTogglePopover}> 31 | {this._renderPopover()} 32 | </IconButton> 33 | ); 34 | } 35 | 36 | _renderPopover() { 37 | if (!this.props.showPopover) { 38 | return null; 39 | } 40 | return ( 41 | <InputPopover 42 | defaultValue={this.props.defaultValue} 43 | checkOptions={this.props.checkOptions} 44 | onSubmit={this._onSubmit} 45 | onCancel={this._hidePopover} 46 | /> 47 | ); 48 | } 49 | 50 | _onSubmit() { 51 | this.props.onSubmit(...arguments); 52 | } 53 | 54 | _hidePopover() { 55 | if (this.props.showPopover) { 56 | this.props.onTogglePopover(...arguments); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | require('css-modules-require-hook')({ 2 | generateScopedName: '[name]__[local]___[hash:base64:5]', 3 | }); 4 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel-core/register 2 | --require ./test/init.js 3 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*eslint-env node */ 2 | var path = require('path'); 3 | var webpack = require('webpack'); 4 | 5 | var rules = [ 6 | { 7 | test: /\.js$/, 8 | use: ['babel-loader'], 9 | exclude: /node_modules/, 10 | }, 11 | { 12 | test: /\.css$/, 13 | exclude: /\.global\.css$/, 14 | use: [ 15 | { 16 | loader: 'style-loader', 17 | options: {sourceMap: true}, 18 | }, 19 | { 20 | loader: 'css-loader', 21 | options: { 22 | modules: true, 23 | importLoaders: true, 24 | localIdentName: '[name]__[local]___[hash:base64:5]', 25 | }, 26 | }, 27 | ], 28 | }, 29 | { 30 | test: /\.global\.css$/, 31 | use: ['style-loader', 'raw-loader'], 32 | }, 33 | ]; 34 | 35 | module.exports = [{ 36 | entry: './src/RichTextEditor.js', 37 | output: { 38 | path: path.join(__dirname, 'dist'), 39 | filename: 'react-rte.js', 40 | libraryTarget: 'commonjs2', 41 | }, 42 | externals: { 43 | react: 'react', 44 | 'react-dom': 'react-dom', 45 | }, 46 | module: { 47 | rules: rules, 48 | }, 49 | plugins: [ 50 | new webpack.DefinePlugin({ 51 | 'process.env': { 52 | NODE_ENV: JSON.stringify('production'), 53 | }, 54 | }), 55 | new webpack.optimize.UglifyJsPlugin(), 56 | new webpack.optimize.ModuleConcatenationPlugin(), 57 | ], 58 | }, { 59 | entry: './src/demo.js', 60 | output: { 61 | path: path.join(__dirname, 'dist'), 62 | publicPath: '/dist/', 63 | filename: 'demo.js', 64 | }, 65 | module: { 66 | rules: rules, 67 | }, 68 | }]; 69 | --------------------------------------------------------------------------------