diff --git a/package-lock.json b/package-lock.json index 634fc0aa73dce..37baac458da62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18231,7 +18231,7 @@ "react-native-linear-gradient": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-2/react-native-linear-gradient-2.5.6-wp-2.tgz", "react-native-modal": "^11.10.0", "react-native-prompt-android": "https://raw.githubusercontent.com/wordpress-mobile/react-native-prompt-android/v1.0.0-wp-2/react-native-prompt-android-1.0.0-wp-2.tgz", - "react-native-reanimated": "https://raw.githubusercontent.com/wordpress-mobile/react-native-reanimated/2.4.1-wp-1/react-native-reanimated-2.4.1-wp-1.tgz", + "react-native-reanimated": "https://raw.githubusercontent.com/wordpress-mobile/react-native-reanimated/2.4.1-wp-2/react-native-reanimated-2.4.1-wp-2.tgz", "react-native-safe-area": "^0.5.0", "react-native-safe-area-context": "3.2.0", "react-native-sass-transformer": "^1.1.1", @@ -50931,8 +50931,8 @@ "integrity": "sha512-9whL4Kc5OU5Q89Dneq8oT8vpQTA/cEz24EIPXEQ2KGo1Dkf4qzer5+98YXJM2F8yitCP8UKHOL8WIiE7zukXBA==" }, "react-native-reanimated": { - "version": "https://raw.githubusercontent.com/wordpress-mobile/react-native-reanimated/2.4.1-wp-1/react-native-reanimated-2.4.1-wp-1.tgz", - "integrity": "sha512-1IHFrxRxL6Rkc4+OdKjoNq8QI/W+nJqq/dhHy5+e4lQscs93zMczvrdJN/ntd7ZcqCSmWcMQJjuZo58/8tYD0Q==", + "version": "https://raw.githubusercontent.com/wordpress-mobile/react-native-reanimated/2.4.1-wp-2/react-native-reanimated-2.4.1-wp-2.tgz", + "integrity": "sha512-8Mu7150ezI5PGBYAatqhQlau0nkeXMVNZIODAU7l1e7qjfEALZiuxKMkvWhFw1xBCqx+qRv24yYns7I5GGiZGQ==", "requires": { "@babel/plugin-transform-object-assign": "^7.10.4", "@types/invariant": "^2.2.35", diff --git a/packages/block-editor/src/components/block-draggable/draggable-chip.native.js b/packages/block-editor/src/components/block-draggable/draggable-chip.native.js new file mode 100644 index 0000000000000..682c4f5b2cd49 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/draggable-chip.native.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { dragHandle } from '@wordpress/icons'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; +import styles from './style.scss'; + +const shadowStyle = { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, +}; + +/** + * Block draggable chip component + * + * @param {Object} props Component props. + * @param {Object} [props.icon] Block icon. + * @return {JSX.Element} Chip component. + */ +export default function BlockDraggableChip( { icon } ) { + const containerStyle = usePreferredColorSchemeStyle( + styles[ 'draggable-chip__container' ], + styles[ 'draggable-chip__container--dark' ] + ); + + return ( + + + { icon && } + + ); +} diff --git a/packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.js b/packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.js new file mode 100644 index 0000000000000..4ffbdbd1b8ac3 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.js @@ -0,0 +1,181 @@ +/** + * External dependencies + */ +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + useAnimatedReaction, + runOnJS, +} from 'react-native-reanimated'; +import { + useSafeAreaInsets, + useSafeAreaFrame, +} from 'react-native-safe-area-context'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { generateHapticFeedback } from '@wordpress/react-native-bridge'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import { useBlockListContext } from '../block-list/block-list-context'; +import styles from './dropping-insertion-point.scss'; + +/** + * Dropping zone indicator component. + * + * This component shows where a block can be dropped when it's being dragged. + * + * @param {Object} props Component props. + * @param {Object} props.scroll Scroll offset object. + * @param {Object} props.currentYPosition Current Y coordinate position when dragging. + * @param {import('react-native-reanimated').SharedValue} props.isDragging Whether or not dragging has started. + * @param {import('react-native-reanimated').SharedValue} props.targetBlockIndex Current block target index. + * + * @return {JSX.Element} The component to be rendered. + */ +export default function DroppingInsertionPoint( { + scroll, + currentYPosition, + isDragging, + targetBlockIndex, +} ) { + const { + getBlockOrder, + isBlockBeingDragged, + isDraggingBlocks, + getPreviousBlockClientId, + getNextBlockClientId, + } = useSelect( blockEditorStore ); + + const { blocksLayouts, findBlockLayoutByClientId } = useBlockListContext(); + const { top, bottom } = useSafeAreaInsets(); + const { height } = useSafeAreaFrame(); + const safeAreaOffset = top + bottom; + const maxHeight = + height - + ( safeAreaOffset + styles[ 'dropping-insertion-point' ].height ); + + const blockYPosition = useSharedValue( 0 ); + const opacity = useSharedValue( 0 ); + + useAnimatedReaction( + () => isDragging.value, + ( value ) => { + if ( ! value ) { + opacity.value = 0; + blockYPosition.value = 0; + } + } + ); + + function getSelectedBlockIndicatorPosition( positions ) { + const currentYPositionWithScroll = + currentYPosition.value + scroll.offsetY.value; + const midpoint = ( positions.top + positions.bottom ) / 2; + + return midpoint < currentYPositionWithScroll + ? positions.bottom + : positions.top; + } + + function setIndicatorPosition( index ) { + const insertionPointIndex = index; + const order = getBlockOrder(); + const isDraggingAnyBlocks = isDraggingBlocks(); + + if ( + ! isDraggingAnyBlocks || + insertionPointIndex === null || + ! order.length + ) { + return; + } + + let previousClientId = order[ insertionPointIndex - 1 ]; + let nextClientId = order[ insertionPointIndex ]; + + while ( isBlockBeingDragged( previousClientId ) ) { + previousClientId = getPreviousBlockClientId( previousClientId ); + } + + while ( isBlockBeingDragged( nextClientId ) ) { + nextClientId = getNextBlockClientId( nextClientId ); + } + + const previousElement = previousClientId + ? findBlockLayoutByClientId( + blocksLayouts.current, + previousClientId + ) + : null; + const nextElement = nextClientId + ? findBlockLayoutByClientId( blocksLayouts.current, nextClientId ) + : null; + + const previousElementPosition = previousElement + ? previousElement.y + previousElement.height + : 0; + const nextElementPosition = nextElement ? nextElement.y : 0; + + const elementsPositions = { + top: Math.floor( + previousElement ? previousElementPosition : nextElementPosition + ), + bottom: Math.floor( + nextElement ? nextElementPosition : previousElementPosition + ), + }; + + const nextPosition = + elementsPositions.top !== elementsPositions.bottom + ? getSelectedBlockIndicatorPosition( elementsPositions ) + : elementsPositions.top; + + if ( nextPosition && blockYPosition.value !== nextPosition ) { + opacity.value = 0; + blockYPosition.value = nextPosition; + opacity.value = withTiming( 1 ); + generateHapticFeedback(); + } + } + + useAnimatedReaction( + () => targetBlockIndex.value, + ( value, previous ) => { + if ( value !== previous ) { + runOnJS( setIndicatorPosition )( value ); + } + } + ); + + const animatedStyles = useAnimatedStyle( () => { + const translationY = blockYPosition.value - scroll.offsetY.value; + // Prevents overflowing behind the header/footer + const shouldHideIndicator = + translationY < 0 || translationY > maxHeight; + + return { + opacity: shouldHideIndicator ? 0 : opacity.value, + transform: [ + { + translateY: translationY, + }, + ], + }; + } ); + + const insertionPointStyles = [ + styles[ 'dropping-insertion-point' ], + animatedStyles, + ]; + + return ( + + ); +} diff --git a/packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.scss b/packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.scss new file mode 100644 index 0000000000000..7305efce4d19e --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.scss @@ -0,0 +1,8 @@ +.dropping-insertion-point { + position: absolute; + left: $dashed-border-space; + right: $dashed-border-space; + height: 3; + background-color: $blue-wordpress; + z-index: 1; +} diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js new file mode 100644 index 0000000000000..a52396db27fc4 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -0,0 +1,415 @@ +/** + * External dependencies + */ +import { AccessibilityInfo } from 'react-native'; +import Animated, { + runOnJS, + runOnUI, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, + ZoomInEasyDown, + ZoomOutEasyDown, +} from 'react-native-reanimated'; + +/** + * WordPress dependencies + */ +import { Draggable, DraggableTrigger } from '@wordpress/components'; +import { select, useSelect, useDispatch } from '@wordpress/data'; +import { + useCallback, + useEffect, + useRef, + useState, + Platform, +} from '@wordpress/element'; +import { getBlockType } from '@wordpress/blocks'; +import { generateHapticFeedback } from '@wordpress/react-native-bridge'; +import RCTAztecView from '@wordpress/react-native-aztec'; + +/** + * Internal dependencies + */ +import useScrollWhenDragging from './use-scroll-when-dragging'; +import DraggableChip from './draggable-chip'; +import { store as blockEditorStore } from '../../store'; +import { useBlockListContext } from '../block-list/block-list-context'; +import DroppingInsertionPoint from './dropping-insertion-point'; +import useBlockDropZone from '../use-block-drop-zone'; +import styles from './style.scss'; + +const CHIP_OFFSET_TO_TOUCH_POSITION = 32; +const BLOCK_OPACITY_ANIMATION_CONFIG = { duration: 350 }; +const BLOCK_OPACITY_ANIMATION_DELAY = 250; +const DEFAULT_LONG_PRESS_MIN_DURATION = 500; +const DEFAULT_IOS_LONG_PRESS_MIN_DURATION = + DEFAULT_LONG_PRESS_MIN_DURATION - 50; + +/** + * Block draggable wrapper component + * + * This component handles all the interactions for dragging blocks. + * It relies on the block list and its context for dragging, hence it + * should be rendered between the `BlockListProvider` component and the + * block list rendering. It also requires listening to scroll events, + * therefore for this purpose, it returns the `onScroll` event handler + * that should be attached to the list that renders the blocks. + * + * + * @param {Object} props Component props. + * @param {JSX.Element} props.children Children to be rendered. + * + * @return {Function} Render function that passes `onScroll` event handler. + */ +const BlockDraggableWrapper = ( { children } ) => { + const [ draggedBlockIcon, setDraggedBlockIcon ] = useState(); + + const { + selectBlock, + startDraggingBlocks, + stopDraggingBlocks, + } = useDispatch( blockEditorStore ); + + const { scrollRef } = useBlockListContext(); + const animatedScrollRef = useAnimatedRef(); + animatedScrollRef( scrollRef ); + + const scroll = { + offsetY: useSharedValue( 0 ), + }; + const chip = { + x: useSharedValue( 0 ), + y: useSharedValue( 0 ), + width: useSharedValue( 0 ), + height: useSharedValue( 0 ), + }; + const currentYPosition = useSharedValue( 0 ); + const isDragging = useSharedValue( false ); + + const [ + startScrolling, + scrollOnDragOver, + stopScrolling, + draggingScrollHandler, + ] = useScrollWhenDragging(); + + const scrollHandler = ( event ) => { + 'worklet'; + const { contentOffset } = event; + scroll.offsetY.value = contentOffset.y; + + draggingScrollHandler( event ); + }; + + const { + onBlockDragOver, + onBlockDragEnd, + onBlockDrop, + targetBlockIndex, + } = useBlockDropZone(); + + // Stop dragging blocks if the block draggable is unmounted. + useEffect( () => { + return () => { + if ( isDragging.value ) { + stopDraggingBlocks(); + } + }; + }, [] ); + + const setDraggedBlockIconByClientId = ( clientId ) => { + const blockName = select( blockEditorStore ).getBlockName( clientId ); + const blockIcon = getBlockType( blockName )?.icon; + if ( blockIcon ) { + setDraggedBlockIcon( blockIcon ); + } + }; + + const onStartDragging = ( { clientId, position } ) => { + if ( clientId ) { + startDraggingBlocks( [ clientId ] ); + setDraggedBlockIconByClientId( clientId ); + runOnUI( startScrolling )( position.y ); + generateHapticFeedback(); + } else { + // We stop dragging if no block is found. + runOnUI( stopDragging )(); + } + }; + + const onStopDragging = ( { clientId } ) => { + if ( clientId ) { + onBlockDrop( { + // Dropping is only allowed at root level + srcRootClientId: '', + srcClientIds: [ clientId ], + type: 'block', + } ); + selectBlock( clientId ); + setDraggedBlockIcon( undefined ); + } + onBlockDragEnd(); + stopDraggingBlocks(); + }; + + const onChipLayout = ( { nativeEvent: { layout } } ) => { + if ( layout.width > 0 ) { + chip.width.value = layout.width; + } + if ( layout.height > 0 ) { + chip.height.value = layout.height; + } + }; + + const startDragging = ( { x, y, id } ) => { + 'worklet'; + const dragPosition = { x, y }; + chip.x.value = dragPosition.x; + chip.y.value = dragPosition.y; + currentYPosition.value = dragPosition.y; + + isDragging.value = true; + + runOnJS( onStartDragging )( { clientId: id, position: dragPosition } ); + }; + + const updateDragging = ( { x, y } ) => { + 'worklet'; + const dragPosition = { x, y }; + chip.x.value = dragPosition.x; + chip.y.value = dragPosition.y; + currentYPosition.value = dragPosition.y; + + runOnJS( onBlockDragOver )( { x, y: y + scroll.offsetY.value } ); + + // Update scrolling velocity + scrollOnDragOver( dragPosition.y ); + }; + + const stopDragging = ( { id } ) => { + 'worklet'; + isDragging.value = false; + + stopScrolling(); + runOnJS( onStopDragging )( { clientId: id } ); + }; + + const chipDynamicStyles = useAnimatedStyle( () => { + return { + transform: [ + { translateX: chip.x.value - chip.width.value / 2 }, + { + translateY: + chip.y.value - + chip.height.value - + CHIP_OFFSET_TO_TOUCH_POSITION, + }, + ], + }; + } ); + const chipStyles = [ + chipDynamicStyles, + styles[ 'draggable-chip__wrapper' ], + ]; + + return ( + <> + + + { children( { onScroll: scrollHandler } ) } + + + { draggedBlockIcon && ( + + + + ) } + + + ); +}; + +/** + * Block draggable component + * + * This component serves for animating the block when it is being dragged. + * Hence, it should be wrapped around the rendering of a block. + * + * @param {Object} props Component props. + * @param {JSX.Element} props.children Children to be rendered. + * @param {string} props.clientId Client id of the block. + * @param {string} [props.draggingClientId] Client id to use for dragging. If not defined, the value from `clientId` will be used. + * @param {boolean} [props.enabled] Enables the draggable trigger. + * + * @return {Function} Render function which includes the parameter `isDraggable` to determine if the block can be dragged. + */ +const BlockDraggable = ( { + clientId, + children, + draggingClientId, + enabled = true, +} ) => { + const wasBeingDragged = useRef( false ); + const [ isEditingText, setIsEditingText ] = useState( false ); + const [ isScreenReaderEnabled, setIsScreenReaderEnabled ] = useState( + false + ); + + const draggingAnimation = { + opacity: useSharedValue( 1 ), + }; + + const startDraggingBlock = () => { + draggingAnimation.opacity.value = withTiming( + 0.4, + BLOCK_OPACITY_ANIMATION_CONFIG + ); + }; + + const stopDraggingBlock = () => { + draggingAnimation.opacity.value = withDelay( + BLOCK_OPACITY_ANIMATION_DELAY, + withTiming( 1, BLOCK_OPACITY_ANIMATION_CONFIG ) + ); + }; + + const { isDraggable, isBeingDragged, isBlockSelected } = useSelect( + ( _select ) => { + const { + getBlockRootClientId, + getTemplateLock, + isBlockBeingDragged, + getSelectedBlockClientId, + } = _select( blockEditorStore ); + const rootClientId = getBlockRootClientId( clientId ); + const templateLock = rootClientId + ? getTemplateLock( rootClientId ) + : null; + const selectedBlockClientId = getSelectedBlockClientId(); + + return { + isBeingDragged: isBlockBeingDragged( clientId ), + isDraggable: 'all' !== templateLock, + isBlockSelected: + selectedBlockClientId && selectedBlockClientId === clientId, + }; + }, + [ clientId ] + ); + + useEffect( () => { + if ( isBeingDragged !== wasBeingDragged.current ) { + if ( isBeingDragged ) { + startDraggingBlock(); + } else { + stopDraggingBlock(); + } + } + wasBeingDragged.current = isBeingDragged; + }, [ isBeingDragged ] ); + + const onFocusChangeAztec = useCallback( ( { isFocused } ) => { + setIsEditingText( isFocused ); + }, [] ); + + useEffect( () => { + let mounted = true; + + const isAnyAztecInputFocused = RCTAztecView.InputState.isFocused(); + if ( isAnyAztecInputFocused ) { + setIsEditingText( isAnyAztecInputFocused ); + } + + RCTAztecView.InputState.addFocusChangeListener( onFocusChangeAztec ); + + const screenReaderChangedListener = AccessibilityInfo.addEventListener( + 'screenReaderChanged', + setIsScreenReaderEnabled + ); + AccessibilityInfo.isScreenReaderEnabled().then( + ( screenReaderEnabled ) => { + if ( mounted ) { + setIsScreenReaderEnabled( screenReaderEnabled ); + } + } + ); + + return () => { + mounted = false; + + RCTAztecView.InputState.removeFocusChangeListener( + onFocusChangeAztec + ); + + screenReaderChangedListener.remove(); + }; + }, [] ); + + const onLongPressDraggable = useCallback( () => { + // Ensure that no text input is focused when starting the dragging gesture in order to prevent conflicts with text editing. + RCTAztecView.InputState.blurCurrentFocusedElement(); + }, [] ); + + const animatedWrapperStyles = useAnimatedStyle( () => { + return { + opacity: draggingAnimation.opacity.value, + }; + } ); + const wrapperStyles = [ + animatedWrapperStyles, + styles[ 'draggable-wrapper__container' ], + ]; + + const canDragBlock = + enabled && + ! isScreenReaderEnabled && + ( ! isBlockSelected || ! isEditingText ); + + if ( ! isDraggable ) { + return children( { isDraggable: false } ); + } + + return ( + + + { children( { isDraggable: true } ) } + + + ); +}; + +export { BlockDraggableWrapper }; +export default BlockDraggable; diff --git a/packages/block-editor/src/components/block-draggable/style.native.scss b/packages/block-editor/src/components/block-draggable/style.native.scss new file mode 100644 index 0000000000000..ad93c9ea17b06 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/style.native.scss @@ -0,0 +1,19 @@ +.draggable-wrapper__container { + flex: 1; +} + +.draggable-chip__wrapper { + position: absolute; + z-index: 10; +} + +.draggable-chip__container { + flex-direction: row; + padding: 16px; + background-color: $gray-0; + border-radius: 8px; +} + +.draggable-chip__container--dark { + background-color: $app-background-dark-alt; +} diff --git a/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js b/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js new file mode 100644 index 0000000000000..ef5c437206bbd --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import { useWindowDimensions } from 'react-native'; +import { + useSharedValue, + useAnimatedRef, + scrollTo, + useAnimatedReaction, + withTiming, + withRepeat, + cancelAnimation, + Easing, +} from 'react-native-reanimated'; + +/** + * Internal dependencies + */ +import { useBlockListContext } from '../block-list/block-list-context'; + +const SCROLL_INACTIVE_DISTANCE_PX = 50; +const SCROLL_INTERVAL_MS = 1000; +const VELOCITY_MULTIPLIER = 5000; + +/** + * React hook that scrolls the scroll container when a block is being dragged. + * + * @return {Function[]} `startScrolling`, `scrollOnDragOver`, `stopScrolling` + * functions to be called in `onDragStart`, `onDragOver` + * and `onDragEnd` events respectively. Additionally, + * `scrollHandler` function is returned which should be + * called in the `onScroll` event of the block list. + */ +export default function useScrollWhenDragging() { + const { scrollRef } = useBlockListContext(); + const animatedScrollRef = useAnimatedRef(); + animatedScrollRef( scrollRef ); + + const { height: windowHeight } = useWindowDimensions(); + + const velocityY = useSharedValue( 0 ); + const offsetY = useSharedValue( 0 ); + const dragStartY = useSharedValue( 0 ); + const animationTimer = useSharedValue( 0 ); + const isAnimationTimerActive = useSharedValue( false ); + const isScrollActive = useSharedValue( false ); + + const scroll = { + offsetY: useSharedValue( 0 ), + maxOffsetY: useSharedValue( 0 ), + }; + const scrollHandler = ( event ) => { + 'worklet'; + const { contentSize, contentOffset, layoutMeasurement } = event; + scroll.offsetY.value = contentOffset.y; + scroll.maxOffsetY.value = contentSize.height - layoutMeasurement.height; + }; + + const stopScrolling = () => { + 'worklet'; + cancelAnimation( animationTimer ); + + isAnimationTimerActive.value = false; + isScrollActive.value = false; + velocityY.value = 0; + }; + + const startScrolling = ( y ) => { + 'worklet'; + stopScrolling(); + offsetY.value = scroll.offsetY.value; + dragStartY.value = y; + + animationTimer.value = 0; + animationTimer.value = withRepeat( + withTiming( 1, { + duration: SCROLL_INTERVAL_MS, + easing: Easing.linear, + } ), + -1, + true + ); + isAnimationTimerActive.value = true; + }; + + const scrollOnDragOver = ( y ) => { + 'worklet'; + const dragDistance = Math.max( + Math.abs( y - dragStartY.value ) - SCROLL_INACTIVE_DISTANCE_PX, + 0 + ); + const distancePercentage = dragDistance / windowHeight; + + if ( ! isScrollActive.value ) { + isScrollActive.value = dragDistance > 0; + } else if ( y > dragStartY.value ) { + // User is dragging downwards. + velocityY.value = VELOCITY_MULTIPLIER * distancePercentage; + } else if ( y < dragStartY.value ) { + // User is dragging upwards. + velocityY.value = -VELOCITY_MULTIPLIER * distancePercentage; + } else { + velocityY.value = 0; + } + }; + + useAnimatedReaction( + () => animationTimer.value, + ( value, previous ) => { + if ( velocityY.value === 0 ) { + return; + } + + const delta = Math.abs( value - previous ); + let newOffset = offsetY.value + delta * velocityY.value; + + if ( scroll.maxOffsetY.value !== 0 ) { + newOffset = Math.max( + 0, + Math.min( scroll.maxOffsetY.value, newOffset ) + ); + } else { + // Scroll values are empty until receiving the first scroll event. + // In that case, the max offset is unknown and we can't clamp the + // new offset value. + newOffset = Math.max( 0, newOffset ); + } + + offsetY.value = newOffset; + scrollTo( animatedScrollRef, 0, offsetY.value, false ); + } + ); + + return [ startScrolling, scrollOnDragOver, stopScrolling, scrollHandler ]; +} diff --git a/packages/block-editor/src/components/block-list/block-list-context.native.js b/packages/block-editor/src/components/block-list/block-list-context.native.js new file mode 100644 index 0000000000000..be028a25c9e41 --- /dev/null +++ b/packages/block-editor/src/components/block-list/block-list-context.native.js @@ -0,0 +1,175 @@ +/** + * External dependencies + */ +import { orderBy } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +export const DEFAULT_BLOCK_LIST_CONTEXT = { + scrollRef: null, + blocksLayouts: { current: {} }, + findBlockLayoutByClientId, + getBlockLayoutsOrderedByYCoord, + findBlockLayoutByPosition, + updateBlocksLayouts, +}; + +const Context = createContext( DEFAULT_BLOCK_LIST_CONTEXT ); +const { Provider, Consumer } = Context; + +/** + * Finds a block's layout data by position. + * + * @param {Object} data Blocks layouts object. + * @param {Object} position Position to use for finding the block. + * @param {number} position.x X coordinate. + * @param {number} position.y Y coordinate. + * + * @return {Object|undefined} Found block layout data that matches the provided position. If none is found, `undefined` will be returned. + */ +function findBlockLayoutByPosition( data, position ) { + // Only enabled for root level blocks + return Object.values( data ).find( ( block ) => { + return ( + position.x >= block.x && + position.x <= block.x + block.width && + position.y >= block.y && + position.y <= block.y + block.height + ); + } ); +} + +/** + * Finds a block's layout data by its client Id. + * + * @param {Object} data Blocks layouts object. + * @param {string} clientId Block's clientId. + * + * @return {Object} Found block layout data. + */ +function findBlockLayoutByClientId( data, clientId ) { + return Object.entries( data ).reduce( ( acc, entry ) => { + const item = entry[ 1 ]; + if ( acc ) { + return acc; + } + if ( item?.clientId === clientId ) { + return item; + } + if ( item?.innerBlocks && Object.keys( item.innerBlocks ).length > 0 ) { + return findBlockLayoutByClientId( item.innerBlocks, clientId ); + } + return null; + }, null ); +} + +/** + * Deletes the layout data of a block by its client Id. + * + * @param {Object} data Blocks layouts object. + * @param {string} clientId Block's clientsId. + * + * @return {Object} Updated data object. + */ +export function deleteBlockLayoutByClientId( data, clientId ) { + return Object.keys( data ).reduce( ( acc, key ) => { + if ( key !== clientId ) { + acc[ key ] = data[ key ]; + } + if ( + data[ key ]?.innerBlocks && + Object.keys( data[ key ].innerBlocks ).length > 0 + ) { + if ( acc[ key ] ) { + acc[ key ].innerBlocks = deleteBlockLayoutByClientId( + data[ key ].innerBlocks, + clientId + ); + } + } + return acc; + }, {} ); +} + +/** + * Orders the block's layout data by its Y coordinate. + * + * @param {Object} data Blocks layouts object. + * + * @return {Object} Blocks layouts object ordered by its Y coordinate. + */ +function getBlockLayoutsOrderedByYCoord( data ) { + // Only enabled for root level blocks. + // Using lodash orderBy due to hermes not having + // stable support for native .sort(). It will be + // supported in the React Native version 0.68.0. + return orderBy( data, [ 'y', 'asc' ] ); +} + +/** + * Updates or deletes a block's layout data in the blocksLayouts object, + * in case of deletion, the layout data is not required. + * + * @param {Object} blocksLayouts Blocks layouts object. + * @param {Object} blockData Block's layout data to add or remove to/from the blockLayouts object. + * @param {string} blockData.clientId Block's clientId. + * @param {?string} blockData.rootClientId Optional. Block's rootClientId. + * @param {?boolean} blockData.shouldRemove Optional. Flag to remove it from the blocksLayout list. + * @param {number} blockData.width Block's width. + * @param {number} blockData.height Block's height. + * @param {number} blockData.x Block's x coordinate (relative to the parent). + * @param {number} blockData.y Block's y coordinate (relative to the parent). + */ + +function updateBlocksLayouts( blocksLayouts, blockData ) { + const { clientId, rootClientId, shouldRemove, ...layoutProps } = blockData; + + if ( clientId && shouldRemove ) { + blocksLayouts.current = deleteBlockLayoutByClientId( + blocksLayouts.current, + clientId + ); + return; + } + + if ( clientId && ! rootClientId ) { + blocksLayouts.current[ clientId ] = { + clientId, + rootClientId, + ...layoutProps, + innerBlocks: { + ...blocksLayouts.current[ clientId ]?.innerBlocks, + }, + }; + } else if ( clientId && rootClientId ) { + const block = findBlockLayoutByClientId( + blocksLayouts.current, + rootClientId + ); + + if ( block ) { + block.innerBlocks[ clientId ] = { + clientId, + rootClientId, + ...layoutProps, + innerBlocks: { + ...block.innerBlocks[ clientId ]?.innerBlocks, + }, + }; + } + } +} + +export { Provider as BlockListProvider, Consumer as BlockListConsumer }; + +/** + * Hook that returns the block list context. + * + * @return {Object} Block list context + */ +export const useBlockListContext = () => { + return useContext( Context ); +}; diff --git a/packages/block-editor/src/components/block-list/block-list-item-cell.native.js b/packages/block-editor/src/components/block-list/block-list-item-cell.native.js new file mode 100644 index 0000000000000..c399643a63399 --- /dev/null +++ b/packages/block-editor/src/components/block-list/block-list-item-cell.native.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { useEffect, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useBlockListContext } from './block-list-context'; + +function BlockListItemCell( { children, clientId, rootClientId } ) { + const { blocksLayouts, updateBlocksLayouts } = useBlockListContext(); + + useEffect( () => { + return () => { + updateBlocksLayouts( blocksLayouts, { + clientId, + shouldRemove: true, + } ); + }; + }, [] ); + + const onLayout = useCallback( + ( { nativeEvent: { layout } } ) => { + updateBlocksLayouts( blocksLayouts, { + clientId, + rootClientId, + ...layout, + } ); + }, + [ clientId, rootClientId, updateBlocksLayouts ] + ); + + return { children }; +} + +export default BlockListItemCell; 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 33317778e66b2..822194e749906 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -31,6 +31,7 @@ import BlockEdit from '../block-edit'; import BlockInvalidWarning from './block-invalid-warning'; import BlockMobileToolbar from '../block-mobile-toolbar'; import { store as blockEditorStore } from '../../store'; +import BlockDraggable from '../block-draggable'; const emptyArray = []; function BlockForType( { @@ -189,6 +190,8 @@ class BlockListBlock extends Component { marginHorizontal, isInnerBlockSelected, name, + draggingEnabled, + draggingClientId, } = this.props; if ( ! attributes || ! blockType ) { @@ -256,14 +259,22 @@ class BlockListBlock extends Component { ] } /> ) } - { isValid ? ( - this.getBlockForType() - ) : ( - - ) } + + { () => + isValid ? ( + this.getBlockForType() + ) : ( + + ) + } + ) } @@ -308,6 +320,7 @@ export default compose( [ withSelect( ( select, { clientId } ) => { const { getBlockIndex, + getBlockCount, getSettings, isBlockSelected, getBlock, @@ -315,6 +328,7 @@ export default compose( [ getLowestCommonAncestorWithSelectedBlock, getBlockParents, hasSelectedInnerBlock, + getBlockHierarchyRootClientId, } = select( blockEditorStore ); const order = getBlockIndex( clientId ); @@ -359,6 +373,18 @@ export default compose( [ const baseGlobalStyles = getSettings() ?.__experimentalGlobalStylesBaseStyles; + const hasInnerBlocks = getBlockCount( clientId ) > 0; + // For blocks with inner blocks, we only enable the dragging in the nested + // blocks if any of them are selected. This way we prevent the long-press + // gesture from being disabled for elements within the block UI. + const draggingEnabled = + ! hasInnerBlocks || + isSelected || + ! hasSelectedInnerBlock( clientId, true ); + // Dragging nested blocks is not supported yet. For this reason, the block to be dragged + // will be the top in the hierarchy. + const draggingClientId = getBlockHierarchyRootClientId( clientId ); + return { icon, name: name || 'core/missing', @@ -366,6 +392,8 @@ export default compose( [ title, attributes, blockType, + draggingClientId, + draggingEnabled, isSelected, isInnerBlockSelected, isValid, diff --git a/packages/block-editor/src/components/block-list/index.native.js b/packages/block-editor/src/components/block-list/index.native.js index 9d1c6f1725427..3ec3fefc8207b 100644 --- a/packages/block-editor/src/components/block-list/index.native.js +++ b/packages/block-editor/src/components/block-list/index.native.js @@ -25,10 +25,15 @@ import { __ } from '@wordpress/i18n'; import styles from './style.scss'; import BlockListAppender from '../block-list-appender'; import BlockListItem from './block-list-item'; +import BlockListItemCell from './block-list-item-cell'; +import { + BlockListProvider, + BlockListConsumer, + DEFAULT_BLOCK_LIST_CONTEXT, +} from './block-list-context'; +import { BlockDraggableWrapper } from '../block-draggable'; import { store as blockEditorStore } from '../../store'; -const BlockListContext = createContext(); - export const OnCaretVerticalPositionChange = createContext(); const stylesMemo = {}; @@ -78,6 +83,9 @@ export class BlockList extends Component { ); this.renderEmptyList = this.renderEmptyList.bind( this ); this.getExtraData = this.getExtraData.bind( this ); + this.getCellRendererComponent = this.getCellRendererComponent.bind( + this + ); this.onLayout = this.onLayout.bind( this ); @@ -154,6 +162,17 @@ export class BlockList extends Component { return this.extraData; } + getCellRendererComponent( { children, item } ) { + const { rootClientId } = this.props; + return ( + + ); + } + onLayout( { nativeEvent } ) { const { layout } = nativeEvent; const { blockWidth } = this.state; @@ -173,17 +192,24 @@ export class BlockList extends Component { const { isRootList } = this.props; // Use of Context to propagate the main scroll ref to its children e.g InnerBlocks. const blockList = isRootList ? ( - - { this.renderList() } - + + + { ( { onScroll } ) => this.renderList( { onScroll } ) } + + ) : ( - - { ( ref ) => + + { ( { scrollRef } ) => this.renderList( { - parentScrollRef: ref, + parentScrollRef: scrollRef, } ) } - + ); return ( @@ -212,7 +238,7 @@ export class BlockList extends Component { contentResizeMode, blockWidth, } = this.props; - const { parentScrollRef } = extraProps; + const { parentScrollRef, onScroll } = extraProps; const { blockToolbar, @@ -279,6 +305,7 @@ export class BlockList extends Component { data={ blockClientIds } keyExtractor={ identity } renderItem={ this.renderItem } + CellRendererComponent={ this.getCellRendererComponent } shouldPreventAutomaticScroll={ this.shouldFlatListPreventAutomaticScroll } @@ -286,6 +313,7 @@ export class BlockList extends Component { ListHeaderComponent={ header } ListEmptyComponent={ ! isReadOnly && this.renderEmptyList } ListFooterComponent={ this.renderBlockListFooter } + onScroll={ onScroll } /> { this.shouldShowInnerBlockAppender() && ( { + it( "finds a block's layout data at root level", () => { + const { findBlockLayoutByClientId } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = BLOCKS_LAYOUTS_DATA; + + const blockRootLevel = findBlockLayoutByClientId( + currentBlockLayouts, + ROOT_LEVEL_ID + ); + + expect( blockRootLevel ).toEqual( + expect.objectContaining( { clientId: ROOT_LEVEL_ID } ) + ); + } ); + + it( "finds a nested block's layout data with inner blocks", () => { + const { findBlockLayoutByClientId } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = BLOCKS_LAYOUTS_DATA; + + const nestedBlock = findBlockLayoutByClientId( + currentBlockLayouts, + NESTED_WITH_INNER_BLOCKS_ID + ); + + expect( nestedBlock ).toEqual( + expect.objectContaining( { clientId: NESTED_WITH_INNER_BLOCKS_ID } ) + ); + } ); + + it( "finds a deep nested block's layout data", () => { + const { findBlockLayoutByClientId } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = BLOCKS_LAYOUTS_DATA; + + const deepNestedBlock = findBlockLayoutByClientId( + currentBlockLayouts, + DEEP_NESTED_ID + ); + + expect( deepNestedBlock ).toEqual( + expect.objectContaining( { clientId: DEEP_NESTED_ID } ) + ); + } ); +} ); + +describe( 'deleteBlockLayoutByClientId', () => { + it( "deletes a block's layout data at root level", () => { + const { findBlockLayoutByClientId } = DEFAULT_BLOCK_LIST_CONTEXT; + const defaultBlockLayouts = cloneDeep( BLOCKS_LAYOUTS_DATA ); + const currentBlockLayouts = deleteBlockLayoutByClientId( + defaultBlockLayouts, + ROOT_LEVEL_ID + ); + + const findDeletedBlock = findBlockLayoutByClientId( + currentBlockLayouts, + ROOT_LEVEL_ID + ); + + expect( findDeletedBlock ).toBeNull(); + } ); + + it( "deletes a nested block's layout data with inner blocks", () => { + const { findBlockLayoutByClientId } = DEFAULT_BLOCK_LIST_CONTEXT; + const defaultBlockLayouts = cloneDeep( BLOCKS_LAYOUTS_DATA ); + const currentBlockLayouts = deleteBlockLayoutByClientId( + defaultBlockLayouts, + NESTED_WITH_INNER_BLOCKS_ID + ); + + const findDeletedBlock = findBlockLayoutByClientId( + currentBlockLayouts, + NESTED_WITH_INNER_BLOCKS_ID + ); + + expect( findDeletedBlock ).toBeNull(); + } ); + + it( "deletes a deep nested block's layout data", () => { + const { findBlockLayoutByClientId } = DEFAULT_BLOCK_LIST_CONTEXT; + const defaultBlockLayouts = cloneDeep( BLOCKS_LAYOUTS_DATA ); + const currentBlockLayouts = deleteBlockLayoutByClientId( + defaultBlockLayouts, + DEEP_NESTED_ID + ); + + const findDeletedBlock = findBlockLayoutByClientId( + currentBlockLayouts, + DEEP_NESTED_ID + ); + + expect( findDeletedBlock ).toBeNull(); + } ); +} ); + +describe( 'updateBlocksLayouts', () => { + it( "adds a new block's layout data at root level with an empty object", () => { + const { + blocksLayouts, + findBlockLayoutByClientId, + updateBlocksLayouts, + } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = cloneDeep( blocksLayouts ); + const BLOCK_CLIENT_ID = PARAGRAPH_BLOCK_LAYOUT_DATA.clientId; + + updateBlocksLayouts( currentBlockLayouts, PARAGRAPH_BLOCK_LAYOUT_DATA ); + + const findAddedBlock = findBlockLayoutByClientId( + currentBlockLayouts.current, + BLOCK_CLIENT_ID + ); + + expect( findAddedBlock ).toEqual( + expect.objectContaining( { + clientId: BLOCK_CLIENT_ID, + rootClientId: undefined, + } ) + ); + } ); + + it( "adds a new block's layout data at root level with inner blocks", () => { + const { + findBlockLayoutByClientId, + updateBlocksLayouts, + } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = { + current: cloneDeep( BLOCKS_LAYOUTS_DATA ), + }; + const PARENT_BLOCK_CLIENT_ID = GROUP_BLOCK_LAYOUT_DATA.clientId; + + // Add parent block + updateBlocksLayouts( currentBlockLayouts, GROUP_BLOCK_LAYOUT_DATA ); + + const findAddedParentBlock = findBlockLayoutByClientId( + currentBlockLayouts.current, + PARENT_BLOCK_CLIENT_ID + ); + + expect( findAddedParentBlock ).toEqual( + expect.objectContaining( { clientId: PARENT_BLOCK_CLIENT_ID } ) + ); + + // Add inner block to it's parent + updateBlocksLayouts( currentBlockLayouts, { + ...PARAGRAPH_BLOCK_LAYOUT_DATA, + rootClientId: PARENT_BLOCK_CLIENT_ID, + } ); + + const findAddedInnerBlock = findBlockLayoutByClientId( + currentBlockLayouts.current, + PARAGRAPH_BLOCK_LAYOUT_DATA.clientId + ); + + expect( findAddedInnerBlock ).toEqual( + expect.objectContaining( { + clientId: PARAGRAPH_BLOCK_LAYOUT_DATA.clientId, + rootClientId: PARENT_BLOCK_CLIENT_ID, + } ) + ); + } ); + + it( "adds a new block's layout data at deep level", () => { + const { + findBlockLayoutByClientId, + updateBlocksLayouts, + } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = { + current: cloneDeep( BLOCKS_LAYOUTS_DATA ), + }; + + // Add block layout data to it's parents inner blocks + updateBlocksLayouts( currentBlockLayouts, { + ...PARAGRAPH_BLOCK_LAYOUT_DATA, + rootClientId: NESTED_WITH_INNER_BLOCKS_ID, + } ); + + const findAddedInnerBlock = findBlockLayoutByClientId( + currentBlockLayouts.current, + PARAGRAPH_BLOCK_LAYOUT_DATA.clientId + ); + + expect( findAddedInnerBlock ).toEqual( + expect.objectContaining( { + clientId: PARAGRAPH_BLOCK_LAYOUT_DATA.clientId, + rootClientId: NESTED_WITH_INNER_BLOCKS_ID, + } ) + ); + } ); + + it( "deletes a block's layout data at root level", () => { + const { + findBlockLayoutByClientId, + updateBlocksLayouts, + } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = { + current: cloneDeep( BLOCKS_LAYOUTS_DATA ), + }; + + updateBlocksLayouts( currentBlockLayouts, { + shouldRemove: true, + clientId: ROOT_LEVEL_ID, + } ); + + const findDeletedBlock = findBlockLayoutByClientId( + currentBlockLayouts.current, + ROOT_LEVEL_ID + ); + + expect( findDeletedBlock ).toBeNull(); + } ); + + it( "deletes a block's layout data at a deep level", () => { + const { + findBlockLayoutByClientId, + updateBlocksLayouts, + } = DEFAULT_BLOCK_LIST_CONTEXT; + const currentBlockLayouts = { + current: cloneDeep( BLOCKS_LAYOUTS_DATA ), + }; + + updateBlocksLayouts( currentBlockLayouts, { + shouldRemove: true, + clientId: DEEP_NESTED_ID, + } ); + + const findDeletedBlock = findBlockLayoutByClientId( + currentBlockLayouts.current, + DEEP_NESTED_ID + ); + + expect( findDeletedBlock ).toBeNull(); + } ); +} ); diff --git a/packages/block-editor/src/components/block-list/test/fixtures/block-list-context.native.js b/packages/block-editor/src/components/block-list/test/fixtures/block-list-context.native.js new file mode 100644 index 0000000000000..af74c07ec8e0a --- /dev/null +++ b/packages/block-editor/src/components/block-list/test/fixtures/block-list-context.native.js @@ -0,0 +1,79 @@ +export const ROOT_LEVEL_ID = 'e59528f8-fb35-4ec1-aec6-5a065c236fa1'; +export const ROOT_LEVEL_WITH_INNER_BLOCKS_ID = + '72a9220f-4c3d-4b00-bae1-4506513f63d8'; +export const NESTED_WITH_INNER_BLOCKS_ID = + '9f3d1f1e-df85-485d-af63-dc8cb1b93cbc'; +export const DEEP_NESTED_ID = 'abec845a-e4de-43fb-96f7-80dc3d51ad7a'; + +export const BLOCKS_LAYOUTS_DATA = { + [ ROOT_LEVEL_ID ]: { + clientId: ROOT_LEVEL_ID, + width: 390, + height: 54, + x: 0, + y: 83, + innerBlocks: {}, + }, + [ ROOT_LEVEL_WITH_INNER_BLOCKS_ID ]: { + clientId: ROOT_LEVEL_WITH_INNER_BLOCKS_ID, + width: 390, + height: 386, + x: 0, + y: 137, + innerBlocks: { + '62839858-48b0-44ed-b834-1343a1357e54': { + clientId: '62839858-48b0-44ed-b834-1343a1357e54', + rootClientId: ROOT_LEVEL_WITH_INNER_BLOCKS_ID, + width: 390, + height: 54, + x: 0, + y: 0, + innerBlocks: {}, + }, + [ NESTED_WITH_INNER_BLOCKS_ID ]: { + clientId: NESTED_WITH_INNER_BLOCKS_ID, + rootClientId: ROOT_LEVEL_WITH_INNER_BLOCKS_ID, + width: 390, + height: 332, + x: 0, + y: 54, + innerBlocks: { + '435d62a4-afa7-457c-a894-b04390d7b447': { + clientId: '435d62a4-afa7-457c-a894-b04390d7b447', + rootClientId: NESTED_WITH_INNER_BLOCKS_ID, + width: 358, + height: 54, + x: 0, + y: 0, + innerBlocks: {}, + }, + [ DEEP_NESTED_ID ]: { + clientId: DEEP_NESTED_ID, + rootClientId: NESTED_WITH_INNER_BLOCKS_ID, + width: 358, + height: 98, + x: 0, + y: 54, + innerBlocks: {}, + }, + }, + }, + }, + }, +}; + +export const PARAGRAPH_BLOCK_LAYOUT_DATA = { + clientId: '22dda04f-4718-45b2-8cd2-36cedb9eae4d', + width: 390, + height: 98, + x: 0, + y: 83, +}; + +export const GROUP_BLOCK_LAYOUT_DATA = { + clientId: 'e18249d9-ec06-4f54-b71e-6ec59be5213e', + width: 390, + height: 164, + x: 0, + y: 83, +}; 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 6dd50c19da57e..7e0ba45b76d42 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 @@ -15,6 +15,7 @@ import { useState, useEffect } from '@wordpress/element'; */ import styles from './style.scss'; import BlockMover from '../block-mover'; +import BlockDraggable from '../block-draggable'; import BlockActionsMenu from './block-actions-menu'; import { BlockSettingsButton } from '../block-settings'; import { store as blockEditorStore } from '../../store'; @@ -33,6 +34,7 @@ const BlockMobileToolbar = ( { blockWidth, anchorNodeRef, isFullWidth, + draggingClientId, } ) => { const [ fillsLength, setFillsLength ] = useState( null ); const [ appenderWidth, setAppenderWidth ] = useState( 0 ); @@ -73,7 +75,12 @@ const BlockMobileToolbar = ( { /> ) } - + + { () => } + { /* Render only one settings icon even if we have more than one fill - need for hooks with controls. */ } 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 35a7503cef4a8..a4a3bd60f16ba 100644 --- a/packages/block-editor/src/components/block-mover/index.native.js +++ b/packages/block-editor/src/components/block-mover/index.native.js @@ -11,7 +11,7 @@ import { __ } from '@wordpress/i18n'; import { Picker, ToolbarButton } from '@wordpress/components'; import { withInstanceId, compose } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; -import { useRef, useState } from '@wordpress/element'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; /** * Internal dependencies @@ -36,6 +36,7 @@ export const BlockMover = ( { isStackedHorizontally, } ) => { const pickerRef = useRef(); + const [ shouldPresentPicker, setShouldPresentPicker ] = useState( false ); const [ blockPageMoverState, setBlockPageMoverState ] = useState( undefined ); @@ -46,9 +47,17 @@ export const BlockMover = ( { } setBlockPageMoverState( direction ); - pickerRef.current.presentPicker(); + setShouldPresentPicker( true ); }; + // Ensure that the picker is only presented after state updates. + useEffect( () => { + if ( shouldPresentPicker ) { + pickerRef.current?.presentPicker(); + setShouldPresentPicker( false ); + } + }, [ shouldPresentPicker ] ); + const { description: { backwardButtonHint, @@ -86,6 +95,15 @@ export const BlockMover = ( { if ( option && option.onSelect ) option.onSelect(); }; + const onLongPressMoveUp = useCallback( + showBlockPageMover( BLOCK_MOVER_DIRECTION_TOP ), + [] + ); + const onLongPressMoveDown = useCallback( + showBlockPageMover( BLOCK_MOVER_DIRECTION_BOTTOM ), + [] + ); + if ( ! canMove || ( isFirst && isLast && ! rootClientId ) ) { return null; } @@ -96,7 +114,7 @@ export const BlockMover = ( { title={ ! isFirst ? backwardButtonTitle : firstBlockTitle } isDisabled={ isFirst } onClick={ onMoveUp } - onLongPress={ showBlockPageMover( BLOCK_MOVER_DIRECTION_TOP ) } + onLongPress={ onLongPressMoveUp } icon={ backwardButtonIcon } extraProps={ { hint: backwardButtonHint } } /> @@ -105,9 +123,7 @@ export const BlockMover = ( { title={ ! isLast ? forwardButtonTitle : lastBlockTitle } isDisabled={ isLast } onClick={ onMoveDown } - onLongPress={ showBlockPageMover( - BLOCK_MOVER_DIRECTION_BOTTOM - ) } + onLongPress={ onLongPressMoveDown } icon={ forwardButtonIcon } extraProps={ { hint: forwardButtonHint, diff --git a/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap b/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap index 83e30a4a1b0c4..79b79766caaa6 100644 --- a/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap +++ b/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap @@ -34,6 +34,9 @@ Array [ } > { + const { x, y, width, height } = element; + const rect = { + x: element.x, + y: element.y, + top: y, + right: x + width, + bottom: y + height, + left: x, + width, + height, + }; + const [ distance, edge ] = getDistanceToNearestEdge( + position, + rect, + allowedEdges + ); + + if ( candidateDistance === undefined || distance < candidateDistance ) { + // If the user is dropping to the trailing edge of the block + // add 1 to the index to represent dragging after. + // Take RTL languages into account where the left edge is + // the trailing edge. + const isTrailingEdge = + edge === 'bottom' || + ( ! isRightToLeft && edge === 'right' ) || + ( isRightToLeft && edge === 'left' ); + const offset = isTrailingEdge ? 1 : 0; + + // Update the currently known best candidate. + candidateDistance = distance; + candidateIndex = index + offset; + } + } ); + return candidateIndex; +} + +/** + * @typedef {Object} WPBlockDropZoneConfig + * @property {string} rootClientId The root client id for the block list. + */ + +/** + * A React hook that can be used to make a block list handle drag and drop. + * + * @param {WPBlockDropZoneConfig} dropZoneConfig configuration data for the drop zone. + * + * @return {Object} An object that contains `targetBlockIndex` and the event + * handlers `onBlockDragOver`, `onBlockDragEnd` and `onBlockDrop`. + */ +export default function useBlockDropZone( { + // An undefined value represents a top-level block. Default to an empty + // string for this so that `targetRootClientId` can be easily compared to + // values returned by the `getRootBlockClientId` selector, which also uses + // an empty string to represent top-level blocks. + rootClientId: targetRootClientId = '', +} = {} ) { + const targetBlockIndex = useSharedValue( null ); + + const { getBlockListSettings, getSettings } = useSelect( blockEditorStore ); + const { + blocksLayouts, + getBlockLayoutsOrderedByYCoord, + } = useBlockListContext(); + + const getSortedBlocksLayouts = useCallback( () => { + return getBlockLayoutsOrderedByYCoord( blocksLayouts.current ); + }, [ blocksLayouts.current ] ); + + const isRTL = getSettings().isRTL; + + const onBlockDrop = useOnBlockDrop(); + + const throttled = useThrottle( + useCallback( + ( event ) => { + const sortedBlockLayouts = getSortedBlocksLayouts(); + + const targetIndex = getNearestBlockIndex( + sortedBlockLayouts, + { x: event.x, y: event.y }, + getBlockListSettings( targetRootClientId )?.orientation, + isRTL + ); + if ( targetIndex !== null ) { + targetBlockIndex.value = targetIndex ?? 0; + } + }, + [ + getSortedBlocksLayouts, + getNearestBlockIndex, + getBlockListSettings, + targetBlockIndex, + ] + ), + 200 + ); + + return { + onBlockDragOver( event ) { + throttled( event ); + }, + onBlockDragEnd() { + throttled.cancel(); + targetBlockIndex.value = null; + }, + onBlockDrop: ( event ) => { + if ( targetBlockIndex.value !== null ) { + onBlockDrop( { + ...event, + targetRootClientId, + targetBlockIndex: targetBlockIndex.value, + } ); + } + }, + targetBlockIndex, + }; +} diff --git a/packages/block-editor/src/components/use-on-block-drop/index.native.js b/packages/block-editor/src/components/use-on-block-drop/index.native.js new file mode 100644 index 0000000000000..629827fd56ad2 --- /dev/null +++ b/packages/block-editor/src/components/use-on-block-drop/index.native.js @@ -0,0 +1,119 @@ +/** + * WordPress dependencies + */ +import { cloneBlock } from '@wordpress/blocks'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +/** + * A function that returns an event handler function for block drop events. + * + * @param {Function} getBlockIndex A function that gets the index of a block. + * @param {Function} getClientIdsOfDescendants A function that gets the client ids of descendant blocks. + * @param {Function} moveBlocksToPosition A function that moves blocks. + * @param {Function} insertBlocks A function that inserts blocks. + * @param {Function} clearSelectedBlock A function that clears block selection. + * @return {Function} The event handler for a block drop event. + */ +export function onBlockDrop( + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition, + insertBlocks, + clearSelectedBlock +) { + return ( { + blocks, + srcClientIds: sourceClientIds, + srcRootClientId: sourceRootClientId, + targetBlockIndex, + targetRootClientId, + type: dropType, + } ) => { + // If the user is inserting a block. + if ( dropType === 'inserter' ) { + clearSelectedBlock(); + const blocksToInsert = blocks.map( ( block ) => + cloneBlock( block ) + ); + insertBlocks( + blocksToInsert, + targetBlockIndex, + targetRootClientId, + true, + null + ); + } + + // If the user is moving a block. + if ( dropType === 'block' ) { + const sourceBlockIndex = getBlockIndex( sourceClientIds[ 0 ] ); + + // If the user is dropping to the same position, return early. + if ( + sourceRootClientId === targetRootClientId && + sourceBlockIndex === targetBlockIndex + ) { + return; + } + + // If the user is attempting to drop a block within its own + // nested blocks, return early as this would create infinite + // recursion. + if ( + sourceClientIds.includes( targetRootClientId ) || + getClientIdsOfDescendants( sourceClientIds ).some( + ( id ) => id === targetRootClientId + ) + ) { + return; + } + + const isAtSameLevel = sourceRootClientId === targetRootClientId; + const draggedBlockCount = sourceClientIds.length; + + // If the block is kept at the same level and moved downwards, + // subtract to take into account that the blocks being dragged + // were removed from the block list above the insertion point. + const insertIndex = + isAtSameLevel && sourceBlockIndex < targetBlockIndex + ? targetBlockIndex - draggedBlockCount + : targetBlockIndex; + + moveBlocksToPosition( + sourceClientIds, + sourceRootClientId, + targetRootClientId, + insertIndex + ); + } + }; +} + +/** + * A React hook for handling block drop events. + * + * @return {Function} The event handler for a block drop event. + */ +export default function useOnBlockDrop() { + const { getBlockIndex, getClientIdsOfDescendants } = useSelect( + blockEditorStore + ); + const { + insertBlocks, + moveBlocksToPosition, + clearSelectedBlock, + } = useDispatch( blockEditorStore ); + + return onBlockDrop( + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition, + insertBlocks, + clearSelectedBlock + ); +} diff --git a/packages/block-library/src/cover/controls.native.js b/packages/block-library/src/cover/controls.native.js index 90403c98b62a7..090abaa9ffd05 100644 --- a/packages/block-library/src/cover/controls.native.js +++ b/packages/block-library/src/cover/controls.native.js @@ -167,7 +167,6 @@ function Controls( { styles.mediaPreview, mediaBackground, ] } - onLongPress={ openMediaOptions } > { IMAGE_BACKGROUND_TYPE === backgroundType && ( diff --git a/packages/block-library/src/cover/edit.native.js b/packages/block-library/src/cover/edit.native.js index 90b2846666b3d..79695d4ce105b 100644 --- a/packages/block-library/src/cover/edit.native.js +++ b/packages/block-library/src/cover/edit.native.js @@ -402,7 +402,6 @@ const Cover = ( { diff --git a/packages/block-library/src/file/edit.native.js b/packages/block-library/src/file/edit.native.js index 6049f0454cf72..a981f18ef290c 100644 --- a/packages/block-library/src/file/edit.native.js +++ b/packages/block-library/src/file/edit.native.js @@ -452,7 +452,6 @@ export class FileEdit extends Component { diff --git a/packages/block-library/src/media-text/edit.native.js b/packages/block-library/src/media-text/edit.native.js index 530452eb63593..3cb2c41052028 100644 --- a/packages/block-library/src/media-text/edit.native.js +++ b/packages/block-library/src/media-text/edit.native.js @@ -275,6 +275,8 @@ class MediaTextEdit extends Component { const widthString = `${ temporaryMediaWidth }%`; const innerBlockWidth = shouldStack ? 100 : 100 - temporaryMediaWidth; const innerBlockWidthString = `${ innerBlockWidth }%`; + const hasMedia = + mediaType === MEDIA_TYPE_IMAGE || mediaType === MEDIA_TYPE_VIDEO; const innerBlockContainerStyle = [ { width: innerBlockWidthString }, @@ -344,7 +346,7 @@ class MediaTextEdit extends Component { <> { mediaType === MEDIA_TYPE_IMAGE && this.getControls() } - { ( isMediaSelected || mediaType === MEDIA_TYPE_VIDEO ) && ( + { hasMedia && (