diff --git a/.gitignore b/.gitignore
index d9ec90cd..7f13cc4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ _*
/dist
/lib
/node_modules
+.idea
\ No newline at end of file
diff --git a/demo.html b/demo.html
index 9d1ca070..07780294 100644
--- a/demo.html
+++ b/demo.html
@@ -2,10 +2,11 @@
React Rich Text Editor Example
+
-
-
-
+
+
+
diff --git a/package.json b/package.json
index eadbd072..27b137a1 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "react-rte",
+ "name": "react-rte-image",
"version": "0.3.0",
"description": "React Rich Text Editor",
"main": "dist/react-rte.js",
diff --git a/src/RichTextEditor.js b/src/RichTextEditor.js
index da937fd8..40df484b 100644
--- a/src/RichTextEditor.js
+++ b/src/RichTextEditor.js
@@ -4,12 +4,14 @@ import {CompositeDecorator, Editor, EditorState, Modifier, RichUtils} from 'draf
import getDefaultKeyBinding from 'draft-js/lib/getDefaultKeyBinding';
import changeBlockDepth from './lib/changeBlockDepth';
import changeBlockType from './lib/changeBlockType';
+import getBlocksInSelection from './lib/getBlocksInSelection';
import insertBlockAfter from './lib/insertBlockAfter';
import isListItem from './lib/isListItem';
import isSoftNewlineEvent from 'draft-js/lib/isSoftNewlineEvent';
import EditorToolbar from './lib/EditorToolbar';
import EditorValue from './lib/EditorValue';
import LinkDecorator from './lib/LinkDecorator';
+import ImageDecorator from './lib/ImageDecorator';
import cx from 'classnames';
import autobind from 'class-autobind';
import {EventEmitter} from 'events';
@@ -20,7 +22,7 @@ import './Draft.global.css';
// $FlowIssue - Flow doesn't understand CSS Modules
import styles from './RichTextEditor.css';
-import type {ContentBlock} from 'draft-js';
+import {ContentBlock, Entity} from 'draft-js';
const MAX_LIST_DEPTH = 2;
@@ -226,10 +228,43 @@ export default class RichTextEditor extends Component {
_onChange(editorState: EditorState) {
let {onChange, value} = this.props;
- if (onChange != null) {
- let newValue = value.setEditorState(editorState);
- onChange(newValue);
+ if (onChange == null) {
+ return;
}
+ let newValue = value.setEditorState(editorState);
+ let newEditorState = newValue.getEditorState();
+ this._handleInlineImageSelection(newEditorState);
+ onChange(newValue);
+ }
+
+ _handleInlineImageSelection(editorState: EditorState) {
+ let selection = editorState.getSelection();
+ let blocks = getBlocksInSelection(editorState);
+
+ const selectImage = (block, offset) => {
+ const imageKey = block.getEntityAt(offset);
+ Entity.mergeData(imageKey, {selected: true});
+ };
+
+ let isInMiddleBlock = (index) => index > 0 && index < blocks.size - 1;
+ let isWithinStartBlockSelection = (offset, index) => (
+ index === 0 && offset > selection.getStartOffset()
+ );
+ let isWithinEndBlockSelection = (offset, index) => (
+ index === blocks.size - 1 && offset < selection.getEndOffset()
+ );
+
+ blocks.toIndexedSeq().forEach((block, index) => {
+ ImageDecorator.strategy(
+ block,
+ (offset) => {
+ if (isWithinStartBlockSelection(offset, index) ||
+ isInMiddleBlock(index) ||
+ isWithinEndBlockSelection(offset, index)) {
+ selectImage(block, offset);
+ }
+ });
+ });
}
_focus() {
@@ -251,7 +286,7 @@ function getBlockStyle(block: ContentBlock): string {
}
}
-const decorator = new CompositeDecorator([LinkDecorator]);
+const decorator = new CompositeDecorator([LinkDecorator, ImageDecorator]);
function createEmptyValue(): EditorValue {
return EditorValue.createEmpty(decorator);
diff --git a/src/lib/EditorToolbar.js b/src/lib/EditorToolbar.js
index 6d918e93..874ef374 100644
--- a/src/lib/EditorToolbar.js
+++ b/src/lib/EditorToolbar.js
@@ -3,7 +3,7 @@ import {hasCommandModifier} from 'draft-js/lib/KeyBindingUtil';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
-import {EditorState, Entity, RichUtils} from 'draft-js';
+import {EditorState, Entity, RichUtils, Modifier} from 'draft-js';
import {ENTITY_TYPE} from 'draft-js-utils';
import {
INLINE_STYLE_BUTTONS,
@@ -37,6 +37,7 @@ type Props = {
type State = {
showLinkInput: boolean;
+ showImageInput: boolean;
};
export default class EditorToolbar extends Component {
@@ -48,6 +49,7 @@ export default class EditorToolbar extends Component {
autobind(this);
this.state = {
showLinkInput: false,
+ showImageInput: false,
};
}
@@ -68,6 +70,7 @@ export default class EditorToolbar extends Component {
{this._renderInlineStyleButtons()}
{this._renderBlockTypeButtons()}
{this._renderLinkButtons()}
+ {this._renderImageButton()}
{this._renderBlockTypeDropdown()}
{this._renderUndoRedo()}
@@ -154,6 +157,20 @@ export default class EditorToolbar extends Component {
);
}
+ _renderImageButton(): React.Element {
+ return (
+
+
+
+ );
+ }
+
_renderUndoRedo(): React.Element {
let {editorState} = this.props;
let canUndo = editorState.getUndoStack().size !== 0;
@@ -208,6 +225,40 @@ export default class EditorToolbar extends Component {
this.setState({showLinkInput: !isShowing});
}
+ _toggleShowImageInput(event: ?Object) {
+ let isShowing = this.state.showImageInput;
+ // If this is a hide request, decide if we should focus the editor.
+ if (isShowing) {
+ let shouldFocusEditor = true;
+ if (event && event.type === 'click') {
+ // TODO: Use a better way to get the editor root node.
+ let editorRoot = ReactDOM.findDOMNode(this).parentNode;
+ let {activeElement} = document;
+ let wasClickAway = (activeElement == null || activeElement === document.body);
+ if (!wasClickAway && !editorRoot.contains(activeElement)) {
+ shouldFocusEditor = false;
+ }
+ }
+ if (shouldFocusEditor) {
+ this.props.focusEditor();
+ }
+ }
+ this.setState({showImageInput: !isShowing});
+ }
+
+ _setImage(src: string) {
+ let {editorState} = this.props;
+ let contentState = editorState.getCurrentContent();
+ let selection = editorState.getSelection();
+ let entityKey = Entity.create(ENTITY_TYPE.IMAGE, 'IMMUTABLE', {src});
+ const updatedContent = Modifier.insertText(contentState, selection, ' ', null, entityKey);
+ this.setState({showImageInput: false});
+ this.props.onChange(
+ EditorState.push(editorState, updatedContent)
+ );
+ this._focusEditor();
+ }
+
_setLink(url: string) {
let {editorState} = this.props;
let selection = editorState.getSelection();
diff --git a/src/lib/ImageDecorator.js b/src/lib/ImageDecorator.js
new file mode 100644
index 00000000..c8172cc5
--- /dev/null
+++ b/src/lib/ImageDecorator.js
@@ -0,0 +1,23 @@
+/* @flow */
+import ImageSpan from '../ui/ImageSpan';
+import {Entity} from 'draft-js';
+import {ENTITY_TYPE} from 'draft-js-utils';
+
+import type {ContentBlock} from 'draft-js';
+
+type EntityRangeCallback = (start: number, end: number) => void;
+
+function findImageEntities(contentBlock: ContentBlock, callback: EntityRangeCallback) {
+ contentBlock.findEntityRanges((character) => {
+ const entityKey = character.getEntity();
+ return (
+ entityKey != null &&
+ Entity.get(entityKey).getType() === ENTITY_TYPE.IMAGE
+ );
+ }, callback);
+}
+
+export default {
+ strategy: findImageEntities,
+ component: ImageSpan,
+};
diff --git a/src/lib/getBlocksInSelection.js b/src/lib/getBlocksInSelection.js
new file mode 100644
index 00000000..9b5503fd
--- /dev/null
+++ b/src/lib/getBlocksInSelection.js
@@ -0,0 +1,22 @@
+/* @flow */
+import {EditorState} from 'draft-js';
+import {OrderedMap} from 'immutable';
+
+export default function getBlocksInSelection(
+ editorState: EditorState,
+): EditorState {
+ let contentState = editorState.getCurrentContent();
+ let blockMap = contentState.getBlockMap();
+ let selection = editorState.getSelection();
+ if (selection.isCollapsed()) {
+ return new OrderedMap();
+ }
+
+ let startKey = selection.getStartKey();
+ let endKey = selection.getEndKey();
+ if (startKey === endKey) {
+ return new OrderedMap({startKey: contentState.getBlockForKey(startKey)});
+ }
+ let blocksUntilEnd = blockMap.takeUntil((block, key) => key === endKey);
+ return blocksUntilEnd.skipUntil((block, key) => key === startKey);
+}
diff --git a/src/ui/IconButton.css b/src/ui/IconButton.css
index 09e5c1a7..061e7c11 100644
--- a/src/ui/IconButton.css
+++ b/src/ui/IconButton.css
@@ -74,6 +74,12 @@
background-size: 14px;
}
+.icon-image {
+ composes: icon;
+ background-image: url("data:image/svg+xml;utf8;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pgo8IS0tIEdlbmVyYXRvcjogQWRvYmUgSWxsdXN0cmF0b3IgMTYuMC4wLCBTVkcgRXhwb3J0IFBsdWctSW4gLiBTVkcgVmVyc2lvbjogNi4wMCBCdWlsZCAwKSAgLS0+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0iQ2FwYV8xIiB4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjUxMnB4IiBoZWlnaHQ9IjUxMnB4IiB2aWV3Qm94PSIwIDAgNTMzLjMzMyA1MzMuMzM0IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1MzMuMzMzIDUzMy4zMzQ7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8cGF0aCBkPSJNNDY2LjY2NywxMDBoLTQwMHYzMzMuMzMzaDQwMFYxMDB6IE01MzMuMzMzLDMzLjMzM0w1MzMuMzMzLDMzLjMzM1Y1MDBIMFYzMy4zMzNINTMzLjMzM3ogTTQzMy4zMzMsNDAwSDEwMHYtNjYuNjY3ICAgbDEwMC0xNjYuNjY3bDEzNi45NzksMTY2LjY2N2w5Ni4zNTQtNjYuNjY2VjMwMFY0MDB6IE0zMzMuMzMzLDE4My4zMzNjMCwyNy42MTQsMjIuMzg2LDUwLDUwLDUwczUwLTIyLjM4Niw1MC01MHMtMjIuMzg2LTUwLTUwLTUwICAgUzMzMy4zMzMsMTU1LjcxOSwzMzMuMzMzLDE4My4zMzN6IiBmaWxsPSIjMDAwMDAwIi8+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPC9zdmc+Cg==");
+ background-size: 14px;
+}
+
.icon-cancel {
composes: icon;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMjMuNzggMTkuMjhMMTYuNSAxMmw3LjI4LTcuMjhhLjc0OC43NDggMCAwIDAgMC0xLjA2TDIwLjM0LjIxOGEuNzUuNzUgMCAwIDAtMS4wNi0uMDAyTDEyIDcuNDk4IDQuNzE3LjIyYS43NDguNzQ4IDAgMCAwLTEuMDYgMEwuMjE3IDMuNjZhLjc1Ljc1IDAgMCAwIDAgMS4wNkw3LjQ5NyAxMmwtNy4yOCA3LjI4YS43NDguNzQ4IDAgMCAwIDAgMS4wNmwzLjQ0IDMuNDRhLjc1Ljc1IDAgMCAwIDEuMDYuMDAybDcuMjgtNy4yOCA3LjI4MiA3LjI4Yy4wNzguMDc4LjE3LjEzNS4yNjguMTcuMjY3LjEuNTguMDQ0Ljc5My0uMTdsMy40NC0zLjQ0YS43NS43NSAwIDAgMCAwLTEuMDZ6Ii8+PC9zdmc+");
diff --git a/src/ui/ImageSpan.css b/src/ui/ImageSpan.css
new file mode 100644
index 00000000..8ccebfef
--- /dev/null
+++ b/src/ui/ImageSpan.css
@@ -0,0 +1,29 @@
+.root {
+ background-repeat: no-repeat;
+ display: inline-block;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.resize {
+ border: 1px dashed #78a300;
+ position: relative;
+ max-width: 100%;
+ display: inline-block;
+ line-height: 0;
+ top: -1px;
+ left: -1px,
+}
+
+.resizeHandle {
+ cursor: nwse-resize;
+ position: absolute;
+ z-index: 2;
+ line-height: 1;
+ bottom: -4px;
+ right: -5px;
+ border: 1px solid white;
+ background-color: #78a300;
+ width: 8px;
+ height: 8px;
+}
diff --git a/src/ui/ImageSpan.js b/src/ui/ImageSpan.js
new file mode 100644
index 00000000..6c5b4e6e
--- /dev/null
+++ b/src/ui/ImageSpan.js
@@ -0,0 +1,104 @@
+/* @flow */
+
+import autobind from 'class-autobind';
+import cx from 'classnames';
+import React, {Component} from 'react';
+import {Entity} from 'draft-js';
+
+// $FlowIssue - Flow doesn't understand CSS Modules
+import styles from './ImageSpan.css';
+
+// TODO: Use a more specific type here.
+type ReactNode = any;
+
+type Props = {
+ children: ReactNode;
+ entityKey: string;
+ className?: string;
+};
+
+type State = {
+ width: number;
+ height: number;
+};
+
+export default class ImageSpan extends Component {
+ props: Props;
+ state: State;
+
+ constructor(props: Props) {
+ super(props);
+ autobind(this);
+ const entity = Entity.get(this.props.entityKey);
+ const {width, height} = entity.getData();
+ this.state = {
+ width,
+ height,
+ };
+ }
+
+ componentDidMount() {
+ const {width, height} = this.state;
+ const entity = Entity.get(this.props.entityKey);
+ const image = new Image();
+ const {src} = entity.getData();
+ image.src = src;
+ image.onload = () => {
+ if (width == null || height == null) {
+ // TODO: isMounted?
+ this.setState({width: image.width, height: image.height});
+ Entity.mergeData(
+ this.props.entityKey,
+ {
+ width: image.width,
+ height: image.height,
+ originalWidth: image.width,
+ originalHeight: image.height,
+ }
+ );
+ }
+ };
+ }
+
+ render() {
+ const {width, height} = this.state;
+ let {className} = this.props;
+ const entity = Entity.get(this.props.entityKey);
+ const {src} = entity.getData();
+
+ className = cx(className, styles.root);
+ const imageStyle = {
+ verticalAlign: 'bottom',
+ backgroundImage: `url("${src}")`,
+ backgroundSize: `${width}px ${height}px`,
+ lineHeight: `${height}px`,
+ fontSize: `${height}px`,
+ width,
+ height,
+ letterSpacing: width,
+ };
+
+ return (
+
+ {this.props.children}
+
+ );
+ }
+
+ _onClick() {
+ console.log('image click');
+ }
+
+ _handleResize(event: Object, data: Object) {
+ const {width, height} = data.size;
+ this.setState({width, height});
+ Entity.mergeData(
+ this.props.entityKey,
+ {width, height}
+ );
+ }
+}