diff --git a/packages/block-editor/src/components/block-list/block.native.js b/packages/block-editor/src/components/block-list/block.native.js index c9a7eecaf25e17..755f0fc0db0398 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -6,7 +6,7 @@ import { View, Text, TouchableWithoutFeedback } from 'react-native'; /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; +import { Component, createRef } from '@wordpress/element'; import { GlobalStylesContext } from '@wordpress/components'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose, withPreferredColorScheme } from '@wordpress/compose'; @@ -29,6 +29,13 @@ class BlockListBlock extends Component { this.insertBlocksAfter = this.insertBlocksAfter.bind( this ); this.onFocus = this.onFocus.bind( this ); + this.getBlockWidth = this.getBlockWidth.bind( this ); + + this.state = { + blockWidth: 0, + }; + + this.anchorNodeRef = createRef(); } onFocus() { @@ -47,6 +54,15 @@ class BlockListBlock extends Component { } } + getBlockWidth( { nativeEvent } ) { + const { layout } = nativeEvent; + const { blockWidth } = this.state; + + if ( blockWidth !== layout.width ) { + this.setState( { blockWidth: layout.width } ); + } + } + getBlockForType() { return ( @@ -78,6 +94,7 @@ class BlockListBlock extends Component { contentStyle={ this.props.contentStyle } onDeleteBlock={ this.props.onDeleteBlock } /> + ); } } @@ -114,6 +131,12 @@ class BlockListBlock extends Component { isInnerBlockSelected, } = this.props; + if ( ! attributes || ! blockType ) { + return null; + } + + const { blockWidth } = this.state; + const accessibilityLabel = getAccessibleBlockLabel( blockType, attributes, @@ -170,7 +193,10 @@ class BlockListBlock extends Component { icon={ icon } /> ) } - + { isSelected && ( ) } diff --git a/packages/block-editor/src/components/block-mobile-toolbar/block-actions-menu.native.js b/packages/block-editor/src/components/block-mobile-toolbar/block-actions-menu.native.js new file mode 100644 index 00000000000000..f1faded14ee58b --- /dev/null +++ b/packages/block-editor/src/components/block-mobile-toolbar/block-actions-menu.native.js @@ -0,0 +1,197 @@ +/** + * External dependencies + */ +import { Platform, findNodeHandle } from 'react-native'; +import { partial, first, castArray, last, compact } from 'lodash'; +/** + * WordPress dependencies + */ +import { ToolbarButton, Picker } from '@wordpress/components'; +import { getBlockType, getDefaultBlockName } from '@wordpress/blocks'; +import { __, sprintf } from '@wordpress/i18n'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { withInstanceId, compose } from '@wordpress/compose'; +import { moreHorizontalMobile, trash, cog } from '@wordpress/icons'; +import { useRef } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { getMoversSetup } from '../block-mover/mover-description'; + +const BlockActionsMenu = ( { + onDelete, + isStackedHorizontally, + wrapBlockSettings, + wrapBlockMover, + openGeneralSidebar, + onMoveDown, + onMoveUp, + isFirst, + isLast, + blockTitle, + isEmptyDefaultBlock, + blockMobileToolbarRef, +} ) => { + const pickerRef = useRef(); + const moversOptions = { keys: [ 'icon', 'actionTitle' ], blockTitle }; + + const { + icon: { backward: backwardButtonIcon, forward: forwardButtonIcon }, + actionTitle: { + backward: backwardButtonTitle, + forward: forwardButtonTitle, + }, + } = getMoversSetup( isStackedHorizontally, moversOptions ); + + const deleteOption = { + id: 'deleteOption', + // translators: %s: block title e.g: "Paragraph". + label: sprintf( __( 'Remove %s' ), blockTitle ), + value: 'deleteOption', + icon: trash, + separated: true, + disabled: isEmptyDefaultBlock, + }; + + const settingsOption = { + id: 'settingsOption', + // translators: %s: block title e.g: "Paragraph". + label: sprintf( __( '%s Settings' ), blockTitle ), + value: 'settingsOption', + icon: cog, + }; + + const backwardButtonOption = { + id: 'backwardButtonOption', + label: backwardButtonTitle, + value: 'backwardButtonOption', + icon: backwardButtonIcon, + disabled: isFirst, + }; + + const forwardButtonOption = { + id: 'forwardButtonOption', + label: forwardButtonTitle, + value: 'forwardButtonOption', + icon: forwardButtonIcon, + disabled: isLast, + }; + + const options = compact( [ + wrapBlockMover && backwardButtonOption, + wrapBlockMover && forwardButtonOption, + wrapBlockSettings && settingsOption, + deleteOption, + ] ); + + function onPickerSelect( value ) { + switch ( value ) { + case deleteOption.value: + onDelete(); + break; + case settingsOption.value: + openGeneralSidebar(); + break; + case forwardButtonOption.value: + onMoveDown(); + break; + case backwardButtonOption.value: + onMoveUp(); + break; + } + } + + function onPickerPresent() { + if ( pickerRef.current ) { + pickerRef.current.presentPicker(); + } + } + + const disabledButtonIndices = options + .map( ( option, index ) => option.disabled && index + 1 ) + .filter( Boolean ); + + const accessibilityHint = + Platform.OS === 'ios' + ? __( 'Double tap to open Action Sheet with available options' ) + : __( 'Double tap to open Bottom Sheet with available options' ); + + return ( + <> + + + + ); +}; + +export default compose( + withSelect( ( select, { clientIds } ) => { + const { + getBlockIndex, + getBlockRootClientId, + getBlockOrder, + getBlockName, + getBlock, + } = select( 'core/block-editor' ); + const normalizedClientIds = castArray( clientIds ); + const block = getBlock( normalizedClientIds ); + const blockName = getBlockName( normalizedClientIds ); + const blockType = getBlockType( blockName ); + const blockTitle = blockType.title; + const firstClientId = first( normalizedClientIds ); + const rootClientId = getBlockRootClientId( firstClientId ); + const blockOrder = getBlockOrder( rootClientId ); + + const firstIndex = getBlockIndex( firstClientId, rootClientId ); + const lastIndex = getBlockIndex( + last( normalizedClientIds ), + rootClientId + ); + + const isDefaultBlock = blockName === getDefaultBlockName(); + const isEmptyContent = block.attributes.content === ''; + const isExactlyOneBlock = blockOrder.length === 1; + const isEmptyDefaultBlock = + isExactlyOneBlock && isDefaultBlock && isEmptyContent; + + return { + isFirst: firstIndex === 0, + isLast: lastIndex === blockOrder.length - 1, + rootClientId, + blockTitle, + isEmptyDefaultBlock, + }; + } ), + withDispatch( ( dispatch, { clientIds, rootClientId } ) => { + const { moveBlocksDown, moveBlocksUp } = dispatch( + 'core/block-editor' + ); + const { openGeneralSidebar } = dispatch( 'core/edit-post' ); + + return { + onMoveDown: partial( moveBlocksDown, clientIds, rootClientId ), + onMoveUp: partial( moveBlocksUp, clientIds, rootClientId ), + openGeneralSidebar: () => openGeneralSidebar( 'edit-post/block' ), + }; + } ), + withInstanceId +)( BlockActionsMenu ); diff --git a/packages/block-editor/src/components/block-mobile-toolbar/index.native.js b/packages/block-editor/src/components/block-mobile-toolbar/index.native.js index 0217a668d94830..efe3117bf5d39f 100644 --- a/packages/block-editor/src/components/block-mobile-toolbar/index.native.js +++ b/packages/block-editor/src/components/block-mobile-toolbar/index.native.js @@ -6,50 +6,63 @@ import { Keyboard, View } from 'react-native'; /** * WordPress dependencies */ -import { ToolbarButton } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/compose'; -import { trash } from '@wordpress/icons'; /** * Internal dependencies */ import styles from './style.scss'; import BlockMover from '../block-mover'; +import BlockActionsMenu from './block-actions-menu'; import { BlockSettingsButton } from '../block-settings'; +// Defined breakpoints are used to get a point when +// `settings` and `mover` controls should be wrapped into `BlockActionsMenu` +// and accessed through `BottomSheet`(Android)/`ActionSheet`(iOS). +const BREAKPOINTS = { + wrapSettings: 65, + wrapMover: 150, +}; const BlockMobileToolbar = ( { clientId, onDelete, - order, isStackedHorizontally, -} ) => ( - - + blockWidth, + anchorNodeRef, +} ) => { + const wrapBlockSettings = blockWidth < BREAKPOINTS.wrapSettings; + const wrapBlockMover = blockWidth <= BREAKPOINTS.wrapMover; - + return ( + + { ! wrapBlockMover && ( + + ) } - - { /* Render only one settings icon even if we have more than one fill - need for hooks with controls */ } - { ( fills = [ null ] ) => fills[ 0 ] } - + - + { /* Render only one settings icon even if we have more than one fill - need for hooks with controls */ } + { ( fills = [ null ] ) => fills[ 0 ] } + ) } - onClick={ onDelete } - icon={ trash } - extraProps={ { hint: __( 'Double tap to remove the block' ) } } - /> - -); + + + + ); +}; export default compose( withSelect( ( select, { clientId } ) => { diff --git a/packages/block-editor/src/components/block-mover/index.native.js b/packages/block-editor/src/components/block-mover/index.native.js index ded902f41e2964..aa1892c33e8042 100644 --- a/packages/block-editor/src/components/block-mover/index.native.js +++ b/packages/block-editor/src/components/block-mover/index.native.js @@ -7,45 +7,17 @@ import { first, last, partial, castArray } from 'lodash'; * WordPress dependencies */ import { ToolbarButton } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; import { withSelect, withDispatch } from '@wordpress/data'; import { withInstanceId, compose } from '@wordpress/compose'; -import { arrowUp, arrowDown, arrowLeft, arrowRight } from '@wordpress/icons'; -const horizontalMover = { - backwardButtonIcon: arrowLeft, - forwardButtonIcon: arrowRight, - backwardButtonHint: __( 'Double tap to move the block to the left' ), - forwardButtonHint: __( 'Double tap to move the block to the right' ), - firstBlockTitle: __( 'Move block left' ), - lastBlockTitle: __( 'Move block right' ), - /* translators: accessibility text. %1: current block position (number). %2: next block position (number) */ - backwardButtonTitle: __( - 'Move block left from position %1$s to position %2$s' - ), - /* translators: accessibility text. %1: current block position (number). %2: next block position (number) */ - forwardButtonTitle: __( - 'Move block right from position %1$s to position %2$s' - ), -}; - -const verticalMover = { - backwardButtonIcon: arrowUp, - forwardButtonIcon: arrowDown, - backwardButtonHint: __( 'Double tap to move the block up' ), - forwardButtonHint: __( 'Double tap to move the block down' ), - firstBlockTitle: __( 'Move block up' ), - lastBlockTitle: __( 'Move block down' ), - /* translators: accessibility text. %1: current block position (number). %2: next block position (number) */ - backwardButtonTitle: __( 'Move block up from row %1$s to row %2$s' ), - /* translators: accessibility text. %1: current block position (number). %2: next block position (number) */ - forwardButtonTitle: __( 'Move block down from row %1$s to row %2$s' ), -}; +/** + * Internal dependencies + */ +import { getMoversSetup } from './mover-description'; const BlockMover = ( { isFirst, isLast, - isRTL, isLocked, onMoveDown, onMoveUp, @@ -54,78 +26,35 @@ const BlockMover = ( { isStackedHorizontally, } ) => { const { - backwardButtonIcon, - forwardButtonIcon, - backwardButtonHint, - forwardButtonHint, - firstBlockTitle, - lastBlockTitle, - } = isStackedHorizontally ? horizontalMover : verticalMover; + description: { + backwardButtonHint, + forwardButtonHint, + firstBlockTitle, + lastBlockTitle, + }, + icon: { backward: backwardButtonIcon, forward: forwardButtonIcon }, + title: { backward: backwardButtonTitle, forward: forwardButtonTitle }, + } = getMoversSetup( isStackedHorizontally, { firstIndex } ); if ( isLocked || ( isFirst && isLast && ! rootClientId ) ) { return null; } - const switchButtonPropIfRTL = ( - isBackwardButton, - forwardButtonProp, - backwardButtonProp - ) => { - if ( isRTL && isStackedHorizontally ) { - // for RTL and horizontal direction switch prop between forward and backward button - if ( isBackwardButton ) { - return forwardButtonProp; // set forwardButtonProp for backward button - } - return backwardButtonProp; // set backwardButtonProp for forward button - } - - return isBackwardButton ? backwardButtonProp : forwardButtonProp; - }; - - const getMoverButtonTitle = ( isBackwardButton ) => { - const fromIndex = firstIndex + 1; // current position based on index - // for backwardButton decrease index (move left/up) for forwardButton increase index (move right/down) - const direction = isBackwardButton ? -1 : 1; - const toIndex = fromIndex + direction; // position after move - - const { - backwardButtonTitle, - forwardButtonTitle, - } = isStackedHorizontally ? horizontalMover : verticalMover; - - const buttonTitle = switchButtonPropIfRTL( - isBackwardButton, - forwardButtonTitle, - backwardButtonTitle - ); - - return sprintf( buttonTitle, fromIndex, toIndex ); - }; - - const getArrowIcon = ( isBackwardButton ) => - switchButtonPropIfRTL( - isBackwardButton, - forwardButtonIcon, - backwardButtonIcon - ); - return ( <> { + if ( KEYS.includes( key ) ) { + Object.assign( setup, { + [ key ]: getSetup( key, isStackedHorizontally, { + firstIndex, + blockTitle, + } ), + } ); + } + return setup; + }, {} ); +} + +function switchButtonPropIfRTL( + isBackwardButton, + forwardButtonProp, + backwardButtonProp, + isStackedHorizontally +) { + if ( I18nManager.isRTL && isStackedHorizontally ) { + // for RTL and horizontal direction switch prop between forward and backward button + if ( isBackwardButton ) { + return forwardButtonProp; // set forwardButtonProp for backward button + } + return backwardButtonProp; // set backwardButtonProp for forward button + } + + return isBackwardButton ? backwardButtonProp : forwardButtonProp; +} + +function getSetup() { + const [ key, ...args ] = arguments; + return SETUP_GETTER[ key ].apply( null, [ ...args ] ); +} + +function applyRTLSetup( isBackwardButton, args ) { + return switchButtonPropIfRTL.apply( null, [ isBackwardButton, ...args ] ); +} + +function getMoverDescription( isStackedHorizontally ) { + return isStackedHorizontally ? horizontalMover : verticalMover; +} + +function getArrowIcon( isStackedHorizontally ) { + const { forwardButtonIcon, backwardButtonIcon } = getMoverDescription( + isStackedHorizontally + ); + + const args = [ + forwardButtonIcon, + backwardButtonIcon, + isStackedHorizontally, + ]; + + return { + backward: applyRTLSetup( true, args ), + forward: applyRTLSetup( false, args ), + }; +} + +function getMoverActionTitle( isStackedHorizontally, { blockTitle } ) { + const { firstBlockTitle, lastBlockTitle } = getMoverDescription( + isStackedHorizontally + ); + + const args = [ lastBlockTitle, firstBlockTitle, isStackedHorizontally ]; + + const actionTitlePrev = applyRTLSetup( true, args ); + const actionTitleNext = applyRTLSetup( false, args ); + + return { + backward: sprintf( actionTitlePrev, blockTitle ), + forward: sprintf( actionTitleNext, blockTitle ), + }; +} + +function getMoverButtonTitle( isStackedHorizontally, { firstIndex } ) { + const getIndexes = ( isBackwardButton ) => { + const fromIndex = firstIndex + 1; // current position based on index + // for backwardButton decrease index (move left/up) for forwardButton increase index (move right/down) + const direction = isBackwardButton ? -1 : 1; + const toIndex = fromIndex + direction; // position after move + return [ fromIndex, toIndex ]; + }; + + const { backwardButtonTitle, forwardButtonTitle } = getMoverDescription( + isStackedHorizontally + ); + + const args = [ + backwardButtonTitle, + forwardButtonTitle, + isStackedHorizontally, + ]; + + const buttonTitlePrev = applyRTLSetup( true, args ); + const buttonTitleNext = applyRTLSetup( false, args ); + + return { + backward: sprintf( buttonTitlePrev, ...getIndexes( true ) ), + forward: sprintf( buttonTitleNext, ...getIndexes( false ) ), + }; +} diff --git a/packages/block-editor/src/components/inner-blocks/index.native.js b/packages/block-editor/src/components/inner-blocks/index.native.js index db06f0369abaa3..35665b155e5c39 100644 --- a/packages/block-editor/src/components/inner-blocks/index.native.js +++ b/packages/block-editor/src/components/inner-blocks/index.native.js @@ -52,7 +52,7 @@ function UncontrolledInnerBlocks( props ) { const block = useSelect( ( select ) => select( 'core/block-editor' ).getBlock( clientId ) - ); + ) || { innerBlocks: [] }; useNestedSettingsUpdate( clientId, allowedBlocks, templateLock ); diff --git a/packages/components/src/mobile/picker/index.android.js b/packages/components/src/mobile/picker/index.android.js index 9b0b79b5eaa81a..e380309df3b27e 100644 --- a/packages/components/src/mobile/picker/index.android.js +++ b/packages/components/src/mobile/picker/index.android.js @@ -9,11 +9,22 @@ import { View } from 'react-native'; */ import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; /** * Internal dependencies */ import BottomSheet from '../bottom-sheet'; +import styles from './styles.scss'; + +function Separator() { + const separatorStyle = usePreferredColorSchemeStyle( + styles.separator, + styles.separatorDark + ); + + return ; +} export default class Picker extends Component { constructor() { @@ -35,30 +46,43 @@ export default class Picker extends Component { } onCellPress( value ) { - this.props.onChange( value ); + const { onChange } = this.props; + onChange( value ); this.onClose(); } render() { + const { options, leftAlign, hideCancelButton } = this.props; + const { isVisible } = this.state; + return ( - { this.props.options.map( ( option, index ) => ( - this.onCellPress( option.value ) } - /> + { options.map( ( option, index ) => ( + <> + { options.length > 1 && option.separated && ( + + ) } + + this.onCellPress( option.value ) + } + disabled={ option.disabled } + style={ option.disabled && styles.disabled } + /> + ) ) } - { ! this.props.hideCancelButton && ( + { ! hideCancelButton && ( label ); const fullOptions = [ __( 'Cancel' ) ].concat( labels ); @@ -20,6 +27,9 @@ class Picker extends Component { title, options: fullOptions, cancelButtonIndex: 0, + destructiveButtonIndex, + disabledButtonIndices, + anchor, }, ( buttonIndex ) => { if ( buttonIndex === 0 ) { diff --git a/packages/components/src/mobile/picker/styles.native.scss b/packages/components/src/mobile/picker/styles.native.scss new file mode 100644 index 00000000000000..9a271b80951ef6 --- /dev/null +++ b/packages/components/src/mobile/picker/styles.native.scss @@ -0,0 +1,16 @@ +.separator { + margin-top: $panel-padding / 2; + margin-bottom: $panel-padding / 2; + margin-left: -$panel-padding; + margin-right: -$panel-padding; + height: $border-width; + background-color: $light-gray-400; +} + +.separatorDark { + background-color: $gray-70; +} + +.disabled { + opacity: 0.3; +} diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js index f0f63d29642633..841b0befa6070d 100644 --- a/packages/icons/src/index.js +++ b/packages/icons/src/index.js @@ -90,6 +90,7 @@ export { default as minus } from './library/minus'; export { default as mobile } from './library/mobile'; export { default as more } from './library/more'; export { default as moreHorizontal } from './library/more-horizontal'; +export { default as moreHorizontalMobile } from './library/more-horizontal-mobile'; export { default as moreVertical } from './library/more-vertical'; export { default as navigation } from './library/navigation'; export { default as pageBreak } from './library/page-break'; diff --git a/packages/icons/src/library/more-horizontal-mobile.js b/packages/icons/src/library/more-horizontal-mobile.js new file mode 100644 index 00000000000000..a185c34bd069b5 --- /dev/null +++ b/packages/icons/src/library/more-horizontal-mobile.js @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const moreHorizontalMobile = ( + + + + + +); + +export default moreHorizontalMobile;