├── .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 |
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 |
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 =>
;
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 |
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 |
--------------------------------------------------------------------------------