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 ) => ( + + { + onClose(); + action.blockAction( firstBlockClientId, insidePercentageY, insidePercentageX ); + } } + icon={ action.icon } + > + { action.name } + + + ) ) } + + + ) } +
+ ); +}; + +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 ); + } ); +}