├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── docs └── examples.js ├── example-dist ├── index.html └── style │ └── rich-text-editor.css ├── example └── index.js ├── index.html ├── package.json ├── src ├── BasicHtmlEditor.js ├── components │ ├── BlockStyleControls.js │ ├── EntityControls.js │ ├── InlineStyleControls.js │ ├── Link.js │ └── StyleButton.js ├── config │ └── tagMaps.js └── utils │ ├── draftRawToHtml.js │ ├── findEntities.js │ ├── htmlToContent.js │ └── processInlineStylesAndEntities.js ├── style └── rich-text-editor.css ├── tests └── unit │ └── utils │ ├── draftRawToHtmlTests.js │ └── processInlineStylesTests.js ├── webpack.config.js ├── webpack.dist.config.js └── webpack.example-dist.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "indent": [ 2, 2, {"SwitchCase": 1} ], 5 | "quotes": 0, 6 | "linebreak-style": [ 2, "unix" ], 7 | "semi": [ 2, "always" ], 8 | "no-console": 0, 9 | "valid-jsdoc": 2, 10 | "react/jsx-uses-react": 2, 11 | "react/jsx-uses-vars": 2, 12 | "react/react-in-jsx-scope": 2, 13 | }, 14 | globals: { 15 | }, 16 | "env": { 17 | "es6": true, 18 | "node": true, 19 | "browser": true, 20 | "mocha": true 21 | }, 22 | "extends": "eslint:recommended", 23 | "ecmaFeatures": { 24 | "jsx": true, 25 | "modules": true, 26 | }, 27 | "plugins": [ 28 | "react" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | lib-compiled 5 | coverage 6 | .cache 7 | *bundle.js 8 | *bundle.js.map 9 | log.txt 10 | .idea 11 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | lib-compiled 5 | coverage 6 | .cache 7 | *bundle.js 8 | *bundle.js.map 9 | log.txt 10 | .idea 11 | example-dist 12 | src -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 David Burrows 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # draft-js-BasicHtmlEditor 2 | Basic HTML editor using draft.js - html in, html out 3 | 4 | Proof of concept currently, not production ready! PR's welcome. 5 | 6 | Extends the Rich example from the Draft repo to accept html as its input format, and return html to an `onChange` handler. 7 | 8 | ### Tag Support 9 | 10 | Block tags: `
` 11 | 12 | Inline tags: `
47 | let badlyNestedTags1 = { 48 | "key": "d21l", 49 | "text": "aaaaaaaaaa", 50 | "type": "unstyled", 51 | "depth": 0, 52 | "inlineStyleRanges": [ 53 | { 54 | "offset": 0, 55 | "length": 8, 56 | "style": "BOLD" 57 | }, 58 | { 59 | "offset": 2, 60 | "length": 8, 61 | "style": "ITALIC" 62 | } 63 | ], 64 | "entityRanges": [] 65 | }; 66 | 67 | let badlyNestedTags2 = { 68 | "key": "d21l", 69 | "text": "Here's some text, it's useful but it's quote long this time", 70 | "type": "unstyled", 71 | "depth": 0, 72 | "inlineStyleRanges": [ 73 | { 74 | "offset": 2, 75 | "length": 20, 76 | "style": "BOLD" 77 | }, 78 | { 79 | "offset": 2, 80 | "length": 2, 81 | "style": "ITALIC" 82 | }, 83 | { 84 | "offset": 7, 85 | "length": 30, 86 | "style": "ITALIC" 87 | }, 88 | { 89 | "offset": 50, 90 | "length": 4, 91 | "style": "BOLD" 92 | } 93 | ], 94 | "entityRanges": [] 95 | }; 96 | 97 | // In this case, the editor generates: 98 | 99 | // The quick brown fox jumps over the lazy dog. 100 | // Instead of: 101 | 102 | // The quick brown fox jumps over the lazy dog. 103 | 104 | 105 | describe('processInlineStylesAndEntities', function () { 106 | 107 | it('should order blocks correctly: test case 1', function() { 108 | let html = processInlineStylesAndEntities(inlineTagMap, {}, {}, overlappingInlineStyles); 109 | 110 | expect(html).to.equal('Here\'s some text, it\'s useful'); 111 | }); 112 | 113 | it('should order blocks correctly: test case 2', function() { 114 | let html = processInlineStylesAndEntities(inlineTagMap, {}, {}, overlappingInlineStyles2); 115 | 116 | expect(html).to.equal(`Here's some text, it's useful`); 117 | }); 118 | 119 | it('should close/open nested inline tags properly: test case 1', function () { 120 | let html = processInlineStylesAndEntities(inlineTagMap, {}, {}, badlyNestedTags1); 121 | 122 | expect(html).to.equal( 123 | `aaaaaaaaaa`); 124 | 125 | }); 126 | 127 | it('should close/open tags properly: test case 2', function () { 128 | let html = processInlineStylesAndEntities(inlineTagMap, {}, {}, badlyNestedTags2); 129 | 130 | expect(html).to.equal( 131 | `Here's some text, it's useful but it's quote long this time`); 132 | 133 | }); 134 | 135 | }); 136 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | devtool: 'eval', 6 | resolve: { 7 | fallback: path.join(__dirname, "node_modules"), 8 | alias: { 9 | "react": __dirname + '/node_modules/react', 10 | "react-dom": __dirname + '/node_modules/react-dom' 11 | } 12 | }, 13 | entry: { 14 | example: [ 15 | 'webpack-dev-server/client?http://localhost:3002', 16 | 'webpack/hot/only-dev-server', 17 | "./example/index.js" 18 | ] 19 | }, 20 | output: { 21 | path: './lib', 22 | filename: "bundle.js", 23 | publicPath: "http://localhost:3002/lib/" 24 | }, 25 | plugins: [ 26 | new webpack.HotModuleReplacementPlugin(), 27 | new webpack.NoErrorsPlugin() 28 | ], 29 | module: { 30 | loaders: [ 31 | { 32 | test: /\.css$/, loader: "style-loader!css-loader" 33 | }, 34 | { 35 | test: /\.scss$/, 36 | loader: "style!css!postcss!sass" 37 | }, 38 | { 39 | test: /\.js$/, 40 | exclude: [ /node_modules/], 41 | loaders: ["react-hot", "babel-loader"] 42 | }, 43 | { 44 | test: /node_modules\/lodash-es\//, 45 | loaders: ["react-hot", "babel-loader"] 46 | } 47 | 48 | ] 49 | }, 50 | devServer: { 51 | port: 3002, 52 | inline: true, 53 | hot: true, 54 | publicPath: "/", 55 | contentBase: "./", 56 | headers: { "Access-Control-Allow-Origin": "*" }, 57 | devtool: 'eval' 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /webpack.dist.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | devtool: false, 6 | entry: { 7 | example: [ 8 | "./src/BasicHtmlEditor.js" 9 | ] 10 | }, 11 | output: { 12 | path: './dist', 13 | filename: "index.js", 14 | libraryTarget: 'umd' 15 | }, 16 | externals: { 17 | 'react-dom': 'react-dom', 18 | 'react': 'react' 19 | }, 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.css$/, loader: "style-loader!css-loader" 24 | }, 25 | { 26 | test: /\.scss$/, 27 | loader: "style!css!postcss!sass" 28 | }, 29 | { 30 | test: /\.js$/, 31 | exclude: [ /node_modules/], 32 | loaders: ["babel-loader"] 33 | } 34 | ] 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /webpack.example-dist.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | devtool: 'eval', 6 | resolve: { 7 | fallback: path.join(__dirname, "node_modules"), 8 | alias: { 9 | "react": __dirname + '/node_modules/react', 10 | "react-dom": __dirname + '/node_modules/react-dom' 11 | } 12 | }, 13 | entry: { 14 | example: [ 15 | "./example/index.js" 16 | ] 17 | }, 18 | output: { 19 | path: './example-dist', 20 | filename: "bundle.js" 21 | }, 22 | module: { 23 | loaders: [ 24 | { 25 | test: /\.css$/, loader: "style-loader!css-loader" 26 | }, 27 | { 28 | test: /\.scss$/, 29 | loader: "style!css!postcss!sass" 30 | }, 31 | { 32 | test: /\.js$/, 33 | exclude: [ /node_modules/], 34 | loaders: ["babel-loader"] 35 | } 36 | ] 37 | } 38 | }; 39 | --------------------------------------------------------------------------------
` 13 | 14 | ### Install 15 | 16 | `$ npm install draft-js-basic-html-editor` 17 | 18 | Note: You'll also need to install `react` and `react-dom` if you don't already have them 19 | 20 | #### Webpack 21 | 22 | The component is built without `react` or `react-dom` so you'll need to make sure that Webpack can resolve copies of both those modules. Either add your projects `node_modules` as fallback path: 23 | 24 | ``` 25 | resolve: { 26 | fallback: path.resolve('./node_modules') 27 | } 28 | 29 | ``` 30 | 31 | or create an alias for just those 2 modules 32 | 33 | ``` 34 | resolve: { 35 | alias: { 36 | 'react': path.resolve('./node_modules/react'), 37 | 'react-dom': path.resolve('./node_modules/react-dom'), 38 | } 39 | ``` 40 | 41 | ### Usage 42 | 43 | ```js 44 | 45 | import BasicHtmlEditor from 'draft-js-basic-html-editor'; 46 | 47 | const MyEditor = () => { 48 | const initialHtml = 'hello, World'; 49 | const onChange = html => console.log('updated', html); 50 | 51 | return ( 52 |57 | ) 58 | } 59 | ``` 60 | 61 | ### Demo 62 | 63 | http://dburrows.github.io/draft-js-basic-html-editor/example-dist/ 64 | 65 | #### Development 66 | 67 | $ npm install 68 | $ webpack-dev-server 69 | 70 | #### To Do 71 | 72 | * Block support ✔️ 73 | * Inline tag support ✔️ 74 | * Handle Lists with more than one element ✔️ 75 | * Tests ✔️ 76 | * Links ✔️ 77 | * Images 78 | * Prod build ✔️ 79 | -------------------------------------------------------------------------------- /docs/examples.js: -------------------------------------------------------------------------------- 1 | let example = { 2 | "entityMap": {}, 3 | "blocks": [ 4 | { 5 | "key": "bhssi", 6 | "text": "This is some content", 7 | "type": "header-one", 8 | "depth": 0, 9 | "inlineStyleRanges": [], 10 | "entityRanges": [] 11 | }, 12 | { 13 | "key": "f0bd4", 14 | "text": "A paragraph", 15 | "type": "unstyled", 16 | "depth": 0, 17 | "inlineStyleRanges": [ 18 | { 19 | "offset": 2, 20 | "length": 9, 21 | "style": "ITALIC" 22 | } 23 | ], 24 | "entityRanges": [] 25 | }, 26 | { 27 | "key": "dfh5i", 28 | "text": "Another para", 29 | "type": "unstyled", 30 | "depth": 0, 31 | "inlineStyleRanges": [ 32 | { 33 | "offset": 8, 34 | "length": 4, 35 | "style": "BOLD" 36 | } 37 | ], 38 | "entityRanges": [] 39 | }, 40 | { 41 | "key": "c9vrl", 42 | "text": "Yes", 43 | "type": "blockquote", 44 | "depth": 0, 45 | "inlineStyleRanges": [ 46 | { 47 | "offset": 0, 48 | "length": 3, 49 | "style": "BOLD" 50 | } 51 | ], 52 | "entityRanges": [] 53 | }, 54 | { 55 | "key": "9dn12", 56 | "text": "a one", 57 | "type": "unordered-list-item", 58 | "depth": 0, 59 | "inlineStyleRanges": [ 60 | { 61 | "offset": 0, 62 | "length": 5, 63 | "style": "BOLD" 64 | } 65 | ], 66 | "entityRanges": [] 67 | }, 68 | { 69 | "key": "5dj7l", 70 | "text": "and a two", 71 | "type": "unordered-list-item", 72 | "depth": 0, 73 | "inlineStyleRanges": [ 74 | { 75 | "offset": 0, 76 | "length": 9, 77 | "style": "BOLD" 78 | } 79 | ], 80 | "entityRanges": [] 81 | } 82 | ] 83 | }; 84 | -------------------------------------------------------------------------------- /example-dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example-dist/style/rich-text-editor.css: -------------------------------------------------------------------------------- 1 | .RichEditor-root { 2 | background: #fff; 3 | border: 1px solid #ddd; 4 | font-family: 'Georgia', serif; 5 | font-size: 14px; 6 | padding: 15px; 7 | } 8 | 9 | .RichEditor-editor { 10 | border-top: 1px solid #ddd; 11 | cursor: text; 12 | font-size: 16px; 13 | margin-top: 10px; 14 | } 15 | 16 | .RichEditor-editor .public-DraftEditorPlaceholder-root { 17 | color: #9197a3; 18 | position: absolute; 19 | z-index: 0; 20 | } 21 | 22 | .RichEditor-editor .public-DraftEditorPlaceholder-root, 23 | .RichEditor-editor .public-DraftEditor-content { 24 | margin: 0 -15px -15px; 25 | padding: 15px; 26 | } 27 | 28 | .RichEditor-editor .public-DraftEditor-content { 29 | min-height: 100px; 30 | } 31 | 32 | .RichEditor-hidePlaceholder .public-DraftEditorPlaceholder-root { 33 | display: none; 34 | } 35 | 36 | .RichEditor-editor .public-DraftStyleDefault-block { 37 | margin: 5px 0; 38 | } 39 | 40 | .RichEditor-editor .RichEditor-blockquote { 41 | border-left: 5px solid #eee; 42 | color: #666; 43 | font-family: 'Hoefler Text', 'Georgia', serif; 44 | font-style: italic; 45 | margin: 16px 0; 46 | padding: 10px 20px; 47 | } 48 | 49 | .RichEditor-editor .public-DraftStyleDefault-pre { 50 | background-color: rgba(0, 0, 0, 0.05); 51 | font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace; 52 | font-size: 16px; 53 | padding: 20px; 54 | } 55 | 56 | .RichEditor-controls { 57 | font-family: 'Helvetica', sans-serif; 58 | font-size: 14px; 59 | margin-bottom: 5px; 60 | user-select: none; 61 | } 62 | 63 | .RichEditor-styleButton { 64 | color: #999; 65 | cursor: pointer; 66 | margin-right: 16px; 67 | padding: 2px 0; 68 | } 69 | 70 | .RichEditor-activeButton { 71 | color: #5890ff; 72 | } 73 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import BasicHtmlEditor from '../src/BasicHtmlEditor'; 5 | 6 | 7 | let html = ` 8 | This is a Title
9 |Here's some text, it's useful
10 |More text, some inline styling for some elements
11 | `; 12 | 13 | class BasicHtmlEditorExample extends React.Component { 14 | constructor(props) { 15 | super(props); 16 | this.state = { 17 | html: props.html 18 | }; 19 | } 20 | 21 | updateHtml(html) { 22 | this.setState({ 23 | html 24 | }); 25 | } 26 | 27 | render() { 28 | return ( 29 |30 |41 | ); 42 | } 43 | 44 | } 45 | 46 | ReactDOM.render( 47 |this.updateHtml(html) } 33 | debounce={ 500 } 34 | /> 35 | 36 |40 |Exported HTML
37 |
38 | 39 |, 48 | document.getElementById('app') 49 | ); 50 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draft-js-basic-html-editor", 3 | "version": "1.0.10", 4 | "description": "", 5 | "keywords": [ 6 | "draftjs", 7 | "editor", 8 | "react", 9 | "html" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/dburrows/draft-js-basic-html-editor.git" 14 | }, 15 | "main": "dist/index.js", 16 | "scripts": { 17 | "dist-example": "webpack --config webpack.example-dist.config.js", 18 | "dist": "webpack --display-modules --config webpack.dist.config.js -p", 19 | "test-watch": "mocha --recursive --compilers js:babel-register -r babel-polyfill -w tests/", 20 | "release": "npm run dist && release-it" 21 | }, 22 | "author": "David Burrows ", 23 | "license": "MIT", 24 | "dependencies": { 25 | "draft-js": "^0.9.1", 26 | "lodash": "^4.6.1" 27 | }, 28 | "peerDependencies": { 29 | "react": "^15", 30 | "react-dom": "^15" 31 | }, 32 | "devDependencies": { 33 | "babel-core": "^6.8.0", 34 | "babel-eslint": "^6.0.4", 35 | "babel-loader": "^6.2.4", 36 | "babel-polyfill": "^6.8.0", 37 | "babel-preset-es2015": "^6.6.0", 38 | "babel-preset-react": "^6.5.0", 39 | "babel-register": "^6.8.0", 40 | "chai": "^3.5.0", 41 | "css-loader": "^0.23.1", 42 | "enzyme": "^2.0.0", 43 | "eslint": "^2.9.0", 44 | "eslint-plugin-react": "^5.0.1", 45 | "estraverse": "^4.2.0", 46 | "estraverse-fb": "^1.3.1", 47 | "mocha": "^2.4.5", 48 | "mocha-loader": "^0.7.1", 49 | "node-sass": "^3.4.2", 50 | "react": "^15", 51 | "react-dom": "^15", 52 | "react-hot-loader": "^1.3.0", 53 | "release-it": "^2.3.1", 54 | "sass-loader": "^3.1.2", 55 | "style-loader": "^0.13.0", 56 | "webpack": "^1.12.14", 57 | "webpack-dev-server": "^1.14.1" 58 | }, 59 | "browserify": { 60 | "transform": [ 61 | [ 62 | "babelify", 63 | { 64 | "presets": [ 65 | "es2015", 66 | "react" 67 | ] 68 | } 69 | ] 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/BasicHtmlEditor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import debounce from 'lodash/debounce'; 4 | import { 5 | Editor, 6 | EditorState, 7 | ContentState, 8 | Entity, 9 | RichUtils, 10 | convertToRaw, 11 | CompositeDecorator, 12 | Modifier 13 | } from 'draft-js'; 14 | 15 | import htmlToContent from './utils/htmlToContent'; 16 | import draftRawToHtml from './utils/draftRawToHtml'; 17 | 18 | import Link from './components/Link'; 19 | import EntityControls from './components/EntityControls'; 20 | import InlineStyleControls from './components/InlineStyleControls'; 21 | import BlockStyleControls from './components/BlockStyleControls'; 22 | import findEntities from './utils/findEntities'; 23 | 24 | export default class BasicHtmlEditor extends React.Component { 25 | constructor(props) { 26 | super(props); 27 | let { value } = props; 28 | 29 | const decorator = new CompositeDecorator([ 30 | { 31 | strategy: findEntities.bind(null, 'link'), 32 | component: Link 33 | } 34 | ]); 35 | 36 | this.ENTITY_CONTROLS = [ 37 | {label: 'Add Link', action: this._addLink.bind(this) }, 38 | {label: 'Remove Link', action: this._removeLink.bind(this) } 39 | ]; 40 | 41 | this.INLINE_STYLES = [ 42 | {label: 'Bold', style: 'BOLD'}, 43 | {label: 'Italic', style: 'ITALIC'}, 44 | {label: 'Underline', style: 'UNDERLINE'}, 45 | {label: 'Monospace', style: 'CODE'}, 46 | {label: 'Strikethrough', style: 'STRIKETHROUGH'} 47 | ]; 48 | 49 | this.BLOCK_TYPES = [ 50 | {label: 'P', style: 'unstyled'}, 51 | {label: 'H1', style: 'header-one'}, 52 | {label: 'H2', style: 'header-two'}, 53 | {label: 'Blockquote', style: 'blockquote'}, 54 | {label: 'UL', style: 'unordered-list-item'}, 55 | {label: 'OL', style: 'ordered-list-item'}, 56 | {label: 'Code Block', style: 'code-block'} 57 | ]; 58 | 59 | this.state = { 60 | editorState: value ? 61 | EditorState.createWithContent( 62 | ContentState.createFromBlockArray(htmlToContent(value)), 63 | decorator 64 | ) : 65 | EditorState.createEmpty(decorator) 66 | }; 67 | 68 | // this.focus = () => this.refs.editor.focus(); 69 | this.onChange = (editorState) => { 70 | let previousContent = this.state.editorState.getCurrentContent(); 71 | this.setState({editorState}); 72 | 73 | // only emit html when content changes 74 | if( previousContent !== editorState.getCurrentContent() ) { 75 | this.emitHTML(editorState); 76 | } 77 | }; 78 | 79 | function emitHTML(editorState) { 80 | let raw = convertToRaw( editorState.getCurrentContent() ); 81 | let html = draftRawToHtml(raw); 82 | this.props.onChange(html); 83 | } 84 | this.emitHTML = debounce(emitHTML, this.props.debounce); 85 | 86 | this.handleKeyCommand = (command) => this._handleKeyCommand(command); 87 | this.toggleBlockType = (type) => this._toggleBlockType(type); 88 | this.toggleInlineStyle = (style) => this._toggleInlineStyle(style); 89 | this.handleReturn = (e) => this._handleReturn(e); 90 | this.addLink = this._addLink.bind(this); 91 | this.removeLink = this._removeLink.bind(this); 92 | } 93 | 94 | _handleKeyCommand(command) { 95 | const {editorState} = this.state; 96 | const newState = RichUtils.handleKeyCommand(editorState, command); 97 | if (newState) { 98 | this.onChange(newState); 99 | return true; 100 | } 101 | return false; 102 | } 103 | 104 | _handleReturn(e) { 105 | if (e.metaKey === true) { 106 | return this._addLineBreak(); 107 | } else { 108 | return false; 109 | } 110 | } 111 | 112 | _toggleBlockType(blockType) { 113 | this.onChange( 114 | RichUtils.toggleBlockType( 115 | this.state.editorState, 116 | blockType 117 | ) 118 | ); 119 | } 120 | 121 | _toggleInlineStyle(inlineStyle) { 122 | this.onChange( 123 | RichUtils.toggleInlineStyle( 124 | this.state.editorState, 125 | inlineStyle 126 | ) 127 | ); 128 | } 129 | 130 | _addLineBreak(/* e */) { 131 | let newContent, newEditorState; 132 | const {editorState} = this.state; 133 | const content = editorState.getCurrentContent(); 134 | const selection = editorState.getSelection(); 135 | const block = content.getBlockForKey(selection.getStartKey()); 136 | 137 | console.log(content.toJS(), selection.toJS(), block.toJS()); 138 | 139 | if (block.type === 'code-block') { 140 | newContent = Modifier.insertText(content, selection, '\n'); 141 | newEditorState = EditorState.push(editorState, newContent, 'add-new-line'); 142 | this.onChange(newEditorState); 143 | return true; 144 | } else { 145 | return false; 146 | } 147 | } 148 | 149 | _addLink(/* e */) { 150 | const {editorState} = this.state; 151 | const selection = editorState.getSelection(); 152 | if (selection.isCollapsed()) { 153 | return; 154 | } 155 | const href = window.prompt('Enter a URL'); 156 | const entityKey = Entity.create('link', 'MUTABLE', {href}); 157 | this.onChange(RichUtils.toggleLink(editorState, selection, entityKey)); 158 | } 159 | 160 | _removeLink(/* e */) { 161 | const {editorState} = this.state; 162 | const selection = editorState.getSelection(); 163 | if (selection.isCollapsed()) { 164 | return; 165 | } 166 | this.onChange( RichUtils.toggleLink(editorState, selection, null)); 167 | } 168 | 169 | render() { 170 | const {editorState} = this.state; 171 | 172 | // If the user changes block type before entering any text, we can 173 | // either style the placeholder or hide it. Let's just hide it now. 174 | let className = 'RichEditor-editor'; 175 | var contentState = editorState.getCurrentContent(); 176 | if (!contentState.hasText()) { 177 | if (contentState.getBlockMap().first().getType() !== 'unstyled') { 178 | className += ' RichEditor-hidePlaceholder'; 179 | } 180 | } 181 | 182 | return ( 183 | 184 |211 | 212 | ); 213 | } 214 | } 215 | 216 | // Custom overrides for "code" style. 217 | const styleMap = { 218 | CODE: { 219 | backgroundColor: 'rgba(0, 0, 0, 0.05)', 220 | fontFamily: '"Inconsolata", "Menlo", "Consolas", monospace', 221 | fontSize: 16, 222 | padding: 2 223 | } 224 | }; 225 | 226 | function getBlockStyle(block) { 227 | switch (block.getType()) { 228 | case 'blockquote': return 'RichEditor-blockquote'; 229 | default: return null; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/components/BlockStyleControls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import StyleButton from './StyleButton'; 4 | 5 | export default function BlockStyleControls(props) { 6 | const {editorState, blockTypes} = props; 7 | const selection = editorState.getSelection(); 8 | const blockType = editorState 9 | .getCurrentContent() 10 | .getBlockForKey(selection.getStartKey()) 11 | .getType(); 12 | 13 | return ( 14 |189 | 194 | 198 | 199 | 210 | 15 | {blockTypes.map((type) => 16 |25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/EntityControls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import StyleButton from './StyleButton'; 4 | 5 | export default function EntityControls(props) { 6 | let { entityControls } = props; 7 | var currentStyle = props.editorState.getCurrentInlineStyle(); 8 | return ( 9 |23 | )} 24 | 10 | {entityControls.map(type => 11 |19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/InlineStyleControls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import StyleButton from './StyleButton'; 3 | 4 | export default function InlineStyleControls(props) { 5 | let { inlineStyles } = props; 6 | var currentStyle = props.editorState.getCurrentInlineStyle(); 7 | 8 | return ( 9 |17 | )} 18 | 10 | {inlineStyles.map(type => 11 |20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Entity } from 'draft-js'; 3 | 4 | export default function Link(props) { 5 | const {href} = Entity.get(props.entityKey).getData(); 6 | return ( 7 | 8 | {props.children} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/StyleButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class StyleButton extends React.Component { 4 | constructor() { 5 | super(); 6 | this.onToggle = (e) => { 7 | e.preventDefault(); 8 | this.props.onToggle(this.props.style); 9 | }; 10 | } 11 | 12 | render() { 13 | let className = 'RichEditor-styleButton'; 14 | if (this.props.active) { 15 | className += ' RichEditor-activeButton'; 16 | } 17 | 18 | return ( 19 | 20 | {this.props.label} 21 | 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/config/tagMaps.js: -------------------------------------------------------------------------------- 1 | export const inlineTagMap = { 2 | 'BOLD': ['',''], 3 | 'ITALIC': ['',''], 4 | 'UNDERLINE': ['',''], 5 | 'CODE': ['18 | )} 19 | ','
'], 6 | 'default': ['',''] 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/draftRawToHtml.js: -------------------------------------------------------------------------------- 1 | import processInlineStylesAndEntities from './processInlineStylesAndEntities'; 2 | 3 | let blockTagMap = { 4 | 'header-one': ['','
\n'], 5 | 'header-two': ['','
\n'], 6 | 'unstyled': ['','
\n'], 7 | 'code-block': ['\n'], 8 | 'blockquote': ['','
','\n'], 9 | 'ordered-list-item': ['- ','
\n'], 10 | 'unordered-list-item': ['- ','
\n'], 11 | 'default': ['','
\n'] 12 | }; 13 | 14 | let inlineTagMap = { 15 | 'BOLD': ['',''], 16 | 'ITALIC': ['',''], 17 | 'UNDERLINE': ['',''], 18 | 'CODE': ['','
'], 19 | 'STRIKETHROUGH': ['', ''], 20 | 'default': ['',''] 21 | }; 22 | 23 | let entityTagMap = { 24 | 'link': ['', ''] 25 | }; 26 | 27 | let nestedTagMap = { 28 | 'ordered-list-item': ['', '
'], 29 | 'unordered-list-item': ['', '
'] 30 | }; 31 | 32 | export default function(raw) { 33 | let html = ''; 34 | let nestLevel = []; 35 | let lastIndex = raw.blocks.length - 1; 36 | 37 | raw.blocks.forEach(function(block, index) { 38 | 39 | // close tag if not consecutive same nested 40 | if (nestLevel.length > 0 && nestLevel[0] !== block.type) { 41 | let type = nestLevel.shift(); 42 | html += nestedTagMap[type][1] + '\n'; 43 | } 44 | 45 | // open tag if nested 46 | if ( nestedTagMap[block.type] && nestLevel[0] !== block.type) { 47 | html += nestedTagMap[block.type][0] + '\n'; 48 | nestLevel.unshift(block.type); 49 | } 50 | 51 | let blockTag = blockTagMap[block.type]; 52 | 53 | html += blockTag ? 54 | blockTag[0] + 55 | processInlineStylesAndEntities(inlineTagMap, entityTagMap, raw.entityMap, block) + 56 | blockTag[1] : 57 | blockTagMap['default'][0] + 58 | processInlineStylesAndEntities(inlineTagMap, block) + 59 | blockTagMap['default'][1]; 60 | 61 | // close any unclosed blocks if we've processed all the blocks 62 | if ( index === lastIndex && nestLevel.length > 0 ) { 63 | while(nestLevel.length > 0 ) { 64 | html += nestedTagMap[ nestLevel.shift() ][1]; 65 | } 66 | } 67 | 68 | }); 69 | return html; 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/findEntities.js: -------------------------------------------------------------------------------- 1 | import { Entity } from 'draft-js'; 2 | 3 | export default function findEntities(entityType, contentBlock, callback) { 4 | contentBlock.findEntityRanges( 5 | (character) => { 6 | const entityKey = character.getEntity(); 7 | return ( 8 | entityKey !== null && 9 | Entity.get(entityKey).getType() === entityType 10 | ); 11 | }, 12 | callback 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/htmlToContent.js: -------------------------------------------------------------------------------- 1 | import DraftPasteProcessor from 'draft-js/lib/DraftPasteProcessor'; 2 | 3 | let { processHTML } = DraftPasteProcessor; 4 | 5 | export default function(html) { 6 | return processHTML(html); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/processInlineStylesAndEntities.js: -------------------------------------------------------------------------------- 1 | import template from 'lodash/template'; 2 | import sortBy from 'lodash/sortBy'; 3 | import last from 'lodash/last'; 4 | import clone from 'lodash/clone'; 5 | import indexOf from 'lodash/indexOf' 6 | 7 | export default function processInlineStylesAndEntities(inlineTagMap, entityTagMap, entityMap, block) { 8 | if (!block.inlineStyleRanges && !block.entityRanges) { 9 | //TODO: optimisation, exit early if length === 0 as well 10 | return block.text; 11 | } 12 | 13 | let html = block.text; 14 | let tagInsertMap = {}; 15 | 16 | /* 17 | * ESCAPE CHARS 18 | */ 19 | 20 | let escapeReplacements = [ ['<', '<'], ['&', '&']]; 21 | 22 | escapeReplacements.forEach((arr) =>{ 23 | for(var i=0; i{ 62 | let newInsertMap = []; 63 | let tags = tagInsertMap[key]; 64 | 65 | if (tagStack.length === 0) { 66 | tags.forEach( tag => { 67 | tagStack.unshift(tag); 68 | }); 69 | } else { 70 | let iterateArray = clone(tags); 71 | let tagsToReopen = []; 72 | 73 | iterateArray.forEach( tag => { 74 | let isCloser = tag.substr(0,2) === ''; 75 | let stackTag = tagStack[0]; 76 | let closeMatch = isTagCloseMatch(stackTag, tag); 77 | 78 | 79 | if (!stackTag || closeMatch) { 80 | tagStack.shift(); 81 | newInsertMap.push(tag); 82 | } else if (isCloser) { 83 | let i = tagStack.indexOf(toOpeningTag(tag)); 84 | let earlyClosers = tagStack.splice(0, i+1); 85 | 86 | //get rid of actual tag 87 | earlyClosers.pop(); 88 | 89 | // close tag, add tag to reopen stack 90 | earlyClosers.forEach( t => { 91 | newInsertMap.push(toClosingTag(t)); 92 | tagsToReopen.push(t); 93 | }); 94 | newInsertMap.push(tag); 95 | } else { 96 | tagStack.unshift(tag); 97 | newInsertMap.push(tag); 98 | } 99 | }); 100 | 101 | // add tags that need re-opening to insert map, then set as new insert mat 102 | tagInsertMap[key] = newInsertMap.concat(tagsToReopen); 103 | } 104 | 105 | }); 106 | 107 | function toOpeningTag(t) { 108 | return t.replace('/', ''); 109 | } 110 | function toClosingTag(t) { 111 | if (!t) { return ''; } 112 | return '' + t.substr(1); 113 | } 114 | 115 | function isTagCloseMatch(opener, closer) { 116 | if (!opener || !closer) { return false; } 117 | return opener.substr(1) === closer.substr(2); 118 | } 119 | 120 | /* 121 | * ENTITY RANGER 122 | */ 123 | 124 | let sortedEntityRanges = sortBy(block.entityRanges, 'offset'); 125 | 126 | sortedEntityRanges.forEach(function(range) { 127 | let entity = entityMap[range.key]; 128 | let tag = entityTagMap[entity.type]; 129 | 130 | let compiledTag0 = template(tag[0])(entity.data); 131 | let compiledTag1 = template(tag[1])(entity.data); 132 | 133 | if (!tagInsertMap[range.offset]) { tagInsertMap[range.offset] = []; } 134 | tagInsertMap[range.offset].push(compiledTag0); 135 | 136 | if (tag[1]) { 137 | 138 | if (!tagInsertMap[range.offset+range.length] ) { tagInsertMap[range.offset+range.length] = []; } 139 | // add closing tags to start of array, otherwise tag nesting will be invalid 140 | tagInsertMap[ range.offset+range.length].unshift(compiledTag1); 141 | } 142 | }); 143 | 144 | /* 145 | * GENERATE OUTPUT 146 | */ 147 | 148 | // sort on position, as we'll need to keep track of offset 149 | let orderedKeys = Object.keys(tagInsertMap).sort(function(a, b) { 150 | a = Number(a); 151 | b = Number(b); 152 | if (a > b) { return 1; } 153 | if (a < b) { return -1; } 154 | return 0; 155 | }); 156 | 157 | // insert tags into string, keep track of offset caused by our text insertions 158 | let offset = 0; 159 | orderedKeys.forEach(function(pos) { 160 | let index = Number(pos); 161 | 162 | tagInsertMap[pos].forEach(function(tag) { 163 | 164 | if (typeof tag === 'string') { 165 | html = html.substr(0, offset+index) + 166 | tag + 167 | html.substr(offset+index); 168 | offset += tag.length; 169 | } else { 170 | let [ length, replacement] = tag; 171 | html = html.substr(0, offset+index) + 172 | replacement + 173 | html.substr(offset+index+length); 174 | offset += (replacement.length - length ); 175 | } 176 | 177 | }); 178 | }); 179 | 180 | return html; 181 | } 182 | -------------------------------------------------------------------------------- /style/rich-text-editor.css: -------------------------------------------------------------------------------- 1 | .RichEditor-root { 2 | background: #fff; 3 | border: 1px solid #ddd; 4 | font-family: 'Georgia', serif; 5 | font-size: 14px; 6 | padding: 15px; 7 | } 8 | 9 | .RichEditor-editor { 10 | border-top: 1px solid #ddd; 11 | cursor: text; 12 | font-size: 16px; 13 | margin-top: 10px; 14 | } 15 | 16 | .RichEditor-editor .public-DraftEditorPlaceholder-root { 17 | color: #9197a3; 18 | position: absolute; 19 | z-index: 0; 20 | } 21 | 22 | .RichEditor-editor .public-DraftEditorPlaceholder-root, 23 | .RichEditor-editor .public-DraftEditor-content { 24 | margin: 0 -15px -15px; 25 | padding: 15px; 26 | } 27 | 28 | .RichEditor-editor .public-DraftEditor-content { 29 | min-height: 100px; 30 | } 31 | 32 | .RichEditor-hidePlaceholder .public-DraftEditorPlaceholder-root { 33 | display: none; 34 | } 35 | 36 | .RichEditor-editor .public-DraftStyleDefault-block { 37 | margin: 5px 0; 38 | } 39 | 40 | .RichEditor-editor .RichEditor-blockquote { 41 | border-left: 5px solid #eee; 42 | color: #666; 43 | font-family: 'Hoefler Text', 'Georgia', serif; 44 | font-style: italic; 45 | margin: 16px 0; 46 | padding: 10px 20px; 47 | } 48 | 49 | .RichEditor-editor .public-DraftStyleDefault-pre { 50 | background-color: rgba(0, 0, 0, 0.05); 51 | font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace; 52 | font-size: 16px; 53 | padding: 20px; 54 | } 55 | 56 | .RichEditor-controls { 57 | font-family: 'Helvetica', sans-serif; 58 | font-size: 14px; 59 | margin-bottom: 5px; 60 | user-select: none; 61 | } 62 | 63 | .RichEditor-styleButton { 64 | color: #999; 65 | cursor: pointer; 66 | margin-right: 16px; 67 | padding: 2px 0; 68 | } 69 | 70 | .RichEditor-activeButton { 71 | color: #5890ff; 72 | } 73 | 74 | 75 | .draftjs-bhe_link { 76 | color: #3b5998; 77 | text-decoration: 'underline'; 78 | } 79 | -------------------------------------------------------------------------------- /tests/unit/utils/draftRawToHtmlTests.js: -------------------------------------------------------------------------------- 1 | import Chai from 'chai'; 2 | const expect = Chai.expect; 3 | import draftRawToHtml from '../../../src/utils/draftRawToHtml'; 4 | 5 | var raw = { 6 | "entityMap": {}, 7 | "blocks": [ 8 | { 9 | "key": "8l6bm", 10 | "text": "This is a Title", 11 | "type": "header-one", 12 | "depth": 0, 13 | "inlineStyleRanges": [], 14 | "entityRanges": [] 15 | }, 16 | { 17 | "key": "4no7m", 18 | "text": "A", 19 | "type": "unstyled", 20 | "depth": 0, 21 | "inlineStyleRanges": [], 22 | "entityRanges": [] 23 | }, 24 | { 25 | "key": "12tpo", 26 | "text": "thing 1", 27 | "type": "unordered-list-item", 28 | "depth": 0, 29 | "inlineStyleRanges": [], 30 | "entityRanges": [] 31 | }, 32 | { 33 | "key": "3cb4g", 34 | "text": "thing 2", 35 | "type": "unordered-list-item", 36 | "depth": 0, 37 | "inlineStyleRanges": [], 38 | "entityRanges": [] 39 | } 40 | ] 41 | }; 42 | 43 | 44 | describe('processInlineStylesAndEntities', function () { 45 | 46 | it('should close open list tags when processed all the blocks', function () { 47 | var html = draftRawToHtml(raw); 48 | 49 | expect(html).to.equal( 50 | ' This is a Title
\n' + 51 | 'A
\n' + 52 | '\n' + 53 | '
' 56 | ); 57 | }); 58 | 59 | }); 60 | -------------------------------------------------------------------------------- /tests/unit/utils/processInlineStylesTests.js: -------------------------------------------------------------------------------- 1 | import Chai from 'chai'; 2 | const expect = Chai.expect; 3 | import processInlineStylesAndEntities from '../../../src/utils/processInlineStylesAndEntities'; 4 | import { inlineTagMap } from '../../../src/config/tagMaps'; 5 | 6 | let overlappingInlineStyles = { 7 | "key": "67tdi", 8 | "text": "Here's some text, it's useful", 9 | "type": "unstyled", 10 | "depth": 0, 11 | "inlineStyleRanges": [ 12 | { 13 | "offset": 12, 14 | "length": 17, 15 | "style": "BOLD" 16 | }, 17 | { 18 | "offset": 23, 19 | "length": 6, 20 | "style": "ITALIC" 21 | } 22 | ], 23 | "entityRanges": [] 24 | }; 25 | 26 | let overlappingInlineStyles2 = { 27 | "key": "d21l", 28 | "text": "Here's some text, it's useful", 29 | "type": "unstyled", 30 | "depth": 0, 31 | "inlineStyleRanges": [ 32 | { 33 | "offset": 18, 34 | "length": 11, 35 | "style": "BOLD" 36 | }, 37 | { 38 | "offset": 7, 39 | "length": 22, 40 | "style": "ITALIC" 41 | } 42 | ], 43 | "entityRanges": [] 44 | }; 45 | 46 | // aaaaaaaaaa- thing 1
\n' + 54 | '- thing 2
\n' + 55 | '