diff --git a/client/src/boot/registerComponents.js b/client/src/boot/registerComponents.js index 3b0f529b..b4e8907b 100644 --- a/client/src/boot/registerComponents.js +++ b/client/src/boot/registerComponents.js @@ -11,6 +11,7 @@ import Summary from 'components/ElementEditor/Summary'; import InlineEditForm from 'components/ElementEditor/InlineEditForm'; import AddElementPopover from 'components/ElementEditor/AddElementPopover'; import HoverBar from 'components/ElementEditor/HoverBar'; +import DragPositionIndicator from 'components/ElementEditor/DragPositionIndicator'; export default () => { Injector.component.registerMany({ @@ -26,5 +27,6 @@ export default () => { ElementInlineEditForm: InlineEditForm, AddElementPopover, HoverBar, + DragPositionIndicator, }); }; diff --git a/client/src/components/ElementEditor/DragPositionIndicator.js b/client/src/components/ElementEditor/DragPositionIndicator.js new file mode 100644 index 00000000..33d99ad2 --- /dev/null +++ b/client/src/components/ElementEditor/DragPositionIndicator.js @@ -0,0 +1,14 @@ +import React, { PureComponent } from 'react'; + +// eslint-disable-next-line react/prefer-stateless-function +class DragPositionIndicator extends PureComponent { + render() { + return ( +
+
+
+ ); + } +} + +export default DragPositionIndicator; diff --git a/client/src/components/ElementEditor/DragPositionIndicator.scss b/client/src/components/ElementEditor/DragPositionIndicator.scss new file mode 100644 index 00000000..cca448a5 --- /dev/null +++ b/client/src/components/ElementEditor/DragPositionIndicator.scss @@ -0,0 +1,15 @@ +.elemental-editor-drag-indicator { + height: 3px; + margin: -2px 0 -1px 0; + background-color: $info; + + &__ball { + position: relative; + height: 7px; + width: 7px; + top: -2px; + left: -3px; + border-radius: 3.5px; + background-color: $info; + } +} diff --git a/client/src/components/ElementEditor/Element.js b/client/src/components/ElementEditor/Element.js index 1c62e4cf..c04b2ab2 100644 --- a/client/src/components/ElementEditor/Element.js +++ b/client/src/components/ElementEditor/Element.js @@ -10,6 +10,37 @@ import { connect } from 'react-redux'; import { loadElementFormStateName } from 'state/editor/loadElementFormStateName'; import { loadElementSchemaValue } from 'state/editor/loadElementSchemaValue'; import * as TabsActions from 'state/tabs/TabsActions'; +import { DragSource, DropTarget } from 'react-dnd'; +import { getEmptyImage } from 'react-dnd-html5-backend'; + +const elementSource = { + beginDrag(props) { + const { element, onDragStart } = props; + if (onDragStart) { + onDragStart(element); + } + return element; + } +}; + +const elementTarget = { + drop(props) { + const { element, onDragDrop } = props; + + if (onDragDrop) { + onDragDrop(element); + } + }, + + hover(props) { + const { element, onDragOver } = props; + + if (onDragOver) { + onDragOver(element); + } + } +}; + /** * The Element component used in the context of an ElementEditor shows the summary @@ -32,6 +63,19 @@ class Element extends Component { }; } + componentDidMount() { + const { connectDragPreview } = this.props; + if (connectDragPreview) { + // Use empty image as a drag preview so browsers don't draw it + // and we can draw whatever we want on the custom drag layer instead. + connectDragPreview(getEmptyImage(), { + // IE fallback: specify that we'd rather screenshot the node + // when it already knows it's being dragged so we can hide it with CSS. + captureDraggingState: true, + }); + } + } + /** * Returns the applicable versioned state class names for the element * @@ -157,6 +201,10 @@ class Element extends Component { link, editTabs, activeTab, + connectDragSource, + connectDropTarget, + isDragging, + isOver, } = this.props; const { previewExpanded } = this.state; @@ -174,11 +222,13 @@ class Element extends Component { 'element-editor__element', { 'element-editor__element--expandable': element.InlineEditable, + 'element-editor__element--dragging': isDragging, + 'element-editor__element--dragged-over': isOver, }, this.getVersionedStateClassName() ); - return ( + return connectDropTarget(connectDragSource(
- ); + )); } } @@ -269,6 +319,14 @@ Element.propTypes = { activeTab: PropTypes.string, tabSetName: PropTypes.string, onActivateTab: PropTypes.func, + connectDragSource: PropTypes.func.isRequired, + connectDragPreview: PropTypes.func.isRequired, + connectDropTarget: PropTypes.func.isRequired, + isDragging: PropTypes.bool.isRequired, + isOver: PropTypes.bool.isRequired, + onDragOver: PropTypes.func, // eslint-disable-line react/no-unused-prop-types + onDragDrop: PropTypes.func, // eslint-disable-line react/no-unused-prop-types + onDragStart: PropTypes.func, // eslint-disable-line react/no-unused-prop-types }; Element.defaultProps = { @@ -278,6 +336,15 @@ Element.defaultProps = { export { Element as Component }; export default compose( + DropTarget('element', elementTarget, (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + })), + DragSource('element', elementSource, (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + connectDragPreview: connect.dragPreview(), + isDragging: monitor.isDragging(), + })), connect(mapStateToProps, mapDispatchToProps), inject( ['ElementHeader', 'ElementContent'], diff --git a/client/src/components/ElementEditor/Element.scss b/client/src/components/ElementEditor/Element.scss index 470152e5..10bc7132 100644 --- a/client/src/components/ElementEditor/Element.scss +++ b/client/src/components/ElementEditor/Element.scss @@ -10,7 +10,19 @@ outline-width: 0; } + &:hover { + .element-editor-header__drag-handle { + display: block; + } + } + &:last-child { border-bottom: 0; } + + &--dragging { + opacity: 0.3; + cursor: grabbing; + cursor: -webkit-grabbing; + } } diff --git a/client/src/components/ElementEditor/ElementDragPreview.js b/client/src/components/ElementEditor/ElementDragPreview.js new file mode 100644 index 00000000..c6fc5a6c --- /dev/null +++ b/client/src/components/ElementEditor/ElementDragPreview.js @@ -0,0 +1,61 @@ +import React, { Component, PropTypes } from 'react'; +import Header from 'components/ElementEditor/Header'; +import { DragLayer } from 'react-dnd'; +import { elementType } from 'types/elementType'; + +// eslint-disable-next-line react/prefer-stateless-function +class ElementDragPreview extends Component { + render() { + const { isDragging, element, currentOffset } = this.props; + + if (!isDragging || !currentOffset) { + return null; + } + + const { x, y } = currentOffset; + + const thing = element || { + ID: 2, + Title: 'Something blah', + Version: 5, + IsLiveVersion: true, + IsPublished: true, + BlockSchema: { iconClass: 'font-icon-block-form' }, + }; + + const transform = `translate(${x}px, ${y}px)`; + const style = { + transform, + WebkitTransform: transform, + }; + + return ( +
+
+
+ ); + } +} + +ElementDragPreview.propTypes = { + element: elementType, + isDragging: PropTypes.bool, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }), +}; + +export default DragLayer(monitor => ({ + element: monitor.getItem(), + currentOffset: monitor.getSourceClientOffset(), + isDragging: monitor.isDragging(), +}))(ElementDragPreview); diff --git a/client/src/components/ElementEditor/ElementDragPreview.scss b/client/src/components/ElementEditor/ElementDragPreview.scss new file mode 100644 index 00000000..89bd0888 --- /dev/null +++ b/client/src/components/ElementEditor/ElementDragPreview.scss @@ -0,0 +1,11 @@ +.element-editor-drag-preview { + top: 0; + left: 0; + position: fixed; + pointer-events: none; + z-index: 100; // Higher than CMS tree view + background-color: $white; + border: 1px solid $border-color; + padding: $spacer-sm $panel-padding-x; + box-shadow: $z-depth-1; +} diff --git a/client/src/components/ElementEditor/ElementEditor.js b/client/src/components/ElementEditor/ElementEditor.js index 6d0fcfd4..e3f15654 100644 --- a/client/src/components/ElementEditor/ElementEditor.js +++ b/client/src/components/ElementEditor/ElementEditor.js @@ -4,12 +4,55 @@ import { elementTypeType } from 'types/elementTypeType'; import { connect } from 'react-redux'; import { compose } from 'redux'; import { loadElementFormStateName } from 'state/editor/loadElementFormStateName'; +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import classnames from 'classnames'; + +import ElementDragPreview from 'components/ElementEditor/ElementDragPreview'; /** * The ElementEditor is used in the CMS to manage a list or nested lists of * elements for a page or other DataObject. */ class ElementEditor extends PureComponent { + constructor(props) { + super(props); + + this.state = { + dragTargetElementId: false, + isDragging: false, + draggedElement: null, + }; + + this.handleDragOver = this.handleDragOver.bind(this); + this.handleDragDrop = this.handleDragDrop.bind(this); + this.handleDragStart = this.handleDragStart.bind(this); + } + + handleDragStart(element) { + this.setState({ + draggedElement: element, + }); + } + + handleDragOver(element) { + const id = element ? element.ID : false; + + if (this.state.dragTargetElementId !== id) { + this.setState({ + dragTargetElementId: id, + isDragging: true, + }); + } + } + + handleDragDrop() { + this.setState({ + dragTargetElementId: false, + isDragging: false, + }); + } + render() { const { fieldName, @@ -20,18 +63,31 @@ class ElementEditor extends PureComponent { elementalAreaId, elementTypes, } = this.props; + const { dragTargetElementId, isDragging } = this.state; + + const classNames = classnames('element-editor', { + 'element-editor--dragging': isDragging, + }); return ( -
+
+
); @@ -67,6 +123,7 @@ function mapStateToProps(state) { export { ElementEditor as Component }; export default compose( + DragDropContext(HTML5Backend), connect(mapStateToProps), inject( ['ElementToolbar', 'ElementList'], diff --git a/client/src/components/ElementEditor/ElementEditor.scss b/client/src/components/ElementEditor/ElementEditor.scss new file mode 100644 index 00000000..0688c174 --- /dev/null +++ b/client/src/components/ElementEditor/ElementEditor.scss @@ -0,0 +1,5 @@ +.element-editor { + &--dragging { + cursor: grabbing; + } +} diff --git a/client/src/components/ElementEditor/ElementList.js b/client/src/components/ElementEditor/ElementList.js index 3fb4001c..e8138bea 100644 --- a/client/src/components/ElementEditor/ElementList.js +++ b/client/src/components/ElementEditor/ElementList.js @@ -32,9 +32,15 @@ class ElementList extends Component { const { ElementComponent, HoverBarComponent, + DragIndicatorComponent, blocks, elementTypes, elementalAreaId, + isDragging, + dragTargetElementId, + onDragDrop, + onDragOver, + onDragStart, } = this.props; // Blocks can be either null or an empty array @@ -46,12 +52,15 @@ class ElementList extends Component { return
{i18n._t('ElementList.ADD_BLOCKS', 'Add blocks to place your content')}
; } - return blocks.map((element) => ( + const output = blocks.map((element) => (
)); + + if (isDragging) { + const indicatorIndex = dragTargetElementId ? + blocks.findIndex(element => element.ID === dragTargetElementId) + 1 : 0; + + output.splice(indicatorIndex, 0, ); + } + + return output; } /** @@ -79,8 +97,8 @@ class ElementList extends Component { render() { const { blocks } = this.props; const listClassNames = classNames( - 'elemental-editor__list', - { 'elemental-editor__list--empty': !blocks || !blocks.length } + 'elemental-editor-list', + { 'elemental-editor-list--empty': !blocks || !blocks.length } ); return ( @@ -97,6 +115,11 @@ ElementList.propTypes = { blocks: PropTypes.arrayOf(elementType), loading: PropTypes.bool, elementalAreaId: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + dragTargetElementId: PropTypes.string, + onDragOver: PropTypes.func, + onDragDrop: PropTypes.func, + onDragStart: PropTypes.func, }; ElementList.defaultProps = { @@ -107,11 +130,12 @@ ElementList.defaultProps = { export { ElementList as Component }; export default inject( - ['Element', 'Loading', 'HoverBar'], - (ElementComponent, LoadingComponent, HoverBarComponent) => ({ + ['Element', 'Loading', 'HoverBar', 'DragPositionIndicator'], + (ElementComponent, LoadingComponent, HoverBarComponent, DragIndicatorComponent) => ({ ElementComponent, LoadingComponent, HoverBarComponent, + DragIndicatorComponent, }), () => 'ElementEditor.ElementList' )(ElementList); diff --git a/client/src/components/ElementEditor/ElementList.scss b/client/src/components/ElementEditor/ElementList.scss index 069a3d4f..e3c70242 100644 --- a/client/src/components/ElementEditor/ElementList.scss +++ b/client/src/components/ElementEditor/ElementList.scss @@ -1,4 +1,4 @@ -.elemental-editor__list { +.elemental-editor-list { background-color: $white; border-bottom: 1px solid $border-color-light; border-top: 1px solid $border-color-light; diff --git a/client/src/components/ElementEditor/Header.js b/client/src/components/ElementEditor/Header.js index c69ef0a3..9196c50c 100644 --- a/client/src/components/ElementEditor/Header.js +++ b/client/src/components/ElementEditor/Header.js @@ -64,6 +64,7 @@ class Header extends Component { fontIcon, expandable, previewExpanded, + simple, ElementActionsComponent, } = this.props; @@ -73,6 +74,11 @@ class Header extends Component { 'element-editor-header__title--none': !title, }); const expandTitle = i18n._t('ElementHeader.EXPAND', 'Show editable fields'); + const containerClasses = classNames( + 'element-editor-header', { + 'element-editor-header--simple': simple + } + ); const expandCaretClasses = classNames( 'element-editor-header__expand', { @@ -83,26 +89,26 @@ class Header extends Component { ); return ( -
+
+
+ +
{this.renderVersionedStateMessage()} - {elementType} - + }

{title || noTitle}

-
+ {!simple &&
{expandable &&
} -
+
}
); } @@ -126,6 +132,7 @@ Header.propTypes = { isPublished: PropTypes.bool, elementType: PropTypes.string, fontIcon: PropTypes.string, + simple: PropTypes.bool, ElementActionsComponent: React.PropTypes.oneOfType([React.PropTypes.node, React.PropTypes.func]), expandable: PropTypes.bool, previewExpanded: PropTypes.bool, diff --git a/client/src/components/ElementEditor/Header.scss b/client/src/components/ElementEditor/Header.scss index ffcaedcb..3ffadd34 100644 --- a/client/src/components/ElementEditor/Header.scss +++ b/client/src/components/ElementEditor/Header.scss @@ -74,6 +74,22 @@ } } + &__drag-handle { + display: none; + position: absolute; + left: 5px; + cursor: grab; + cursor: -webkit-grab; + } + + &--simple &__drag-handle { + display: block; + } + + &--simple &__info { + width: 460px; + } + .dropdown-item.active { cursor: default; } diff --git a/client/src/components/ElementEditor/Toolbar.js b/client/src/components/ElementEditor/Toolbar.js index 74f60705..e2d35099 100644 --- a/client/src/components/ElementEditor/Toolbar.js +++ b/client/src/components/ElementEditor/Toolbar.js @@ -1,12 +1,28 @@ import React, { PureComponent, PropTypes } from 'react'; import { inject } from 'lib/Injector'; import { elementTypeType } from 'types/elementTypeType'; +import { DropTarget } from 'react-dnd'; + +const toolbarTarget = { + drop(props) { + const { onDragDrop } = props; + if (onDragDrop) { + onDragDrop(); + } + }, + hover(props) { + const { onDragOver } = props; + if (onDragOver) { + onDragOver(); + } + } +}; // eslint-disable-next-line react/prefer-stateless-function class Toolbar extends PureComponent { render() { - const { AddNewButtonComponent, elementTypes, elementalAreaId } = this.props; - return ( + const { AddNewButtonComponent, elementTypes, elementalAreaId, connectDropTarget } = this.props; + return connectDropTarget(
({ + connectDropTarget: connect.dropTarget(), +}))(inject( ['ElementAddNewButton'], (AddNewButtonComponent) => ({ AddNewButtonComponent, }), () => 'ElementEditor.ElementToolbar' -)(Toolbar); +)(Toolbar)); diff --git a/client/src/components/ElementEditor/tests/Element-test.js b/client/src/components/ElementEditor/tests/Element-test.js index 1434ba72..8d4fe0ce 100644 --- a/client/src/components/ElementEditor/tests/Element-test.js +++ b/client/src/components/ElementEditor/tests/Element-test.js @@ -28,14 +28,25 @@ describe('Element', () => { IsPublished: true, }; + const identity = el => el; + + const defaultProps = { + HeaderComponent, + ContentComponent, + connectDragSource: identity, + connectDragPreview: identity, + connectDropTarget: identity, + isDragging: false, + isOver: false, + }; + describe('render()', () => { it('should render the HeaderComponent and the ContentComponent', () => { const wrapper = shallow( ); @@ -53,8 +64,7 @@ describe('Element', () => { } } link={'admin/pages/edit/EditForm/7/field/ElementalArea/item/2/edit?stage=Stage'} - HeaderComponent={HeaderComponent} - ContentComponent={ContentComponent} + {...defaultProps} /> ); @@ -73,8 +83,7 @@ describe('Element', () => { IsPublished: false, }} link="/" - HeaderComponent={HeaderComponent} - ContentComponent={ContentComponent} + {...defaultProps} /> ); @@ -90,8 +99,7 @@ describe('Element', () => { IsLiveVersion: false, }} link="/" - HeaderComponent={HeaderComponent} - ContentComponent={ContentComponent} + {...defaultProps} /> ); @@ -107,8 +115,7 @@ describe('Element', () => { IsLiveVersion: true, }} link="/" - HeaderComponent={HeaderComponent} - ContentComponent={ContentComponent} + {...defaultProps} /> ); diff --git a/client/src/styles/bundle.scss b/client/src/styles/bundle.scss index 586c43da..7995a8ca 100644 --- a/client/src/styles/bundle.scss +++ b/client/src/styles/bundle.scss @@ -20,3 +20,6 @@ @import "../components/ElementEditor/Toolbar"; @import "../components/ElementEditor/AddElementPopover"; @import "../components/ElementEditor/HoverBar"; +@import "../components/ElementEditor/DragPositionIndicator"; +@import "../components/ElementEditor/ElementEditor"; +@import "../components/ElementEditor/ElementDragPreview"; diff --git a/package.json b/package.json index e39f7d41..31767808 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,11 @@ "graphql-tag": "^0.1.17", "jquery": "^3.2.1", "react": "15.3.1", - "react-dom": "15.3.1", "react-addons-test-utils": "15.3.1", "react-apollo": "^0.7.1", + "react-dom": "15.3.1", + "react-dnd": "^2.2.3", + "react-dnd-html5-backend": "^2.2.3", "react-redux": "^4.4.1", "reactstrap": "^5.0.0-beta", "redux": "^3.3.1"