├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── build └── index.html ├── index.js ├── package.json ├── scripts └── tests.sh ├── src ├── main │ ├── app.js │ └── editor │ │ ├── components │ │ ├── Editor.js │ │ ├── action │ │ │ └── ActionBar.js │ │ ├── blocks │ │ │ ├── Atomic.js │ │ │ └── styles.css │ │ ├── controls │ │ │ ├── BlockStyleControls.js │ │ │ ├── ImageControl.js │ │ │ ├── InlineStyleControls.js │ │ │ ├── LinkControl.js │ │ │ ├── StyleButton.js │ │ │ └── styles.css │ │ ├── entities │ │ │ ├── Link.js │ │ │ └── constants │ │ │ │ └── Types.js │ │ ├── media │ │ │ └── Image.js │ │ ├── preview │ │ │ └── Preview.js │ │ └── styles.css │ │ ├── container │ │ ├── align │ │ │ ├── Alignable.js │ │ │ └── styles.css │ │ └── resize │ │ │ ├── Resizable.js │ │ │ └── styles.css │ │ ├── decorators │ │ └── LinkDecorator.js │ │ ├── modifier │ │ ├── Modifier.js │ │ ├── copyBlock.js │ │ ├── moveBlock.js │ │ ├── removeBlock.js │ │ └── utils │ │ │ ├── AtomicBlockUtils.js │ │ │ ├── BlockUtils.js │ │ │ └── SelectionUtils.js │ │ ├── styles │ │ └── editor.global.css │ │ └── utils │ │ └── Utils.js └── test │ ├── .eslintrc │ ├── editor │ ├── components │ │ └── action │ │ │ └── ActionBar.spec.js │ └── container │ │ └── align │ │ └── Alignable.spec.js │ ├── setup.js │ └── utils │ ├── TestUtils.js │ ├── mountWithMuiContext.js │ └── simulateTouchTap.js ├── webpack.config.build.js ├── webpack.config.dev.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["react-hot-loader/babel"], 3 | "presets": [ 4 | "es2015", 5 | "react" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config*.js 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": "airbnb", 6 | "rules": { 7 | "indent": ["error", 4, {"SwitchCase": 1}], 8 | "comma-dangle": ["error", "never"], 9 | "prefer-template": "warn", 10 | "react/prefer-stateless-function": "warn", 11 | "react/jsx-indent": ["error", 4], 12 | "no-underscore-dangle": ["error", { "allowAfterThis": true }], 13 | "semi": ["error", "always"], 14 | "react/jsx-filename-extension": ["error", { "extensions": [".js", ".jsx"] }], 15 | "react/no-string-refs": "off", 16 | "react/no-find-dom-node": "warn", 17 | "import/prefer-default-export": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | build/assets 4 | .tags 5 | dist 6 | lib 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 FrederikS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | http://frederiks.github.io/richie/ 4 | 5 | ## Usage 6 | 7 | `npm install richie --save` 8 | 9 | 10 | ``` 11 | import React from 'react'; 12 | import ReactDOM from 'react-dom'; 13 | import { Editor } from 'richie'; 14 | import getMuiTheme from 'material-ui/styles/getMuiTheme'; 15 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; 16 | 17 | const handleImageFile = (file, callback) => { 18 | const reader = new FileReader(); 19 | reader.onload = (e) => { 20 | callback(e.target.result); 21 | }; 22 | reader.readAsDataURL(file); 23 | }; 24 | 25 | ReactDOM.render( 26 | 27 | 28 | , 29 | document.getElementById('editor') 30 | ); 31 | 32 | ``` 33 | 34 | ## Requirements 35 | 36 | You need to define a `handleImageFile` callback and pass it through the related `Editor` Component property. The example above handles selected image files with returning a Data-URL. But in other cases you may like to upload them to a server and reference the url. The returned url is used as src attribute of the img tag. 37 | 38 | ## API 39 | 40 | For using the editors output you can pass an `onChange` callback to the `Editor` Component. The callback gets passed in the [`RawDraftContentState`](https://facebook.github.io/draft-js/docs/api-reference-data-conversion.html#converttoraw) as parameter can be used for the `Preview` Component of the richie library. 41 | 42 | ``` 43 | import React from 'react'; 44 | import ReactDOM from 'react-dom'; 45 | import { Editor, Preview } from 'richie'; 46 | 47 | const renderOutput = (rawContent) => { 48 | ReactDOM.render( 49 | , 50 | document.getElementById('output') 51 | ); 52 | }; 53 | 54 | ReactDOM.render( 55 | , 56 | document.getElementById('editor') 57 | ); 58 | ``` 59 | 60 | ## Peer-Dependencies 61 | 62 | `npm install react react-dom material-ui --save` 63 | -------------------------------------------------------------------------------- /build/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Richie 6 | 7 | 8 |
9 |
10 |
11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import Editor from './src/main/editor/components/Editor'; 2 | import Preview from './src/main/editor/components/preview/Preview'; 3 | 4 | export { Editor, Preview }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "richie", 3 | "version": "0.0.17", 4 | "author": "Frederik Steffen ", 5 | "description": "Rich-Text-Editor implemented with draft.js", 6 | "main": "dist/Richie.js", 7 | "files": [ 8 | "dist/", 9 | "LICENSE" 10 | ], 11 | "keywords": [ 12 | "draftjs", 13 | "draft-js", 14 | "editor", 15 | "react", 16 | "richtext" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/FrederikS/richie.git" 21 | }, 22 | "license": "MIT", 23 | "scripts": { 24 | "start": "node_modules/.bin/webpack-dev-server --config webpack.config.dev.js --inline --hot", 25 | "lint": "node_modules/.bin/eslint index.js src", 26 | "build:dist": "node_modules/.bin/webpack --config webpack.config.build.js", 27 | "build": "npm test && npm run clean && npm run build:dist && npm run deploy", 28 | "prepublish": "npm run build", 29 | "clean": "rm -rf dist", 30 | "build:demo": "node_modules/.bin/webpack", 31 | "deploy": "npm run build:demo && node_modules/.bin/gh-pages -d build", 32 | "test": "scripts/tests.sh" 33 | }, 34 | "peerDependencies": { 35 | "material-ui": "^0.16.4", 36 | "react": "^15.1.0", 37 | "react-dom": "^15.1.0" 38 | }, 39 | "dependencies": { 40 | "classnames": "^2.2.5", 41 | "draft-js": "^0.9.1", 42 | "immutable": "^3.8.1", 43 | "interact.js": "^1.2.6", 44 | "react-tap-event-plugin": "^2.0.1" 45 | }, 46 | "devDependencies": { 47 | "babel-cli": "^6.7.7", 48 | "babel-eslint": "^7.1.1", 49 | "babel-loader": "^6.2.4", 50 | "babel-plugin-add-module-exports": "^0.2.1", 51 | "babel-polyfill": "^6.9.1", 52 | "babel-preset-es2015": "^6.6.0", 53 | "babel-preset-react": "^6.5.0", 54 | "chai": "^3.5.0", 55 | "chai-enzyme": "^0.6.1", 56 | "css-loader": "^0.26.0", 57 | "css-modules-require-hook": "^4.0.5", 58 | "enzyme": "^2.3.0", 59 | "eslint": "^3.2.2", 60 | "eslint-config-airbnb": "^13.0.0", 61 | "eslint-loader": "^1.3.0", 62 | "eslint-plugin-import": "^2.2.0", 63 | "eslint-plugin-jsx-a11y": "^2.2.3", 64 | "eslint-plugin-mocha": "^4.3.0", 65 | "eslint-plugin-react": "^6.0.0", 66 | "gh-pages": "^0.12.0", 67 | "jsdom": "^9.8.3", 68 | "material-ui": "^0.16.4", 69 | "mocha": "^3.0.0", 70 | "react": "^15.1.0", 71 | "react-addons-test-utils": "^15.1.0", 72 | "react-dom": "^15.1.0", 73 | "react-hot-loader": "^3.0.0-beta.6", 74 | "sinon": "^1.17.4", 75 | "sinon-chai": "^2.8.0", 76 | "style-loader": "^0.13.1", 77 | "webpack": "^1.13.0", 78 | "webpack-dev-server": "^1.14.1" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | node_modules/.bin/mocha \ 4 | --compilers js:node_modules/babel-register,js:./src/test/setup.js \ 5 | --recursive \ 6 | './src/test/**/*.spec.js' 7 | -------------------------------------------------------------------------------- /src/main/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | import ReactDOM from 'react-dom'; 3 | import React from 'react'; 4 | import getMuiTheme from 'material-ui/styles/getMuiTheme'; 5 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; 6 | import injectTapEventPlugin from 'react-tap-event-plugin'; 7 | import { Editor, Preview } from '../../index'; 8 | 9 | injectTapEventPlugin(); 10 | 11 | const handleImageFile = (file, callback) => { 12 | const reader = new FileReader(); 13 | reader.onload = (e) => { 14 | callback(e.target.result); 15 | }; 16 | reader.readAsDataURL(file); 17 | }; 18 | 19 | const renderOutput = (rawContent) => { 20 | ReactDOM.render(, document.getElementById('output')); 21 | }; 22 | 23 | ReactDOM.render(( 24 | 25 | 26 | 27 | ), document.getElementById('editor')); 28 | -------------------------------------------------------------------------------- /src/main/editor/components/Editor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Editor, 4 | EditorState, 5 | RichUtils, 6 | AtomicBlockUtils, 7 | convertToRaw, 8 | CompositeDecorator 9 | } from 'draft-js'; 10 | import InlineStyleControls from '../components/controls/InlineStyleControls'; 11 | import styles from './styles.css'; 12 | import '../styles/editor.global.css'; 13 | import LinkControl from './controls/LinkControl'; 14 | import ImageControl from './controls/ImageControl'; 15 | import LinkDecorator from '../decorators/LinkDecorator'; 16 | import Atomic from './blocks/Atomic'; 17 | import BlockStyleControls, { getBlockStyle } from '../components/controls/BlockStyleControls'; 18 | import { moveBlock } from '../modifier/Modifier'; 19 | 20 | class MyEditor extends React.Component { 21 | constructor(props) { 22 | super(props); 23 | this.focus = () => this.refs.editor.focus(); 24 | this.onChange = editorState => this._onChange(editorState); 25 | this.handleKeyCommand = command => this._handleKeyCommand(command); 26 | this.toggleBlockType = type => this._toggleBlockType(type); 27 | this.toggleInlineStyle = style => this._toggleInlineStyle(style); 28 | this.toggleLink = linkEntity => this._toggleLink(linkEntity); 29 | this.addImage = imageEntity => this._addImage(imageEntity); 30 | this.handleDrop = (selectionState, dataTransfer) => this._handleDrop( 31 | selectionState, 32 | dataTransfer 33 | ); 34 | this.getBlockRenderer = block => this._getBlockRenderer(block); 35 | 36 | this.state = { 37 | editorState: EditorState.createEmpty(new CompositeDecorator([LinkDecorator])) 38 | }; 39 | } 40 | 41 | // eslint-disable-next-line class-methods-use-this 42 | _getBlockRenderer(block) { 43 | switch (block.getType()) { 44 | case 'atomic': 45 | return { 46 | component: Atomic, 47 | editable: false, 48 | props: { 49 | editable: true 50 | } 51 | }; 52 | default: 53 | return null; 54 | } 55 | } 56 | 57 | _onChange(editorState) { 58 | this.props.onChange(convertToRaw(editorState.getCurrentContent())); 59 | this.setState({ editorState }); 60 | } 61 | 62 | _handleKeyCommand(command) { 63 | const { editorState } = this.state; 64 | const newState = RichUtils.handleKeyCommand(editorState, command); 65 | if (newState) { 66 | this.onChange(newState); 67 | return true; 68 | } 69 | return false; 70 | } 71 | 72 | _toggleBlockType(blockType) { 73 | this.onChange(RichUtils.toggleBlockType( 74 | this.state.editorState, 75 | blockType 76 | )); 77 | } 78 | 79 | _toggleInlineStyle(inlineStyle) { 80 | this.onChange(RichUtils.toggleInlineStyle( 81 | this.state.editorState, 82 | inlineStyle 83 | )); 84 | } 85 | 86 | _toggleLink(linkEntity) { 87 | const { editorState } = this.state; 88 | this.onChange(RichUtils.toggleLink( 89 | editorState, 90 | editorState.getSelection(), 91 | linkEntity 92 | )); 93 | } 94 | 95 | _addImage(imageEntity) { 96 | this.onChange(AtomicBlockUtils.insertAtomicBlock( 97 | this.state.editorState, 98 | imageEntity, 99 | ' ' 100 | )); 101 | } 102 | 103 | _handleDrop(dropSelection, dataTransfer) { 104 | const { editorState } = this.state; 105 | const blockKey = dataTransfer.data.getData('block-key'); 106 | const contentWithMovedBlock = moveBlock( 107 | editorState.getCurrentContent(), 108 | dropSelection, 109 | blockKey 110 | ); 111 | this.onChange(EditorState.push(editorState, contentWithMovedBlock, 'insert-fragment')); 112 | return true; 113 | } 114 | 115 | render() { 116 | const { editorState } = this.state; 117 | const selection = editorState.getSelection(); 118 | const currentBlockType = editorState 119 | .getCurrentContent() 120 | .getBlockForKey(selection.getStartKey()) 121 | .getType(); 122 | const { handleImageFile } = this.props; 123 | return ( 124 |
125 | 129 | 133 | 134 | 135 | {/* eslint-disable jsx-a11y/no-static-element-interactions */} 136 |
e.preventDefault()} 140 | > 141 | 151 |
152 | {/* eslint-enable jsx-a11y/no-static-element-interactions */} 153 |
154 | ); 155 | } 156 | } 157 | 158 | MyEditor.propTypes = { 159 | handleImageFile: React.PropTypes.func.isRequired, 160 | onChange: React.PropTypes.func 161 | }; 162 | 163 | MyEditor.defaultProps = { 164 | onChange: () => {} 165 | }; 166 | 167 | export default MyEditor; 168 | -------------------------------------------------------------------------------- /src/main/editor/components/action/ActionBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Tabs, Tab } from 'material-ui/Tabs'; 3 | 4 | const ActionBar = (props) => { 5 | const { onLeftClicked, onCenterClicked, onRightClicked, selectedIndex } = props; 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | ActionBar.propTypes = { 16 | className: React.PropTypes.string, 17 | onLeftClicked: React.PropTypes.func.isRequired, 18 | onCenterClicked: React.PropTypes.func.isRequired, 19 | onRightClicked: React.PropTypes.func.isRequired, 20 | selectedIndex: React.PropTypes.number 21 | }; 22 | 23 | export default ActionBar; 24 | -------------------------------------------------------------------------------- /src/main/editor/components/blocks/Atomic.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Entity } from 'draft-js'; 4 | import Image from '../../components/media/Image'; 5 | import Types from '../../components/entities/constants/Types'; 6 | import Resizable from '../../container/resize/Resizable'; 7 | import Alignable from '../../container/align/Alignable'; 8 | import { valuesAsArray } from '../../utils/Utils'; 9 | import styles from './styles.css'; 10 | 11 | class AtomicBlock extends React.Component { 12 | 13 | constructor(props) { 14 | super(props); 15 | this.onResize = (width, height) => this._onResize(width, height); 16 | this.onAlign = alignment => this._onAlign(alignment); 17 | this.onDragStart = e => this._onDragStart(e); 18 | } 19 | 20 | componentDidMount() { 21 | this._updateAlignment(); 22 | } 23 | 24 | componentDidUpdate() { 25 | this._updateAlignment(); 26 | } 27 | 28 | _updateAlignment() { 29 | const parentNode = ReactDOM.findDOMNode(this).parentNode; 30 | valuesAsArray(styles).forEach(className => parentNode.classList.remove(className)); 31 | const entity = Entity.get(this.props.block.getEntityAt(0)); 32 | parentNode.classList.add(styles[entity.getData().alignment]); 33 | } 34 | 35 | _onResize(width, height) { 36 | const { editable } = this.props.blockProps; 37 | if (editable) { 38 | const entityKey = this.props.block.getEntityAt(0); 39 | Entity.mergeData(entityKey, { width, height }); 40 | } 41 | } 42 | 43 | _onAlign(alignment) { 44 | const { editable } = this.props.blockProps; 45 | if (editable) { 46 | const entityKey = this.props.block.getEntityAt(0); 47 | Entity.mergeData(entityKey, { alignment }); 48 | } 49 | } 50 | 51 | _onDragStart(e) { 52 | e.dataTransfer.effectAllowed = 'move'; // eslint-disable-line no-param-reassign 53 | e.dataTransfer.dropEffect = 'move'; // eslint-disable-line no-param-reassign 54 | e.dataTransfer.setData('block-key', this.props.block.key); 55 | } 56 | 57 | _renderComponent(Component, entityData) { 58 | const { editable } = this.props.blockProps; 59 | return editable ? ( 60 | 61 | 62 | {Component} 63 | 64 | 65 | ) : Component; 66 | } 67 | 68 | render() { 69 | const { editable } = this.props.blockProps; 70 | const entity = Entity.get(this.props.block.getEntityAt(0)); 71 | const type = entity.getType(); 72 | switch (type) { 73 | case Types.IMAGE: { 74 | const { src, title, width, height } = entity.getData(); 75 | return this._renderComponent( 76 | 84 | , entity.getData()); 85 | } 86 | default: 87 | return ''; 88 | } 89 | } 90 | } 91 | 92 | AtomicBlock.propTypes = { 93 | /* eslint-disable react/forbid-prop-types */ 94 | block: React.PropTypes.object.isRequired, 95 | blockProps: React.PropTypes.object.isRequired 96 | /* eslint-enable react/forbid-prop-types */ 97 | }; 98 | 99 | export default AtomicBlock; 100 | -------------------------------------------------------------------------------- /src/main/editor/components/blocks/styles.css: -------------------------------------------------------------------------------- 1 | .left { 2 | text-align: left; 3 | float: left; 4 | } 5 | 6 | .right { 7 | float: right; 8 | text-align: right; 9 | } 10 | 11 | .center { 12 | text-align: center; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/editor/components/controls/BlockStyleControls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import StyleButton from './StyleButton'; 3 | import styles from './styles.css'; 4 | 5 | const BLOCK_TYPES = [ 6 | { label: 'H1', style: 'header-one' }, 7 | { label: 'H2', style: 'header-two' }, 8 | { label: 'H3', style: 'header-three' }, 9 | { label: 'Blockquote', style: 'blockquote' }, 10 | { label: 'UL', style: 'unordered-list-item' }, 11 | { label: 'OL', style: 'ordered-list-item' }, 12 | { label: 'Code Block', style: 'code-block' } 13 | ]; 14 | 15 | export function getBlockStyle(block) { 16 | switch (block.getType()) { 17 | case 'blockquote': 18 | return styles.blockquote; 19 | case 'code-block': 20 | return styles.code; 21 | case 'atomic': 22 | return styles.atomic; 23 | default: 24 | return null; 25 | } 26 | } 27 | 28 | const BlockStyleControls = (props) => { 29 | const { currentType } = props; 30 | return ( 31 |
32 | {BLOCK_TYPES.map(type => 33 | 40 | )} 41 |
42 | ); 43 | }; 44 | 45 | BlockStyleControls.propTypes = { 46 | currentType: React.PropTypes.string.isRequired, 47 | onToggle: React.PropTypes.func 48 | }; 49 | 50 | export default BlockStyleControls; 51 | -------------------------------------------------------------------------------- /src/main/editor/components/controls/ImageControl.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Entity } from 'draft-js'; 3 | import IconButton from 'material-ui/IconButton'; 4 | import FlatButton from 'material-ui/FlatButton'; 5 | import PhotoIcon from 'material-ui/svg-icons/editor/insert-photo'; 6 | import UploadIcon from 'material-ui/svg-icons/file/file-upload'; 7 | import Dialog from 'material-ui/Dialog'; 8 | import TextField from 'material-ui/TextField'; 9 | import styles from './styles.css'; 10 | import Types from '../../components/entities/constants/Types'; 11 | 12 | class ImageControl extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | open: false, 17 | src: '', 18 | title: '', 19 | fileName: '' 20 | }; 21 | 22 | this.handleOpen = () => this._handleOpen(); 23 | this.setTitle = e => this._setTitle(e); 24 | this.handleFiles = e => this._handleFiles(e); 25 | this.insertImage = () => this._insertImage(); 26 | this.handleClose = () => this._handleClose(); 27 | this.confirmTitle = e => this._confirmTitle(e); 28 | this.openFileDialog = () => this._openFileDialog(); 29 | } 30 | 31 | _handleOpen() { 32 | this.setState({ 33 | open: true 34 | }); 35 | } 36 | 37 | _handleClose() { 38 | this.setState({ 39 | open: false 40 | }); 41 | } 42 | 43 | _setTitle(e) { 44 | this.setState({ 45 | title: e.target.value 46 | }); 47 | } 48 | 49 | _handleFiles(e) { 50 | const imageFile = e.target.files[0]; 51 | this.props.handleImageFile(imageFile, (src) => { 52 | this.setState({ 53 | src, 54 | fileName: imageFile.name 55 | }, () => this.refs['img-title'].focus()); 56 | }); 57 | } 58 | 59 | _insertImage() { 60 | const { src, title } = this.state; 61 | const entityKey = Entity.create(Types.IMAGE, 'IMMUTABLE', { 62 | src, 63 | title, 64 | alignment: 'center' 65 | }); 66 | this.props.onImageAdd(entityKey); 67 | this.handleClose(); 68 | } 69 | 70 | _confirmTitle(e) { 71 | if (e.keyCode === 13) { 72 | this.insertImage(); 73 | } 74 | } 75 | 76 | _openFileDialog() { 77 | this.refs['image-file-input'].click(); 78 | } 79 | 80 | render() { 81 | const actions = [ 82 | , 87 | 93 | ]; 94 | return ( 95 | 96 | 101 | 102 | 103 | 110 | 118 |
119 | 126 | } 130 | onTouchTap={this.openFileDialog} 131 | /> 132 |
133 | 141 |
142 |
143 | ); 144 | } 145 | } 146 | 147 | ImageControl.propTypes = { 148 | onImageAdd: React.PropTypes.func, 149 | handleImageFile: React.PropTypes.func.isRequired 150 | }; 151 | 152 | ImageControl.defaultProps = { 153 | onImageAdd: () => {} 154 | }; 155 | 156 | export default ImageControl; 157 | -------------------------------------------------------------------------------- /src/main/editor/components/controls/InlineStyleControls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import StyleButton from './StyleButton'; 3 | import styles from './styles.css'; 4 | 5 | const INLINE_STYLES = [ 6 | { label: 'Bold', style: 'BOLD' }, 7 | { label: 'Italic', style: 'ITALIC' }, 8 | { label: 'Underline', style: 'UNDERLINE' }, 9 | { label: 'Monospace', style: 'CODE' } 10 | ]; 11 | 12 | const InlineStyleControls = (props) => { 13 | const { currentStyle } = props; 14 | return ( 15 |
16 | {INLINE_STYLES.map(type => 17 | 24 | )} 25 |
26 | ); 27 | }; 28 | 29 | InlineStyleControls.propTypes = { 30 | // eslint-disable-next-line react/forbid-prop-types 31 | currentStyle: React.PropTypes.object.isRequired, 32 | onToggle: React.PropTypes.func 33 | }; 34 | 35 | export default InlineStyleControls; 36 | -------------------------------------------------------------------------------- /src/main/editor/components/controls/LinkControl.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Entity } from 'draft-js'; 3 | import IconButton from 'material-ui/IconButton'; 4 | import LinkIcon from 'material-ui/svg-icons/content/link'; 5 | import Dialog from 'material-ui/Dialog'; 6 | import TextField from 'material-ui/TextField'; 7 | import FlatButton from 'material-ui/FlatButton'; 8 | import Snackbar from 'material-ui/Snackbar'; 9 | import { findLinkEntities } from '../../decorators/LinkDecorator'; 10 | import Types from '../../components/entities/constants/Types'; 11 | 12 | function findLinkEntityRangesIn(blockContent) { 13 | const entityRanges = []; 14 | findLinkEntities(blockContent, (start, end) => { 15 | entityRanges.push({ start, end }); 16 | }); 17 | return entityRanges; 18 | } 19 | 20 | function matchesExactly(selectionState, entityRange) { 21 | return selectionState.getStartOffset() === entityRange.start && 22 | selectionState.getEndOffset() === entityRange.end; 23 | } 24 | 25 | class LinkControl extends React.Component { 26 | constructor(props) { 27 | super(props); 28 | this.state = { 29 | open: false, 30 | url: '', 31 | showLinkError: false 32 | }; 33 | this.toggleLink = () => this._toggleLink(); 34 | this.setUrl = e => this._setUrl(e); 35 | this.handleOpen = () => this._handleOpen(); 36 | this.handleClose = () => this._handleClose(); 37 | this.confirmLink = e => this._confirmLink(e); 38 | this.closeLinkError = () => this._closeLinkError(); 39 | } 40 | 41 | _toggleLink() { 42 | const { url } = this.state; 43 | const entityKey = url ? Entity.create(Types.LINK, 'MUTABLE', { href: url }) : null; 44 | this.props.onToggle(entityKey); 45 | this.handleClose(); 46 | } 47 | 48 | _confirmLink(e) { 49 | if (e.keyCode === 13) { 50 | this.toggleLink(); 51 | } 52 | } 53 | 54 | _setUrl(e) { 55 | this.setState({ 56 | url: e.target.value 57 | }); 58 | } 59 | 60 | _handleClose() { 61 | this.setState({ 62 | open: false 63 | }); 64 | } 65 | 66 | _handleOpen() { 67 | if (!this.props.editorState.getSelection().isCollapsed()) { 68 | this.setState({ 69 | open: true, 70 | url: this._getCurrentUrl() 71 | }, () => { 72 | this.refs['url-input'].focus(); 73 | }); 74 | } else { 75 | this.setState({ 76 | showLinkError: true 77 | }); 78 | } 79 | } 80 | 81 | _closeLinkError() { 82 | this.setState({ 83 | showLinkError: false 84 | }); 85 | } 86 | 87 | _getCurrentUrl() { 88 | let url; 89 | const { editorState } = this.props; 90 | const selection = editorState.getSelection(); 91 | const blockContent = editorState.getCurrentContent() 92 | .getBlockForKey(selection.getAnchorKey()); 93 | const linkEntityRanges = findLinkEntityRangesIn(blockContent); 94 | if (linkEntityRanges.length === 1 && matchesExactly(selection, linkEntityRanges[0])) { 95 | const entityKey = blockContent.getEntityAt(0); 96 | const entity = entityKey ? Entity.get(entityKey) : null; 97 | url = entity && entity.getType() === Types.LINK ? entity.getData().href : ''; 98 | } else { 99 | url = ''; 100 | } 101 | return url; 102 | } 103 | 104 | render() { 105 | const actions = [ 106 | , 111 | 117 | ]; 118 | return ( 119 | 120 | 125 | 126 | 127 | 134 | 142 | 143 | 149 | 150 | ); 151 | } 152 | } 153 | 154 | LinkControl.propTypes = { 155 | // eslint-disable-next-line react/forbid-prop-types 156 | editorState: React.PropTypes.object.isRequired, 157 | onToggle: React.PropTypes.func 158 | }; 159 | 160 | LinkControl.defaultProps = { 161 | onToggle: () => {} 162 | }; 163 | 164 | export default LinkControl; 165 | -------------------------------------------------------------------------------- /src/main/editor/components/controls/StyleButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import styles from './styles.css'; 4 | 5 | const classNamesWithStyles = classNames.bind(styles); 6 | 7 | class StyleButton extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.onToggle = (e) => { 11 | e.preventDefault(); 12 | this.props.onToggle(this.props.style); 13 | }; 14 | } 15 | 16 | render() { 17 | const className = classNamesWithStyles(styles.styleButton, { 18 | activeButton: this.props.active 19 | }); 20 | return ( 21 | 22 | {this.props.label} 23 | 24 | ); 25 | } 26 | } 27 | 28 | StyleButton.propTypes = { 29 | active: React.PropTypes.bool, 30 | label: React.PropTypes.string, 31 | onToggle: React.PropTypes.func, 32 | style: React.PropTypes.string.isRequired 33 | }; 34 | 35 | StyleButton.defaultProps = { 36 | active: false, 37 | onToggle: () => {} 38 | }; 39 | 40 | export default StyleButton; 41 | -------------------------------------------------------------------------------- /src/main/editor/components/controls/styles.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | font-size: 14px; 3 | margin-bottom: 5px; 4 | user-select: none; 5 | } 6 | 7 | .style-button { 8 | color: #999; 9 | cursor: pointer; 10 | margin-right: 16px; 11 | padding: 2px 0; 12 | display: inline-block; 13 | } 14 | 15 | .active-button { 16 | color: #5890ff; 17 | } 18 | 19 | .blockquote { 20 | border-left: 5px solid #eee; 21 | color: #666; 22 | font-family: 'Hoefler Text', 'Georgia', serif; 23 | font-style: italic; 24 | padding: 15px 15px 15px 15px; 25 | margin-bottom: 0; 26 | } 27 | 28 | .blockquote + .blockquote { 29 | margin-top: 0; 30 | padding-top: 0; 31 | } 32 | 33 | .blockquote + *:not(.blockquote) { 34 | margin-top: 16px; 35 | } 36 | 37 | .code { 38 | background-color: rgba(0, 0, 0, 0.05); 39 | font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace; 40 | font-size: 16px; 41 | padding: 15px; 42 | margin: 0; 43 | } 44 | 45 | .code + .code { 46 | padding-top: 2.5px; 47 | padding-bottom: 2.5px; 48 | } 49 | 50 | pre .code:first-child { 51 | padding-bottom: 2.5px; 52 | } 53 | 54 | pre .code:last-child { 55 | padding-bottom: 15px; 56 | } 57 | 58 | .file-input { 59 | display: none; 60 | opacity: 0; 61 | } 62 | 63 | .row { 64 | display: flex 65 | } 66 | 67 | .grow-item { 68 | flex: 1 1 auto; 69 | } 70 | 71 | .atomic { 72 | cursor: initial; 73 | } 74 | -------------------------------------------------------------------------------- /src/main/editor/components/entities/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Entity } from 'draft-js'; 3 | 4 | const Link = (props) => { 5 | const { entityKey, children } = props; 6 | const { href } = Entity.get(entityKey).getData(); 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | 14 | Link.propTypes = { 15 | entityKey: React.PropTypes.string.isRequired, 16 | children: React.PropTypes.node.isRequired 17 | }; 18 | 19 | export default Link; 20 | -------------------------------------------------------------------------------- /src/main/editor/components/entities/constants/Types.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | IMAGE: 'IMAGE', 3 | LINK: 'LINK' 4 | }); 5 | -------------------------------------------------------------------------------- /src/main/editor/components/media/Image.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Image = props => {props.title}; 4 | 5 | /* eslint-disable react/no-unused-prop-types */ 6 | Image.propTypes = { 7 | src: React.PropTypes.string.isRequired, 8 | title: React.PropTypes.string, 9 | width: React.PropTypes.number, 10 | height: React.PropTypes.number, 11 | draggable: React.PropTypes.bool 12 | }; 13 | 14 | Image.defaultProps = { 15 | draggable: true 16 | }; 17 | 18 | export default Image; 19 | -------------------------------------------------------------------------------- /src/main/editor/components/preview/Preview.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Editor, EditorState, convertFromRaw, CompositeDecorator } from 'draft-js'; 3 | import { getBlockStyle } from '../../components/controls/BlockStyleControls'; 4 | import '../../styles/editor.global.css'; 5 | import LinkDecorator from '../../decorators/LinkDecorator'; 6 | import Atomic from '../blocks/Atomic'; 7 | 8 | class Preview extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.getBlockRenderer = block => this._getBlockRenderer(block); 12 | this.decorator = new CompositeDecorator([LinkDecorator]); 13 | const editorState = props.rawContent ? 14 | EditorState.createWithContent(convertFromRaw(props.rawContent), this.decorator) : 15 | EditorState.createEmpty(this.decorator); 16 | this.state = { editorState }; 17 | } 18 | 19 | componentWillReceiveProps(nextProps) { 20 | if (nextProps.rawContent) { 21 | const newContent = convertFromRaw(nextProps.rawContent); 22 | this.setState({ 23 | editorState: EditorState.createWithContent(newContent, this.decorator) 24 | }); 25 | } 26 | } 27 | 28 | // eslint-disable-next-line class-methods-use-this 29 | _getBlockRenderer(block) { 30 | switch (block.getType()) { 31 | case 'atomic': 32 | return { 33 | component: Atomic, 34 | editable: false, 35 | props: { 36 | editable: false 37 | } 38 | }; 39 | default: 40 | return null; 41 | } 42 | } 43 | 44 | render() { 45 | return ( 46 | 53 | ); 54 | } 55 | } 56 | 57 | Preview.propTypes = { 58 | // eslint-disable-next-line react/forbid-prop-types 59 | rawContent: React.PropTypes.object 60 | }; 61 | 62 | export default Preview; 63 | -------------------------------------------------------------------------------- /src/main/editor/components/styles.css: -------------------------------------------------------------------------------- 1 | .editor-root { 2 | background: #fff; 3 | border: 1px solid #ddd; 4 | font-size: 14px; 5 | padding: 15px; 6 | margin: 25px 0 25px 0; 7 | } 8 | 9 | .editor { 10 | border-top: 1px solid #ddd; 11 | cursor: text; 12 | font-size: 16px; 13 | margin-top: 10px; 14 | min-height: 500px; 15 | overflow: hidden; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/editor/container/align/Alignable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import ActionBar from '../../components/action/ActionBar'; 4 | import styles from './styles.css'; 5 | 6 | class Alignable extends React.Component { 7 | 8 | constructor(props) { 9 | super(props); 10 | this.alignLeft = () => this._alignLeft(); 11 | this.alignCenter = () => this._alignCenter(); 12 | this.alignRight = () => this._alignRight(); 13 | this.showActionBar = () => this._showActionBar(); 14 | 15 | this.state = { showActionBar: false }; 16 | } 17 | 18 | componentDidMount() { 19 | this.eventListener = (e) => { 20 | const domNode = ReactDOM.findDOMNode(this); 21 | if (!domNode.contains(e.target)) { 22 | this.setState({ showActionBar: false }); 23 | } 24 | }; 25 | document.addEventListener('click', this.eventListener, false); 26 | } 27 | 28 | componentWillUnmount() { 29 | document.removeEventListener('click', this.eventListener); 30 | } 31 | 32 | _showActionBar() { 33 | this.setState({ 34 | showActionBar: true 35 | }); 36 | } 37 | 38 | render() { 39 | const { showActionBar } = this.state; 40 | return ( 41 |
42 | {showActionBar ? this.props.onAlign('left')} 46 | onCenterClicked={() => this.props.onAlign('center')} 47 | onRightClicked={() => this.props.onAlign('right')} 48 | /> : false} 49 | {/* eslint-disable jsx-a11y/no-static-element-interactions */} 50 |
51 | {this.props.children} 52 |
53 | {/* eslint-enable jsx-a11y/no-static-element-interactions */} 54 |
55 | ); 56 | } 57 | 58 | } 59 | 60 | Alignable.propTypes = { 61 | children: React.PropTypes.node.isRequired, 62 | onAlign: React.PropTypes.func, 63 | alignment: React.PropTypes.string 64 | }; 65 | 66 | Alignable.defaultProps = { 67 | onAlign: () => {} 68 | }; 69 | 70 | export default Alignable; 71 | -------------------------------------------------------------------------------- /src/main/editor/container/align/styles.css: -------------------------------------------------------------------------------- 1 | .alignable { 2 | display: inline-block; 3 | position: relative; 4 | } 5 | 6 | .action-bar { 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | right: 0; 11 | z-index: 10; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/editor/container/resize/Resizable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import interact from 'interact.js'; 4 | import classNames from 'classnames/bind'; 5 | import styles from './styles.css'; 6 | 7 | const classNamesWithStyles = classNames.bind(styles); 8 | 9 | class Resizable extends React.Component { 10 | 11 | constructor(props) { 12 | super(props); 13 | this.startEdit = () => this._startEdit(); 14 | this.enableResizable = () => this._enableResizable(); 15 | this.disableResizable = () => this._disableResizable(); 16 | this.state = { 17 | editMode: false 18 | }; 19 | } 20 | 21 | componentDidMount() { 22 | this.eventListener = (e) => { 23 | const domNode = ReactDOM.findDOMNode(this); 24 | if (e.target !== domNode) { 25 | this.setState({ editMode: false }, this.disableResizable); 26 | } 27 | }; 28 | document.addEventListener('click', this.eventListener, false); 29 | } 30 | 31 | componentWillUnmount() { 32 | document.removeEventListener('click', this.eventListener); 33 | } 34 | 35 | _disableResizable() { 36 | const domNode = ReactDOM.findDOMNode(this); 37 | interact(domNode).unset(); 38 | } 39 | 40 | _enableResizable() { 41 | const domNode = ReactDOM.findDOMNode(this); 42 | interact(domNode) 43 | .resizable({ 44 | preserveAspectRatio: true, 45 | edges: { right: true, bottom: true } 46 | }) 47 | .on('resizemove', (event) => { 48 | const target = event.target; 49 | let x = (parseFloat(target.getAttribute('data-x')) || 0); 50 | let y = (parseFloat(target.getAttribute('data-y')) || 0); 51 | 52 | // update the element's style 53 | target.style.width = `${event.rect.width}px`; 54 | target.style.height = `${event.rect.height}px`; 55 | 56 | // translate when resizing from top or left edges 57 | x += event.deltaRect.left; 58 | y += event.deltaRect.top; 59 | 60 | target.style.webkitTransform = target.style.transform = 61 | `translate(${x}px, ${y}px)`; 62 | 63 | target.setAttribute('data-x', x); 64 | target.setAttribute('data-y', y); 65 | }) 66 | .on('resizeend', (event) => { 67 | const width = event.target.width; 68 | const height = event.target.height; 69 | this.props.onResize(width, height); 70 | }) 71 | .on('move', (event) => { 72 | const target = event.target; 73 | target.style.cursor = document.getElementsByTagName('html')[0].style.cursor; 74 | }); 75 | } 76 | 77 | _startEdit() { 78 | this.setState({ editMode: true }, this.enableResizable); 79 | } 80 | 81 | render() { 82 | const { editMode } = this.state; 83 | const className = classNamesWithStyles({ 84 | selected: editMode 85 | }); 86 | const children = React.Children.only(this.props.children); 87 | return React.cloneElement(children, { 88 | className, 89 | onClick: this.startEdit 90 | }); 91 | } 92 | } 93 | 94 | Resizable.propTypes = { 95 | children: React.PropTypes.node.isRequired, 96 | onResize: React.PropTypes.func 97 | }; 98 | 99 | Resizable.defaultProps = { 100 | onResize: () => {} 101 | }; 102 | 103 | export default Resizable; 104 | -------------------------------------------------------------------------------- /src/main/editor/container/resize/styles.css: -------------------------------------------------------------------------------- 1 | .selected { 2 | outline-color: rgb(77, 144, 254); // #4D90FE 3 | outline-offset: -2px; 4 | outline-style: auto; 5 | outline-width: 6px; 6 | cursor: move; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/editor/decorators/LinkDecorator.js: -------------------------------------------------------------------------------- 1 | import { Entity } from 'draft-js'; 2 | import Link from '../components/entities/Link'; 3 | import Types from '../components/entities/constants/Types'; 4 | 5 | export function findLinkEntities(contentBlock, callback) { 6 | contentBlock.findEntityRanges((character) => { 7 | const entityKey = character.getEntity(); 8 | return (entityKey !== null && Entity.get(entityKey).getType() === Types.LINK); 9 | }, callback); 10 | } 11 | 12 | export default Object.freeze({ 13 | strategy: findLinkEntities, 14 | component: Link 15 | }); 16 | -------------------------------------------------------------------------------- /src/main/editor/modifier/Modifier.js: -------------------------------------------------------------------------------- 1 | import copyBlock from './copyBlock'; 2 | import removeBlock from './removeBlock'; 3 | import moveBlock from './moveBlock'; 4 | 5 | export { copyBlock, removeBlock, moveBlock }; 6 | -------------------------------------------------------------------------------- /src/main/editor/modifier/copyBlock.js: -------------------------------------------------------------------------------- 1 | import { copyAtomicBlock } from './utils/AtomicBlockUtils'; 2 | 3 | export default function (contentState, targetSelection, blockKey) { 4 | const block = contentState.getBlockForKey(blockKey); 5 | let newContentState; 6 | switch (block.getType()) { 7 | case 'atomic': 8 | newContentState = copyAtomicBlock(contentState, targetSelection, block); 9 | break; 10 | default: 11 | newContentState = contentState; 12 | } 13 | return newContentState; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/editor/modifier/moveBlock.js: -------------------------------------------------------------------------------- 1 | import copyBlock from './copyBlock'; 2 | import removeBlock from './removeBlock'; 3 | import { getBlockRange } from './utils/BlockUtils'; 4 | import { isSelectionSame } from './utils/SelectionUtils'; 5 | 6 | export default function (contentState, targetSelection, blockKey) { 7 | let newContentState; 8 | const blockRange = getBlockRange(contentState, blockKey); 9 | if (!isSelectionSame(targetSelection, blockRange)) { 10 | const contentWithCopiedBlock = copyBlock(contentState, targetSelection, blockKey); 11 | newContentState = removeBlock(contentWithCopiedBlock, blockKey); 12 | } else { 13 | newContentState = contentState; 14 | } 15 | return newContentState; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/editor/modifier/removeBlock.js: -------------------------------------------------------------------------------- 1 | import { Modifier } from 'draft-js'; 2 | import { getBlockRange } from './utils/BlockUtils'; 3 | 4 | export default function (contentState, blockKey) { 5 | const blockRange = getBlockRange(contentState, blockKey); 6 | 7 | const contentWithResettedBlock = Modifier.setBlockType( 8 | contentState, 9 | blockRange, 10 | 'unstyled' 11 | ); 12 | 13 | return Modifier.removeRange( 14 | contentWithResettedBlock, 15 | blockRange, 16 | 'backward' 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/editor/modifier/utils/AtomicBlockUtils.js: -------------------------------------------------------------------------------- 1 | import { 2 | Modifier, 3 | CharacterMetadata, 4 | ContentBlock, 5 | BlockMapBuilder, 6 | genKey 7 | } from 'draft-js'; 8 | import { List, Repeat } from 'immutable'; 9 | 10 | const copyAtomicBlock = (contentState, targetSelection, block) => { 11 | const entityKey = block.getEntityAt(0); 12 | 13 | const afterRemoval = Modifier.removeRange(contentState, targetSelection, 'backward'); 14 | const afterSplit = Modifier.splitBlock(afterRemoval, afterRemoval.getSelectionAfter()); 15 | const insertionTarget = afterSplit.getSelectionAfter(); 16 | const asAtomicBlock = Modifier.setBlockType(afterSplit, insertionTarget, 'atomic'); 17 | 18 | const charData = CharacterMetadata.create({ entity: entityKey }); 19 | const fragmentArray = [new ContentBlock({ 20 | key: genKey(), 21 | type: 'atomic', 22 | text: block.getText(), 23 | characterList: List(Repeat(charData, block.getText().length)) // eslint-disable-line new-cap 24 | }), new ContentBlock({ 25 | key: genKey(), 26 | type: 'unstyled', 27 | text: '', 28 | characterList: List() // eslint-disable-line new-cap 29 | })]; 30 | 31 | const fragment = BlockMapBuilder.createFromArray(fragmentArray); 32 | const withAtomicBlock = Modifier.replaceWithFragment(asAtomicBlock, insertionTarget, fragment); 33 | const newContent = withAtomicBlock.merge({ 34 | selectionBefore: targetSelection, 35 | selectionAfter: withAtomicBlock.getSelectionAfter().set('hasFocus', true) 36 | }); 37 | 38 | return newContent; 39 | }; 40 | 41 | export { copyAtomicBlock }; 42 | -------------------------------------------------------------------------------- /src/main/editor/modifier/utils/BlockUtils.js: -------------------------------------------------------------------------------- 1 | import { SelectionState } from 'draft-js'; 2 | 3 | const getBlockRange = (contentState, blockKey) => { 4 | const block = contentState.getBlockForKey(blockKey); 5 | 6 | return new SelectionState({ 7 | anchorKey: blockKey, 8 | anchorOffset: 0, 9 | focusKey: blockKey, 10 | focusOffset: block.getLength(), 11 | isBackward: false 12 | }); 13 | }; 14 | 15 | export { getBlockRange }; 16 | -------------------------------------------------------------------------------- /src/main/editor/modifier/utils/SelectionUtils.js: -------------------------------------------------------------------------------- 1 | function isSelectionSame({ anchorKey: ak1, focusKey: fk1 }, { anchorKey: ak2, focusKey: fk2 }) { 2 | return ak1 === ak2 && fk1 === fk2; 3 | } 4 | 5 | export { isSelectionSame }; 6 | -------------------------------------------------------------------------------- /src/main/editor/styles/editor.global.css: -------------------------------------------------------------------------------- 1 | .public-DraftEditorPlaceholder-root, 2 | .public-DraftEditor-content { 3 | margin: 0 -15px -15px; 4 | padding: 15px; 5 | } 6 | -------------------------------------------------------------------------------- /src/main/editor/utils/Utils.js: -------------------------------------------------------------------------------- 1 | const valuesAsArray = obj => Object.keys(obj).map(key => obj[key]); 2 | 3 | export { valuesAsArray }; 4 | -------------------------------------------------------------------------------- /src/test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "mocha" 4 | ], 5 | "rules": { 6 | "mocha/no-global-tests": 2, 7 | "mocha/handle-done-callback": 2, 8 | "mocha/no-exclusive-tests": 1, 9 | "mocha/no-skipped-tests": 1, 10 | "no-unused-expressions": 0, 11 | "import/no-extraneous-dependencies": 0, 12 | }, 13 | "env": { 14 | "mocha": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/test/editor/components/action/ActionBar.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import chai from 'chai'; 3 | import chaiEnzyme from 'chai-enzyme'; 4 | import chaiSinon from 'sinon-chai'; 5 | import sinon from 'sinon'; 6 | import ActionBar from '../../../../main/editor/components/action/ActionBar'; 7 | import { mountWithMuiContext, simulateTouchTap } from '../../../utils/TestUtils'; 8 | 9 | chai.should(); 10 | chai.use(chaiEnzyme()); 11 | chai.use(chaiSinon); 12 | 13 | describe('', () => { 14 | it('should render actionbar with left center right buttons', () => { 15 | const actionBar = mountWithMuiContext( 16 | {}} 18 | onCenterClicked={() => {}} 19 | onRightClicked={() => {}} 20 | /> 21 | ); 22 | 23 | actionBar.find('button').should.have.length(3); 24 | actionBar.find('button').at(0).should.have.text('left'); 25 | actionBar.find('button').at(1).should.have.text('center'); 26 | actionBar.find('button').at(2).should.have.text('right'); 27 | }); 28 | 29 | it('should invoke left-callback on left-click', () => { 30 | const left = sinon.spy(); 31 | 32 | const actionBar = mountWithMuiContext( 33 | {}} 36 | onRightClicked={() => {}} 37 | /> 38 | ); 39 | 40 | simulateTouchTap(actionBar.find('button').at(0)); 41 | left.should.have.been.calledOnce; 42 | }); 43 | 44 | it('should invoke center-callback on center-click', () => { 45 | const center = sinon.spy(); 46 | 47 | const actionBar = mountWithMuiContext( 48 | {}} 50 | onCenterClicked={center} 51 | onRightClicked={() => {}} 52 | /> 53 | ); 54 | 55 | simulateTouchTap(actionBar.find('button').at(1)); 56 | center.should.have.been.calledOnce; 57 | }); 58 | 59 | it('should invoke right-callback on right-click', () => { 60 | const right = sinon.spy(); 61 | 62 | const actionBar = mountWithMuiContext( 63 | {}} 65 | onCenterClicked={() => {}} 66 | onRightClicked={right} 67 | /> 68 | ); 69 | 70 | simulateTouchTap(actionBar.find('button').at(2)); 71 | right.should.have.been.calledOnce; 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/test/editor/container/align/Alignable.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import chai from 'chai'; 3 | import { shallow } from 'enzyme'; 4 | import chaiEnzyme from 'chai-enzyme'; 5 | import chaiSinon from 'sinon-chai'; 6 | import sinon from 'sinon'; 7 | import Alignable from '../../../../main/editor/container/align/Alignable'; 8 | import ActionBar from '../../../../main/editor/components/action/ActionBar'; 9 | import { mountWithMuiContext, simulateTouchTap } from '../../../utils/TestUtils'; 10 | 11 | chai.should(); 12 | chai.use(chaiEnzyme()); 13 | chai.use(chaiSinon); 14 | 15 | describe('', () => { 16 | it('should render a div tag', () => { 17 | const alignable = shallow(
foo
); 18 | 19 | alignable.should.have.tagName('div'); 20 | }); 21 | 22 | it('should have showActionBar-state set false and no ActionBar rendered', () => { 23 | const alignable = shallow(
foo
); 24 | 25 | alignable.state('showActionBar').should.be.false; 26 | alignable.should.not.contain( 27 | 32 | ); 33 | }); 34 | 35 | it('should render an ActionBar on children-wrapper clicked', () => { 36 | const alignable = mountWithMuiContext(
foo
); 37 | alignable.ref('alignable').simulate('click'); 38 | 39 | alignable.state('showActionBar').should.be.true; 40 | alignable.find(ActionBar).should.have.length(1); 41 | }); 42 | 43 | it('should render children in wrapper element with showActionBar callback on click', () => { 44 | const alignable = mountWithMuiContext(
foo
); 45 | 46 | alignable.should.contain( 47 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions 48 |
49 |
foo
50 |
51 | ); 52 | alignable.should.have.ref('alignable'); 53 | }); 54 | 55 | it('should invoke onAlign with left on left button click', () => { 56 | const onAlign = sinon.spy(); 57 | const alignable = mountWithMuiContext( 58 | 59 |
foo
60 |
61 | ); 62 | alignable.setState({ showActionBar: true }); 63 | simulateTouchTap(alignable.find(ActionBar).find('button').at(0)); 64 | 65 | onAlign.should.have.been.calledWith('left'); 66 | }); 67 | 68 | it('should invoke onAlign with center on center button click', () => { 69 | const onAlign = sinon.spy(); 70 | const alignable = mountWithMuiContext( 71 | 72 |
foo
73 |
74 | ); 75 | alignable.setState({ showActionBar: true }); 76 | simulateTouchTap(alignable.find(ActionBar).find('button').at(1)); 77 | 78 | onAlign.should.have.been.calledWith('center'); 79 | }); 80 | 81 | it('should invoke onAlign with right on right button click', () => { 82 | const onAlign = sinon.spy(); 83 | const alignable = mountWithMuiContext( 84 | 85 |
foo
86 |
87 | ); 88 | alignable.setState({ showActionBar: true }); 89 | simulateTouchTap(alignable.find(ActionBar).find('button').at(2)); 90 | 91 | onAlign.should.have.been.calledWith('right'); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/test/setup.js: -------------------------------------------------------------------------------- 1 | const hook = require('css-modules-require-hook'); 2 | const jsdom = require('jsdom').jsdom; 3 | 4 | const exposedProperties = ['window', 'navigator', 'document']; 5 | 6 | hook({ 7 | generateScopedName: '[local]' 8 | }); 9 | 10 | global.document = jsdom(''); 11 | global.window = document.defaultView; 12 | 13 | Object.keys(document.defaultView).forEach((property) => { 14 | if (typeof global[property] === 'undefined') { 15 | exposedProperties.push(property); 16 | global[property] = document.defaultView[property]; 17 | } 18 | }); 19 | 20 | global.navigator = { 21 | userAgent: 'mocha' 22 | }; 23 | -------------------------------------------------------------------------------- /src/test/utils/TestUtils.js: -------------------------------------------------------------------------------- 1 | import mountWithMuiContext from './mountWithMuiContext'; 2 | import simulateTouchTap from './simulateTouchTap'; 3 | 4 | export { mountWithMuiContext, simulateTouchTap }; 5 | -------------------------------------------------------------------------------- /src/test/utils/mountWithMuiContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import getMuiTheme from 'material-ui/styles/getMuiTheme'; 4 | 5 | export default function (node) { 6 | return mount(node, { 7 | context: { muiTheme: getMuiTheme() }, 8 | childContextTypes: { muiTheme: React.PropTypes.object.isRequired } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/test/utils/simulateTouchTap.js: -------------------------------------------------------------------------------- 1 | import ReactTestUtils from 'react-addons-test-utils'; 2 | import ReactDOM from 'react-dom'; 3 | import injectTapEventPlugin from 'react-tap-event-plugin'; 4 | 5 | injectTapEventPlugin(); 6 | 7 | export default function (component) { 8 | const node = ReactDOM.findDOMNode(component.node); 9 | return ReactTestUtils.Simulate.touchTap(node); 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.build.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: path.resolve(__dirname, 'index.js'), 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: 'Richie.js', 9 | libraryTarget: 'umd', 10 | }, 11 | externals: [ 12 | 'immutable', 13 | 'react', 14 | 'react-dom', 15 | 'react-addons-transition-group', 16 | 'react-addons-create-fragment', 17 | 'draft-js' 18 | ], 19 | resolve: { 20 | extensions: ['', '.js', '.jsx'] 21 | }, 22 | module: { 23 | preLoaders: [ 24 | { test: /\.jsx?$/, loader: 'eslint', exclude: /node_modules/ } 25 | ], 26 | loaders: [ 27 | { 28 | test: /\.jsx?$/, 29 | exclude: /node_modules/, 30 | loaders: ['babel-loader'] 31 | }, 32 | { test: /\.css$/, exclude: /\.global\.css$/, loader: 'style-loader!css-loader?modules&camelCase' }, 33 | { test: /\.global\.css$/, loader: 'style-loader!css-loader' } 34 | ] 35 | }, 36 | plugins: [ 37 | new webpack.optimize.UglifyJsPlugin({ 38 | compress: { 39 | warnings: false 40 | } 41 | }) 42 | ], 43 | eslint: { 44 | failOnWarning: false, 45 | failOnError: true 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var baseConfig = require('./webpack.config'); 4 | 5 | baseConfig.entry = [ 6 | 'babel-polyfill', 7 | // Webpack Development Server mit Hot Reloading 8 | 'webpack-dev-server/client?http://localhost:3001', 9 | 'webpack/hot/dev-server', 10 | path.resolve(__dirname, 'src/main/app.js') 11 | ]; 12 | 13 | baseConfig.devtool = 'inline-source-map'; 14 | 15 | baseConfig.devServer = { 16 | contentBase: 'build', 17 | port: 3001 18 | }; 19 | 20 | baseConfig.plugins = [ 21 | new webpack.HotModuleReplacementPlugin(), 22 | new webpack.NoErrorsPlugin() 23 | ]; 24 | 25 | module.exports = baseConfig; 26 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: [ 6 | 'babel-polyfill', 7 | path.resolve(__dirname, 'src/main/app.js') 8 | ], 9 | output: { 10 | path: path.resolve(__dirname, 'build/assets'), 11 | publicPath: '/assets/', 12 | filename: 'bundle.js' 13 | }, 14 | resolve: { 15 | extensions: ['', '.js', '.jsx'] 16 | }, 17 | module: { 18 | preLoaders: [ 19 | { test: /\.jsx?$/, loader: 'eslint', exclude: /node_modules/ } 20 | ], 21 | loaders: [ 22 | { 23 | test: /\.jsx?$/, 24 | exclude: /node_modules/, 25 | loaders: ['babel-loader'] 26 | }, 27 | { test: /\.css$/, exclude: /\.global\.css$/, loader: 'style-loader!css-loader?modules&camelCase' }, 28 | { test: /\.global\.css$/, loader: 'style-loader!css-loader' } 29 | ] 30 | }, 31 | plugins: [ 32 | new webpack.NoErrorsPlugin(), 33 | new webpack.DefinePlugin({ 34 | 'process.env': { 35 | // This has effect on the react lib size 36 | 'NODE_ENV': JSON.stringify('production'), 37 | } 38 | }), 39 | new webpack.optimize.UglifyJsPlugin(), 40 | new webpack.optimize.OccurrenceOrderPlugin(), 41 | new webpack.optimize.DedupePlugin() 42 | ], 43 | eslint: { 44 | failOnWarning: false, 45 | failOnError: true 46 | } 47 | }; 48 | --------------------------------------------------------------------------------