diff --git a/package.json b/package.json index 1bd5bcc4..93041dec 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,12 @@ }, "dependencies": { "classnames": "^2.2.3", - "draft-js": "^0.4.0", - "draft-js-export-html": "^0.1.9", - "draft-js-export-markdown": "^0.1.3", + "draft-js": "^0.5.0", + "draft-js-export-html": "^0.1.13", + "draft-js-export-markdown": "^0.1.5", "draft-js-import-html": "^0.1.5", "draft-js-import-markdown": "^0.1.2", - "draft-js-tools": "^0.1.2", + "draft-js-utils": "^0.1.3", "immutable": "^3.7.6" }, "peerDependencies": { diff --git a/src/RichTextEditor.js b/src/RichTextEditor.js index b39c7102..fae6f3d1 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; @@ -219,9 +221,42 @@ export default class RichTextEditor extends Component { _onChange(editorState: EditorState) { let newValue = this.props.value.setEditorState(editorState); + let newEditorState = newValue.getEditorState(); + + this._handleInlineImageSelection(newEditorState); this.props.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() { this.refs.editor.focus(); } @@ -241,7 +276,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 b88572ea..427b7e59 100644 --- a/src/lib/EditorToolbar.js +++ b/src/lib/EditorToolbar.js @@ -41,6 +41,7 @@ export default class EditorToolbar extends Component { autobind(this); this.state = { showLinkInput: false, + showImageInput: false, }; } @@ -60,6 +61,7 @@ export default class EditorToolbar extends Component { {this._renderInlineStyleButtons()} {this._renderBlockTypeButtons()} {this._renderLinkButtons()} + {this._renderImageButton()} {this._renderBlockTypeDropdown()} {this._renderUndoRedo()} @@ -146,6 +148,27 @@ export default class EditorToolbar extends Component { ); } + _renderImageButton(): React.Element { + const {editorState} = this.props; + let selection = editorState.getSelection(); + let entity = this._getEntityAtCursor(); + let hasSelection = !selection.isCollapsed(); + let isCursorOnImage = (entity != null && entity.type === ENTITY_TYPE.IMAGE); + let shouldShowImageButton = hasSelection || isCursorOnImage; + return ( + + + + ); + } + _renderUndoRedo(): React.Element { let {editorState} = this.props; let canUndo = editorState.getUndoStack().size !== 0; @@ -200,6 +223,38 @@ 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 selection = editorState.getSelection(); + let entityKey = Entity.create(ENTITY_TYPE.IMAGE, 'IMMUTABLE', {src}); + this.setState({showImageInput: false}); + this.props.onChange( + RichUtils.toggleLink(editorState, selection, entityKey) + ); + 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..981ceb54 --- /dev/null +++ b/src/lib/ImageDecorator.js @@ -0,0 +1,22 @@ +/* @flow */ +import {Entity} from 'draft-js'; +import {ENTITY_TYPE} from 'draft-js-tools'; +import type {ContentBlock} from 'draft-js'; +import ImageSpan from '../ui/ImageSpan'; + +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..d5a5d1f0 --- /dev/null +++ b/src/ui/ImageSpan.css @@ -0,0 +1,34 @@ +.root { + color: transparent; + background-repeat: no-repeat; + display: inline-block; + text-align: right; + 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; +} + +.selected { + background-color: #B4D5FE; +} diff --git a/src/ui/ImageSpan.js b/src/ui/ImageSpan.js new file mode 100644 index 00000000..721e27c4 --- /dev/null +++ b/src/ui/ImageSpan.js @@ -0,0 +1,104 @@ +/* @flow */ + +import React, {Component} from 'react'; +import {Entity} from 'draft-js'; +import cx from 'classnames'; + +// $FlowIssue - Flow doesn't understand CSS Modules +import styles from './ImageSpan.css'; + +type Props = { + children: React.Node; + entityKey: string; + className: ?string; +}; + +export default class ImageSpan extends Component { + constructor(props: Props) { + super(props); + const entity = Entity.get(this.props.entityKey); + const {width, height} = entity.getData(); + this.state = {resizing: false, width, height}; + } + + _handleImageSpanClick = () => { + this.setState({resizing: !this.state.resizing}); + } + + _handleResize = (event, {size}) => { + const {width, height} = size; + this.setState({width, height}); + Entity.mergeData( + this.props.entityKey, + {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; + + const self = this; + image.onload = () => { + if (width == null || height == null) { + self.setState({width: image.width, height: image.height}); + Entity.mergeData( + self.props.entityKey, + { + width: image.width, + height: image.height, + originalWidth: image.width, + originalHeight: image.height, + } + ); + } + }; + } + + render() { + const {resizing, width, height} = this.state; + let {className} = this.props; + const entity = Entity.get(this.props.entityKey); + const {src, selected} = entity.getData(); + + const resizingStyles = resizing ? styles.resize : {}; + + className = cx(className, { + [styles.root]: true, + [styles.selected]: selected, + }); + const imageStyle = { + //...resizingStyles, + backgroundImage: `url("${src}")`, + backgroundSize: `${width}px ${height}px`, + width, + height, + }; + + const imageSpan = ( + + {this.props.children} + + ); + + // const view = resizing ? + // + // {imageSpan} + // : + // imageSpan; + + return imageSpan; + } +}