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 && (