diff --git a/assets/src/stories-editor/blocks/amp-story-page/copy-paste-handler.js b/assets/src/stories-editor/blocks/amp-story-page/copy-paste-handler.js
new file mode 100644
index 00000000000..3bbdfbf6f1a
--- /dev/null
+++ b/assets/src/stories-editor/blocks/amp-story-page/copy-paste-handler.js
@@ -0,0 +1,128 @@
+/**
+ * External dependencies
+ */
+import PropTypes from 'prop-types';
+
+/**
+ * WordPress dependencies
+ */
+import { pasteHandler, serialize } from '@wordpress/blocks';
+import { documentHasSelection } from '@wordpress/dom';
+import { withDispatch, useSelect, useDispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { ensureAllowedBlocksOnPaste } from '../../helpers';
+
+function CopyPasteHandler( { children, onCopy, clientId, isSelected } ) {
+ const {
+ isFirstPage,
+ canUserUseUnfilteredHTML,
+ } = useSelect(
+ ( select ) => {
+ const {
+ getBlockOrder,
+ getSettings,
+ } = select( 'core/block-editor' );
+ const { __experimentalCanUserUseUnfilteredHTML } = getSettings();
+ return {
+ isFirstPage: getBlockOrder().indexOf( clientId ) === 0,
+ canUserUseUnfilteredHTML: __experimentalCanUserUseUnfilteredHTML,
+ };
+ }, [ clientId ]
+ );
+
+ const { insertBlocks } = useDispatch( 'core/block-editor' );
+
+ const onPaste = ( event ) => {
+ // Ignore if the Page is not the selected page.
+ if ( ! isSelected ) {
+ return;
+ }
+ const clipboardData = event.clipboardData;
+
+ let plainText = '';
+ let html = '';
+
+ // IE11 only supports `Text` as an argument for `getData` and will
+ // otherwise throw an invalid argument error, so we try the standard
+ // arguments first, then fallback to `Text` if they fail.
+ try {
+ plainText = clipboardData.getData( 'text/plain' );
+ html = clipboardData.getData( 'text/html' );
+ } catch ( error1 ) {
+ try {
+ html = clipboardData.getData( 'Text' );
+ } catch ( error2 ) {
+ // Some browsers like UC Browser paste plain text by default and
+ // don't support clipboardData at all, so allow default
+ // behaviour.
+ return;
+ }
+ }
+
+ event.preventDefault();
+
+ const mode = 'BLOCKS';
+
+ const content = pasteHandler( {
+ HTML: html,
+ plainText,
+ mode,
+ canUserUseUnfilteredHTML,
+ } );
+
+ if ( content.length > 0 ) {
+ insertBlocks( ensureAllowedBlocksOnPaste( content, clientId, isFirstPage ), null, clientId );
+ }
+ };
+
+ return (
+
+ { children }
+
+ );
+}
+
+CopyPasteHandler.propTypes = {
+ children: PropTypes.object.isRequired,
+ clientId: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onCopy: PropTypes.func.isRequired,
+};
+
+export default withDispatch( ( dispatch, ownProps, { select } ) => {
+ const {
+ getBlocksByClientId,
+ getSelectedBlockClientIds,
+ hasMultiSelection,
+ } = select( 'core/block-editor' );
+ const { clearCopiedMarkup, setCopiedMarkup } = dispatch( 'amp/story' );
+
+ /**
+ * Copy handler for ensuring that the store's copiedMarkup is in sync with what's actually in clipBoard.
+ * If it's not a block that's being copied, let's clear the copiedMarkup.
+ * Otherwise, let's set the copied markup.
+ */
+ const onCopy = () => {
+ const selectedBlockClientIds = getSelectedBlockClientIds();
+
+ if ( selectedBlockClientIds.length === 0 ) {
+ clearCopiedMarkup();
+ return;
+ }
+
+ // Let native copy behaviour take over in input fields.
+ if ( ! hasMultiSelection() && documentHasSelection() ) {
+ clearCopiedMarkup();
+ return;
+ }
+ const serialized = serialize( getBlocksByClientId( selectedBlockClientIds ) );
+ setCopiedMarkup( serialized );
+ };
+
+ return {
+ onCopy,
+ };
+} )( CopyPasteHandler );
diff --git a/assets/src/stories-editor/blocks/amp-story-page/edit.js b/assets/src/stories-editor/blocks/amp-story-page/edit.js
index e6cb802b92f..4f0d950eda4 100644
--- a/assets/src/stories-editor/blocks/amp-story-page/edit.js
+++ b/assets/src/stories-editor/blocks/amp-story-page/edit.js
@@ -31,7 +31,6 @@ import {
import {
withSelect,
withDispatch,
- dispatch,
} from '@wordpress/data';
import { compose } from '@wordpress/compose';
@@ -52,6 +51,8 @@ import {
isVideoSizeExcessive,
} from '../../../common/helpers';
+import CopyPasteHandler from './copy-paste-handler';
+
import {
ALLOWED_CHILD_BLOCKS,
ALLOWED_MOVABLE_BLOCKS,
@@ -210,7 +211,16 @@ class PageEdit extends Component {
}
render() { // eslint-disable-line complexity
- const { attributes, media, setAttributes, totalAnimationDuration, allowedBlocks, allowedBackgroundMediaTypes } = this.props;
+ const {
+ attributes,
+ clientId,
+ isSelected,
+ media,
+ setAttributes,
+ totalAnimationDuration,
+ allowedBlocks,
+ allowedBackgroundMediaTypes,
+ } = this.props;
const {
mediaId,
@@ -441,20 +451,24 @@ class PageEdit extends Component {
-
- { /* todo: show poster image as background-image instead */ }
- { VIDEO_BACKGROUND_TYPE === mediaType && media && (
-
-
-
- ) }
- { backgroundColors.length > 0 && (
-
- ) }
-
-
+
+
+ { /* todo: show poster image as background-image instead */ }
+ { VIDEO_BACKGROUND_TYPE === mediaType && media && (
+
+
+
+ ) }
+ { backgroundColors.length > 0 && (
+
+ ) }
+
+
+
>
);
}
@@ -478,6 +492,7 @@ PageEdit.propTypes = {
autoAdvanceAfterDuration: PropTypes.number,
mediaAlt: PropTypes.string,
} ).isRequired,
+ isSelected: PropTypes.bool.isRequired,
setAttributes: PropTypes.func.isRequired,
media: PropTypes.object,
allowedBlocks: PropTypes.arrayOf( PropTypes.string ).isRequired,
@@ -491,7 +506,7 @@ PageEdit.propTypes = {
};
export default compose(
- withDispatch( () => {
+ withDispatch( ( dispatch ) => {
const { moveBlockToPosition } = dispatch( 'core/block-editor' );
return {
moveBlockToPosition,
@@ -499,7 +514,10 @@ export default compose(
} ),
withSelect( ( select, { clientId, attributes } ) => {
const { getMedia } = select( 'core' );
- const { getBlockOrder, getBlockRootClientId } = select( 'core/block-editor' );
+ const {
+ getBlockOrder,
+ getBlockRootClientId,
+ } = select( 'core/block-editor' );
const { getAnimatedBlocks, getSettings } = select( 'amp/story' );
const isFirstPage = getBlockOrder().indexOf( clientId ) === 0;
diff --git a/assets/src/stories-editor/components/block-mover/draggable.js b/assets/src/stories-editor/components/block-mover/draggable.js
index 54a287abd97..64f06313346 100644
--- a/assets/src/stories-editor/components/block-mover/draggable.js
+++ b/assets/src/stories-editor/components/block-mover/draggable.js
@@ -23,11 +23,11 @@ import {
STORY_PAGE_INNER_HEIGHT,
} from '../../constants';
-const { Image } = window;
+const { Image, navigator } = window;
const cloneWrapperClass = 'components-draggable__clone';
-const isChromeUA = ( ) => /Chrome/i.test( window.navigator.userAgent );
+const isChromeUA = ( ) => /Chrome/i.test( navigator.userAgent );
const documentHasIframes = ( ) => [ ...document.getElementById( 'editor' ).querySelectorAll( 'iframe' ) ].length > 0;
class Draggable extends Component {
diff --git a/assets/src/stories-editor/components/draggable-text.js b/assets/src/stories-editor/components/draggable-text.js
index 3d60f5be544..ce4ef29b30f 100644
--- a/assets/src/stories-editor/components/draggable-text.js
+++ b/assets/src/stories-editor/components/draggable-text.js
@@ -114,7 +114,7 @@ DraggableText.propTypes = {
isSelected: PropTypes.bool.isRequired,
toggleIsEditing: PropTypes.func.isRequired,
toggleOverlay: PropTypes.func.isRequired,
- text: PropTypes.string.isRequired,
+ text: PropTypes.string,
textStyle: PropTypes.shape( {
color: PropTypes.string,
fontSize: PropTypes.string,
diff --git a/assets/src/stories-editor/components/higher-order/with-right-click-handler.js b/assets/src/stories-editor/components/higher-order/with-right-click-handler.js
new file mode 100644
index 00000000000..b7e5166c1fe
--- /dev/null
+++ b/assets/src/stories-editor/components/higher-order/with-right-click-handler.js
@@ -0,0 +1,117 @@
+/**
+ * WordPress dependencies
+ */
+import { withSelect } from '@wordpress/data';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { render } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
+import { ALLOWED_CHILD_BLOCKS } from '../../constants';
+import { getBlockDOMNode, getPercentageFromPixels } from '../../helpers';
+import { RightClickMenu } from '../';
+
+const applyWithSelect = withSelect( ( select, props ) => {
+ const { isReordering, getCopiedMarkup, getCurrentPage } = select( 'amp/story' );
+ const {
+ getSelectedBlockClientIds,
+ hasMultiSelection,
+ } = select( 'core/block-editor' );
+
+ const { name } = props;
+
+ const onContextMenu = ( event ) => {
+ const selectedBlockClientIds = getSelectedBlockClientIds();
+
+ if ( selectedBlockClientIds.length === 0 ) {
+ return;
+ }
+ // If nothing is in the saved markup, use the default behavior.
+ if ( 'amp/amp-story-page' === name && ! getCopiedMarkup().length ) {
+ return;
+ }
+
+ // Let's ignore if some text has been selected.
+ const selectedText = window.getSelection().toString();
+ // Let's ignore multi-selection for now.
+ if ( hasMultiSelection() || selectedText.length ) {
+ return;
+ }
+
+ const editLayout = document.querySelector( '.edit-post-layout' );
+ if ( ! document.getElementById( 'amp-story-right-click-menu' ) ) {
+ const menuWrapper = document.createElement( 'div' );
+ menuWrapper.id = 'amp-story-right-click-menu';
+
+ editLayout.appendChild( menuWrapper );
+ }
+
+ // Calculate the position to display the right click menu.
+ const wrapperDimensions = editLayout.getBoundingClientRect();
+ const toolBar = document.querySelector( '.edit-post-header' );
+
+ // If Toolbar is available then consider that as well.
+ let toolBarHeight = 0;
+ if ( toolBar ) {
+ toolBarHeight = toolBar.clientHeight;
+ }
+ const relativePositionX = event.clientX - wrapperDimensions.left;
+ const relativePositionY = event.clientY - wrapperDimensions.top - toolBarHeight;
+ const clientId = getCurrentPage();
+
+ let insidePercentageY = 0;
+ let insidePercentageX = 0;
+
+ const page = getBlockDOMNode( clientId );
+ if ( page ) {
+ const pagePostions = page.getBoundingClientRect();
+ const insideY = event.clientY - pagePostions.top;
+ const insideX = event.clientX - pagePostions.left;
+ insidePercentageY = getPercentageFromPixels( 'y', insideY );
+ insidePercentageX = getPercentageFromPixels( 'x', insideX );
+ }
+
+ render(
+ ,
+ document.getElementById( 'amp-story-right-click-menu' )
+ );
+
+ event.preventDefault();
+ };
+
+ return {
+ onContextMenu,
+ isReordering: isReordering(),
+ };
+} );
+
+/**
+ * Higher-order component that adds right click handler to each inner block.
+ *
+ * @return {Function} Higher-order component.
+ */
+export default createHigherOrderComponent(
+ ( BlockEdit ) => {
+ return applyWithSelect( ( props ) => {
+ const { name, onContextMenu, isReordering } = props;
+ const isPageBlock = 'amp/amp-story-page' === name;
+
+ // Add for page block and inner blocks.
+ if ( ! isPageBlock && ! ALLOWED_CHILD_BLOCKS.includes( name ) ) {
+ return ;
+ }
+
+ // Not relevant for reordering.
+ if ( isReordering ) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+ } );
+ },
+ 'withRightClickHandler'
+);
diff --git a/assets/src/stories-editor/components/index.js b/assets/src/stories-editor/components/index.js
index 0c45a5aafa8..eeb2c22a9ac 100644
--- a/assets/src/stories-editor/components/index.js
+++ b/assets/src/stories-editor/components/index.js
@@ -14,6 +14,7 @@ export { default as StoryBlockMover } from './block-mover';
export { default as PageInserter } from './page-inserter';
export { default as FontFamilyPicker } from './font-family-picker';
export { default as RotatableBox } from './rotatable-box';
+export { default as RightClickMenu } from './right-click-menu';
export { default as PreviewPicker } from './preview-picker';
export { default as Inserter } from './inserter';
export { default as MediaInserter } from './media-inserter';
@@ -25,6 +26,7 @@ export { default as withAttributes } from './higher-order/with-attributes';
export { default as withBlockName } from './higher-order/with-block-name';
export { default as withHasSelectedInnerBlock } from './higher-order/with-has-selected-inner-block';
export { default as withPageNumber } from './higher-order/with-page-number';
+export { default as withRightClickHandler } from './higher-order/with-right-click-handler';
export { default as withStoryFeaturedImageNotice } from './higher-order/with-story-featured-image-notice';
export { default as withEditFeaturedImage } from './with-edit-featured-image';
export { default as withCustomVideoBlockEdit } from './with-custom-video-block-edit';
diff --git a/assets/src/stories-editor/components/right-click-menu/edit.css b/assets/src/stories-editor/components/right-click-menu/edit.css
new file mode 100644
index 00000000000..3a70f9194cc
--- /dev/null
+++ b/assets/src/stories-editor/components/right-click-menu/edit.css
@@ -0,0 +1,17 @@
+#amp-story-right-click-menu {
+ position: absolute;
+}
+
+#amp-story-right-click-menu .amp-story-right-click-menu__popover {
+ position: absolute;
+ top: 0 !important;
+ left: 0 !important;
+}
+
+.amp-story-right-click-menu__popover.is-top.is-from-bottom .components-popover__content {
+ margin-bottom: -8px;
+}
+
+.amp-right-click-menu__container {
+ position: absolute;
+}
diff --git a/assets/src/stories-editor/components/right-click-menu/index.js b/assets/src/stories-editor/components/right-click-menu/index.js
new file mode 100644
index 00000000000..f04ede72b27
--- /dev/null
+++ b/assets/src/stories-editor/components/right-click-menu/index.js
@@ -0,0 +1,299 @@
+/**
+ * External dependencies
+ */
+import { castArray } from 'lodash';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { cloneBlock, pasteHandler, serialize } from '@wordpress/blocks';
+import { useEffect, useState, useRef } from '@wordpress/element';
+import {
+ MenuGroup,
+ MenuItem,
+ NavigableMenu,
+ Popover,
+} from '@wordpress/components';
+import { withDispatch, withSelect } from '@wordpress/data';
+import { compose } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import './edit.css';
+import useOutsideClickChecker from './outside-click-checker';
+import {
+ copyTextToClipBoard,
+ ensureAllowedBlocksOnPaste,
+} from '../../helpers';
+import { ALLOWED_MOVABLE_BLOCKS } from '../../constants';
+
+const POPOVER_PROPS = {
+ className: 'amp-story-right-click-menu__popover block-editor-block-settings-menu__popover editor-block-settings-menu__popover',
+ position: 'bottom left',
+};
+
+const RightClickMenu = ( props ) => {
+ const {
+ clientIds,
+ clientX,
+ clientY,
+ insidePercentageX,
+ insidePercentageY,
+ copyBlock,
+ cutBlock,
+ getBlock,
+ getCopiedMarkup,
+ removeBlock,
+ duplicateBlock,
+ pasteBlock,
+ } = props;
+ const [ isOpen, setIsOpen ] = useState( true );
+
+ useEffect( () => {
+ setIsOpen( true );
+ }, [ clientIds, clientX, clientY ] );
+
+ const blockClientIds = castArray( clientIds );
+ const firstBlockClientId = blockClientIds[ 0 ];
+ const block = getBlock( firstBlockClientId );
+ const isPageBlock = block ? 'amp/amp-story-page' === block.name : false;
+
+ const onClose = () => {
+ setIsOpen( false );
+ };
+
+ const containerRef = useRef( null );
+ useOutsideClickChecker( containerRef, onClose );
+
+ const position = {
+ top: clientY,
+ left: clientX,
+ };
+
+ let blockActions = [];
+
+ // Don't allow any actions other than pasting with Page.
+ if ( ! isPageBlock ) {
+ blockActions = [
+ {
+ name: __( 'Copy Block', 'amp' ),
+ blockAction: copyBlock,
+ icon: 'admin-page',
+ className: 'right-click-copy',
+ },
+ {
+ name: __( 'Cut Block', 'amp' ),
+ blockAction: cutBlock,
+ icon: 'clipboard',
+ className: 'right-click-cut',
+ },
+ {
+ name: __( 'Duplicate Block', 'amp' ),
+ blockAction: duplicateBlock,
+ icon: 'admin-page',
+ className: 'right-click-duplicate',
+ },
+ {
+ name: __( 'Remove Block', 'amp' ),
+ blockAction: removeBlock,
+ icon: 'trash',
+ className: 'right-click-remove',
+ },
+ ];
+ }
+
+ // If it's Page block and clipboard is empty, don't display anything.
+ if ( ! getCopiedMarkup().length && isPageBlock ) {
+ return '';
+ }
+
+ if ( getCopiedMarkup().length ) {
+ blockActions.push(
+ {
+ name: __( 'Paste', 'amp' ),
+ blockAction: pasteBlock,
+ icon: 'pressthis',
+ className: 'right-click-paste',
+ }
+ );
+ }
+
+ return (
+
+ { isOpen && (
+
+
+ { blockActions.map( ( action ) => (
+
+
+
+ ) ) }
+
+
+ ) }
+
+ );
+};
+
+RightClickMenu.propTypes = {
+ clientIds: PropTypes.array.isRequired,
+ clientX: PropTypes.number.isRequired,
+ clientY: PropTypes.number.isRequired,
+ insidePercentageX: PropTypes.number,
+ insidePercentageY: PropTypes.number,
+ copyBlock: PropTypes.func.isRequired,
+ cutBlock: PropTypes.func.isRequired,
+ getBlock: PropTypes.func.isRequired,
+ getCopiedMarkup: PropTypes.func.isRequired,
+ removeBlock: PropTypes.func.isRequired,
+ duplicateBlock: PropTypes.func.isRequired,
+ pasteBlock: PropTypes.func.isRequired,
+};
+
+const applyWithSelect = withSelect( ( select ) => {
+ const {
+ getBlock,
+ getBlockOrder,
+ getBlockRootClientId,
+ getSettings,
+ } = select( 'core/block-editor' );
+
+ const { getCopiedMarkup } = select( 'amp/story' );
+
+ return {
+ getBlock,
+ getBlockOrder,
+ getBlockRootClientId,
+ getSettings,
+ getCopiedMarkup,
+ };
+} );
+
+const applyWithDispatch = withDispatch( ( dispatch, props ) => {
+ const {
+ getBlock,
+ getBlockOrder,
+ getBlockRootClientId,
+ getCopiedMarkup,
+ getSettings,
+ } = props;
+ const {
+ removeBlock,
+ insertBlock,
+ insertBlocks,
+ updateBlockAttributes,
+ } = dispatch( 'core/block-editor' );
+
+ const { setCopiedMarkup } = dispatch( 'amp/story' );
+
+ const { __experimentalCanUserUseUnfilteredHTML: canUserUseUnfilteredHTML } = getSettings();
+
+ const copyBlock = ( clientId ) => {
+ const block = getBlock( clientId );
+ const serialized = serialize( block );
+
+ // Set the copied block to component state for being able to Paste.
+ setCopiedMarkup( serialized );
+ copyTextToClipBoard( serialized );
+ };
+
+ const processTextToPaste = ( text, clientId, insidePercentageY, insidePercentageX ) => {
+ const mode = 'BLOCKS';
+ const content = pasteHandler( {
+ HTML: '',
+ plainText: text,
+ mode,
+ tagName: null,
+ canUserUseUnfilteredHTML,
+ } );
+
+ const clickedBlock = getBlock( clientId );
+ // Get the page client ID to paste to.
+ let pageClientId;
+ if ( 'amp/amp-story-page' === clickedBlock.name ) {
+ pageClientId = clickedBlock.clientId;
+ } else {
+ pageClientId = getBlockRootClientId( clientId );
+ }
+
+ if ( ! pageClientId || ! content.length ) {
+ return;
+ }
+
+ const isFirstPage = getBlockOrder().indexOf( pageClientId ) === 0;
+ insertBlocks( ensureAllowedBlocksOnPaste( content, pageClientId, isFirstPage ), null, pageClientId ).then( ( { blocks } ) => {
+ for ( const block of blocks ) {
+ if ( ALLOWED_MOVABLE_BLOCKS.includes( block.name ) ) {
+ updateBlockAttributes( block.clientId, {
+ positionTop: insidePercentageY,
+ positionLeft: insidePercentageX,
+ } );
+ }
+ }
+ } ).catch( () => {} );
+ };
+
+ return {
+ removeBlock,
+ duplicateBlock( clientId ) {
+ const block = getBlock( clientId );
+ if ( 'amp/amp-story-cta' === block.name ) {
+ return;
+ }
+
+ const rootClientId = getBlockRootClientId( clientId );
+ const clonedBlock = cloneBlock( block );
+ insertBlock( clonedBlock, null, rootClientId );
+ },
+ copyBlock,
+ cutBlock( clientId ) {
+ // First copy block and then remove it.
+ copyBlock( clientId );
+ removeBlock( clientId );
+ },
+ pasteBlock( clientId, insidePercentageY, insidePercentageX ) {
+ const { navigator } = window;
+
+ if ( navigator.clipboard && navigator.clipboard.readText ) {
+ // We have to ask permissions for being able to read from clipboard.
+ navigator.clipboard.readText().
+ then( ( clipBoardText ) => {
+ // If got permission, paste from clipboard.
+ processTextToPaste( clipBoardText, clientId, insidePercentageY, insidePercentageX );
+ } ).catch( () => {
+ // If forbidden, use the markup from state instead.
+ const text = getCopiedMarkup();
+ processTextToPaste( text, clientId, insidePercentageY, insidePercentageX );
+ } );
+ } else {
+ const text = getCopiedMarkup();
+ processTextToPaste( text, clientId, insidePercentageY, insidePercentageX );
+ }
+ },
+ };
+} );
+
+export default compose(
+ applyWithSelect,
+ applyWithDispatch,
+)( RightClickMenu );
diff --git a/assets/src/stories-editor/components/right-click-menu/outside-click-checker.js b/assets/src/stories-editor/components/right-click-menu/outside-click-checker.js
new file mode 100644
index 00000000000..31b91f8d750
--- /dev/null
+++ b/assets/src/stories-editor/components/right-click-menu/outside-click-checker.js
@@ -0,0 +1,36 @@
+/**
+ * WordPress dependencies
+ */
+import { useEffect } from '@wordpress/element';
+
+/**
+ * Detects clicks outside of the referenced element.
+ *
+ * @param {Object} ref Reference.
+ * @param {Function} onDetected Action when detected.
+ */
+const useOutsideClickChecker = ( ref, onDetected ) => {
+ /**
+ * Close the Popover if outside click was detected.
+ *
+ * @param {Object} event Click event.
+ */
+ function handleClickOutside( event ) {
+ if ( ref.current && ! ref.current.contains( event.target ) ) {
+ onDetected();
+ }
+ }
+
+ useEffect( () => {
+ // Handle click outside only if the the menu has been added.
+ if ( ref.current && ref.current.innerHTML ) {
+ document.addEventListener( 'mousedown', handleClickOutside );
+ }
+ return () => {
+ // Unbind when cleaning up.
+ document.removeEventListener( 'mousedown', handleClickOutside );
+ };
+ } );
+};
+
+export default useOutsideClickChecker;
diff --git a/assets/src/stories-editor/helpers/index.js b/assets/src/stories-editor/helpers/index.js
index d8fe32507e2..8d7be36a0c7 100644
--- a/assets/src/stories-editor/helpers/index.js
+++ b/assets/src/stories-editor/helpers/index.js
@@ -1745,6 +1745,75 @@ export const processMedia = ( media ) => {
};
};
+/**
+ * Copy text to clipboard by using temporary input field.
+ *
+ * @param {string} text Text to copy.
+ */
+export const copyTextToClipBoard = ( text ) => {
+ // Create temporary input element for being able to copy.
+ const tmpInput = document.createElement( 'textarea' );
+ tmpInput.setAttribute( 'readonly', '' );
+ tmpInput.style = {
+ position: 'absolute',
+ left: '-9999px',
+ };
+ tmpInput.value = text;
+ document.body.appendChild( tmpInput );
+ tmpInput.select();
+ document.execCommand( 'copy' );
+ // Remove the temporary element.
+ document.body.removeChild( tmpInput );
+};
+
+/**
+ * Ensure that only allowed blocks are pasted.
+ *
+ * @param {[]} blocks Array of blocks.
+ * @param {string} clientId Page ID.
+ * @param {boolean} isFirstPage If is first page.
+ * @return {[]} Filtered blocks.
+ */
+export const ensureAllowedBlocksOnPaste = ( blocks, clientId, isFirstPage ) => {
+ const allowedBlocks = [];
+ // @todo This will need handling for Page Attachment once it's available.
+ blocks.forEach( ( block ) => {
+ switch ( block.name ) {
+ // Skip copying Page.
+ case 'amp/amp-story-page':
+ return;
+ case 'amp/amp-story-cta':
+ // If the content has CTA block or it's the first page, don't add it.
+ const ctaBlock = getCallToActionBlock( clientId );
+ if ( ctaBlock || isFirstPage ) {
+ return;
+ }
+ allowedBlocks.push( block );
+ break;
+ default:
+ if ( ALLOWED_CHILD_BLOCKS.includes( block.name ) ) {
+ allowedBlocks.push( block );
+ }
+ break;
+ }
+ } );
+ return allowedBlocks;
+};
+
+/**
+ * Given a block client ID, returns the corresponding DOM node for the block,
+ * if exists. As much as possible, this helper should be avoided, and used only
+ * in cases where isolated behaviors need remote access to a block node.
+ *
+ * @param {string} clientId Block client ID.
+ * @param {Element} scope an optional DOM Element to which the selector should be scoped
+ *
+ * @return {Element} Block DOM node.
+ */
+export const getBlockDOMNode = ( clientId, scope = document ) => {
+ return scope.querySelector( `[data-block="${ clientId }"]` );
+};
+
/**
* Returns a movable block's wrapper element.
*
diff --git a/assets/src/stories-editor/helpers/test/ensureAllowedBlocksOnPaste.js b/assets/src/stories-editor/helpers/test/ensureAllowedBlocksOnPaste.js
new file mode 100644
index 00000000000..9b6805eb139
--- /dev/null
+++ b/assets/src/stories-editor/helpers/test/ensureAllowedBlocksOnPaste.js
@@ -0,0 +1,28 @@
+/**
+ * Internal dependencies
+ */
+import { ensureAllowedBlocksOnPaste } from '../';
+
+describe( 'pasting blocks', () => {
+ describe( 'ensureAllowedBlocksOnPaste', () => {
+ it( 'should not allow pasting blocks that are not allowed', () => {
+ const blocks = [
+ {
+ name: 'amp/amp-story-cta',
+ },
+ {
+ name: 'some-random-block',
+ },
+ {
+ name: 'amp/amp-story-text',
+ },
+ {
+ name: 'core/paragraph',
+ },
+ ];
+ const filteredBlocks = ensureAllowedBlocksOnPaste( blocks, 'abc123', true );
+ expect( filteredBlocks ).toHaveLength( 1 );
+ expect( filteredBlocks[ 0 ].name ).toStrictEqual( 'amp/amp-story-text' );
+ } );
+ } );
+} );
diff --git a/assets/src/stories-editor/index.js b/assets/src/stories-editor/index.js
index bbf0019ccd7..978e6de191d 100644
--- a/assets/src/stories-editor/index.js
+++ b/assets/src/stories-editor/index.js
@@ -40,6 +40,7 @@ import {
withActivePageState,
withStoryBlockDropZone,
withCallToActionValidation,
+ withRightClickHandler,
} from './components';
import {
maybeEnqueueFontStyle,
@@ -316,6 +317,7 @@ addFilter( 'blocks.registerBlockType', 'ampStoryEditorBlocks/filterBlockTransfor
addFilter( 'blocks.registerBlockType', 'ampStoryEditorBlocks/deprecateCoreBlocks', deprecateCoreBlocks );
addFilter( 'editor.BlockEdit', 'ampStoryEditorBlocks/addStorySettings', withAmpStorySettings );
addFilter( 'editor.BlockEdit', 'ampStoryEditorBlocks/addPageNumber', withPageNumber );
+addFilter( 'editor.BlockEdit', 'ampStoryEditorBlocks/rightClickHandler', withRightClickHandler );
addFilter( 'editor.BlockEdit', 'ampStoryEditorBlocks/addEditFeaturedImage', withEditFeaturedImage );
addFilter( 'editor.BlockEdit', 'ampEditorBlocks/addVideoBlockPreview', withCustomVideoBlockEdit, 9 );
addFilter( 'editor.PostFeaturedImage', 'ampStoryEditorBlocks/addFeaturedImageNotice', withStoryFeaturedImageNotice );
diff --git a/assets/src/stories-editor/store/actions.js b/assets/src/stories-editor/store/actions.js
index a4e199bff75..a21310d410d 100644
--- a/assets/src/stories-editor/store/actions.js
+++ b/assets/src/stories-editor/store/actions.js
@@ -188,3 +188,27 @@ export function resetOrder( order ) {
order,
};
}
+
+/**
+ * Returns an action object for setting copied block markup.
+ *
+ * @param {string} markup Markup copied to clipboard.
+ * @return {Object} Action object.
+ */
+export function setCopiedMarkup( markup ) {
+ return {
+ type: 'SET_COPIED_MARKUP',
+ markup,
+ };
+}
+
+/**
+ * Returns an action object signalling that copied markup needs to be cleared.
+ *
+ * @return {Object} Action object.
+ */
+export function clearCopiedMarkup() {
+ return {
+ type: 'CLEAR_COPIED_MARKUP',
+ };
+}
diff --git a/assets/src/stories-editor/store/index.js b/assets/src/stories-editor/store/index.js
index 9ed2a7523a0..b688c5244bc 100644
--- a/assets/src/stories-editor/store/index.js
+++ b/assets/src/stories-editor/store/index.js
@@ -33,6 +33,7 @@ const store = registerStore(
order: [],
isReordering: false,
},
+ copiedMarkup: '',
},
}
);
diff --git a/assets/src/stories-editor/store/reducer.js b/assets/src/stories-editor/store/reducer.js
index bfd90af8080..22b3e585fb8 100644
--- a/assets/src/stories-editor/store/reducer.js
+++ b/assets/src/stories-editor/store/reducer.js
@@ -287,4 +287,25 @@ export function blocks( state = {}, action ) {
}
}
-export default combineReducers( { animations, currentPage, blocks } );
+export function copiedMarkup( state = {}, action ) {
+ switch ( action.type ) {
+ case 'SET_COPIED_MARKUP':
+ const { markup } = action;
+ if ( 'string' === typeof markup ) {
+ return markup;
+ }
+ return state;
+
+ case 'CLEAR_COPIED_MARKUP':
+ if ( '' === state ) {
+ return state;
+ }
+
+ return '';
+
+ default:
+ return state;
+ }
+}
+
+export default combineReducers( { animations, currentPage, blocks, copiedMarkup } );
diff --git a/assets/src/stories-editor/store/selectors.js b/assets/src/stories-editor/store/selectors.js
index 91c911e60a8..ef3e85051a1 100644
--- a/assets/src/stories-editor/store/selectors.js
+++ b/assets/src/stories-editor/store/selectors.js
@@ -172,6 +172,16 @@ export function isReordering( state ) {
return state.blocks.isReordering || false;
}
+/**
+ * Returns copied markup for pasting workaround.
+ *
+ * @param {Object} state Editor state.
+ * @return {string} Markup.
+ */
+export function getCopiedMarkup( state ) {
+ return state.copiedMarkup || '';
+}
+
/**
* Returns the stories editor settings.
*
diff --git a/assets/src/stories-editor/store/test/actions.js b/assets/src/stories-editor/store/test/actions.js
index 0b9ea0587c8..c24699cd26b 100644
--- a/assets/src/stories-editor/store/test/actions.js
+++ b/assets/src/stories-editor/store/test/actions.js
@@ -11,6 +11,8 @@ import {
movePageToPosition,
saveOrder,
resetOrder,
+ setCopiedMarkup,
+ clearCopiedMarkup,
playAnimation,
stopAnimation,
} from '../actions';
@@ -184,4 +186,25 @@ describe( 'actions', () => {
} );
} );
} );
+
+ describe( 'setCopiedMarkup', () => {
+ it( 'should return the SET_COPIED_MARKUP action', () => {
+ const result = setCopiedMarkup( 'Hello' );
+
+ expect( result ).toStrictEqual( {
+ type: 'SET_COPIED_MARKUP',
+ markup: 'Hello',
+ } );
+ } );
+ } );
+
+ describe( 'clearCopiedMarkup', () => {
+ it( 'should return the CLEAR_COPIED_MARKUP action', () => {
+ const result = clearCopiedMarkup();
+
+ expect( result ).toStrictEqual( {
+ type: 'CLEAR_COPIED_MARKUP',
+ } );
+ } );
+ } );
} );
diff --git a/assets/src/stories-editor/store/test/reducer.js b/assets/src/stories-editor/store/test/reducer.js
index 33ff2703529..46fba64b55c 100644
--- a/assets/src/stories-editor/store/test/reducer.js
+++ b/assets/src/stories-editor/store/test/reducer.js
@@ -10,6 +10,7 @@ import {
animations,
currentPage,
blocks,
+ copiedMarkup,
} from '../reducer';
import { ANIMATION_STATUS } from '../constants';
@@ -245,4 +246,46 @@ describe( 'reducers', () => {
} );
} );
} );
+
+ describe( 'copiedMarkup()', () => {
+ it( 'should set copied markup', () => {
+ const markup = '';
+ const state = copiedMarkup( undefined, {
+ type: 'SET_COPIED_MARKUP',
+ markup,
+ } );
+
+ expect( state ).toStrictEqual( markup );
+ } );
+
+ it( 'should not set non-string values', () => {
+ const markups = [
+ false,
+ 999,
+ [],
+ ];
+ markups.forEach( ( markup ) => {
+ const state = copiedMarkup( undefined, {
+ type: 'SET_COPIED_MARKUP',
+ markup,
+ } );
+ expect( state ).toStrictEqual( {} );
+ } );
+ } );
+
+ it( 'should clear markup', () => {
+ const originals = [
+ 999,
+ false,
+ 'Hello',
+ ];
+
+ originals.forEach( ( original ) => {
+ const state = copiedMarkup( original, {
+ type: 'CLEAR_COPIED_MARKUP',
+ } );
+ expect( state ).toStrictEqual( '' );
+ } );
+ } );
+ } );
} );
diff --git a/tests/e2e/config/bootstrap.js b/tests/e2e/config/bootstrap.js
index 38dbc0b7ef2..8aaaf742e78 100644
--- a/tests/e2e/config/bootstrap.js
+++ b/tests/e2e/config/bootstrap.js
@@ -122,11 +122,11 @@ function observeConsoleLogging() {
} );
}
-/*
- Before every test suite run, delete all content created by the test. This ensures
- other posts/comments/etc. aren't dirtying tests and tests don't depend on
- each other's side-effects.
-*/
+/**
+ * Before every test suite run, delete all content created by the test. This ensures
+ * other posts/comments/etc. aren't dirtying tests and tests don't depend on
+ * each other's side-effects.
+ */
// eslint-disable-next-line jest/require-top-level-describe
beforeAll( async () => {
capturePageEventsForTearDown();
diff --git a/tests/e2e/specs/stories-editor/media-inserter.js b/tests/e2e/specs/stories-editor/media-inserter.js
index 00037903e03..9cc8ab2706e 100644
--- a/tests/e2e/specs/stories-editor/media-inserter.js
+++ b/tests/e2e/specs/stories-editor/media-inserter.js
@@ -121,6 +121,7 @@ describe( 'Stories Editor Screen', () => {
await page.click( SELECT_BUTTON );
// Wait for image to appear in the block.
+ await page.waitForSelector( '.wp-block-image img' );
await expect( page ).toMatchElement( '.wp-block-image img' );
} );
@@ -139,6 +140,7 @@ describe( 'Stories Editor Screen', () => {
await page.click( SELECT_BUTTON );
// Wait for image to appear in the block.
+ await page.waitForSelector( '.wp-block-video video' );
await expect( page ).toMatchElement( '.wp-block-video video' );
} );
diff --git a/tests/e2e/specs/stories-editor/right-click-menu.js b/tests/e2e/specs/stories-editor/right-click-menu.js
new file mode 100644
index 00000000000..7c1ebbbbe22
--- /dev/null
+++ b/tests/e2e/specs/stories-editor/right-click-menu.js
@@ -0,0 +1,146 @@
+/**
+ * WordPress dependencies
+ */
+import { createNewPost, getAllBlocks, selectBlockByClientId } from '@wordpress/e2e-test-utils';
+
+/**
+ * Internal dependencies
+ */
+import {
+ activateExperience,
+ clickButton,
+ deactivateExperience,
+ goToPreviousPage,
+ insertBlock,
+ removeAllBlocks,
+} from '../../utils';
+
+async function openRightClickMenu( el ) {
+ await el.click( {
+ button: 'right',
+ } );
+}
+
+describe( 'Right Click Menu', () => {
+ beforeAll( async () => {
+ await activateExperience( 'stories' );
+ } );
+
+ afterAll( async () => {
+ await deactivateExperience( 'stories' );
+ } );
+
+ const BLOCK_SELECTOR = '.wp-block-amp-amp-story-post-author';
+ const POPOVER_SELECTOR = '.amp-story-right-click-menu__popover';
+ const ACTIVE_PAGE_SELECTOR = '.amp-page-active';
+
+ beforeEach( async () => {
+ await createNewPost( { postType: 'amp_story' } );
+ await removeAllBlocks();
+ await page.waitForSelector( ACTIVE_PAGE_SELECTOR );
+ await insertBlock( 'Author' );
+ await page.waitForSelector( BLOCK_SELECTOR );
+ } );
+
+ it( 'opens the right click menu with the block actions when clicking on a block', async () => {
+ const block = await page.$( BLOCK_SELECTOR );
+ await openRightClickMenu( block );
+
+ expect( page ).toMatchElement( POPOVER_SELECTOR );
+ expect( page ).toMatchElement( POPOVER_SELECTOR + ' .right-click-copy' );
+ expect( page ).toMatchElement( POPOVER_SELECTOR + ' .right-click-cut' );
+ expect( page ).toMatchElement( POPOVER_SELECTOR + ' .right-click-remove' );
+ expect( page ).toMatchElement( POPOVER_SELECTOR + ' .right-click-duplicate' );
+ } );
+
+ it( 'does not open the menu by clicking on a page', async () => {
+ const pageBlock = await page.$( ACTIVE_PAGE_SELECTOR );
+ await openRightClickMenu( pageBlock );
+
+ expect( page ).not.toMatchElement( POPOVER_SELECTOR );
+ } );
+
+ it( 'should open right click menu for pasting on a page if a block has been copied previously', async () => {
+ const block = await page.$( BLOCK_SELECTOR );
+ await openRightClickMenu( block );
+
+ await clickButton( 'Copy Block' );
+ const pageBlock = await page.$( ACTIVE_PAGE_SELECTOR );
+ await openRightClickMenu( pageBlock );
+
+ expect( page ).toMatchElement( POPOVER_SELECTOR + ' .right-click-paste' );
+ } );
+
+ it( 'should allow copying and pasting a block', async () => {
+ const block = await page.$( BLOCK_SELECTOR );
+ await openRightClickMenu( block );
+
+ await clickButton( 'Copy Block' );
+
+ await removeAllBlocks();
+ await page.waitForSelector( ACTIVE_PAGE_SELECTOR );
+ const pageBlock = await page.$( ACTIVE_PAGE_SELECTOR );
+ await openRightClickMenu( pageBlock );
+
+ await clickButton( 'Paste' );
+
+ expect( page ).toMatchElement( ACTIVE_PAGE_SELECTOR + ' ' + BLOCK_SELECTOR );
+ } );
+
+ it( 'should allow cutting and pasting a block', async () => {
+ const block = await page.$( BLOCK_SELECTOR );
+ await openRightClickMenu( block );
+
+ await clickButton( 'Cut Block' );
+ expect( page ).not.toMatchElement( BLOCK_SELECTOR );
+
+ const pageBlock = await page.$( ACTIVE_PAGE_SELECTOR );
+ await openRightClickMenu( pageBlock );
+
+ await clickButton( 'Paste' );
+
+ expect( page ).toMatchElement( BLOCK_SELECTOR );
+ } );
+
+ it( 'should allow duplicating a block', async () => {
+ const block = await page.$( BLOCK_SELECTOR );
+ await openRightClickMenu( block );
+
+ await clickButton( 'Duplicate Block' );
+
+ const nodes = await page.$x(
+ '//div[contains(@class, "wp-block-amp-amp-story-post-author")]'
+ );
+ expect( nodes ).toHaveLength( 2 );
+ } );
+
+ it( 'should allow removing a block', async () => {
+ const block = await page.$( BLOCK_SELECTOR );
+ await openRightClickMenu( block );
+
+ await clickButton( 'Remove Block' );
+ expect( page ).not.toMatchElement( BLOCK_SELECTOR );
+ } );
+
+ it( 'should not allow pasting disallowed blocks', async () => {
+ const firstPageClientId = ( await getAllBlocks() )[ 0 ].clientId;
+ await insertBlock( 'Page' );
+ await insertBlock( 'Call to Action' );
+
+ const callToActionSelector = '.wp-block-amp-amp-story-cta';
+ const ctaBlock = await page.waitForSelector( callToActionSelector );
+ await openRightClickMenu( ctaBlock );
+
+ await clickButton( 'Copy Block' );
+
+ await goToPreviousPage();
+ await selectBlockByClientId( firstPageClientId );
+ const pageBlock = await page.$( `#block-${ firstPageClientId }` );
+ // Wait for transition time 300ms.
+ await page.waitFor( 300 );
+ await openRightClickMenu( pageBlock );
+
+ await clickButton( 'Paste' );
+ expect( page ).not.toMatchElement( `#block-${ firstPageClientId } ${ callToActionSelector }` );
+ } );
+} );
diff --git a/tests/e2e/specs/stories-editor/story-templates.js b/tests/e2e/specs/stories-editor/story-templates.js
index a161939b733..011c0803546 100644
--- a/tests/e2e/specs/stories-editor/story-templates.js
+++ b/tests/e2e/specs/stories-editor/story-templates.js
@@ -12,6 +12,7 @@ import {
clickOnMoreMenuItem,
deactivateExperience,
openTemplateInserter,
+ removeAllBlocks,
searchForBlock as searchForStoryBlock,
getBlocksOnPage,
wpDataSelect,
@@ -30,12 +31,7 @@ async function addReusableBlock() {
await clickOnMoreMenuItem( 'Top Toolbar' );
}
- // Remove all blocks from the post so that we're working with a clean slate.
- await page.evaluate( () => {
- const blocks = wp.data.select( 'core/block-editor' ).getBlocks();
- const clientIds = blocks.map( ( block ) => block.clientId );
- wp.data.dispatch( 'core/block-editor' ).removeBlocks( clientIds );
- } );
+ await removeAllBlocks();
// Insert a paragraph block
await insertBlock( 'Paragraph' );
diff --git a/tests/e2e/utils/index.js b/tests/e2e/utils/index.js
index 65dfa7c8b24..ff12a6c6c8e 100644
--- a/tests/e2e/utils/index.js
+++ b/tests/e2e/utils/index.js
@@ -11,6 +11,7 @@ export { openGlobalBlockInserter } from './open-global-block-inserter';
export { openMediaInserter } from './open-media-inserter';
export { openPreviewPage } from './open-preview-page';
export { openTemplateInserter } from './open-template-inserter';
+export { removeAllBlocks } from './remove-all-blocks';
export { searchForBlock } from './search-for-block';
export { selectBlockByClassName } from './select-block-by-classname';
export { switchEditorModeTo } from './switch-editor-mode-to';
diff --git a/tests/e2e/utils/remove-all-blocks.js b/tests/e2e/utils/remove-all-blocks.js
new file mode 100644
index 00000000000..11034b0562b
--- /dev/null
+++ b/tests/e2e/utils/remove-all-blocks.js
@@ -0,0 +1,12 @@
+/**
+ * Removes all blocks from post to ensure clean state.
+ *
+ * @return {Promise} Promise.
+ */
+export async function removeAllBlocks() {
+ await page.evaluate( () => {
+ const blocks = wp.data.select( 'core/block-editor' ).getBlocks();
+ const clientIds = blocks.map( ( block ) => block.clientId );
+ wp.data.dispatch( 'core/block-editor' ).removeBlocks( clientIds );
+ } );
+}