From b440515cfe42ad895d79dad9ef65ba04521211e2 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 11 May 2022 17:32:17 +0200 Subject: [PATCH] [RNMobile] Drag & drop blocks feature (#40424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Mobile] - Drag & drop blocks - Fetch and share blocks layout size and position coordinates (#39089) * Mobile - Block list - Extract block list context into a separate file and add support to store the blocks layouts data and coordinates. * Mobile - Block list - Adds block list item cell to get the onLayout data and use updateBlocksLayouts to store it. It is needed to use CellRendererComponent to be able to get the right position coordinates * Mobile - Block list - Store block layouts data for inner blocks in a deep level * Mobile - BlockList ItemCell - Destructuring props * Mobile - BlockListContext - Rename findByRootId to findBlockLayoutByClientId * Mobile - BlockListContext - Rename deleteByClientId to deleteBlockLayoutByClientId * Mobile - BlockListContext - Store default context and use it for initialization * Mobile - BlockListContext - Add param's docs * Mobile - Block list context - Export findBlockLayoutByClientId * Mobile - Block list context - Update comments * Mobile - Block list context - Unit tests * Mobile - Block list context - update unit tests * [Mobile] - Draggable component (#39551) * Mobile - Add Draggable component * Mobile - Draggable - Fix composition of gestures, enable Pan gesture once LongPress is recognized * Mobile - Draggable component - Track if the gesture Pan started, if it didn't and long pressure was activated it should call onDragEnd * Mobile - Draggable component - Add props documentation * Mobile - Draggable component - Update documentation * Mobile - Draggable component - Refactor that includes: - Usage of only one onEnd callback for both gestures. - Removes the hasPanStarted value since the onEnd callback is unified - Adds shouldCancelWhenOutside for the Pan gesture. - Removes the simultaneousWithExternalGesture since the Pan gesture is manually activated and composed with the LongPress gesture. - Add isIOS check within onTouchesMove for the state.fail() call. * Mobile - Draggable component - Use Platform from @wordpress/element * [RNMobile] `BlockDraggable` component (#39617) * Use animated scroll handler in KeyboardAwareFlatList * Add hook for scrolling the block list while dragging * Improve scroll animation in useScrollWhenDragging * Add draggable chip component * Add block draggable component * Remove icon prop from draggable chip component * Add draggable placeholder * Fix draggable chip location * Wrap BlockListItemCell with BlockDraggable * Fix block draggable placeholder style * Animate scale property instead of opacity of draggable chip * Fix draggable placeholder container height calculation * Fix BlockDraggable height animation * Move draggable to BlockDraggableWrapper * Disable isDragging when long-press gesture ends * Fix onLayout calculation in block list item cell * Add findBlockLayoutByPosition helper * Set up dragging block by position * Remove animate scroll velocity * Remove useScrollWhenDragging hook This hook will be introduced in a separate PR * Remove react-native-reanimated mock * Rename CHIP_OFFSET_TO_TOUCH_POSITION constant * Remove unused shared values of chip component * Stop dragging when no block is found * Fix drag position calculation * Update html text input styles * Unify container component within html text input * Use only a single client id in block draggable * Add documentation to block draggable components * Add documentation to block draggable chip component * Add documentation to findBlockLayoutByPosition * Update scrollOffsetTarget calculation * Fix typos in block draggable * Add draggable wrapper container style * Add dark mode styles for draggable chip * Add dark mode styles for block draggable * Get container height from blocks layout data * Replace inline callback functions with useCallback hook * Update collapse/expand animation when dragging a block * Force draggable chip to be displayed upfront * Remove refs from dependencies arrays References can be omitted from the dependencies arrays since React guarantees that they are inmutable. * [RNMobile] Add `useScrollWhenDragging` hook (#39705) * Introduce useScrollWhenDragging hook * Cancel animation timer on stop scrolling * Add documentation to useScrollWhenDragging hook * Replace Dimensions with useWindowDimensions hook * [RNMobile] Prevent draggable gesture when any text input is focused (#39890) * [Mobile] Adds useBlockDropZone and DroppingInsertionPoint (#39891) * Adds useBlockDropZone and DroppingInsertionPoint * Fix dropping insertion point position when scrolling * Mobile - DroppingInsertionPoint - Fixes: - Avoid showing the indicator when no blocks are being dragged. - Allow showing the dropping indicator at the end of the content. - Prevent checks within useState for isBlockBeingDragged, if no blocks are being dragged. - Prevent looking for a block layout if the clientId is undefined. * Mobile - DroppingInsertionPoint - Add documentation * Mobile - useBlockDropZone - Add documentation * Mobile - useBlockDropZone - Add missing dependencies * Mobile - Block list context - Updates getBlockLayoutsOrderedByYCoord to make it compatible with hermes, currently it doesn't support using the native .sort. It also adds documentation of the function. * Mobile - Updates: useBlockDropZone: - Avoid using showInsertionPoint to avoid re-renders and bad performance. - Passes the current target index as a shared value to pass it to DroppingInsertionPoint. DroppingInsertionPoint: - Detects when targetBlockIndex changes from the dragging animation to update the indicator's position. - Fixes an issue where the last element couldn't be reached. * Mobile - Block list - Revert changes for the insertion point checks * Mobile - Updates documentation: - BlockListContext: Some typos and clarifications. - DroppingInsertionPoint: Update component description. - useBlockDropZone: Update comments. * Mobile - DroppingInsertionPoint - Remove usage of useCallback * Mobile - DroppingInsertionPoint - Remove static styles from useAnimatedStyles and merge both in a separate constant. * Mobile - DroppingInsertionPoint - Move component to the BlockDraggable folder, which is where it's rendered from. * Mobile - DroppingInsertionPoint - Remove usage of hasStartedDraggingOver in favor of isDragging. * Mobile - DroppingInsertionPoint - Remove unneeded braces. Co-authored-by: Carlos Garcia * [RNMobile] Add `useOnBlockDrop` hook to update blocks order when dropping a block (#39884) * Add useOnBlockDrop hook * Add currentClientId ref to block draggable * Add onBlockDrop to block drop zone hook * Trigger onBlockDrop event when dragging finishes * Fix content overflow when dragging a selected block * [RNMobile] Update drag & drop animations (#40104) * Update drag&drop block animation * Keep block layout data instead of clientId in block draggable * Automatically select the dropped block * Delay opacity animation when dropping a block * Check current client Id existence on stop dragging * [RNMobile] Disable text editing when long-pressing a block in favor of drag & drop gesture (#40101) * Reduce default min duration for long-press gesture * Consume long click event on Android if Aztec text input is not focused * Ensure that drag events are not executed when text input focused * Replace onLongPress with long-press gesture handler in Button component * Use LongPressGestureHandler in button component * Update block-mover snapshots * [RNMobile] Refactor draggable logic and introduce `DraggableTrigger` component (#40406) * Present block mover picker only after state updates * Refactor draggable component * Use DraggableTrigger in BlockDraggable * Move BlockDraggable render to BlockListBlock component * Fix long-press gesture when editing a text on iOS * Memoize draggable provider value to prevent re-renders * Fix dragging not being disabled after scrolling * Reduce calls to event handlers of pan and long-press gestures * Prevent onDragEnd event to be called upon mounting * Add DEFAULT_IOS_LONG_PRESS_MIN_DURATION constant * [RNMobile] Update animation of drag & drop chip component (#40409) * Use layout animations when rendering the chip component Additionally, the block icon is now provided from the BlockDraggable component. * Fix chip layout calculation * Set block icon as state value * Update enter/exit animation duration of chip component * Mobile - DroppingInsertionPoint - Update indicator position for selected blocks (#40510) * Mobile - DroppingInsertionPoint - Update the indicator's position for the current selected block for both top and bottom positions depending on the current position when dragging. * Replace parseInt to Math.floor * [RNMobile] Add haptic feedback to drag & drop feature (#40423) * Add generate haptic feedback function into RN bridge * Add haptic feedback to drag & drop * Reduce haptic feedback intensity on iOS * [RNMobile] Fix issues related to editing text and dragging gesture (#40480) * Add input state functionality to Aztec * Control drag enabling with Aztec input state * Force disabling text editing when dragging * Add documentation to AztecInputState * Update changelog * Add tests for Aztec input state * Update focus change listener logic * Update listen to focus change event test * Fix react-native-aztec module mock * Fix wrong call to removeFocusChangeListener * Fix updating currentFocusedElement value * Check if an inner block is selected when enabling dragging * Wrap draggable long-press handler with useCallback * Add documentation to notifyListeners function * Mobile - Draggable - Disable multiple touches (#40519) * Mobile - Draggable - Disable multiple touches by getting the first touch during onTouchesMove, since using the maxPointers config works unexpectedly on Android. * Mobile - Draggable - Order touch events by ID and use the first element * Pass isPanActive to the LongPress onEnd callback to not update the isDragging flag when the panning event is active * Mobile - Draggable - Store the first touch ID instead of picking the first event within the allTouches event array. * Mobile - DroppingInsertionPoint - Hide indicator when it overflows outside the content (#40658) * Mobile - Provider - Adds SafeAreaProvider * Mobile - DroppingInsertionPoint - Hide indicator if the current position overflows over the header or footer * Fix tests, adds react-native-safe-area-context mock * Mobile - DroppingInsertionPoint - Use the height value from the useSafeAreaFrame instead * Mobile - DroppingInsertionPoint - Update mocks * Mobile - Disable long press event in media blocks (#40651) * Mobile - Disable long press event in media blocks * Mobile - Media & Text - Remove extra param * Mobile - Media & Text - Show replace media button for both Image and video * Mobile - DraggingInsertionPoint - Prevent crash when accessing a null element (#40689) * Mobile - DraggingInsertionPoint - Fix crash when there's only one block in the editor, the previousElement is null. * Mobile - DroppingInsertionPoint - Avoid having NaN values * Mobile - Draggable - Add onTouchesCancelled to handle manually ending the drag & drop event in cases like sending the app to background or opening the notifications center on iOS (#40729) * [RNMobile] Fix Android crash when closing the editor while dragging (#40810) * Remove Reanimated transitive dependency on Android * Test Android crash fix by changing Reanimated version * Bump react-native-reanimated dependency version * Bump react-native-reanimated dependency on Android * Update package-lock.json file * Bump react-native-reanimated dependency version * Revert "Test Android crash fix by changing Reanimated version" This reverts commit d01cdae6ce09e98a4bf42ab11719fbe2b753b288. * Mobile - AztecView - Trigger notifyInputChange on focus/blur (#40788) * Mobile - BlockDraggable - Set isEditingText to false and update it with the initialization of the AztecView listener * Mobile - AztecView - Move notifyInputChange to the focus/blur functions within AztecInputState, to fix an issue where these are called directly. * [RNMobile] Disable dragging when a screen reader is enabled (#40852) * [RNMobile] Allow dragging from block toolbar (#40886) * Enable dragging from block toolbar * Ensure root block is dragged for nested blocks * Add draggingClientId prop to BlockDraggable With this change the `BlockDraggable` component might receive two different client ids, one is the client id of the block where the component is rendered, and the other (which is optional) is used for identifying the block to be dragged. * Set dragging always enabled for block toolbar The block toolbar is only visible when the block is selected so it's safe to allow dragging in all cases, as it won't affect other UI elements. * Update dragging enabled calculation In order to prevent issues related to disabling the long-press gesture in elements within the block UI, the logic for enabling the dragging has been updated. Now it will enabled in the following cases: - The block doesn't have inner blocks. This applies to root blocks and nested blocks without further nested blocks. - Blocks with nested blocks if the block is selected. - Blocks with nested blocks if none of the nested blocks is selected. * Update Podfile.lock * Update react-native-editor changelog * Fix text block query locator in Android E2E tests * Fix Cover block E2E test * Fix Spacer block E2E test Co-authored-by: Gerardo Pacheco --- package-lock.json | 6 +- .../block-draggable/draggable-chip.native.js | 49 +++ .../dropping-insertion-point.native.js | 181 ++++++++ .../dropping-insertion-point.native.scss | 8 + .../block-draggable/index.native.js | 415 ++++++++++++++++++ .../block-draggable/style.native.scss | 19 + .../use-scroll-when-dragging.native.js | 135 ++++++ .../block-list/block-list-context.native.js | 175 ++++++++ .../block-list/block-list-item-cell.native.js | 42 ++ .../src/components/block-list/block.native.js | 44 +- .../src/components/block-list/index.native.js | 48 +- .../test/block-list-context.native.js | 253 +++++++++++ .../fixtures/block-list-context.native.js | 79 ++++ .../block-mobile-toolbar/index.native.js | 9 +- .../components/block-mover/index.native.js | 28 +- .../test/__snapshots__/index.native.js.snap | 6 + .../use-block-drop-zone/index.native.js | 173 ++++++++ .../use-on-block-drop/index.native.js | 119 +++++ .../src/cover/controls.native.js | 1 - .../block-library/src/cover/edit.native.js | 1 - .../block-library/src/file/edit.native.js | 1 - .../block-library/src/image/edit.native.js | 1 - .../src/media-text/edit.native.js | 4 +- .../src/media-text/media-container.native.js | 6 +- .../components/src/button/index.native.js | 51 ++- .../components/src/draggable/index.native.js | 215 +++++++++ .../src/draggable/style.native.scss | 3 + packages/components/src/index.native.js | 1 + .../html-text-input/container.android.js | 23 - .../mobile/html-text-input/container.ios.js | 50 --- .../mobile/html-text-input/index.native.js | 74 ++-- .../mobile/html-text-input/style.android.scss | 16 +- .../src/mobile/html-text-input/style.ios.scss | 16 +- .../{style-common.native.scss => style.scss} | 16 + .../keyboard-aware-flat-list/index.android.js | 20 +- .../keyboard-aware-flat-list/index.ios.js | 121 +++-- packages/compose/src/index.native.js | 1 + .../src/components/provider/index.native.js | 3 +- packages/react-native-aztec/CHANGELOG.md | 1 + .../ReactNativeAztec/ReactAztecText.java | 11 + .../react-native-aztec/src/AztecInputState.js | 113 +++++ packages/react-native-aztec/src/AztecView.js | 20 +- .../src/test/AztecInputState.test.js | 101 +++++ .../android/react-native-bridge/build.gradle | 7 +- .../src/main/AndroidManifest.xml | 3 + .../RNReactNativeGutenbergBridgeModule.java | 20 + packages/react-native-bridge/index.js | 7 + .../ios/RNReactNativeGutenbergBridge.m | 1 + .../ios/RNReactNativeGutenbergBridge.swift | 5 + packages/react-native-editor/CHANGELOG.md | 1 + .../gutenberg-editor-cover.test.js | 32 +- .../gutenberg-editor-spacer.test.js | 4 +- .../__device-tests__/pages/editor-page.js | 4 +- .../android/app/build.gradle | 6 +- packages/react-native-editor/ios/Podfile.lock | 4 +- packages/react-native-editor/package.json | 2 +- .../@wordpress/react-native-aztec/index.js | 14 +- test/native/__mocks__/styleMock.js | 3 + test/native/setup.js | 26 +- 59 files changed, 2527 insertions(+), 271 deletions(-) create mode 100644 packages/block-editor/src/components/block-draggable/draggable-chip.native.js create mode 100644 packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.js create mode 100644 packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.scss create mode 100644 packages/block-editor/src/components/block-draggable/index.native.js create mode 100644 packages/block-editor/src/components/block-draggable/style.native.scss create mode 100644 packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js create mode 100644 packages/block-editor/src/components/block-list/block-list-context.native.js create mode 100644 packages/block-editor/src/components/block-list/block-list-item-cell.native.js create mode 100644 packages/block-editor/src/components/block-list/test/block-list-context.native.js create mode 100644 packages/block-editor/src/components/block-list/test/fixtures/block-list-context.native.js create mode 100644 packages/block-editor/src/components/use-block-drop-zone/index.native.js create mode 100644 packages/block-editor/src/components/use-on-block-drop/index.native.js create mode 100644 packages/components/src/draggable/index.native.js create mode 100644 packages/components/src/draggable/style.native.scss delete mode 100644 packages/components/src/mobile/html-text-input/container.android.js delete mode 100644 packages/components/src/mobile/html-text-input/container.ios.js rename packages/components/src/mobile/html-text-input/{style-common.native.scss => style.scss} (54%) create mode 100644 packages/react-native-aztec/src/AztecInputState.js create mode 100644 packages/react-native-aztec/src/test/AztecInputState.test.js 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 && (