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