diff --git a/components/draggable/README.md b/components/draggable/README.md new file mode 100644 index 00000000000000..974f1eab06db62 --- /dev/null +++ b/components/draggable/README.md @@ -0,0 +1,37 @@ +# Draggable + +`Draggable` is a Component that can wrap any element to make it draggable. When used, a cross-browser (including IE) customisable drag image is created. The component clones the specified element on drag-start and uses the clone as a drag image during drag-over. Discards the clone on drag-end. + +## Props + +The component accepts the following props: + +### elementId + +The HTML id of the element to clone on drag + +- Type: `string` +- Required: Yes + +### transferData + +Arbitrary data object attached to the drag and drop event. + +- Type: `Object` +- Required: Yes + +### onDragStart + +The function called when dragging starts. + +- Type: `Function` +- Required: No +- Default: `noop` + +### onDragEnd + +The function called when dragging ends. + +- Type: `Function` +- Required: No +- Default: `noop` diff --git a/components/draggable/index.js b/components/draggable/index.js new file mode 100644 index 00000000000000..a36d0f036aa7d3 --- /dev/null +++ b/components/draggable/index.js @@ -0,0 +1,161 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress Dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal Dependencies + */ +import withSafeTimeout from '../higher-order/with-safe-timeout'; +import './style.scss'; + +const dragImageClass = 'components-draggable__invisible-drag-image'; +const cloneWrapperClass = 'components-draggable__clone'; +const cloneHeightTransformationBreakpoint = 700; +const clonePadding = 20; + +class Draggable extends Component { + constructor() { + super( ...arguments ); + this.onDragStart = this.onDragStart.bind( this ); + this.onDragOver = this.onDragOver.bind( this ); + this.onDragEnd = this.onDragEnd.bind( this ); + } + + componentWillUnmount() { + this.removeDragClone(); + } + + /** + * Removes the element clone, resets cursor, and removes drag listener. + * @param {Object} event The non-custom DragEvent. + */ + onDragEnd( event ) { + const { onDragEnd = noop } = this.props; + this.removeDragClone(); + // Reset cursor. + document.body.classList.remove( 'is-dragging-components-draggable' ); + event.preventDefault(); + + this.props.setTimeout( onDragEnd ); + } + + /* + * Updates positioning of element clone based on mouse movement during dragging. + * @param {Object} event The non-custom DragEvent. + */ + onDragOver( event ) { + this.cloneWrapper.style.top = + `${ parseInt( this.cloneWrapper.style.top, 10 ) + event.clientY - this.cursorTop }px`; + this.cloneWrapper.style.left = + `${ parseInt( this.cloneWrapper.style.left, 10 ) + event.clientX - this.cursorLeft }px`; + + // Update cursor coordinates. + this.cursorLeft = event.clientX; + this.cursorTop = event.clientY; + } + + /** + * - Clones the current element and spawns clone over original element. + * - Adds a fake temporary drag image to avoid browser defaults. + * - Sets transfer data. + * - Adds dragover listener. + * @param {Object} event The non-custom DragEvent. + * @param {string} elementId The HTML id of the element to be dragged. + * @param {Object} transferData The data to be set to the event's dataTransfer - to be accessible in any later drop logic. + */ + onDragStart( event ) { + const { elementId, transferData, onDragStart = noop } = this.props; + const element = document.getElementById( elementId ); + if ( ! element ) { + event.preventDefault(); + return; + } + + // Set a fake drag image to avoid browser defaults. Remove from DOM + // right after. event.dataTransfer.setDragImage is not supported yet in + // IE, we need to check for its existence first. + if ( 'function' === typeof event.dataTransfer.setDragImage ) { + const dragImage = document.createElement( 'div' ); + dragImage.id = `drag-image-${ elementId }`; + dragImage.classList.add( dragImageClass ); + document.body.appendChild( dragImage ); + event.dataTransfer.setDragImage( dragImage, 0, 0 ); + this.props.setTimeout( () => { + document.body.removeChild( dragImage ); + } ); + } + + event.dataTransfer.setData( 'text', JSON.stringify( transferData ) ); + + // Prepare element clone and append to element wrapper. + const elementRect = element.getBoundingClientRect(); + const elementWrapper = element.parentNode; + const elementTopOffset = parseInt( elementRect.top, 10 ); + const elementLeftOffset = parseInt( elementRect.left, 10 ); + const clone = element.cloneNode( true ); + clone.id = `clone-${ elementId }`; + this.cloneWrapper = document.createElement( 'div' ); + this.cloneWrapper.classList.add( cloneWrapperClass ); + this.cloneWrapper.style.width = `${ elementRect.width + ( clonePadding * 2 ) }px`; + + if ( elementRect.height > cloneHeightTransformationBreakpoint ) { + // Scale down clone if original element is larger than 700px. + this.cloneWrapper.style.transform = 'scale(0.5)'; + this.cloneWrapper.style.transformOrigin = 'top left'; + // Position clone near the cursor. + this.cloneWrapper.style.top = `${ event.clientY - 100 }px`; + this.cloneWrapper.style.left = `${ event.clientX }px`; + } else { + // Position clone right over the original element (20px padding). + this.cloneWrapper.style.top = `${ elementTopOffset - clonePadding }px`; + this.cloneWrapper.style.left = `${ elementLeftOffset - clonePadding }px`; + } + + // Hack: Remove iFrames as it's causing the embeds drag clone to freeze + [ ...clone.querySelectorAll( 'iframe' ) ].forEach( child => child.parentNode.removeChild( child ) ); + + this.cloneWrapper.appendChild( clone ); + elementWrapper.appendChild( this.cloneWrapper ); + + // Mark the current cursor coordinates. + this.cursorLeft = event.clientX; + this.cursorTop = event.clientY; + // Update cursor to 'grabbing', document wide. + document.body.classList.add( 'is-dragging-components-draggable' ); + document.addEventListener( 'dragover', this.onDragOver ); + + this.props.setTimeout( onDragStart ); + } + + removeDragClone() { + document.removeEventListener( 'dragover', this.onDragOver ); + if ( this.cloneWrapper && this.cloneWrapper.parentNode ) { + // Remove clone. + this.cloneWrapper.parentNode.removeChild( this.cloneWrapper ); + this.cloneWrapper = null; + } + } + + render() { + const { children, className } = this.props; + return ( +
+ { children } +
+ ); + } +} + +export default withSafeTimeout( Draggable ); diff --git a/components/draggable/style.scss b/components/draggable/style.scss new file mode 100644 index 00000000000000..6d4f9845840db5 --- /dev/null +++ b/components/draggable/style.scss @@ -0,0 +1,20 @@ +body.is-dragging-components-draggable { + cursor: move;/* Fallback for IE/Edge < 14 */ + cursor: grabbing !important; +} + +.components-draggable__invisible-drag-image { + position: fixed; + left: -1000px; + height: 50px; + width: 50px; +} + +.components-draggable__clone { + position: fixed; + padding: 20px; + background: transparent; + pointer-events: none; + z-index: z-index( '.components-draggable__clone' ); + opacity: 0.8; +} diff --git a/components/drop-zone/README.md b/components/drop-zone/README.md new file mode 100644 index 00000000000000..acec85b06275b5 --- /dev/null +++ b/components/drop-zone/README.md @@ -0,0 +1,47 @@ +# DropZone + +`DropZone` is a Component creating a drop zone area taking the full size of its parent element. It supports dropping files, HTML content or any other HTML drop event. To work properly this components needs to be wrapped in a `DropZoneProvider`. + +## Usage + +```jsx +import { DropZoneProvider, DropZone } from '@wordpress/components'; + +function MyComponent() { + return ( + +
+ console.log( 'do something' ) } /> +
+
+ ); +} +``` + +## Props + +The component accepts the following props: + +### onFilesDrop + +The function is called when dropping a file into the `DropZone`. It receives two arguments: an array of dropped files and a position object which the following shape: `{ x: 'left|right', y: 'top|bottom' }`. The position object indicates whether the drop event happened closer to the top or bottom edges and left or right ones. + +- Type: `Function` +- Required: No +- Default: `noop` + +### onHTMLDrop + +The function is called when dropping a file into the `DropZone`. It receives two arguments: the HTML being dropped and a position object. + +- Type: `Function` +- Required: No +- Default: `noop` + +### onDrop + +The function is generic drop handler called if the `onFilesDrop` or `onHTMLDrop` are not called. It receives two arguments: The drop `event` object and the position object. + +- Type: `Function` +- Required: No +- Default: `noop` diff --git a/components/drop-zone/index.js b/components/drop-zone/index.js index 1d439572eb5dd5..f4b70f0e36e673 100644 --- a/components/drop-zone/index.js +++ b/components/drop-zone/index.js @@ -21,9 +21,6 @@ class DropZone extends Component { super( ...arguments ); this.setZoneNode = this.setZoneNode.bind( this ); - this.onDrop = this.onDrop.bind( this ); - this.onFilesDrop = this.onFilesDrop.bind( this ); - this.onHTMLDrop = this.onHTMLDrop.bind( this ); this.state = { isDraggingOverDocument: false, @@ -36,9 +33,9 @@ class DropZone extends Component { this.context.dropzones.add( { element: this.zone, updateState: this.setState.bind( this ), - onDrop: this.onDrop, - onFilesDrop: this.onFilesDrop, - onHTMLDrop: this.onHTMLDrop, + onDrop: this.props.onDrop, + onFilesDrop: this.props.onFilesDrop, + onHTMLDrop: this.props.onHTMLDrop, } ); } @@ -46,24 +43,6 @@ class DropZone extends Component { this.context.dropzones.remove( this.zone ); } - onDrop() { - if ( this.props.onDrop ) { - this.props.onDrop( ...arguments ); - } - } - - onFilesDrop() { - if ( this.props.onFilesDrop ) { - this.props.onFilesDrop( ...arguments ); - } - } - - onHTMLDrop() { - if ( this.props.onHTMLDrop ) { - this.props.onHTMLDrop( ...arguments ); - } - } - setZoneNode( node ) { this.zone = node; } diff --git a/components/drop-zone/provider.js b/components/drop-zone/provider.js index 912a3324553f9b..343e50d4627a13 100644 --- a/components/drop-zone/provider.js +++ b/components/drop-zone/provider.js @@ -1,12 +1,12 @@ /** * External dependencies */ -import { isEqual, without, some, filter, findIndex, noop, throttle } from 'lodash'; +import { isEqual, find, some, filter, noop, throttle } from 'lodash'; /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; +import { Component, findDOMNode } from '@wordpress/element'; class DropZoneProvider extends Component { constructor() { @@ -27,7 +27,7 @@ class DropZoneProvider extends Component { } dragOverListener( event ) { - this.toggleDraggingOverDocument( event ); + this.toggleDraggingOverDocument( event, this.getDragEventType( event ) ); event.preventDefault(); } @@ -48,6 +48,7 @@ class DropZoneProvider extends Component { window.addEventListener( 'dragover', this.dragOverListener ); window.addEventListener( 'drop', this.onDrop ); window.addEventListener( 'mouseup', this.resetDragState ); + this.container = findDOMNode( this ); } componentWillUnmount() { @@ -80,7 +81,29 @@ class DropZoneProvider extends Component { } ); } - toggleDraggingOverDocument( event ) { + getDragEventType( event ) { + if ( event.dataTransfer ) { + if ( event.dataTransfer.types.indexOf( 'Files' ) !== -1 ) { + return 'file'; + } + + if ( event.dataTransfer.types.indexOf( 'text/html' ) !== -1 ) { + return 'html'; + } + } + + return 'default'; + } + + doesDropzoneSupportType( dropzone, type ) { + return ( + ( type === 'file' && dropzone.onFilesDrop ) || + ( type === 'html' && dropzone.onHTMLDrop ) || + ( type === 'default' && dropzone.onDrop ) + ); + } + + toggleDraggingOverDocument( event, dragEventType ) { // In some contexts, it may be necessary to capture and redirect the // drag event (e.g. atop an `iframe`). To accommodate this, you can // create an instance of CustomEvent with the original event specified @@ -90,14 +113,23 @@ class DropZoneProvider extends Component { const detail = window.CustomEvent && event instanceof window.CustomEvent ? event.detail : event; // Index of hovered dropzone. - const hoveredDropZone = findIndex( this.dropzones, ( { element } ) => - this.isWithinZoneBounds( element, detail.clientX, detail.clientY ) + + const hoveredDropZones = filter( this.dropzones, ( dropzone ) => + this.doesDropzoneSupportType( dropzone, dragEventType ) && + this.isWithinZoneBounds( dropzone.element, detail.clientX, detail.clientY ) ); + // Find the leaf dropzone not containing another dropzone + const hoveredDropZone = find( hoveredDropZones, zone => ( + ! some( hoveredDropZones, subZone => subZone !== zone && zone.element.parentElement.contains( subZone.element ) ) + ) ); + + const hoveredDropZoneIndex = this.dropzones.indexOf( hoveredDropZone ); + let position = null; - if ( hoveredDropZone !== -1 ) { - const rect = this.dropzones[ hoveredDropZone ].element.getBoundingClientRect(); + if ( hoveredDropZone ) { + const rect = hoveredDropZone.element.getBoundingClientRect(); position = { x: detail.clientX - rect.left < rect.right - detail.clientX ? 'left' : 'right', @@ -110,38 +142,36 @@ class DropZoneProvider extends Component { if ( ! this.state.isDraggingOverDocument ) { dropzonesToUpdate = this.dropzones; - } else if ( hoveredDropZone !== this.state.hoveredDropZone ) { + } else if ( hoveredDropZoneIndex !== this.state.hoveredDropZone ) { if ( this.state.hoveredDropZone !== -1 ) { dropzonesToUpdate.push( this.dropzones[ this.state.hoveredDropZone ] ); } - if ( hoveredDropZone !== -1 ) { - dropzonesToUpdate.push( this.dropzones[ hoveredDropZone ] ); + if ( hoveredDropZone ) { + dropzonesToUpdate.push( hoveredDropZone ); } } else if ( - hoveredDropZone !== -1 && - hoveredDropZone === this.state.hoveredDropZone && + hoveredDropZone && + hoveredDropZoneIndex === this.state.hoveredDropZone && ! isEqual( position, this.state.position ) ) { - dropzonesToUpdate.push( this.dropzones[ hoveredDropZone ] ); + dropzonesToUpdate.push( hoveredDropZone ); } // Notifying the dropzones dropzonesToUpdate.map( ( dropzone ) => { const index = this.dropzones.indexOf( dropzone ); dropzone.updateState( { - isDraggingOverElement: index === hoveredDropZone, - position: index === hoveredDropZone ? position : null, - isDraggingOverDocument: true, + isDraggingOverElement: index === hoveredDropZoneIndex, + position: index === hoveredDropZoneIndex ? position : null, + isDraggingOverDocument: this.doesDropzoneSupportType( dropzone, dragEventType ), } ); } ); this.setState( { isDraggingOverDocument: true, - hoveredDropZone, + hoveredDropZone: hoveredDropZoneIndex, position, } ); - - event.preventDefault(); } isWithinZoneBounds( dropzone, x, y ) { @@ -158,8 +188,7 @@ class DropZoneProvider extends Component { ); }; - const childZones = without( dropzone.parentElement.querySelectorAll( '.components-drop-zone' ), dropzone ); - return ! some( childZones, isWithinElement ) && isWithinElement( dropzone ); + return isWithinElement( dropzone ); } onDrop( event ) { @@ -168,22 +197,21 @@ class DropZoneProvider extends Component { event.dataTransfer && event.dataTransfer.files.length; // eslint-disable-line no-unused-expressions const { position, hoveredDropZone } = this.state; - const dropzone = hoveredDropZone !== -1 ? this.dropzones[ hoveredDropZone ] : null; - const isValidDropzone = !! dropzone && dropzone.element.contains( event.target ); - + const dragEventType = this.getDragEventType( event ); + const dropzone = this.dropzones[ hoveredDropZone ]; + const isValidDropzone = !! dropzone && this.container.contains( event.target ); this.resetDragState(); - if ( isValidDropzone && !! dropzone.onDrop ) { - dropzone.onDrop( event, position ); - } - - if ( event.dataTransfer && isValidDropzone ) { - const files = event.dataTransfer.files; - const HTML = event.dataTransfer.getData( 'text/html' ); - if ( files.length && dropzone.onFilesDrop ) { - dropzone.onFilesDrop( [ ...event.dataTransfer.files ], position ); - } else if ( HTML && dropzone.onHTMLDrop ) { - dropzone.onHTMLDrop( HTML, position ); + if ( isValidDropzone ) { + switch ( dragEventType ) { + case 'file': + dropzone.onFilesDrop( [ ...event.dataTransfer.files ], position ); + break; + case 'html': + dropzone.onHTMLDrop( event.dataTransfer.getData( 'text/html' ), position ); + break; + case 'default': + dropzone.onDrop( event, position ); } } diff --git a/components/index.js b/components/index.js index e5ddfd5a7c6c2d..789c92124067d5 100644 --- a/components/index.js +++ b/components/index.js @@ -10,6 +10,7 @@ export { default as CodeEditor } from './code-editor'; export { default as Dashicon } from './dashicon'; export { DateTimePicker, DatePicker, TimePicker } from './date-time'; export { default as Disabled } from './disabled'; +export { default as Draggable } from './draggable'; export { default as DropZone } from './drop-zone'; export { default as DropZoneProvider } from './drop-zone/provider'; export { default as Dropdown } from './dropdown'; diff --git a/components/placeholder/style.scss b/components/placeholder/style.scss index 8f79745b7e0dd2..6e23d6339ed030 100644 --- a/components/placeholder/style.scss +++ b/components/placeholder/style.scss @@ -30,6 +30,7 @@ width: 100%; max-width: 280px; flex-wrap: wrap; + z-index: z-index( '.components-placeholder__fieldset' ); p { font-family: $default-font; diff --git a/edit-post/assets/stylesheets/_z-index.scss b/edit-post/assets/stylesheets/_z-index.scss index b701ad4f8257e1..181201f2114fad 100644 --- a/edit-post/assets/stylesheets/_z-index.scss +++ b/edit-post/assets/stylesheets/_z-index.scss @@ -29,6 +29,16 @@ $z-layers: ( '.edit-post-header': 30, '.wp-block-image__resize-handlers': 1, // Resize handlers above sibling inserter + // Should have lower index than anything else positioned inside the block containers + '.editor-block-list__block-draggable': 0, + + // The draggable element should show up above the entire UI + '.components-draggable__clone': 1000000000, + + // Should have higher index than the inset/underlay used for dragging + '.components-placeholder__fieldset': 1, + '.editor-block-list__block-edit .reusable-block-edit-panel *': 1, + // Show drop zone above most standard content, but below any overlays '.components-drop-zone': 100, '.components-drop-zone__content': 110, diff --git a/editor/components/block-drop-zone/index.js b/editor/components/block-drop-zone/index.js index 7b7565bc70829c..18a227fb677ac0 100644 --- a/editor/components/block-drop-zone/index.js +++ b/editor/components/block-drop-zone/index.js @@ -14,51 +14,89 @@ import { getBlockTransforms, findTransform, } from '@wordpress/blocks'; -import { compose } from '@wordpress/element'; +import { compose, Component } from '@wordpress/element'; /** * Internal dependencies */ -import { insertBlocks, updateBlockAttributes } from '../../store/actions'; +import { insertBlocks, updateBlockAttributes, moveBlockToPosition } from '../../store/actions'; -function BlockDropZone( { index, isLocked, ...props } ) { - if ( isLocked ) { - return null; +class BlockDropZone extends Component { + constructor() { + super( ...arguments ); + + this.onFilesDrop = this.onFilesDrop.bind( this ); + this.onHTMLDrop = this.onHTMLDrop.bind( this ); + this.onDrop = this.onDrop.bind( this ); } - const getInsertIndex = ( position ) => { + getInsertIndex( position ) { + const { index } = this.props; if ( index !== undefined ) { return position.y === 'top' ? index : index + 1; } - }; + } - const onDropFiles = ( files, position ) => { + onFilesDrop( files, position ) { const transformation = findTransform( getBlockTransforms( 'from' ), ( transform ) => transform.type === 'files' && transform.isMatch( files ) ); if ( transformation ) { - const insertIndex = getInsertIndex( position ); - const blocks = transformation.transform( files, props.updateBlockAttributes ); - props.insertBlocks( blocks, insertIndex ); + const insertIndex = this.getInsertIndex( position ); + const blocks = transformation.transform( files, this.props.updateBlockAttributes ); + this.props.insertBlocks( blocks, insertIndex ); } - }; + } - const onHTMLDrop = ( HTML, position ) => { + onHTMLDrop( HTML, position ) { const blocks = rawHandler( { HTML, mode: 'BLOCKS' } ); if ( blocks.length ) { - props.insertBlocks( blocks, getInsertIndex( position ) ); + this.props.insertBlocks( blocks, this.getInsertIndex( position ) ); + } + } + + onDrop( event, position ) { + if ( ! event.dataTransfer ) { + return; + } + + let uid, type, rootUID, fromIndex; + + try { + ( { uid, type, rootUID, fromIndex } = JSON.parse( event.dataTransfer.getData( 'text' ) ) ); + } catch ( err ) { + return; + } + + if ( type !== 'block' ) { + return; } - }; - - return ( - - ); + const { index } = this.props; + const positionIndex = this.getInsertIndex( position ); + + // If the block is kept at the same level and moved downwards, subtract + // to account for blocks shifting upward to occupy its old position. + const insertIndex = index && fromIndex < index && rootUID === this.props.rootUID ? positionIndex - 1 : positionIndex; + this.props.moveBlockToPosition( uid, rootUID, insertIndex ); + } + + render() { + const { isLocked } = this.props; + if ( isLocked ) { + return null; + } + + return ( + + ); + } } export default compose( @@ -84,8 +122,12 @@ export default compose( updateBlockAttributes( ...args ) { dispatch( updateBlockAttributes( ...args ) ); }, + moveBlockToPosition( uid, fromRootUID, index ) { + const { rootUID, layout } = ownProps; + dispatch( moveBlockToPosition( uid, fromRootUID, rootUID, layout, index ) ); + }, }; - } + }, ), withContext( 'editor' )( ( settings ) => { const { templateLock } = settings; diff --git a/editor/components/block-list/block-draggable.js b/editor/components/block-list/block-draggable.js new file mode 100644 index 00000000000000..3c6ccabaf99a70 --- /dev/null +++ b/editor/components/block-list/block-draggable.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { Draggable } from '@wordpress/components'; + +function BlockDraggable( { rootUID, index, uid, layout, isDragging, ...props } ) { + const className = classnames( 'editor-block-list__block-draggable', { + 'is-visible': isDragging, + } ); + + const transferData = { + type: 'block', + fromIndex: index, + rootUID, + uid, + layout, + }; + + return ( + +
+
+ ); +} + +export default BlockDraggable; diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js index 170c14e5abc8fe..f872aa53594483 100644 --- a/editor/components/block-list/block.js +++ b/editor/components/block-list/block.js @@ -44,6 +44,7 @@ import BlockContextualToolbar from './block-contextual-toolbar'; import BlockMultiControls from './multi-controls'; import BlockMobileToolbar from './block-mobile-toolbar'; import BlockInsertionPoint from './insertion-point'; +import BlockDraggable from './block-draggable'; import IgnoreNestedEvents from './ignore-nested-events'; import InserterWithShortcuts from '../inserter-with-shortcuts'; import Inserter from '../inserter'; @@ -86,19 +87,22 @@ export class BlockListBlock extends Component { this.maybeHover = this.maybeHover.bind( this ); this.hideHoverEffects = this.hideHoverEffects.bind( this ); this.mergeBlocks = this.mergeBlocks.bind( this ); + this.insertBlocksAfter = this.insertBlocksAfter.bind( this ); this.onFocus = this.onFocus.bind( this ); this.preventDrag = this.preventDrag.bind( this ); this.onPointerDown = this.onPointerDown.bind( this ); this.deleteOrInsertAfterWrapper = this.deleteOrInsertAfterWrapper.bind( this ); this.onBlockError = this.onBlockError.bind( this ); - this.insertBlocksAfter = this.insertBlocksAfter.bind( this ); this.onTouchStart = this.onTouchStart.bind( this ); this.onClick = this.onClick.bind( this ); + this.onDragStart = this.onDragStart.bind( this ); + this.onDragEnd = this.onDragEnd.bind( this ); this.selectOnOpen = this.selectOnOpen.bind( this ); this.hadTouchStart = false; this.state = { error: null, + dragging: false, isHovered: false, }; } @@ -393,6 +397,14 @@ export class BlockListBlock extends Component { this.setState( { error } ); } + onDragStart() { + this.setState( { dragging: true } ); + } + + onDragEnd() { + this.setState( { dragging: false } ); + } + selectOnOpen( open ) { if ( open && ! this.props.isSelected ) { this.props.onSelect(); @@ -408,6 +420,7 @@ export class BlockListBlock extends Component { isLocked, isFirst, isLast, + uid, rootUID, layout, renderBlockMenu, @@ -435,7 +448,7 @@ export class BlockListBlock extends Component { const shouldShowSettingsMenu = shouldShowMovers; const shouldShowContextualToolbar = shouldAppearSelected && isValid && showContextualToolbar; const shouldShowMobileToolbar = shouldAppearSelected; - const { error } = this.state; + const { error, dragging } = this.state; // Insertion point can only be made visible when the side inserter is // not present, and either the block is at the extent of a selection or @@ -453,6 +466,7 @@ export class BlockListBlock extends Component { 'is-multi-selected': isMultiSelected, 'is-hovered': isHovered, 'is-reusable': isReusableBlock( blockType ), + 'is-hidden': dragging, 'is-typing': isTypingWithinBlock, } ); @@ -466,6 +480,7 @@ export class BlockListBlock extends Component { ...blockType.getEditWrapperProps( block.attributes ), }; } + const blockElementId = `block-${ uid }`; // Disable reasons: // @@ -478,6 +493,7 @@ export class BlockListBlock extends Component { /* eslint-disable jsx-a11y/mouse-events-have-key-events, jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ return ( + { ! isMultiSelected && ( + + ) } { shouldShowInsertionPoint && ( @@ -509,7 +537,7 @@ export class BlockListBlock extends Component { /> { shouldShowMovers && ( ) } - { isHovered && } + { isHovered && } { shouldShowContextualToolbar && } { isFirstMultiSelected && } + { isValid && mode === 'visual' && ( ) } { isValid && mode === 'html' && ( - + ) } { ! isValid && [
@@ -565,7 +594,7 @@ export class BlockListBlock extends Component { { shouldShowMobileToolbar && ( ) } @@ -574,7 +603,7 @@ export class BlockListBlock extends Component { { showSideInserter && (
- +
( { onChange( uid, attributes ) { dispatch( updateBlockAttributes( uid, attributes ) ); }, - onSelect( uid = ownProps.uid, initialPosition ) { dispatch( selectBlock( uid, initialPosition ) ); }, - onInsertBlocks( blocks, index ) { const { rootUID, layout } = ownProps; @@ -628,15 +655,12 @@ const mapDispatchToProps = ( dispatch, ownProps ) => ( { dispatch( insertBlocks( blocks, index, rootUID ) ); }, - onRemove( uid ) { dispatch( removeBlock( uid ) ); }, - onMerge( ...args ) { dispatch( mergeBlocks( ...args ) ); }, - onReplace( blocks ) { const { layout } = ownProps; @@ -646,11 +670,9 @@ const mapDispatchToProps = ( dispatch, ownProps ) => ( { dispatch( replaceBlocks( [ ownProps.uid ], blocks ) ); }, - onMetaChange( meta ) { dispatch( editPost( { meta } ) ); }, - toggleSelection( selectionEnabled ) { dispatch( toggleSelection( selectionEnabled ) ); }, diff --git a/editor/components/block-list/style.scss b/editor/components/block-list/style.scss index 7afdf59eb7073c..366b6cac3c2b77 100644 --- a/editor/components/block-list/style.scss +++ b/editor/components/block-list/style.scss @@ -1,3 +1,88 @@ +.components-draggable__clone { + .editor-block-list__block-draggable { + background: white; + box-shadow: $shadow-popover; + + @include break-small { + left: 0; + right: 0; + } + } + + // Hide the Block UI when dragging the block + // This ensures the page scroll properly (no sticky elements) + .editor-block-contextual-toolbar, + .editor-block-mover, + .editor-block-settings-menu { + // I think important is fine here to avoid over complexing the selector + display: none !important; + } +} + +.editor-block-list__block-draggable { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: z-index( '.editor-block-list__block-draggable' ); + + > .editor-block-list__block-draggable-inner { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: $light-gray-200; + visibility: hidden; + + @include break-small { + margin: 0 48px; + } + } + + &.is-visible > .editor-block-list__block-draggable-inner { + visibility: visible; + } + + @include break-small { + left: -$block-side-ui-padding; + right: -$block-side-ui-padding; + + // Full width blocks don't have the place to expand on the side + .editor-block-list__block[data-align="full"] & { + left: 0; + right: 0; + } + } + + + cursor: move;/* Fallback for IE/Edge < 14 */ + cursor: grab; +} + + +// Allow Drag & Drop when clicking on the empty area of the mover and the settings menu +.editor-block-list__block .editor-block-mover, +.editor-block-list__block .editor-block-settings-menu { + pointer-events: none; + + > * { + pointer-events: auto; + } +} + +.editor-block-list__block { + &.is-hidden *, + &.is-hidden > * { + visibility: hidden; + } + + .editor-block-list__block-edit .reusable-block-edit-panel * { + z-index: z-index( '.editor-block-list__block-edit .reusable-block-edit-panel *' ); + } +} + .editor-block-list__layout { // make room in the main content column for the side UI // the side UI uses negative margins to position itself so as to not affect the block width @@ -70,7 +155,6 @@ background-image: linear-gradient( to bottom, transparent, #fff ); } - /** * Hovered Block style */ @@ -305,7 +389,7 @@ } // Dropzones - & .components-drop-zone { + & > .components-drop-zone { border: none; top: -4px; bottom: -3px; @@ -343,6 +427,10 @@ bottom: -.9px; // utilize full vertical space to increase hoverable area padding: 0; width: $block-side-ui-width; + + /* Necessary for drag indicator */ + cursor: move;/* Fallback for IE/Edge < 14 */ + cursor: grab; } // Elevate when selected or hovered @@ -432,6 +520,8 @@ .editor-block-list .editor-inserter { margin: $item-spacing; + cursor: move;/* Fallback for IE/Edge < 14 */ + cursor: grab; .editor-inserter__toggle { color: $dark-gray-300; diff --git a/editor/components/block-settings-menu/index.js b/editor/components/block-settings-menu/index.js index b60e5980d80f88..e4438622ba58cc 100644 --- a/editor/components/block-settings-menu/index.js +++ b/editor/components/block-settings-menu/index.js @@ -27,8 +27,8 @@ function BlockSettingsMenu( { onSelect, focus, rootUID, - renderBlockMenu = ( { children } ) => children } -) { + renderBlockMenu = ( { children } ) => children, +} ) { const count = uids.length; return ( diff --git a/editor/components/default-block-appender/index.js b/editor/components/default-block-appender/index.js index e66f2ff8aca43c..2af57da42a424e 100644 --- a/editor/components/default-block-appender/index.js +++ b/editor/components/default-block-appender/index.js @@ -42,7 +42,7 @@ export function DefaultBlockAppender( {
- + { + describe( 'insertAt', () => { + it( 'should insert a unique item at a given position', () => { + const array = [ 'a', 'b', 'd' ]; + expect( insertAt( array, 'c', 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd' ] + ); + } ); + + it( 'should insert multiple items at a given position', () => { + const array = [ 'a', 'b', 'e' ]; + expect( insertAt( array, [ 'c', 'd' ], 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd', 'e' ] + ); + } ); + } ); + + describe( 'moveTo', () => { + it( 'should move an item to a given position', () => { + const array = [ 'a', 'b', 'd', 'c' ]; + expect( moveTo( array, 3, 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd' ] + ); + } ); + + it( 'should move an item upwards to a given position', () => { + const array = [ 'b', 'a', 'c', 'd' ]; + expect( moveTo( array, 0, 1 ) ).toEqual( + [ 'a', 'b', 'c', 'd' ] + ); + } ); + + it( 'should move multiple items to a given position', () => { + const array = [ 'a', 'c', 'd', 'b', 'e' ]; + expect( moveTo( array, 1, 2, 2 ) ).toEqual( + [ 'a', 'b', 'c', 'd', 'e' ] + ); + } ); + } ); +} ); diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js index 6a1925c949eb1e..fbc45f52b2c1eb 100644 --- a/editor/store/test/reducer.js +++ b/editor/store/test/reducer.js @@ -882,6 +882,93 @@ describe( 'state', () => { expect( state.present.blockOrder[ '' ] ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); } ); + it( 'should move block to lower index', () => { + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + uid: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCK_TO_POSITION', + uid: 'ribs', + index: 0, + } ); + + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs', 'chicken', 'veggies' ] ); + } ); + + it( 'should move block to higher index', () => { + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + uid: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCK_TO_POSITION', + uid: 'ribs', + index: 2, + } ); + + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'chicken', 'veggies', 'ribs' ] ); + } ); + + it( 'should not move block if passed same index', () => { + const original = editor( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + uid: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + uid: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + uid: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = editor( original, { + type: 'MOVE_BLOCK_TO_POSITION', + uid: 'ribs', + index: 1, + } ); + + expect( state.present.blockOrder[ '' ] ).toEqual( [ 'chicken', 'ribs', 'veggies' ] ); + } ); + describe( 'edits()', () => { it( 'should save newly edited properties', () => { const original = editor( undefined, {