diff --git a/assets/src/stories-editor/blocks/amp-story-page/edit.css b/assets/src/stories-editor/blocks/amp-story-page/edit.css index 27c3f755b06..64e00a95007 100644 --- a/assets/src/stories-editor/blocks/amp-story-page/edit.css +++ b/assets/src/stories-editor/blocks/amp-story-page/edit.css @@ -76,6 +76,45 @@ opacity: .5; } +.editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-draggable-hover { + opacity: 1; +} + +.editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-draggable-hover-droppable::before, +.editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-draggable-hover-amp-amp-story-cta.amp-page-draggable-hover-droppable::before { + display: block; + content: ""; + position: absolute; + top: -4px; + bottom: -4px; + left: -4px; + right: -4px; + border: 4px solid theme(primary); + z-index: 10; +} + +.editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-draggable-hover-amp-amp-story-cta.amp-page-draggable-hover-droppable::before { + top: calc(80% - 2px); +} + +.editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-draggable-hover-undroppable::after, +.editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-draggable-hover-amp-amp-story-cta.amp-page-draggable-hover-droppable::after { + display: block; + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: #fff; + opacity: .8; + z-index: 10; +} + +.editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-draggable-hover-amp-amp-story-cta.amp-page-draggable-hover-droppable::after { + bottom: calc(20% + 2px); +} + .editor-block-list__layout [data-type="amp/amp-story-page"].amp-page-inactive > div { pointer-events: none; } diff --git a/assets/src/stories-editor/components/block-mover/README.md b/assets/src/stories-editor/components/block-mover/README.md index acab41d0dcb..d091ab71cc4 100644 --- a/assets/src/stories-editor/components/block-mover/README.md +++ b/assets/src/stories-editor/components/block-mover/README.md @@ -9,8 +9,6 @@ In addition the component also uses default `IgnoreNestedEvents` component which ## Unchanged files. The following files of the component are 100% or almost unchanged: -- block-draggable.js: - - This file is mainly copied from core, the only difference is switching to using internal Draggable - ignore-nested-events.js (unchanged) ## Modified files @@ -19,6 +17,9 @@ draggable.js: This file has been modified to display the clone in relation to th - The clone styling has been changed to ignore the % values and to match the size of the original element; - Resizing the dragged clone has been removed. +block-draggable.js: This file has been extended to allow drag between pages +- Assumes that blocks are only draggable inside pages, not inside other types of elements + block-drag-area.js: This was renamed from from drag-handle.js - Now wraps draggable element(s) as children, whereas it used to appear alongside them. - Enables dragging the entire block. diff --git a/assets/src/stories-editor/components/block-mover/block-draggable.js b/assets/src/stories-editor/components/block-mover/block-draggable.js index 9e61d906d7c..87528df6f0b 100644 --- a/assets/src/stories-editor/components/block-mover/block-draggable.js +++ b/assets/src/stories-editor/components/block-mover/block-draggable.js @@ -10,14 +10,19 @@ import PropTypes from 'prop-types'; /** * WordPress dependencies */ -import { withSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; +import { useRef } from '@wordpress/element'; /** * Internal dependencies */ +import { useIsBlockAllowedOnPage, useMoveBlockToPage } from '../../helpers'; import Draggable from './draggable'; -const BlockDraggable = ( { children, clientId, blockName, rootClientId, blockElementId, index, onDragStart, onDragEnd } ) => { +const BlockDraggable = ( { children, clientId, blockName, blockElementId, onDragStart, onDragEnd } ) => { + const { rootClientId } = useSelect( ( select ) => select( 'core/block-editor' ).getBlockRootClientId( clientId ) ); + const { index } = useSelect( ( select ) => select( 'core/block-editor' ).getBlockIndex( clientId, rootClientId ) ); + const transferData = { type: 'block', srcIndex: index, @@ -25,6 +30,80 @@ const BlockDraggable = ( { children, clientId, blockName, rootClientId, blockEle srcClientId: clientId, }; + const { moveBlockToPage, getPageByOffset } = useMoveBlockToPage( clientId ); + + const isBlockAllowedOnPage = useIsBlockAllowedOnPage(); + + // This holds the currently highlighted element, if any + const hoverElement = useRef( { pageId: null, element: null, classes: [] } ); + + /** + * Clear highlighting of pages. + */ + const clearHighlight = () => { + // Unhighlight old highlighted page. + if ( hoverElement.current.element ) { + hoverElement.current.element.classList.remove( ...hoverElement.current.classes ); + } + + // Reset current hover element + hoverElement.current = { pageId: null, element: null, classes: [] }; + }; + + /** + * On the page given by the offset, set classes indicating that an element drag on this page is in progress, which element is being dragged and whether a drop is allowed on this page. + * + * @param {number} offset Integer specifying offset from current page - e.g. -2 on page 3 will return id of page 1. Page indices are zero-based. + */ + const setHighlightByOffset = ( offset ) => { + // Get neighboring page. + const pageId = getPageByOffset( offset ); + const hasHighlightChanged = pageId !== hoverElement.current.pageId; + + if ( ! hasHighlightChanged ) { + return; + } + + clearHighlight(); + + // Highlight page and mark whether drop is allowed or not. + if ( pageId ) { + // Drop is always allowed on the initial page (offset=0) + const isAllowed = offset === 0 || isBlockAllowedOnPage( blockName, pageId ); + const classes = [ + 'amp-page-draggable-hover', + `amp-page-draggable-hover-${ blockName.replace( /\W/g, '-' ) }`, + isAllowed ? 'amp-page-draggable-hover-droppable' : 'amp-page-draggable-hover-undroppable', + ]; + + const element = document.getElementById( `block-${ pageId }` ); + element.classList.add( ...classes ); + + hoverElement.current = { element, pageId, classes }; + } + }; + + /** + * Drop this element on the page `offset` away from the current page (if possible). If dropped, update the position of the element on the new page. + * + * Currently this function removes the old element and creates a clone on the new page. + * + * @param {number} offset Integer specifying offset from current page - e.g. -2 on page 3 will return id of page 1. Offset must be non-zero. + * @param {Object} newAttributes Object with attributes to update on element on new page. + */ + const dropElementByOffset = ( offset, newAttributes ) => { + const pageId = getPageByOffset( offset ); + if ( ! pageId ) { + return; + } + + if ( ! isBlockAllowedOnPage( blockName, pageId ) ) { + return; + } + + moveBlockToPage( pageId, newAttributes ); + }; + return ( { ( { onDraggableStart, onDraggableEnd } ) => { @@ -46,21 +128,13 @@ const BlockDraggable = ( { children, clientId, blockName, rootClientId, blockEle }; BlockDraggable.propTypes = { - index: PropTypes.number.isRequired, - rootClientId: PropTypes.string, - clientId: PropTypes.string, - blockElementId: PropTypes.string, - blockName: PropTypes.string, - children: PropTypes.node.isRequired, + clientId: PropTypes.string.isRequired, + blockElementId: PropTypes.string.isRequired, + blockName: PropTypes.string.isRequired, + children: PropTypes.any.isRequired, onDragStart: PropTypes.func, onDragEnd: PropTypes.func, }; -export default withSelect( ( select, { clientId } ) => { - const { getBlockIndex, getBlockRootClientId } = select( 'core/block-editor' ); - const rootClientId = getBlockRootClientId( clientId ); - return { - index: getBlockIndex( clientId, rootClientId ), - rootClientId, - }; -} )( BlockDraggable ); +export default BlockDraggable; + diff --git a/assets/src/stories-editor/components/block-mover/draggable.js b/assets/src/stories-editor/components/block-mover/draggable.js index 907483966f4..4f520611e6f 100644 --- a/assets/src/stories-editor/components/block-mover/draggable.js +++ b/assets/src/stories-editor/components/block-mover/draggable.js @@ -17,12 +17,21 @@ import { withSafeTimeout } from '@wordpress/compose'; /** * Internal dependencies */ -import { getPixelsFromPercentage } from '../../helpers'; +import { + getPixelsFromPercentage, + getPercentageFromPixels, + getRelativeElementPosition, + isCTABlock, +} from '../../helpers'; import { STORY_PAGE_INNER_WIDTH, STORY_PAGE_INNER_HEIGHT, + STORY_PAGE_INNER_HEIGHT_FOR_CTA, + STORY_PAGE_MARGIN, } from '../../constants'; +const PAGE_AND_MARGIN = STORY_PAGE_INNER_WIDTH + STORY_PAGE_MARGIN; + const { Image, navigator } = window; const cloneWrapperClass = 'components-draggable__clone'; @@ -61,13 +70,41 @@ class Draggable extends Component { * @param {Object} event The non-custom DragEvent. */ onDragEnd = ( event ) => { - const { onDragEnd = noop } = this.props; + const { clearHighlight, dropElementByOffset, blockName, setTimeout, onDragEnd = noop } = this.props; if ( event ) { event.preventDefault(); } + // Make sure to clear highlight. + clearHighlight(); + + // Attempt drop on neighbor if offset + if ( this.pageOffset !== 0 ) { + // All this is about calculating the position of the (correct) element on the new page. + const currentElementTop = parseInt( this.cloneWrapper.style.top ); + const currentElementLeft = parseInt( this.cloneWrapper.style.left ); + const newLeft = currentElementLeft - ( this.pageOffset * PAGE_AND_MARGIN ); + + let baseHeight, xAttribute, yAttribute; + if ( isCTABlock( blockName ) ) { + baseHeight = STORY_PAGE_INNER_HEIGHT_FOR_CTA; + xAttribute = 'btnPositionLeft'; + yAttribute = 'btnPositionTop'; + } else { + baseHeight = STORY_PAGE_INNER_HEIGHT; + xAttribute = 'positionLeft'; + yAttribute = 'positionTop'; + } + + const newAttributes = { + [ xAttribute ]: getPercentageFromPixels( 'x', newLeft, STORY_PAGE_INNER_WIDTH ), + [ yAttribute ]: getPercentageFromPixels( 'y', currentElementTop, baseHeight ), + }; + dropElementByOffset( this.pageOffset, newAttributes ); + } + this.resetDragState(); - this.props.setTimeout( onDragEnd ); + setTimeout( onDragEnd ); } /** @@ -76,10 +113,11 @@ class Draggable extends Component { * @param {Object} event The non-custom DragEvent. */ onDragOver = ( event ) => { + const { setHighlightByOffset, blockName } = this.props; const top = parseInt( this.cloneWrapper.style.top ) + event.clientY - this.cursorTop; // Don't allow the CTA button to go over its top limit. - if ( 'amp/amp-story-cta' === this.props.blockName ) { + if ( isCTABlock( blockName ) ) { this.cloneWrapper.style.top = top >= 0 ? `${ top }px` : '0px'; } else { this.cloneWrapper.style.top = `${ top }px`; @@ -91,6 +129,21 @@ class Draggable extends Component { // Update cursor coordinates. this.cursorLeft = event.clientX; this.cursorTop = event.clientY; + + // Check if mouse (*not* element, but actual cursor) is over neighboring page to either side. + const currentElementLeft = parseInt( this.cloneWrapper.style.left ); + const cursorLeftRelativeToPage = currentElementLeft + this.cursorLeftInsideElement; + const isOffRight = cursorLeftRelativeToPage > PAGE_AND_MARGIN; + const isOffLeft = cursorLeftRelativeToPage < -STORY_PAGE_MARGIN; + this.pageOffset = 0; + if ( isOffLeft || isOffRight ) { + // Check how far off we are to that side - on large screens you can drag elements 2+ pages over to either side. + this.pageOffset = ( isOffLeft ? + -Math.ceil( ( -STORY_PAGE_MARGIN - cursorLeftRelativeToPage ) / PAGE_AND_MARGIN ) : + Math.ceil( ( cursorLeftRelativeToPage - PAGE_AND_MARGIN ) / PAGE_AND_MARGIN ) + ); + } + setHighlightByOffset( this.pageOffset ); } onDrop = () => { @@ -108,9 +161,9 @@ class Draggable extends Component { */ onDragStart = ( event ) => { const { blockName, elementId, transferData, onDragStart = noop } = this.props; - const isCTABlock = 'amp/amp-story-cta' === blockName; + const blockIsCTA = isCTABlock( blockName ); // In the CTA block only the inner element (the button) is draggable, not the whole block. - const element = isCTABlock ? document.getElementById( elementId ) : document.getElementById( elementId ).parentNode; + const element = blockIsCTA ? document.getElementById( elementId ) : document.getElementById( elementId ).parentNode; if ( ! element ) { event.preventDefault(); @@ -149,11 +202,18 @@ class Draggable extends Component { this.cloneWrapper.style.transform = clone.style.transform; // 20% of the full value in case of CTA block. - const baseHeight = isCTABlock ? STORY_PAGE_INNER_HEIGHT / 5 : STORY_PAGE_INNER_HEIGHT; + const baseHeight = blockIsCTA ? STORY_PAGE_INNER_HEIGHT_FOR_CTA : STORY_PAGE_INNER_HEIGHT; // Position clone over the original element. - this.cloneWrapper.style.top = `${ getPixelsFromPercentage( 'y', parseInt( clone.style.top ), baseHeight ) }px`; - this.cloneWrapper.style.left = `${ getPixelsFromPercentage( 'x', parseInt( clone.style.left ), STORY_PAGE_INNER_WIDTH ) }px`; + const top = getPixelsFromPercentage( 'y', parseInt( clone.style.top ), baseHeight ); + const left = getPixelsFromPercentage( 'x', parseInt( clone.style.left ), STORY_PAGE_INNER_WIDTH ); + this.cloneWrapper.style.top = `${ top }px`; + this.cloneWrapper.style.left = `${ left }px`; + + // Get starting position information + const absolutePositionOfPage = getRelativeElementPosition( elementWrapper, document.documentElement ); + const absoluteElementLeft = absolutePositionOfPage.left + left; + this.cursorLeftInsideElement = event.clientX - absoluteElementLeft; clone.id = `clone-${ elementId }`; clone.style.top = 0; @@ -229,8 +289,11 @@ Draggable.propTypes = { } ), onDragStart: PropTypes.func, onDragEnd: PropTypes.func, + clearHighlight: PropTypes.func.isRequired, + setHighlightByOffset: PropTypes.func.isRequired, + dropElementByOffset: PropTypes.func.isRequired, setTimeout: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, + children: PropTypes.func.isRequired, }; export default withSafeTimeout( Draggable ); diff --git a/assets/src/stories-editor/components/block-navigation/index.js b/assets/src/stories-editor/components/block-navigation/index.js index 74937b62f96..c7c4d142938 100644 --- a/assets/src/stories-editor/components/block-navigation/index.js +++ b/assets/src/stories-editor/components/block-navigation/index.js @@ -6,9 +6,8 @@ import PropTypes from 'prop-types'; /** * WordPress dependencies */ -import { withSelect, useSelect, useDispatch } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { DropZoneProvider, NavigableMenu } from '@wordpress/components'; -import { compose, ifCondition } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; /** @@ -96,6 +95,12 @@ function BlockNavigation() { }; }, [] ); + const isReordering = useSelect( ( select ) => select( 'amp/story' ).isReordering(), [] ); + + if ( isReordering ) { + return null; + } + const hasBlocks = blocks.length > 0 || unMovableBlock; return ( @@ -122,13 +127,5 @@ function BlockNavigation() { ); } -export default compose( - withSelect( ( select ) => { - const { isReordering } = select( 'amp/story' ); +export default BlockNavigation; - return { - isReordering: isReordering(), - }; - } ), - ifCondition( ( { isReordering } ) => ! isReordering ), -)( BlockNavigation ); diff --git a/assets/src/stories-editor/components/editor-carousel/index.js b/assets/src/stories-editor/components/editor-carousel/index.js index f59ca7519b9..49863d3009d 100644 --- a/assets/src/stories-editor/components/editor-carousel/index.js +++ b/assets/src/stories-editor/components/editor-carousel/index.js @@ -10,12 +10,10 @@ import { useSelect, useDispatch } from '@wordpress/data'; * Internal dependencies */ import { Reorderer } from '../'; -import { STORY_PAGE_INNER_WIDTH } from '../../constants'; +import { STORY_PAGE_INNER_WIDTH, STORY_PAGE_MARGIN } from '../../constants'; import Indicator from './indicator'; import './edit.css'; -// This is the sum of left (20px) and right (30px) margin. -const TOTAL_PAGE_MARGIN = 50; const PAGE_BORDER = 1; const EditorCarousel = () => { @@ -59,7 +57,7 @@ const EditorCarousel = () => { wrapper.current.style.display = 'none'; } else { wrapper.current.style.display = ''; - wrapper.current.style.transform = `translateX(calc(50% - ${ PAGE_BORDER }px - ${ ( STORY_PAGE_INNER_WIDTH + TOTAL_PAGE_MARGIN ) / 2 }px - ${ ( currentIndex ) * TOTAL_PAGE_MARGIN }px - ${ currentIndex * STORY_PAGE_INNER_WIDTH }px))`; + wrapper.current.style.transform = `translateX(calc(50% - ${ PAGE_BORDER }px - ${ ( STORY_PAGE_INNER_WIDTH + STORY_PAGE_MARGIN ) / 2 }px - ${ ( currentIndex ) * STORY_PAGE_MARGIN }px - ${ currentIndex * STORY_PAGE_INNER_WIDTH }px))`; } }, [ currentIndex, isReordering, wrapper ] ); diff --git a/assets/src/stories-editor/components/higher-order/with-call-to-action-validation.js b/assets/src/stories-editor/components/higher-order/with-call-to-action-validation.js index f86b4296f02..35a4d28c33e 100644 --- a/assets/src/stories-editor/components/higher-order/with-call-to-action-validation.js +++ b/assets/src/stories-editor/components/higher-order/with-call-to-action-validation.js @@ -8,6 +8,11 @@ import { Warning } from '@wordpress/block-editor'; import { createHigherOrderComponent, compose } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { isCTABlock } from '../../helpers'; + const enhance = compose( /** * For blocks which are only allowed once per page, provides the @@ -19,7 +24,7 @@ const enhance = compose( withSelect( ( select, props ) => { const { getBlockRootClientId, getBlock, getBlockOrder, getBlocksByClientId } = select( 'core/block-editor' ); - if ( 'amp/amp-story-cta' !== props.name ) { + if ( ! isCTABlock( props.name ) ) { return {}; } @@ -51,7 +56,7 @@ export default createHigherOrderComponent( ( BlockEdit ) => { selectFirst, ...props } ) => { - if ( ! isInvalid || 'amp/amp-story-cta' !== props.name ) { + if ( ! isInvalid || ! isCTABlock( props.name ) ) { return ; } diff --git a/assets/src/stories-editor/components/inserter/index.js b/assets/src/stories-editor/components/inserter/index.js index 92fe1c7f636..44a2faac8ff 100644 --- a/assets/src/stories-editor/components/inserter/index.js +++ b/assets/src/stories-editor/components/inserter/index.js @@ -18,8 +18,7 @@ import { ReactElement } from 'react'; */ import { __ } from '@wordpress/i18n'; import { Dropdown, IconButton } from '@wordpress/components'; -import { compose, ifCondition } from '@wordpress/compose'; -import { withSelect, useSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -56,6 +55,12 @@ const Inserter = ( props ) => { return ! showInserter; } ); + const isReordering = useSelect( ( select ) => select( 'amp/story' ).isReordering(), [] ); + + if ( isReordering ) { + return null; + } + const onToggle = ( isOpen ) => { // Surface toggle callback to parent component if ( props.onToggle ) { @@ -122,17 +127,4 @@ Inserter.propTypes = { isAppender: PropTypes.bool, }; -const applyWithSelect = withSelect( ( select ) => { - const { isReordering } = select( 'amp/story' ); - - return { - isReordering: isReordering(), - }; -} ); - -export default compose( - applyWithSelect, - ifCondition( - ( { isReordering } ) => ! isReordering - ), -)( Inserter ); +export default Inserter; diff --git a/assets/src/stories-editor/components/inserter/menu.js b/assets/src/stories-editor/components/inserter/menu.js index a337cca0b4b..e59c8acff5b 100644 --- a/assets/src/stories-editor/components/inserter/menu.js +++ b/assets/src/stories-editor/components/inserter/menu.js @@ -399,6 +399,22 @@ export default compose( } const destinationRootBlockName = getBlockName( destinationRootClientId ); + /** + * Is the given block allowed on the given page? + * + * @todo Use `useIsBlockAllowedOnPage` once this component is refactored to use hooks. + * + * @param {Object} name The name of the block to test. + * @param {string} pageId Page ID. + * @return {boolean} Returns true if the element is allowed on the page, false otherwise. + */ + const isBlockAllowedOnPage = ( name, pageId ) => { + // canInsertBlockType() alone is not enough, see https://github.com/WordPress/gutenberg/issues/14515 + const blockSettings = getBlockListSettings( pageId ); + const isAllowed = canInsertBlockType( name, pageId ) && blockSettings && blockSettings.allowedBlocks.includes( name ); + return Boolean( isAllowed ); + }; + /* * Filters inserter items to only show blocks that can be inserted given the context. * @@ -411,9 +427,7 @@ export default compose( return true; } - // canInsertBlockType() alone is not enough, see https://github.com/WordPress/gutenberg/issues/14515 - const destinationBlockListSettings = getBlockListSettings( destinationRootClientId ); - return canInsertBlockType( name, getCurrentPage() ) && destinationBlockListSettings && destinationBlockListSettings.allowedBlocks.includes( name ); + return isBlockAllowedOnPage( name, getCurrentPage() ); } ); return { diff --git a/assets/src/stories-editor/components/media-inserter/index.js b/assets/src/stories-editor/components/media-inserter/index.js index 9ddc0a31f2c..1fa489dae48 100644 --- a/assets/src/stories-editor/components/media-inserter/index.js +++ b/assets/src/stories-editor/components/media-inserter/index.js @@ -1,30 +1,76 @@ -/** - * External dependencies - */ -import PropTypes from 'prop-types'; - /** * WordPress dependencies */ import { getBlockType, createBlock } from '@wordpress/blocks'; import { BlockIcon } from '@wordpress/block-editor'; -import { withDispatch, withSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; import { DropdownMenu } from '@wordpress/components'; -import { compose, ifCondition } from '@wordpress/compose'; import { __, sprintf } from '@wordpress/i18n'; + /** * Internal dependencies */ -import { processMedia } from '../../helpers'; -import { - IMAGE_BACKGROUND_TYPE, -} from '../../constants'; +import { processMedia, useIsBlockAllowedOnPage } from '../../helpers'; +import { IMAGE_BACKGROUND_TYPE } from '../../constants'; const POPOVER_PROPS = { position: 'bottom right', }; -const MediaInserter = ( { insertBlock, updateBlock, canInsertBlockType, showInserter, mediaType, allowedVideoMimeTypes } ) => { +const MediaInserter = () => { + const { + currentPage, + blockOrder, + showInserter, + mediaType, + allowedVideoMimeTypes, + } = useSelect( ( select ) => { + const { getCurrentPage } = select( 'amp/story' ); + const { getBlock, getBlockOrder } = select( 'core/block-editor' ); + const { getSettings } = select( 'amp/story' ); + + const _currentPage = getCurrentPage(); + const block = getBlock( _currentPage ); + + return { + currentPage: _currentPage, + blockOrder: getBlockOrder( _currentPage ), + // As used in component + showInserter: select( 'core/edit-post' ).getEditorMode() === 'visual' && select( 'core/editor' ).getEditorSettings().richEditingEnabled, + mediaType: block && block.attributes.mediaType ? block.attributes.mediaType : '', + allowedVideoMimeTypes: getSettings().allowedVideoMimeTypes, + }; + }, [] ); + + const isBlockAllowedOnPage = useIsBlockAllowedOnPage(); + + const { insertBlock, updateBlockAttributes, selectBlock } = useDispatch( 'core/block-editor' ); + + const insertBlockOnPage = useCallback( ( name ) => { + const index = blockOrder.length; + + const insertedBlock = createBlock( name, {} ); + + insertBlock( insertedBlock, index, currentPage ); + }, [ blockOrder, currentPage, insertBlock ] ); + + const updateBlock = useCallback( ( media ) => { + if ( ! currentPage ) { + return; + } + + const processed = processMedia( media ); + updateBlockAttributes( currentPage, processed ); + selectBlock( currentPage ); + }, [ currentPage, selectBlock, updateBlockAttributes ] ); + + const isReordering = useSelect( ( select ) => select( 'amp/story' ).isReordering(), [] ); + + if ( isReordering ) { + return null; + } + const blocks = [ 'core/video', 'core/image', @@ -49,14 +95,14 @@ const MediaInserter = ( { insertBlock, updateBlock, canInsertBlockType, showInse ]; for ( const block of blocks ) { - if ( ! canInsertBlockType( block ) ) { + if ( ! isBlockAllowedOnPage( block, currentPage ) ) { continue; } const blockType = getBlockType( block ); const item = { title: sprintf( __( 'Insert %s', 'amp' ), blockType.title ), - onClick: () => insertBlock( block ), + onClick: () => insertBlockOnPage( block ), disabled: ! showInserter, icon: , }; @@ -79,15 +125,6 @@ const MediaInserter = ( { insertBlock, updateBlock, canInsertBlockType, showInse ); }; -MediaInserter.propTypes = { - insertBlock: PropTypes.func.isRequired, - updateBlock: PropTypes.func.isRequired, - canInsertBlockType: PropTypes.func.isRequired, - showInserter: PropTypes.bool.isRequired, - mediaType: PropTypes.string.isRequired, - allowedVideoMimeTypes: PropTypes.arrayOf( PropTypes.string ).isRequired, -}; - const mediaPicker = ( dialogTitle, mediaType, updateBlock ) => { // Create the media frame. const fileFrame = wp.media( { @@ -112,61 +149,4 @@ const mediaPicker = ( dialogTitle, mediaType, updateBlock ) => { fileFrame.open(); }; -const applyWithSelect = withSelect( ( select ) => { - const { getCurrentPage } = select( 'amp/story' ); - const { canInsertBlockType, getBlockListSettings, getBlock } = select( 'core/block-editor' ); - const { isReordering, getSettings } = select( 'amp/story' ); - - const currentPage = getCurrentPage(); - const block = getBlock( currentPage ); - const mediaType = block && block.attributes.mediaType ? block.attributes.mediaType : ''; - const { allowedVideoMimeTypes } = getSettings(); - - return { - isReordering: isReordering(), - canInsertBlockType: ( name ) => { - // canInsertBlockType() alone is not enough, see https://github.com/WordPress/gutenberg/issues/14515 - const blockSettings = getBlockListSettings( currentPage ); - return canInsertBlockType( name, currentPage ) && blockSettings && blockSettings.allowedBlocks.includes( name ); - }, - // As used in component - showInserter: select( 'core/edit-post' ).getEditorMode() === 'visual' && select( 'core/editor' ).getEditorSettings().richEditingEnabled, - mediaType, - allowedVideoMimeTypes, - }; -} ); - -const applyWithDispatch = withDispatch( ( dispatch, props, { select } ) => { - const { getCurrentPage } = select( 'amp/story' ); - const { getBlockOrder } = select( 'core/block-editor' ); - const { insertBlock } = dispatch( 'core/block-editor' ); - - return { - insertBlock: ( name ) => { - const currentPage = getCurrentPage(); - const index = getBlockOrder( currentPage ).length; - - const insertedBlock = createBlock( name, {} ); - - insertBlock( insertedBlock, index, currentPage ); - }, - updateBlock: ( media ) => { - const clientId = getCurrentPage(); - const { updateBlockAttributes, selectBlock } = dispatch( 'core/block-editor' ); - - if ( ! clientId ) { - return; - } - - const processed = processMedia( media ); - updateBlockAttributes( clientId, processed ); - selectBlock( clientId ); - }, - }; -} ); - -export default compose( - applyWithSelect, - applyWithDispatch, - ifCondition( ( { isReordering } ) => ! isReordering ), -)( MediaInserter ); +export default MediaInserter; diff --git a/assets/src/stories-editor/components/shortcuts/index.js b/assets/src/stories-editor/components/shortcuts/index.js index d9ee4e839d3..720297ec141 100644 --- a/assets/src/stories-editor/components/shortcuts/index.js +++ b/assets/src/stories-editor/components/shortcuts/index.js @@ -3,34 +3,34 @@ */ import { getBlockType, createBlock } from '@wordpress/blocks'; import { BlockIcon } from '@wordpress/block-editor'; -import { withSelect, useSelect, useDispatch } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { IconButton } from '@wordpress/components'; -import { compose, ifCondition } from '@wordpress/compose'; import { useCallback } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { useIsBlockAllowedOnPage } from '../../helpers'; + const Shortcuts = () => { const { currentPage, index, - canInsertBlockType, showInserter, } = useSelect( ( select ) => { const { getCurrentPage } = select( 'amp/story' ); - const { canInsertBlockType: canInsert, getBlockListSettings, getBlockOrder } = select( 'core/block-editor' ); + const { getBlockOrder } = select( 'core/block-editor' ); return { currentPage: getCurrentPage(), index: getBlockOrder( getCurrentPage() ).length, - canInsertBlockType: ( name ) => { - // canInsertBlockType() alone is not enough, see https://github.com/WordPress/gutenberg/issues/14515 - const blockSettings = getBlockListSettings( getCurrentPage() ); - return canInsert( name, getCurrentPage() ) && blockSettings && blockSettings.allowedBlocks.includes( name ); - }, // As used in component showInserter: select( 'core/edit-post' ).getEditorMode() === 'visual' && select( 'core/editor' ).getEditorSettings().richEditingEnabled, }; }, [] ); + const isBlockAllowedOnPage = useIsBlockAllowedOnPage(); + const { insertBlock } = useDispatch( 'core/block-editor' ); const onClick = useCallback( ( name ) => { @@ -39,6 +39,12 @@ const Shortcuts = () => { insertBlock( insertedBlock, index, currentPage ); }, [ currentPage, index, insertBlock ] ); + const isReordering = useSelect( ( select ) => select( 'amp/story' ).isReordering(), [] ); + + if ( isReordering ) { + return null; + } + const blocks = [ 'amp/amp-story-text', 'amp/amp-story-cta', @@ -46,7 +52,7 @@ const Shortcuts = () => { return ( blocks.map( ( block ) => { - if ( ! canInsertBlockType( block ) ) { + if ( ! isBlockAllowedOnPage( block, currentPage ) ) { return null; } @@ -66,13 +72,4 @@ const Shortcuts = () => { ); }; -export default compose( - withSelect( ( select ) => { - const { isReordering } = select( 'amp/story' ); - - return { - isReordering: isReordering(), - }; - } ), - ifCondition( ( { isReordering } ) => ! isReordering ), -)( Shortcuts ); +export default Shortcuts; diff --git a/assets/src/stories-editor/components/story-block-drop-zone.js b/assets/src/stories-editor/components/story-block-drop-zone.js index 8b13a0e2268..0615a04f0f9 100644 --- a/assets/src/stories-editor/components/story-block-drop-zone.js +++ b/assets/src/stories-editor/components/story-block-drop-zone.js @@ -11,23 +11,22 @@ import PropTypes from 'prop-types'; /** * WordPress dependencies */ -import { - DropZone, -} from '@wordpress/components'; +import { DropZone } from '@wordpress/components'; import { useDispatch } from '@wordpress/data'; /** * Internal dependencies */ -import { getPercentageFromPixels } from '../helpers'; +import { getPercentageFromPixels, isCTABlock } from '../helpers'; import { STORY_PAGE_INNER_HEIGHT, + STORY_PAGE_INNER_HEIGHT_FOR_CTA, } from '../constants'; const wrapperElSelector = 'div[data-amp-selected="parent"] .editor-inner-blocks'; const BlockDropZone = ( { srcBlockName, srcClientId } ) => { - const isCTABlock = 'amp/amp-story-cta' === srcBlockName; + const blockIsCTA = isCTABlock( srcBlockName ); const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); @@ -43,7 +42,7 @@ const BlockDropZone = ( { srcBlockName, srcClientId } ) => { wrapperEl; // In case of the CTA block we are not moving the block itself but just the `a` within. - if ( isCTABlock ) { + if ( blockIsCTA ) { elementId = `amp-story-cta-button-${ srcClientId }`; cloneElementId = `clone-amp-story-cta-button-${ srcClientId }`; const btnWrapperSelector = `#block-${ srcClientId } .editor-block-list__block-edit`; @@ -65,7 +64,7 @@ const BlockDropZone = ( { srcBlockName, srcClientId } ) => { } // CTA block can't be rotated. - if ( ! isCTABlock ) { + if ( ! blockIsCTA ) { // We have to remove the rotation for getting accurate position. clone.parentNode.style.visibility = 'hidden'; clone.parentNode.style.transform = 'none'; @@ -76,13 +75,13 @@ const BlockDropZone = ( { srcBlockName, srcClientId } ) => { // We will set the new position based on where the clone was moved to, with reference being the wrapper element. // Lets take the % based on the wrapper for top and left. - const leftPosKey = isCTABlock ? 'btnPositionLeft' : 'positionLeft'; - const topPosKey = isCTABlock ? 'btnPositionTop' : 'positionTop'; + const leftPosKey = blockIsCTA ? 'btnPositionLeft' : 'positionLeft'; + const topPosKey = blockIsCTA ? 'btnPositionTop' : 'positionTop'; // Let's get the base value to measure the top percentage from. let baseHeight = STORY_PAGE_INNER_HEIGHT; - if ( isCTABlock ) { - baseHeight = STORY_PAGE_INNER_HEIGHT / 5; + if ( blockIsCTA ) { + baseHeight = STORY_PAGE_INNER_HEIGHT_FOR_CTA; } updateBlockAttributes( srcClientId, { [ leftPosKey ]: getPercentageFromPixels( 'x', clonePosition.left - wrapperPosition.left ), diff --git a/assets/src/stories-editor/components/with-wrapper-props.js b/assets/src/stories-editor/components/with-wrapper-props.js index 03fa6558c3a..033e4b9715b 100644 --- a/assets/src/stories-editor/components/with-wrapper-props.js +++ b/assets/src/stories-editor/components/with-wrapper-props.js @@ -7,6 +7,7 @@ import { compose } from '@wordpress/compose'; * Internal dependencies */ import { ALLOWED_BLOCKS, ALLOWED_CHILD_BLOCKS } from '../constants'; +import { isCTABlock } from '../helpers'; import { withAttributes, withBlockName, withHasSelectedInnerBlock } from './'; const wrapperWithSelect = compose( @@ -62,7 +63,7 @@ const withWrapperProps = ( BlockListBlock ) => { transform: `scale(var(--preview-scale)) translateX(var(--preview-translateX)) translateY(var(--preview-translateY)) rotate(${ attributes.rotationAngle || 0 }deg)`, }; - if ( 'amp/amp-story-cta' === blockName ) { + if ( isCTABlock( blockName ) ) { innerStyle.transform = `scale(var(--preview-scale))`; } diff --git a/assets/src/stories-editor/constants.js b/assets/src/stories-editor/constants.js index b20870e08cd..bee8a2429eb 100644 --- a/assets/src/stories-editor/constants.js +++ b/assets/src/stories-editor/constants.js @@ -5,6 +5,8 @@ import { __ } from '@wordpress/i18n'; export const STORY_PAGE_INNER_WIDTH = 328; export const STORY_PAGE_INNER_HEIGHT = 553; +export const STORY_PAGE_INNER_HEIGHT_FOR_CTA = Math.floor( STORY_PAGE_INNER_HEIGHT / 5 ); +export const STORY_PAGE_MARGIN = 50; export const MIN_BLOCK_WIDTH = 40; export const MIN_BLOCK_HEIGHTS = { diff --git a/assets/src/stories-editor/helpers/index.js b/assets/src/stories-editor/helpers/index.js index 31e8e8d3610..594526508a7 100644 --- a/assets/src/stories-editor/helpers/index.js +++ b/assets/src/stories-editor/helpers/index.js @@ -57,3 +57,7 @@ export { default as getBlockDOMNode } from './getBlockDOMNode'; export { default as isMovableBlock } from './isMovableBlock'; export { default as metaToAttributeNames } from './metaToAttributeNames'; export { default as parseDropEvent } from './parseDropEvent'; +export { default as getRelativeElementPosition } from './getRelativeElementPosition'; +export { default as isCTABlock } from './isCTABlock'; +export { default as useMoveBlockToPage } from './useMoveBlockToPage'; +export { default as useIsBlockAllowedOnPage } from './useIsBlockAllowedOnPage'; diff --git a/assets/src/stories-editor/helpers/isCTABlock.js b/assets/src/stories-editor/helpers/isCTABlock.js new file mode 100644 index 00000000000..223d007a492 --- /dev/null +++ b/assets/src/stories-editor/helpers/isCTABlock.js @@ -0,0 +1,11 @@ +/** + * Check if block is CTA block. + * + * @param {string} blockName Block name. + * @return {boolean} Boolean if block is / is not a CTA block. + */ +export const isPCTABlock = ( blockName ) => { + return 'amp/amp-story-cta' === blockName; +}; + +export default isPCTABlock; diff --git a/assets/src/stories-editor/helpers/test/useIsBlockAllowedOnPage.js b/assets/src/stories-editor/helpers/test/useIsBlockAllowedOnPage.js new file mode 100644 index 00000000000..f706591a343 --- /dev/null +++ b/assets/src/stories-editor/helpers/test/useIsBlockAllowedOnPage.js @@ -0,0 +1,74 @@ +/** + * Internal dependencies + */ +import useIsBlockAllowedOnPage from '../useIsBlockAllowedOnPage'; + +/* + * A note on the testing here: + * + * `useIsBlockAllowedOnPage` is a hook and you might think it needs to be + * tested as one. + * + * But because it's only a hook because `useSelect` is a hook, and as we mock + * it below to not be a hook, we can test this as a regular non-hook function. + */ + +const mockCanInsertBlockType = jest.fn(); +const mockGetBlockListSettings = jest.fn(); + +jest.mock( '@wordpress/data', () => { + return { + useSelect: () => ( { + canInsertBlockType: mockCanInsertBlockType, + getBlockListSettings: mockGetBlockListSettings, + } ), + }; +} ); + +const BLOCK_NAME = 'any'; +const PAGE_ID = 1; + +describe( 'useIsBlockAllowedOnPage', () => { + it( 'should invoke proper callbacks', () => { + useIsBlockAllowedOnPage()( BLOCK_NAME, PAGE_ID ); + + expect( mockCanInsertBlockType ).toHaveBeenCalledWith( BLOCK_NAME, PAGE_ID ); + expect( mockGetBlockListSettings ).toHaveBeenCalledWith( PAGE_ID ); + } ); + + it( 'should return false if element is not allowed by type regardless of block list', () => { + mockCanInsertBlockType.mockImplementationOnce( () => false ); + mockGetBlockListSettings.mockImplementationOnce( () => ( { allowedBlocks: [ BLOCK_NAME ] } ) ); + + const result = useIsBlockAllowedOnPage()( BLOCK_NAME, PAGE_ID ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false if no block list exist for page', () => { + mockCanInsertBlockType.mockImplementationOnce( () => true ); + mockGetBlockListSettings.mockImplementationOnce( () => null ); + + const result = useIsBlockAllowedOnPage()( BLOCK_NAME, PAGE_ID ); + + expect( result ).toBe( false ); + } ); + + it( 'should return false if block list does not contain element', () => { + mockCanInsertBlockType.mockImplementationOnce( () => true ); + mockGetBlockListSettings.mockImplementationOnce( () => ( { allowedBlocks: [] } ) ); + + const result = useIsBlockAllowedOnPage()( BLOCK_NAME, PAGE_ID ); + + expect( result ).toBe( false ); + } ); + + it( 'should return true iff element is allowed by both type and block list', () => { + mockCanInsertBlockType.mockImplementationOnce( () => true ); + mockGetBlockListSettings.mockImplementationOnce( () => ( { allowedBlocks: [ BLOCK_NAME ] } ) ); + + const result = useIsBlockAllowedOnPage()( BLOCK_NAME, PAGE_ID ); + + expect( result ).toBe( true ); + } ); +} ); diff --git a/assets/src/stories-editor/helpers/test/useMoveBlockToPage.js b/assets/src/stories-editor/helpers/test/useMoveBlockToPage.js new file mode 100644 index 00000000000..1363431ca3b --- /dev/null +++ b/assets/src/stories-editor/helpers/test/useMoveBlockToPage.js @@ -0,0 +1,159 @@ +/** + * Internal dependencies + */ +import useMoveBlockToPage from '../useMoveBlockToPage'; + +/* + * A note on the testing here: + * + * `useMoveBlockToPage` is a hook and you might think it needs to be + * tested as one. + * + * But because it's only a hook because `useSelect` and `useDispatch` are + * hooks, and as we mock those below to not be hooks, we can test this as a + * regular non-hook function. + */ + +const mockGetBlockOrder = jest.fn( () => [] ); // must return Array-like +const mockGetBlock = jest.fn(); +const mockGetCurrentPage = jest.fn(); +const mockSetCurrentPage = jest.fn(); +const mockSelectBlock = jest.fn(); +const mockRemoveBlock = jest.fn(); +const mockInsertBlock = jest.fn(); +const mockUpdateBlockAttributes = jest.fn(); +const mockCloneBlock = jest.fn(); + +jest.mock( '@wordpress/data', () => { + return { + useSelect: ( getter ) => getter( () => ( { + getBlockOrder: mockGetBlockOrder, + getBlock: mockGetBlock, + getCurrentPage: mockGetCurrentPage, + } ) ), + useDispatch: () => ( { + setCurrentPage: mockSetCurrentPage, + selectBlock: mockSelectBlock, + removeBlock: mockRemoveBlock, + insertBlock: mockInsertBlock, + updateBlockAttributes: mockUpdateBlockAttributes, + } ), + }; +} ); + +jest.mock( '@wordpress/blocks', () => { + return { + cloneBlock: ( ...args ) => mockCloneBlock( ...args ), + }; +} ); + +const BLOCK_1 = { clientId: 1 }; +const BLOCK_2 = { clientId: 2 }; +const BLOCK_ID = 1; +const PAGE_1 = 1; +const PAGE_2 = 2; +const PAGE_3 = 3; +const ATTRS = { X: 1 }; + +describe( 'useMoveBlockToPage', () => { + it( 'should invoke getters correctly', () => { + useMoveBlockToPage( BLOCK_ID ); + + expect( mockGetBlockOrder ).toHaveBeenCalledWith(); + expect( mockGetBlock ).toHaveBeenCalledWith( BLOCK_ID ); + expect( mockGetCurrentPage ).toHaveBeenCalledWith(); + } ); + + it( 'should return two functions', () => { + const result = useMoveBlockToPage( BLOCK_ID ); + + expect( result ).toStrictEqual( { + getPageByOffset: expect.any( Function ), + moveBlockToPage: expect.any( Function ), + } ); + } ); + + describe( 'getPageByOffset', () => { + it( 'should return next page correctly', () => { + mockGetBlockOrder.mockImplementationOnce( () => [ PAGE_1, PAGE_2 ] ); + mockGetCurrentPage.mockImplementationOnce( () => PAGE_1 ); + + const { getPageByOffset } = useMoveBlockToPage( BLOCK_ID ); + + const nextPage = getPageByOffset( 1 ); + + expect( nextPage ).toStrictEqual( PAGE_2 ); + } ); + + it( 'should return page two ahead correctly', () => { + mockGetBlockOrder.mockImplementationOnce( () => [ PAGE_1, PAGE_2, PAGE_3 ] ); + mockGetCurrentPage.mockImplementationOnce( () => PAGE_1 ); + + const { getPageByOffset } = useMoveBlockToPage( BLOCK_ID ); + + const nextNextPage = getPageByOffset( 2 ); + + expect( nextNextPage ).toStrictEqual( PAGE_3 ); + } ); + + it( 'should return null if offset is beyond end of list', () => { + mockGetBlockOrder.mockImplementationOnce( () => [ PAGE_1 ] ); + mockGetCurrentPage.mockImplementationOnce( () => PAGE_1 ); + + const { getPageByOffset } = useMoveBlockToPage( BLOCK_ID ); + + const noPage = getPageByOffset( 1 ); + + expect( noPage ).toBeNull(); + } ); + + it( 'should return previous page correctly', () => { + mockGetBlockOrder.mockImplementationOnce( () => [ PAGE_1, PAGE_2 ] ); + mockGetCurrentPage.mockImplementationOnce( () => PAGE_2 ); + + const { getPageByOffset } = useMoveBlockToPage( BLOCK_ID ); + + const previousPage = getPageByOffset( -1 ); + + expect( previousPage ).toStrictEqual( PAGE_1 ); + } ); + + it( 'should return null if offset is before start of list', () => { + mockGetBlockOrder.mockImplementationOnce( () => [ PAGE_1 ] ); + mockGetCurrentPage.mockImplementationOnce( () => PAGE_1 ); + + const { getPageByOffset } = useMoveBlockToPage( BLOCK_ID ); + + const noPage = getPageByOffset( -1 ); + + expect( noPage ).toBeNull(); + } ); + } ); + + describe( 'moveBlockToPage', () => { + it( 'should invoke functions correctly when invoked without attributes', () => { + mockGetBlock.mockImplementationOnce( () => BLOCK_1 ); + mockCloneBlock.mockImplementationOnce( () => BLOCK_2 ); + + const { moveBlockToPage } = useMoveBlockToPage( BLOCK_ID ); + + moveBlockToPage( PAGE_1 ); + + expect( mockRemoveBlock ).toHaveBeenCalledWith( BLOCK_ID ); + expect( mockCloneBlock ).toHaveBeenCalledWith( BLOCK_1 ); + expect( mockInsertBlock ).toHaveBeenCalledWith( BLOCK_2, null, PAGE_1 ); + expect( mockSetCurrentPage ).toHaveBeenCalledWith( PAGE_1 ); + expect( mockSelectBlock ).toHaveBeenCalledWith( PAGE_1 ); + } ); + + it( 'should invoke extra function correctly when invoked with attributes', () => { + mockCloneBlock.mockImplementationOnce( () => BLOCK_2 ); + + const { moveBlockToPage } = useMoveBlockToPage( BLOCK_ID ); + + moveBlockToPage( PAGE_1, ATTRS ); + + expect( mockUpdateBlockAttributes ).toHaveBeenCalledWith( BLOCK_2.clientId, ATTRS ); + } ); + } ); +} ); diff --git a/assets/src/stories-editor/helpers/useIsBlockAllowedOnPage.js b/assets/src/stories-editor/helpers/useIsBlockAllowedOnPage.js new file mode 100644 index 00000000000..f967f70f62b --- /dev/null +++ b/assets/src/stories-editor/helpers/useIsBlockAllowedOnPage.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ + +import { useSelect } from '@wordpress/data'; + +/** + * A hook to be used to determine if a given block type is allowed on a given page? + * + * @return {Function} Returns a function to determine if the given block type can be dropped on a given page. + */ +const useIsBlockAllowedOnPage = () => { + const { getBlockListSettings, canInsertBlockType } = useSelect( ( select ) => select( 'core/block-editor' ), [] ); + + /** + * Is the element allowed on the given page? + * + * @param {Object} blockName The name of the block to test. + * @param {string} pageId Page ID. + * @return {boolean} Returns true if the element is allowed on the page, false otherwise. + */ + return ( blockName, pageId ) => { + // canInsertBlockType() alone is not enough, see https://github.com/WordPress/gutenberg/issues/14515 + const blockSettings = getBlockListSettings( pageId ); + const canInsert = canInsertBlockType( blockName, pageId ); + const isAllowed = canInsert && blockSettings && blockSettings.allowedBlocks.includes( blockName ); + return Boolean( isAllowed ); + }; +}; + +export default useIsBlockAllowedOnPage; diff --git a/assets/src/stories-editor/helpers/useMoveBlockToPage.js b/assets/src/stories-editor/helpers/useMoveBlockToPage.js new file mode 100644 index 00000000000..f8782350692 --- /dev/null +++ b/assets/src/stories-editor/helpers/useMoveBlockToPage.js @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { cloneBlock } from '@wordpress/blocks'; + +/** + * Hook that exposes functions relevant for moving an element to a neigboring page. + * + * @param {string} blockId Id of block to move + * @return {Object} Returns two functions: `getPageByOffset` and `moveBlockToPage`. + */ +const useMoveBlockToPage = ( blockId ) => { + const pages = useSelect( ( select ) => select( 'core/block-editor' ).getBlockOrder(), [] ); + const block = useSelect( ( select ) => select( 'core/block-editor' ).getBlock( blockId ), [ blockId ] ); + const currentPageId = useSelect( ( select ) => select( 'amp/story' ).getCurrentPage(), [] ); + const currentPageIndex = pages.findIndex( ( i ) => i === currentPageId ); + + const { setCurrentPage } = useDispatch( 'amp/story' ); + const { selectBlock, removeBlock, insertBlock, updateBlockAttributes } = useDispatch( 'core/block-editor' ); + + /** + * Get id of neighbor page that is `offset` away from the current page. + * + * If no page exists in that direction, null will be returned. + * + * @param {number} offset Integer specifying offset from current page - e.g. -2 on page 3 will return id of page 1. Page indices are zero-based. + * @return {string} Returns id of target page or null if no page exists there. + */ + const getPageByOffset = ( offset ) => { + const newPageIndex = currentPageIndex + offset; + const isInsidePageCount = newPageIndex >= 0 && newPageIndex < pages.length; + + // Do we even have a neighbor in that direction? + if ( ! isInsidePageCount ) { + return null; + } + + const newPageId = pages[ newPageIndex ]; + return newPageId; + }; + + /** + * Move the element to the given page. Also update the element with the given properties. + * + * Currently this function removes the old element and creates a clone on the new page. + * + * @param {string} pageId Id of page to move element to + * @param {Object} attributes Object with attributes to update on element on new page. + */ + const moveBlockToPage = ( pageId, attributes = null ) => { + // Remove block and add cloned block to new page. + removeBlock( blockId ); + const clonedBlock = cloneBlock( block ); + insertBlock( clonedBlock, null, pageId ); + if ( attributes !== null ) { + updateBlockAttributes( clonedBlock.clientId, attributes ); + } + + // Switch to new page. + setCurrentPage( pageId ); + selectBlock( pageId ); + }; + + return { + getPageByOffset, + moveBlockToPage, + }; +}; + +export default useMoveBlockToPage;