Skip to content

Commit

Permalink
Merge pull request #4048 from ampproject/feature/page-ordering
Browse files Browse the repository at this point in the history
Page reordering from carousel
  • Loading branch information
miina committed Jan 21, 2020
2 parents c050dfe + 494ddff commit 68d3bef
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 29 deletions.
5 changes: 4 additions & 1 deletion assets/src/edit-story/app/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Inspector from '../../components/inspector';
import Library from '../../components/library';
import Canvas from '../../components/canvas';
import { ADMIN_TOOLBAR_HEIGHT, LIBRARY_MIN_WIDTH, LIBRARY_MAX_WIDTH, INSPECTOR_MIN_WIDTH, INSPECTOR_MAX_WIDTH, HEADER_HEIGHT } from '../../constants';
import DropZoneProvider from '../../components/dropzone/dropZoneProvider';

const Editor = styled.div`
font-family: ${ ( { theme } ) => theme.fonts.body1.family };
Expand Down Expand Up @@ -48,7 +49,9 @@ function Layout() {
<Library />
</Area>
<Area area="canv">
<Canvas />
<DropZoneProvider>
<Canvas />
</DropZoneProvider>
</Area>
<Area area="insp">
<Inspector />
Expand Down
37 changes: 19 additions & 18 deletions assets/src/edit-story/components/canvas/canvasLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,27 @@ const Area = styled.div`
`;

function CanvasLayout() {
// @todo SelectionCanvas should not be there, will be addressed separately.
return (
<SelectionCanvas>
<Background>
<Area area="page">
<Background>
<Area area="page">
<SelectionCanvas>
<Page />
</Area>
<Area area="menu">
<PageMenu />
</Area>
<Area area="prev">
<PageNav isNext={ false } />
</Area>
<Area area="next">
<PageNav />
</Area>
<Area area="carousel">
<Carousel />
</Area>
</Background>
</SelectionCanvas>
</SelectionCanvas>
</Area>
<Area area="menu">
<PageMenu />
</Area>
<Area area="prev">
<PageNav isNext={ false } />
</Area>
<Area area="next">
<PageNav />
</Area>
<Area area="carousel">
<Carousel />
</Area>
</Background>
);
}

Expand Down
57 changes: 47 additions & 10 deletions assets/src/edit-story/components/canvas/carousel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { __, sprintf } from '@wordpress/i18n';
*/
import { useStory } from '../../../app';
import { LeftArrow, RightArrow, GridView } from '../../button';
import DropZone from '../../dropzone';

const PAGE_WIDTH = 72;
const PAGE_HEIGHT = 128;
Expand Down Expand Up @@ -58,7 +59,7 @@ const GridViewButton = styled( GridView )`
`;

function Carousel() {
const { state: { pages, currentPageIndex, currentPageId }, actions: { setCurrentPage } } = useStory();
const { state: { pages, currentPageIndex, currentPageId }, actions: { setCurrentPage, arrangePage } } = useStory();
const [ hasHorizontalOverflow, setHasHorizontalOverflow ] = useState( false );
const listRef = useRef();
const pageRefs = useRef( [] );
Expand Down Expand Up @@ -105,6 +106,38 @@ function Carousel() {
} );
}, [ listRef ] );

const getArrangeIndex = ( sourceIndex, dstIndex, position ) => {
// If the dropped element is before the dropzone index then we have to deduct
// that from the index to make up for the "lost" element in the row.
const indexAdjustment = sourceIndex < dstIndex ? -1 : 0;
if ( 'left' === position.x ) {
return dstIndex + indexAdjustment;
}
return dstIndex + 1 + indexAdjustment;
};

const onDragStart = useCallback( ( index ) => ( evt ) => {
const pageData = {
type: 'page',
index,
};
evt.dataTransfer.setData( 'text', JSON.stringify( pageData ) );
}, [] );

const onDrop = ( evt, { position, pageIndex } ) => {
const droppedEl = JSON.parse( evt.dataTransfer.getData( 'text' ) );
if ( ! droppedEl || 'page' !== droppedEl.type ) {
return;
}
const arrangedIndex = getArrangeIndex( droppedEl.index, pageIndex, position );
// Do nothing if the index didn't change.
if ( droppedEl.index !== arrangedIndex ) {
const pageId = pages[ droppedEl.index ].id;
arrangePage( { pageId, position: arrangedIndex } );
setCurrentPage( { pageId } );
}
};

return (
<Wrapper>
<Area area="left-navigation">
Expand All @@ -120,15 +153,19 @@ function Carousel() {
{ pages.map( ( page, index ) => {
const isCurrentPage = index === currentPageIndex;
return (
<Page
key={ index }
onClick={ handleClickPage( page ) }
isActive={ isCurrentPage }
ref={ ( el ) => {
pageRefs.current[ page.id ] = el;
} }
aria-label={ isCurrentPage ? sprintf( __( 'Page %s (current page)', 'amp' ), index + 1 ) : sprintf( __( 'Go to page %s', 'amp' ), index + 1 ) }
/>
<DropZone key={ index } onDrop={ onDrop } pageIndex={ index } >
<Page
key={ index }
draggable="true"
onClick={ handleClickPage( page ) }
onDragStart={ onDragStart( index ) }
isActive={ isCurrentPage }
ref={ ( el ) => {
pageRefs.current[ page.id ] = el;
} }
aria-label={ isCurrentPage ? sprintf( __( 'Page %s (current page)', 'amp' ), index + 1 ) : sprintf( __( 'Go to page %s', 'amp' ), index + 1 ) }
/>
</DropZone>
);
} ) }
</List>
Expand Down
6 changes: 6 additions & 0 deletions assets/src/edit-story/components/dropzone/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* WordPress dependencies
*/
import { createContext } from '@wordpress/element';

export default createContext( { actions: {}, state: {} } );
106 changes: 106 additions & 0 deletions assets/src/edit-story/components/dropzone/dropZoneProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';

/**
* WordPress dependencies
*/
import { useCallback, useState } from '@wordpress/element';

/**
* Internal dependencies
*/
import Context from './context';

function DropZoneProvider( { children } ) {
const [ dropZones, setDropZones ] = useState( [] );
const [ hoveredDropZone, setHoveredDropZone ] = useState( null );

const registerDropZone = useCallback(
( dropZone ) => {
// If dropZone isn't registered yet.
if ( dropZone && ! dropZones.some( ( { node } ) => node === dropZone.node ) ) {
setDropZones( ( oldDropZones ) => ( [ ...oldDropZones, dropZone ] ) );
}
}, [ dropZones ] );

// Unregisters dropzones which node's don't exist.
const unregisterDropZone = useCallback(
( dropZone ) => {
// If dropZone needs unregistering.
if ( dropZones.some( ( dz ) => dz === dropZone ) ) {
setDropZones( ( oldDropZones ) => oldDropZones.filter( ( dz ) => dz !== dropZone ) );
}
}, [ dropZones ] );

const isWithinElementBounds = ( element, x, y ) => {
const rect = element.getBoundingClientRect();
if ( rect.bottom === rect.top || rect.left === rect.right ) {
return false;
}
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
};

const resetHoverState = () => {
setHoveredDropZone( null );
};

const onDragOver = ( evt ) => {
evt.preventDefault();
// Get the hovered dropzone. // @todo Consider dropzone inside dropzone, will we need this?
const foundDropZones = dropZones.filter( ( dropZone ) => {
return isWithinElementBounds( dropZone.node, evt.clientX, evt.clientY );
} );

// If there was a dropzone before and nothing was found now, reset.
if ( hoveredDropZone && ! foundDropZones.length ) {
resetHoverState();
return;
}
const foundDropZone = foundDropZones[ 0 ];
// If dropzone not found, do nothing.
if ( ! foundDropZone || ! foundDropZone.node ) {
return;
}
const rect = foundDropZone.node.getBoundingClientRect();

const position = {
x: evt.clientX - rect.left < rect.right - evt.clientX ? 'left' : 'right',
y: evt.clientY - rect.top < rect.bottom - evt.clientY ? 'top' : 'bottom',
};

setHoveredDropZone( {
node: foundDropZone.node,
position,
} );
};

const state = {
state: {
hoveredDropZone,
dropZones,
},
actions: {
registerDropZone,
unregisterDropZone,
resetHoverState,
},
};
return (
<div onDragOver={ onDragOver }>
<Context.Provider value={ state }>
{ children }
</Context.Provider>
</div>
);
}

DropZoneProvider.propTypes = {
children: PropTypes.oneOfType( [
PropTypes.arrayOf( PropTypes.node ),
PropTypes.node,
] ).isRequired,
};

export default DropZoneProvider;
110 changes: 110 additions & 0 deletions assets/src/edit-story/components/dropzone/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import styled from 'styled-components';

/**
* WordPress dependencies
*/
import { useRef, useState, useLayoutEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import useDropZone from './useDropZone';

const DropZoneComponent = styled.div`
position: relative;
${ ( { borderPosition, theme, highlightWidth } ) => borderPosition && `
:after {
height: 100%;
top: 0;
display: block;
position: absolute;
width: ${ highlightWidth }px;
background: ${ theme.colors.action };
content: '';
${ borderPosition }: -${ highlightWidth / 2 }px;
}
` }
`;

function DropZone( { children, onDrop, pageIndex } ) {
const dropZoneElement = useRef( null );
const [ dropZone, setDropZone ] = useState( null );
const { actions: { registerDropZone, unregisterDropZone, resetHoverState }, state: { hoveredDropZone } } = useDropZone();

useLayoutEffect( () => {
setDropZone( {
node: dropZoneElement.current,
} );
}, [ dropZoneElement ] );

useLayoutEffect( () => {
registerDropZone( dropZone );
return () => {
unregisterDropZone( dropZone );
setDropZone( null );
};
}, [ dropZone, registerDropZone, unregisterDropZone ] );

const getDragType = ( { dataTransfer } ) => {
if ( dataTransfer ) {
if ( Array.isArray( dataTransfer.types ) ) {
if ( dataTransfer.types.includes( 'Files' ) ) {
return 'file';
}
if ( dataTransfer.types.includes( 'text/html' ) ) {
return 'html';
}
} else {
// For Edge, types is DomStringList and not array.
if ( dataTransfer.types.contains( 'Files' ) ) {
return 'file';
}
if ( dataTransfer.types.contains( 'text/html' ) ) {
return 'html';
}
}
}
return 'default';
};

const onDropHandler = ( evt ) => {
resetHoverState();
if ( dropZoneElement.current ) {
const rect = dropZoneElement.current.getBoundingClientRect();
// Get the relative position of the dropping point based on the dropzone.
const relativePosition = {
x: evt.clientX - rect.left < rect.right - evt.clientX ? 'left' : 'right',
y: evt.clientY - rect.top < rect.bottom - evt.clientY ? 'top' : 'bottom',
};
if ( 'default' === getDragType( evt ) ) {
onDrop( evt, { position: relativePosition, pageIndex } );
}
// @todo Support for files when it becomes necessary.
}
evt.preventDefault();
};

const isDropZoneActive = dropZoneElement.current && hoveredDropZone && hoveredDropZone.node === dropZoneElement.current;
// @todo Currently static, can be adjusted for other use cases.
const highlightWidth = 5;
return (
<DropZoneComponent highlightWidth={ highlightWidth } borderPosition={ isDropZoneActive ? hoveredDropZone.position.x : null } ref={ dropZoneElement } onDrop={ onDropHandler }>
{ children }
</DropZoneComponent>
);
}

DropZone.propTypes = {
children: PropTypes.oneOfType( [
PropTypes.arrayOf( PropTypes.node ),
PropTypes.node,
] ).isRequired,
onDrop: PropTypes.func,
pageIndex: PropTypes.number,
};

export default DropZone;
15 changes: 15 additions & 0 deletions assets/src/edit-story/components/dropzone/useDropZone.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* WordPress dependencies
*/
import { useContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import Context from './context';

function useDropZone() {
return useContext( Context );
}

export default useDropZone;

0 comments on commit 68d3bef

Please sign in to comment.