UNPKG

@wordpress/block-editor

Version:
425 lines (378 loc) 10.8 kB
/** * External dependencies */ import { AccessibilityInfo, Platform } from 'react-native'; /** * WordPress dependencies */ import { __, _x } from '@wordpress/i18n'; import { Dropdown, ToolbarButton, Picker } from '@wordpress/components'; import { Component } from '@wordpress/element'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose, withPreferredColorScheme } from '@wordpress/compose'; import { isUnmodifiedDefaultBlock } from '@wordpress/blocks'; import { Icon, plus, plusCircle, plusCircleFilled, insertAfter, insertBefore, } from '@wordpress/icons'; import { setBlockTypeImpressions } from '@wordpress/react-native-bridge'; /** * Internal dependencies */ import styles from './style.scss'; import InserterMenu from './menu'; import BlockInsertionPoint from '../block-list/insertion-point'; import { store as blockEditorStore } from '../../store'; const VOICE_OVER_ANNOUNCEMENT_DELAY = 1000; const defaultRenderToggle = ( { onToggle, disabled, iconStyle, buttonStyle, onLongPress, } ) => { return ( <ToolbarButton title={ _x( 'Add block', 'Generic label for block inserter button' ) } icon={ <Icon icon={ plus } style={ iconStyle } /> } onClick={ onToggle } extraProps={ { hint: __( 'Double tap to add a block' ), // testID is present to disambiguate this element for native UI tests. It's not // usually required for components. See: https://github.com/WordPress/gutenberg/pull/18832#issuecomment-561411389. testID: 'add-block-button', onLongPress, hitSlop: { top: 10, bottom: 10, left: 10, right: 10 }, } } isDisabled={ disabled } customContainerStyles={ buttonStyle } fixedRatio={ false } /> ); }; export class Inserter extends Component { announcementTimeout; constructor() { super( ...arguments ); this.onToggle = this.onToggle.bind( this ); this.renderInserterToggle = this.renderInserterToggle.bind( this ); this.renderContent = this.renderContent.bind( this ); } componentWillUnmount() { clearTimeout( this.announcementTimeout ); } getInsertionOptions() { const addBeforeOption = { value: 'before', label: __( 'Add Block Before' ), icon: plusCircle, }; const replaceCurrentOption = { value: 'replace', label: __( 'Replace Current Block' ), icon: plusCircleFilled, }; const addAfterOption = { value: 'after', label: __( 'Add Block After' ), icon: plusCircle, }; const addToBeginningOption = { value: 'start', label: __( 'Add To Beginning' ), icon: insertBefore, }; const addToEndOption = { value: 'end', label: __( 'Add To End' ), icon: insertAfter, }; const { isAnyBlockSelected, isSelectedBlockReplaceable } = this.props; if ( isAnyBlockSelected ) { if ( isSelectedBlockReplaceable ) { return [ addToBeginningOption, addBeforeOption, replaceCurrentOption, addAfterOption, addToEndOption, ]; } return [ addToBeginningOption, addBeforeOption, addAfterOption, addToEndOption, ]; } return [ addToBeginningOption, addToEndOption ]; } getInsertionIndex( insertionType ) { const { insertionIndexDefault, insertionIndexStart, insertionIndexBefore, insertionIndexAfter, insertionIndexEnd, } = this.props; if ( insertionType === 'start' ) { return insertionIndexStart; } if ( insertionType === 'before' || insertionType === 'replace' ) { return insertionIndexBefore; } if ( insertionType === 'after' ) { return insertionIndexAfter; } if ( insertionType === 'end' ) { return insertionIndexEnd; } return insertionIndexDefault; } shouldReplaceBlock( insertionType ) { const { isSelectedBlockReplaceable } = this.props; if ( insertionType === 'replace' ) { return true; } if ( insertionType === 'default' && isSelectedBlockReplaceable ) { return true; } return false; } onToggle( isOpen ) { const { blockTypeImpressions, onToggle, updateSettings } = this.props; if ( ! isOpen ) { const impressionsRemain = Object.values( blockTypeImpressions ).some( ( count ) => count > 0 ); if ( impressionsRemain ) { const decrementedImpressions = Object.entries( blockTypeImpressions ).reduce( ( acc, [ blockName, count ] ) => ( { ...acc, [ blockName ]: Math.max( count - 1, 0 ), } ), {} ); // Persist block type impression to JavaScript store. updateSettings( { impressions: decrementedImpressions, } ); // Persist block type impression count to native app store. setBlockTypeImpressions( decrementedImpressions ); } } // Surface toggle callback to parent component. if ( onToggle ) { onToggle( isOpen ); } this.onInserterToggledAnnouncement( isOpen ); } onInserterToggledAnnouncement( isOpen ) { AccessibilityInfo.isScreenReaderEnabled().then( ( isEnabled ) => { if ( isEnabled ) { const isIOS = Platform.OS === 'ios'; const announcement = isOpen ? __( 'Scrollable block menu opened. Select a block.' ) : __( 'Scrollable block menu closed.' ); this.announcementTimeout = setTimeout( () => AccessibilityInfo.announceForAccessibility( announcement ), isIOS ? VOICE_OVER_ANNOUNCEMENT_DELAY : 0 ); } } ); } /** * Render callback to display Dropdown toggle element. * * @param {Object} options * @param {Function} options.onToggle Callback to invoke when toggle is * pressed. * @param {boolean} options.isOpen Whether dropdown is currently open. * * @return {Element} Dropdown toggle element. */ renderInserterToggle( { onToggle, isOpen } ) { const { disabled, renderToggle = defaultRenderToggle, getStylesFromColorScheme, showSeparator, } = this.props; if ( showSeparator && isOpen ) { return <BlockInsertionPoint />; } const buttonStyle = getStylesFromColorScheme( styles[ 'inserter-menu__add-block-button' ], styles[ 'inserter-menu__add-block-button--dark' ] ); const iconStyle = getStylesFromColorScheme( styles[ 'inserter-menu__add-block-button-icon' ], styles[ 'inserter-menu__add-block-button-icon--dark' ] ); const onPress = () => { this.setState( { destinationRootClientId: this.props.destinationRootClientId, shouldReplaceBlock: this.shouldReplaceBlock( 'default' ), insertionIndex: this.getInsertionIndex( 'default' ), }, onToggle ); }; const onLongPress = () => { if ( this.picker ) { this.picker.presentPicker(); } }; const onPickerSelect = ( insertionType ) => { this.setState( { destinationRootClientId: this.props.destinationRootClientId, shouldReplaceBlock: this.shouldReplaceBlock( insertionType ), insertionIndex: this.getInsertionIndex( insertionType ), }, onToggle ); }; return ( <> { renderToggle( { onToggle: onPress, isOpen, disabled, iconStyle, buttonStyle, onLongPress, } ) } <Picker ref={ ( instance ) => ( this.picker = instance ) } options={ this.getInsertionOptions() } onChange={ onPickerSelect } hideCancelButton /> </> ); } /** * Render callback to display Dropdown content element. * * @param {Object} options * @param {Function} options.onClose Callback to invoke when dropdown is * closed. * @param {boolean} options.isOpen Whether dropdown is currently open. * * @return {Element} Dropdown content element. */ renderContent( { onClose, isOpen } ) { const { clientId, isAppender } = this.props; const { destinationRootClientId, shouldReplaceBlock, insertionIndex } = this.state; return ( <InserterMenu isOpen={ isOpen } onSelect={ onClose } onDismiss={ onClose } rootClientId={ destinationRootClientId } clientId={ clientId } isAppender={ isAppender } shouldReplaceBlock={ shouldReplaceBlock } insertionIndex={ insertionIndex } /> ); } render() { return ( <Dropdown onToggle={ this.onToggle } headerTitle={ __( 'Add a block' ) } renderToggle={ this.renderInserterToggle } renderContent={ this.renderContent } /> ); } } export default compose( [ withDispatch( ( dispatch ) => { const { updateSettings } = dispatch( blockEditorStore ); return { updateSettings }; } ), withSelect( ( select, { clientId, isAppender, rootClientId } ) => { const { getBlockRootClientId, getBlockSelectionEnd, getBlockOrder, getBlockIndex, getBlock, getSettings: getBlockEditorSettings, } = select( blockEditorStore ); const end = getBlockSelectionEnd(); // `end` argument (id) can refer to the component which is removed // due to pressing `undo` button, that's why we need to check // if `getBlock( end) is valid, otherwise `null` is passed const isAnyBlockSelected = ! isAppender && end && getBlock( end ); const destinationRootClientId = isAnyBlockSelected ? getBlockRootClientId( end ) : rootClientId; const selectedBlockIndex = getBlockIndex( end ); const endOfRootIndex = getBlockOrder( rootClientId ).length; const isSelectedUnmodifiedDefaultBlock = isAnyBlockSelected ? isUnmodifiedDefaultBlock( getBlock( end ) ) : undefined; function getDefaultInsertionIndex() { const { __experimentalShouldInsertAtTheTop: shouldInsertAtTheTop } = getBlockEditorSettings(); // If post title is selected insert as first block. if ( shouldInsertAtTheTop ) { return 0; } // If the clientId is defined, we insert at the position of the block. if ( clientId ) { return getBlockIndex( clientId ); } // If there is a selected block, if ( isAnyBlockSelected ) { // And the last selected block is unmodified (empty), it will be replaced. if ( isSelectedUnmodifiedDefaultBlock ) { return selectedBlockIndex; } // We insert after the selected block. return selectedBlockIndex + 1; } // Otherwise, we insert at the end of the current rootClientId. return endOfRootIndex; } const insertionIndexStart = 0; const insertionIndexBefore = isAnyBlockSelected ? selectedBlockIndex : insertionIndexStart; const insertionIndexAfter = isAnyBlockSelected ? selectedBlockIndex + 1 : endOfRootIndex; const insertionIndexEnd = endOfRootIndex; return { blockTypeImpressions: getBlockEditorSettings().impressions, destinationRootClientId, insertionIndexDefault: getDefaultInsertionIndex(), insertionIndexBefore, insertionIndexAfter, insertionIndexStart, insertionIndexEnd, isAnyBlockSelected: !! isAnyBlockSelected, isSelectedBlockReplaceable: isSelectedUnmodifiedDefaultBlock, }; } ), withPreferredColorScheme, ] )( Inserter );