Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ _*
/dist
/lib
/node_modules
.idea
7 changes: 4 additions & 3 deletions demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
<html>
<head>
<title>React Rich Text Editor Example</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=600, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/assets/react.ico" />
<link rel="stylesheet" href="/assets/css/demo.css" />
<script src="/dist/demo.js"></script>
<link rel="icon" type="image/x-icon" href="assets/react.ico" />
<link rel="stylesheet" href="assets/css/demo.css" />
<script src="dist/demo.js"></script>
</head>
<body></body>
</html>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
45 changes: 40 additions & 5 deletions src/RichTextEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
53 changes: 52 additions & 1 deletion src/lib/EditorToolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -37,6 +37,7 @@ type Props = {

type State = {
showLinkInput: boolean;
showImageInput: boolean;
};

export default class EditorToolbar extends Component {
Expand All @@ -48,6 +49,7 @@ export default class EditorToolbar extends Component {
autobind(this);
this.state = {
showLinkInput: false,
showImageInput: false,
};
}

Expand All @@ -68,6 +70,7 @@ export default class EditorToolbar extends Component {
{this._renderInlineStyleButtons()}
{this._renderBlockTypeButtons()}
{this._renderLinkButtons()}
{this._renderImageButton()}
{this._renderBlockTypeDropdown()}
{this._renderUndoRedo()}
</div>
Expand Down Expand Up @@ -154,6 +157,20 @@ export default class EditorToolbar extends Component {
);
}

_renderImageButton(): React.Element {
return (
<ButtonGroup>
<PopoverIconButton
label="Image"
iconName="image"
showPopover={this.state.showImageInput}
onTogglePopover={this._toggleShowImageInput}
onSubmit={this._setImage}
/>
</ButtonGroup>
);
}

_renderUndoRedo(): React.Element {
let {editorState} = this.props;
let canUndo = editorState.getUndoStack().size !== 0;
Expand Down Expand Up @@ -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();
Expand Down
23 changes: 23 additions & 0 deletions src/lib/ImageDecorator.js
Original file line number Diff line number Diff line change
@@ -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,
};
22 changes: 22 additions & 0 deletions src/lib/getBlocksInSelection.js
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 6 additions & 0 deletions src/ui/IconButton.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions src/ui/ImageSpan.css
Original file line number Diff line number Diff line change
@@ -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;
}
104 changes: 104 additions & 0 deletions src/ui/ImageSpan.js
Original file line number Diff line number Diff line change
@@ -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 (
<span
className={className}
style={imageStyle}
onClick={this._onClick}
>
{this.props.children}
</span>
);
}

_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}
);
}
}