From a51e77349a34853a7651ef404bffe3becf5e576f Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Thu, 17 Mar 2022 16:41:50 +0100 Subject: [PATCH 01/28] [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 --- .../block-list/block-list-context.native.js | 131 +++++++++ .../block-list/block-list-item-cell.native.js | 42 +++ .../src/components/block-list/index.native.js | 40 ++- .../test/block-list-context.native.js | 253 ++++++++++++++++++ .../fixtures/block-list-context.native.js | 79 ++++++ 5 files changed, 537 insertions(+), 8 deletions(-) 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 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..95385b480b3d1 --- /dev/null +++ b/packages/block-editor/src/components/block-list/block-list-context.native.js @@ -0,0 +1,131 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +export const DEFAULT_BLOCK_LIST_CONTEXT = { + scrollRef: null, + blocksLayouts: { current: {} }, + findBlockLayoutByClientId, + updateBlocksLayouts, +}; + +const Context = createContext( DEFAULT_BLOCK_LIST_CONTEXT ); +const { Provider, Consumer } = Context; + +/** + * 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; + }, {} ); +} + +/** + * 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/index.native.js b/packages/block-editor/src/components/block-list/index.native.js index ada27b4ebd9fb..f1dc31da0e91e 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,14 @@ 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 { store as blockEditorStore } from '../../store'; -const BlockListContext = createContext(); - export const OnCaretVerticalPositionChange = createContext(); const stylesMemo = {}; @@ -78,6 +82,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 +161,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 +191,22 @@ 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() } - + ) : ( - - { ( ref ) => + + { ( { scrollRef } ) => this.renderList( { - parentScrollRef: ref, + parentScrollRef: scrollRef, } ) } - + ); return ( @@ -279,6 +302,7 @@ export class BlockList extends Component { data={ blockClientIds } keyExtractor={ identity } renderItem={ this.renderItem } + CellRendererComponent={ this.getCellRendererComponent } shouldPreventAutomaticScroll={ this.shouldFlatListPreventAutomaticScroll } diff --git a/packages/block-editor/src/components/block-list/test/block-list-context.native.js b/packages/block-editor/src/components/block-list/test/block-list-context.native.js new file mode 100644 index 0000000000000..13fd0b1e42cb6 --- /dev/null +++ b/packages/block-editor/src/components/block-list/test/block-list-context.native.js @@ -0,0 +1,253 @@ +/** + * External dependencies + */ +import { cloneDeep } from 'lodash'; + +/** + * Internal dependencies + */ +import { + DEFAULT_BLOCK_LIST_CONTEXT, + deleteBlockLayoutByClientId, +} from '../block-list-context.native'; +import { + BLOCKS_LAYOUTS_DATA, + DEEP_NESTED_ID, + GROUP_BLOCK_LAYOUT_DATA, + NESTED_WITH_INNER_BLOCKS_ID, + PARAGRAPH_BLOCK_LAYOUT_DATA, + ROOT_LEVEL_ID, +} from './fixtures/block-list-context.native'; + +describe( 'findBlockLayoutByClientId', () => { + 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, +}; From 4e5f5f7f00264762b2f612992e710c629460fbc6 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Tue, 22 Mar 2022 14:28:18 +0100 Subject: [PATCH 02/28] =?UTF-8?q?[Mobile]=C2=A0-=20Draggable=20component?= =?UTF-8?q?=20(#39551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../components/src/draggable/index.native.js | 83 +++++++++++++++++++ packages/components/src/index.native.js | 1 + 2 files changed, 84 insertions(+) create mode 100644 packages/components/src/draggable/index.native.js diff --git a/packages/components/src/draggable/index.native.js b/packages/components/src/draggable/index.native.js new file mode 100644 index 0000000000000..016bd084180cd --- /dev/null +++ b/packages/components/src/draggable/index.native.js @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { useSharedValue } from 'react-native-reanimated'; + +/** + * WordPress dependencies + */ +import { Platform } from '@wordpress/element'; + +/** + * Draggable component + * + * @param {Object} props Component props. + * @param {JSX.Element} props.children Children to be rendered. + * @param {number} [props.maxDistance] Maximum distance, that defines how far the finger is allowed to travel during a long press gesture. + * @param {number} [props.minDuration] Minimum time, that a finger must remain pressed on the corresponding view. + * @param {Function} [props.onDragEnd] Callback when dragging ends. + * @param {Function} [props.onDragOver] Callback when dragging happens over an element. + * @param {Function} [props.onDragStart] Callback when dragging starts. + * @param {import('react-native-reanimated').StyleProp} [props.wrapperAnimatedStyles] Animated styles for the wrapper component. + * + * @return {JSX.Element} The component to be rendered. + */ +export default function Draggable( { + children, + maxDistance = 1000, + minDuration = 500, + onDragEnd, + onDragOver, + onDragStart, + wrapperAnimatedStyles, +} ) { + const isDragging = useSharedValue( false ); + + const longPressGesture = Gesture.LongPress() + .onStart( ( ev ) => { + 'worklet'; + isDragging.value = true; + + if ( onDragStart ) { + onDragStart( ev ); + } + } ) + .onEnd( () => { + 'worklet'; + if ( onDragEnd ) { + onDragEnd(); + } + } ) + .maxDistance( maxDistance ) + .minDuration( minDuration ) + .shouldCancelWhenOutside( false ); + + const panGesture = Gesture.Pan() + .manualActivation( true ) + .onTouchesMove( ( _, state ) => { + 'worklet'; + if ( isDragging.value ) { + state.activate(); + } else if ( Platform.isIOS ) { + state.fail(); + } + } ) + .onUpdate( ( ev ) => { + 'worklet'; + if ( onDragOver ) { + onDragOver( ev ); + } + } ) + .shouldCancelWhenOutside( false ); + + const dragHandler = Gesture.Simultaneous( panGesture, longPressGesture ); + + return ( + + + { children } + + + ); +} diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index b7eaab07e538d..108fb95b094be 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -61,6 +61,7 @@ export { filterUnitsWithSettings as filterUnitsWithSettings, } from './unit-control/utils'; export { default as Disabled } from './disabled'; +export { default as Draggable } from './draggable'; // Higher-Order Components. export { default as withConstrainedTabbing } from './higher-order/with-constrained-tabbing'; From 76f8096e1569ad6b02984198fa31c27cc0154f0e Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 30 Mar 2022 10:27:08 +0200 Subject: [PATCH 03/28] [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. --- .../block-draggable/draggable-chip.native.js | 62 +++ .../block-draggable/index.native.js | 352 ++++++++++++++++++ .../block-draggable/style.native.scss | 34 ++ .../block-list/block-list-context.native.js | 23 ++ .../block-list/block-list-item-cell.native.js | 9 +- .../src/components/block-list/index.native.js | 8 +- .../components/src/draggable/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 +++--- test/native/setup.js | 10 - 16 files changed, 639 insertions(+), 196 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/index.native.js create mode 100644 packages/block-editor/src/components/block-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%) 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..2559b2089252b --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/draggable-chip.native.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { dragHandle } from '@wordpress/icons'; +import { useSelect } from '@wordpress/data'; +import { getBlockType } from '@wordpress/blocks'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BlockIcon from '../block-icon'; +import styles from './style.scss'; +import { store as blockEditorStore } from '../../store'; + +const shadowStyle = { + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, +}; + +/** + * Block draggable chip component + * + * @return {JSX.Element} Chip component. + */ +export default function BlockDraggableChip() { + const containerStyle = usePreferredColorSchemeStyle( + styles[ 'draggable-chip__container' ], + styles[ 'draggable-chip__container--dark' ] + ); + + const { blockIcon } = useSelect( ( select ) => { + const { getBlockName, getDraggedBlockClientIds } = select( + blockEditorStore + ); + const draggedBlockClientIds = getDraggedBlockClientIds(); + const blockName = getBlockName( draggedBlockClientIds[ 0 ] ); + + return { + blockIcon: getBlockType( blockName )?.icon, + }; + } ); + + return ( + + + + + ); +} 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..8524e4aa48f94 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -0,0 +1,352 @@ +/** + * External dependencies + */ +import Animated, { + runOnJS, + runOnUI, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, + withTiming, + scrollTo, + useAnimatedReaction, + Easing, +} from 'react-native-reanimated'; + +/** + * WordPress dependencies + */ +import { Draggable } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import DraggableChip from './draggable-chip'; +import { store as blockEditorStore } from '../../store'; +import { useBlockListContext } from '../block-list/block-list-context'; +import styles from './style.scss'; + +const CHIP_OFFSET_TO_TOUCH_POSITION = 32; +const BLOCK_COLLAPSED_HEIGHT = 20; +const EXTRA_OFFSET_WHEN_CLOSE_TO_TOP_EDGE = 80; +const SCROLL_ANIMATION_DURATION = 350; +const COLLAPSE_HEIGHT_ANIMATION_CONFIG = { + duration: 350, + easing: Easing.out( Easing.exp ), +}; +const EXPAND_HEIGHT_ANIMATION_CONFIG = { + duration: 350, + easing: Easing.in( Easing.exp ), +}; +const COLLAPSE_OPACITY_ANIMATION_CONFIG = { duration: 150 }; + +/** + * 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 wrapperStyles = usePreferredColorSchemeStyle( + styles[ 'draggable-wrapper__container' ], + styles[ 'draggable-wrapper__container--dark' ] + ); + + const { startDraggingBlocks, stopDraggingBlocks } = useDispatch( + blockEditorStore + ); + + const { + blocksLayouts, + scrollRef, + findBlockLayoutByPosition, + } = 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 ), + scale: useSharedValue( 0 ), + }; + const isDragging = useSharedValue( false ); + const scrollAnimation = useSharedValue( 0 ); + + const scrollHandler = ( event ) => { + 'worklet'; + const { contentOffset } = event; + scroll.offsetY.value = contentOffset.y; + }; + + // Stop dragging blocks if the block draggable is unmounted. + useEffect( () => { + return () => { + if ( isDragging.value ) { + stopDraggingBlocks(); + } + }; + }, [] ); + + const setupDraggingBlock = ( position ) => { + const blockLayout = findBlockLayoutByPosition( blocksLayouts.current, { + x: position.x, + y: position.y + scroll.offsetY.value, + } ); + + const foundClientId = blockLayout?.clientId; + if ( foundClientId ) { + startDraggingBlocks( [ foundClientId ] ); + + const isBlockOutOfScrollView = blockLayout.y < scroll.offsetY.value; + // If the dragging block is out of the scroll view, we have to + // scroll the block list to show the origin position of the block. + if ( isBlockOutOfScrollView ) { + scrollAnimation.value = scroll.offsetY.value; + const scrollOffsetTarget = Math.max( + 0, + blockLayout.y - EXTRA_OFFSET_WHEN_CLOSE_TO_TOP_EDGE + ); + scrollAnimation.value = withTiming( scrollOffsetTarget, { + duration: SCROLL_ANIMATION_DURATION, + } ); + } + } else { + // We stop dragging if no block is found. + runOnUI( stopDragging )(); + } + }; + + // This hook is used for animating the scroll via a shared value. + useAnimatedReaction( + () => scrollAnimation.value, + ( value ) => { + if ( isDragging.value ) { + scrollTo( animatedScrollRef, 0, value, false ); + } + } + ); + + const onChipLayout = ( { nativeEvent: { layout } } ) => { + chip.width.value = layout.width; + chip.height.value = layout.height; + }; + + const startDragging = ( { x, y } ) => { + 'worklet'; + const dragPosition = { x, y }; + chip.x.value = dragPosition.x; + chip.y.value = dragPosition.y; + + isDragging.value = true; + + chip.scale.value = withTiming( 1 ); + runOnJS( setupDraggingBlock )( dragPosition ); + }; + + const updateDragging = ( { x, y } ) => { + 'worklet'; + const dragPosition = { x, y }; + chip.x.value = dragPosition.x; + chip.y.value = dragPosition.y; + }; + + const stopDragging = () => { + 'worklet'; + isDragging.value = false; + + chip.scale.value = withTiming( 0 ); + runOnJS( stopDraggingBlocks )(); + }; + + 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, + }, + { scaleX: chip.scale.value }, + { scaleY: chip.scale.value }, + ], + }; + } ); + const chipStyles = [ + chipDynamicStyles, + styles[ 'draggable-chip__wrapper' ], + ]; + + return ( + <> + + { children( { onScroll: scrollHandler } ) } + + + + + + ); +}; + +/** + * 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. + * + * @return {Function} Render function which includes the parameter `isDraggable` to determine if the block can be dragged. + */ +const BlockDraggable = ( { clientId, children } ) => { + const { blocksLayouts, findBlockLayoutByClientId } = useBlockListContext(); + + const collapseAnimation = { + opacity: useSharedValue( 0 ), + height: useSharedValue( 0 ), + initialHeight: useSharedValue( 0 ), + }; + + const startBlockDragging = () => { + const blockLayout = findBlockLayoutByClientId( + blocksLayouts.current, + clientId + ); + if ( blockLayout?.height > 0 ) { + collapseAnimation.initialHeight.value = blockLayout.height; + collapseAnimation.height.value = blockLayout.height; + collapseAnimation.opacity.value = withTiming( + 1, + COLLAPSE_OPACITY_ANIMATION_CONFIG, + ( completed ) => { + if ( completed ) { + collapseAnimation.height.value = withTiming( + BLOCK_COLLAPSED_HEIGHT, + COLLAPSE_HEIGHT_ANIMATION_CONFIG + ); + } + } + ); + } + }; + + const stopBlockDragging = () => { + collapseAnimation.height.value = withTiming( + collapseAnimation.initialHeight.value, + EXPAND_HEIGHT_ANIMATION_CONFIG, + ( completed ) => { + if ( completed ) { + collapseAnimation.opacity.value = withTiming( + 0, + COLLAPSE_OPACITY_ANIMATION_CONFIG + ); + } + } + ); + }; + + const { isDraggable, isBeingDragged } = useSelect( + ( select ) => { + const { + getBlockRootClientId, + getTemplateLock, + isBlockBeingDragged, + } = select( blockEditorStore ); + const rootClientId = getBlockRootClientId( clientId ); + const templateLock = rootClientId + ? getTemplateLock( rootClientId ) + : null; + + return { + isBeingDragged: isBlockBeingDragged( clientId ), + isDraggable: 'all' !== templateLock, + }; + }, + [ clientId ] + ); + + useEffect( () => { + if ( isBeingDragged ) { + startBlockDragging(); + } else { + stopBlockDragging(); + } + }, [ isBeingDragged ] ); + + const containerStyles = useAnimatedStyle( () => { + const canAnimateHeight = + collapseAnimation.height.value !== 0 && + collapseAnimation.opacity.value !== 0; + return { + height: canAnimateHeight ? collapseAnimation.height.value : 'auto', + }; + } ); + + const blockStyles = useAnimatedStyle( () => { + return { + opacity: 1 - collapseAnimation.opacity.value, + }; + } ); + + const placeholderDynamicStyles = useAnimatedStyle( () => { + return { + display: collapseAnimation.opacity.value === 0 ? 'none' : 'flex', + opacity: collapseAnimation.opacity.value, + }; + } ); + const placeholderStaticStyles = usePreferredColorSchemeStyle( + styles[ 'draggable-placeholder__container' ], + styles[ 'draggable-placeholder__container--dark' ] + ); + const placeholderStyles = [ + placeholderStaticStyles, + placeholderDynamicStyles, + ]; + + 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..b8133c966e9e4 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/style.native.scss @@ -0,0 +1,34 @@ +.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; +} + +.draggable-placeholder__container { + position: absolute; + top: 0; + left: $solid-border-space; + right: $solid-border-space; + bottom: 0; + z-index: 10; + background-color: $gray-lighten-30; + border-radius: 8px; +} + +.draggable-placeholder__container--dark { + background-color: $gray-darken-30; +} 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 index 95385b480b3d1..21f850ec5551c 100644 --- 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 @@ -7,12 +7,35 @@ export const DEFAULT_BLOCK_LIST_CONTEXT = { scrollRef: null, blocksLayouts: { current: {} }, findBlockLayoutByClientId, + 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. * 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 index c399643a63399..5577b0705d686 100644 --- 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 @@ -12,6 +12,7 @@ import { useEffect, useCallback } from '@wordpress/element'; * Internal dependencies */ import { useBlockListContext } from './block-list-context'; +import BlockDraggable from '../block-draggable'; function BlockListItemCell( { children, clientId, rootClientId } ) { const { blocksLayouts, updateBlocksLayouts } = useBlockListContext(); @@ -36,7 +37,13 @@ function BlockListItemCell( { children, clientId, rootClientId } ) { [ clientId, rootClientId, updateBlocksLayouts ] ); - return { children }; + return ( + + + { () => children } + + + ); } export default BlockListItemCell; 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 f1dc31da0e91e..1f52d0fd7441f 100644 --- a/packages/block-editor/src/components/block-list/index.native.js +++ b/packages/block-editor/src/components/block-list/index.native.js @@ -31,6 +31,7 @@ import { BlockListConsumer, DEFAULT_BLOCK_LIST_CONTEXT, } from './block-list-context'; +import { BlockDraggableWrapper } from '../block-draggable'; import { store as blockEditorStore } from '../../store'; export const OnCaretVerticalPositionChange = createContext(); @@ -197,7 +198,9 @@ export class BlockList extends Component { scrollRef: this.scrollViewRef, } } > - { this.renderList() } + + { ( { onScroll } ) => this.renderList( { onScroll } ) } + ) : ( @@ -235,7 +238,7 @@ export class BlockList extends Component { contentResizeMode, blockWidth, } = this.props; - const { parentScrollRef } = extraProps; + const { parentScrollRef, onScroll } = extraProps; const { blockToolbar, @@ -310,6 +313,7 @@ export class BlockList extends Component { ListHeaderComponent={ header } ListEmptyComponent={ ! isReadOnly && this.renderEmptyList } ListFooterComponent={ this.renderBlockListFooter } + onScroll={ onScroll } /> { this.shouldShowInnerBlockAppender() && ( { 'worklet'; + isDragging.value = false; if ( onDragEnd ) { onDragEnd(); } diff --git a/packages/components/src/mobile/html-text-input/container.android.js b/packages/components/src/mobile/html-text-input/container.android.js deleted file mode 100644 index 68d69783f3b9f..0000000000000 --- a/packages/components/src/mobile/html-text-input/container.android.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * External dependencies - */ -import { ScrollView } from 'react-native'; - -/** - * Internal dependencies - */ -import KeyboardAvoidingView from '../keyboard-avoiding-view'; -import styles from './style.android.scss'; - -const HTMLInputContainer = ( { children, parentHeight } ) => ( - - { children } - -); - -HTMLInputContainer.scrollEnabled = false; - -export default HTMLInputContainer; diff --git a/packages/components/src/mobile/html-text-input/container.ios.js b/packages/components/src/mobile/html-text-input/container.ios.js deleted file mode 100644 index b40214e1eaab0..0000000000000 --- a/packages/components/src/mobile/html-text-input/container.ios.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * External dependencies - */ -import { UIManager, PanResponder } from 'react-native'; - -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import KeyboardAvoidingView from '../keyboard-avoiding-view'; -import styles from './style.ios.scss'; - -class HTMLInputContainer extends Component { - constructor() { - super( ...arguments ); - - this.panResponder = PanResponder.create( { - onStartShouldSetPanResponderCapture: () => true, - - onPanResponderMove: ( e, gestureState ) => { - if ( gestureState.dy > 100 && gestureState.dy < 110 ) { - // Keyboard.dismiss() and this.textInput.blur() are not working here - // They require to know the currentlyFocusedID under the hood but - // during this gesture there's no currentlyFocusedID. - UIManager.blur( e.target ); - } - }, - } ); - } - - render() { - return ( - - { this.props.children } - - ); - } -} - -HTMLInputContainer.scrollEnabled = true; - -export default HTMLInputContainer; diff --git a/packages/components/src/mobile/html-text-input/index.native.js b/packages/components/src/mobile/html-text-input/index.native.js index 438a1ca88ed4b..eab8dafcbd490 100644 --- a/packages/components/src/mobile/html-text-input/index.native.js +++ b/packages/components/src/mobile/html-text-input/index.native.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { TextInput } from 'react-native'; +import { ScrollView, TextInput } from 'react-native'; /** * WordPress dependencies @@ -20,7 +20,7 @@ import { /** * Internal dependencies */ -import HTMLInputContainer from './container'; +import KeyboardAvoidingView from '../keyboard-avoiding-view'; import styles from './style.scss'; export class HTMLTextInput extends Component { @@ -73,7 +73,13 @@ export class HTMLTextInput extends Component { } render() { - const { getStylesFromColorScheme, style } = this.props; + const { + editTitle, + getStylesFromColorScheme, + parentHeight, + style, + title, + } = this.props; const titleStyle = [ styles.htmlViewTitle, style?.text && { color: style.text }, @@ -90,32 +96,42 @@ export class HTMLTextInput extends Component { ...( style?.text && { color: style.text } ), }; return ( - - - - + + + + + + ); } } diff --git a/packages/components/src/mobile/html-text-input/style.android.scss b/packages/components/src/mobile/html-text-input/style.android.scss index 1dca01274d75b..e292901922dbc 100644 --- a/packages/components/src/mobile/html-text-input/style.android.scss +++ b/packages/components/src/mobile/html-text-input/style.android.scss @@ -1,21 +1,7 @@ -@import "./style-common.scss"; - -.htmlView { - font-family: $htmlFont; - padding-left: $padding; - padding-right: $padding; - padding-top: $padding; - padding-bottom: $padding + 16; -} +@import "./style.scss"; .htmlViewTitle { font-family: $htmlFont; padding-left: $padding; padding-right: $padding; - padding-top: $padding; - padding-bottom: $padding; -} - -.scrollView { - flex: 1; } diff --git a/packages/components/src/mobile/html-text-input/style.ios.scss b/packages/components/src/mobile/html-text-input/style.ios.scss index 97cf00a7512ff..cd269a6b9876f 100644 --- a/packages/components/src/mobile/html-text-input/style.ios.scss +++ b/packages/components/src/mobile/html-text-input/style.ios.scss @@ -1,17 +1,4 @@ -@import "./style-common.scss"; - -$title-height: 32; - -.htmlView { - font-family: $htmlFont; - padding-left: $padding; - padding-right: $padding; - padding-bottom: $title-height + $padding; -} - -.htmlViewDark { - color: $textColorDark; -} +@import "./style.scss"; .htmlViewTitle { font-family: $htmlFont; @@ -19,5 +6,4 @@ $title-height: 32; padding-right: $padding; padding-top: $padding; padding-bottom: $padding; - height: $title-height; } diff --git a/packages/components/src/mobile/html-text-input/style-common.native.scss b/packages/components/src/mobile/html-text-input/style.scss similarity index 54% rename from packages/components/src/mobile/html-text-input/style-common.native.scss rename to packages/components/src/mobile/html-text-input/style.scss index c1ac9f155d4c7..89b81e898ad4b 100644 --- a/packages/components/src/mobile/html-text-input/style-common.native.scss +++ b/packages/components/src/mobile/html-text-input/style.scss @@ -21,3 +21,19 @@ $textColorDark: $white; .placeholderDark { color: $gray-50; } + +.htmlView { + font-family: $htmlFont; + padding-left: $padding; + padding-right: $padding; + padding-top: $padding; + padding-bottom: $padding + 16; +} + +.htmlViewDark { + color: $textColorDark; +} + +.scrollView { + flex: 1; +} diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js b/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js index bdfc2ef1fd847..ffdd97dd5acbb 100644 --- a/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js +++ b/packages/components/src/mobile/keyboard-aware-flat-list/index.android.js @@ -2,17 +2,27 @@ * External dependencies */ import { FlatList } from 'react-native'; +import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated'; /** * Internal dependencies */ import KeyboardAvoidingView from '../keyboard-avoiding-view'; -export const KeyboardAwareFlatList = ( props ) => ( - - - -); +const AnimatedFlatList = Animated.createAnimatedComponent( FlatList ); + +export const KeyboardAwareFlatList = ( { innerRef, onScroll, ...props } ) => { + const scrollHandler = useAnimatedScrollHandler( { onScroll } ); + return ( + + + + ); +}; KeyboardAwareFlatList.handleCaretVerticalPositionChange = () => { // no need to handle on Android, it is system managed diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js b/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js index 6c954d451dc17..a8e84aaf1c2a4 100644 --- a/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js +++ b/packages/components/src/mobile/keyboard-aware-flat-list/index.ios.js @@ -4,13 +4,20 @@ import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { FlatList } from 'react-native'; import { isEqual } from 'lodash'; +import Animated, { + useAnimatedScrollHandler, + useSharedValue, +} from 'react-native-reanimated'; /** * WordPress dependencies */ -import { memo } from '@wordpress/element'; +import { memo, useCallback, useRef } from '@wordpress/element'; const List = memo( FlatList, isEqual ); +const AnimatedKeyboardAwareScrollView = Animated.createAnimatedComponent( + KeyboardAwareScrollView +); export const KeyboardAwareFlatList = ( { extraScrollHeight, @@ -19,53 +26,75 @@ export const KeyboardAwareFlatList = ( { autoScroll, scrollViewStyle, inputAccessoryViewHeight, + onScroll, ...listProps -} ) => ( - { - this.scrollViewRef = ref; +} ) => { + const scrollViewRef = useRef(); + const keyboardWillShowIndicator = useRef(); + + const latestContentOffsetY = useSharedValue( -1 ); + + const scrollHandler = useAnimatedScrollHandler( { + onScroll: ( event ) => { + const { contentOffset } = event; + latestContentOffsetY.value = contentOffset.y; + onScroll( event ); + }, + } ); + + const getRef = useCallback( + ( ref ) => { + scrollViewRef.current = ref; innerRef( ref ); - } } - onKeyboardWillHide={ () => { - this.keyboardWillShowIndicator = false; - } } - onKeyboardDidHide={ () => { - setTimeout( () => { - if ( - ! this.keyboardWillShowIndicator && - this.latestContentOffsetY !== undefined && - ! shouldPreventAutomaticScroll() - ) { - // Reset the content position if keyboard is still closed. - if ( this.scrollViewRef ) { - this.scrollViewRef.scrollToPosition( - 0, - this.latestContentOffsetY, - true - ); - } - } - }, 50 ); - } } - onKeyboardWillShow={ () => { - this.keyboardWillShowIndicator = true; - } } - scrollEnabled={ listProps.scrollEnabled } - onScroll={ ( event ) => { - this.latestContentOffsetY = event.nativeEvent.contentOffset.y; - } } - > - - -); + }, + [ innerRef ] + ); + const onKeyboardWillHide = useCallback( () => { + keyboardWillShowIndicator.current = false; + }, [] ); + const onKeyboardDidHide = useCallback( () => { + setTimeout( () => { + if ( + ! keyboardWillShowIndicator.current && + latestContentOffsetY.value !== -1 && + ! shouldPreventAutomaticScroll() + ) { + // Reset the content position if keyboard is still closed. + scrollViewRef.current?.scrollToPosition( + 0, + latestContentOffsetY.value, + true + ); + } + }, 50 ); + }, [ latestContentOffsetY, shouldPreventAutomaticScroll ] ); + const onKeyboardWillShow = useCallback( () => { + keyboardWillShowIndicator.current = true; + }, [] ); + + return ( + + + + ); +}; KeyboardAwareFlatList.handleCaretVerticalPositionChange = ( scrollView, diff --git a/test/native/setup.js b/test/native/setup.js index 5fb9927752771..014b7f0ce667c 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -153,16 +153,6 @@ jest.mock( '@react-native-community/blur', () => () => 'BlurView', { virtual: true, } ); -jest.mock( 'react-native-reanimated', () => { - const Reanimated = require( 'react-native-reanimated/mock' ); - - // The mock for `call` immediately calls the callback which is incorrect - // So we override it with a no-op - Reanimated.default.call = () => {}; - - return Reanimated; -} ); - // Silence the warning: Animated: `useNativeDriver` is not supported because the // native animated module is missing. This was added per React Navigation docs. // https://reactnavigation.org/docs/testing/#mocking-native-modules From f8e4e0efbd216229c3950a56e29e5dab7f7ec90a Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 30 Mar 2022 12:28:16 +0200 Subject: [PATCH 04/28] [RNMobile] Add `useScrollWhenDragging` hook (#39705) * Introduce useScrollWhenDragging hook * Cancel animation timer on stop scrolling * Add documentation to useScrollWhenDragging hook * Replace Dimensions with useWindowDimensions hook --- .../block-draggable/index.native.js | 24 +++- .../use-scroll-when-dragging.native.js | 135 ++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index 8524e4aa48f94..889be86b773e2 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -24,6 +24,7 @@ import { usePreferredColorSchemeStyle } from '@wordpress/compose'; /** * 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'; @@ -90,10 +91,19 @@ const BlockDraggableWrapper = ( { children } ) => { const isDragging = useSharedValue( false ); const scrollAnimation = useSharedValue( 0 ); + const [ + startScrolling, + scrollOnDragOver, + stopScrolling, + draggingScrollHandler, + ] = useScrollWhenDragging(); + const scrollHandler = ( event ) => { 'worklet'; const { contentOffset } = event; scroll.offsetY.value = contentOffset.y; + + draggingScrollHandler( event ); }; // Stop dragging blocks if the block draggable is unmounted. @@ -124,9 +134,13 @@ const BlockDraggableWrapper = ( { children } ) => { 0, blockLayout.y - EXTRA_OFFSET_WHEN_CLOSE_TO_TOP_EDGE ); - scrollAnimation.value = withTiming( scrollOffsetTarget, { - duration: SCROLL_ANIMATION_DURATION, - } ); + scrollAnimation.value = withTiming( + scrollOffsetTarget, + { duration: SCROLL_ANIMATION_DURATION }, + () => startScrolling( position.y ) + ); + } else { + runOnUI( startScrolling )( position.y ); } } else { // We stop dragging if no block is found. @@ -166,6 +180,9 @@ const BlockDraggableWrapper = ( { children } ) => { const dragPosition = { x, y }; chip.x.value = dragPosition.x; chip.y.value = dragPosition.y; + + // Update scrolling velocity + scrollOnDragOver( dragPosition.y ); }; const stopDragging = () => { @@ -174,6 +191,7 @@ const BlockDraggableWrapper = ( { children } ) => { chip.scale.value = withTiming( 0 ); runOnJS( stopDraggingBlocks )(); + stopScrolling(); }; const chipDynamicStyles = useAnimatedStyle( () => { 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 ]; +} From 0b3940e5943d5b1e171e91ce10c7cf5ebc2dc0b9 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Thu, 31 Mar 2022 12:12:26 +0200 Subject: [PATCH 05/28] [RNMobile] Prevent draggable gesture when any text input is focused (#39890) --- .../components/src/draggable/index.native.js | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/components/src/draggable/index.native.js b/packages/components/src/draggable/index.native.js index 9716b8230e1a6..35c5ad8c99095 100644 --- a/packages/components/src/draggable/index.native.js +++ b/packages/components/src/draggable/index.native.js @@ -2,7 +2,8 @@ * External dependencies */ import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { useSharedValue } from 'react-native-reanimated'; +import Animated, { useSharedValue, runOnJS } from 'react-native-reanimated'; +import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; /** * WordPress dependencies @@ -33,10 +34,24 @@ export default function Draggable( { wrapperAnimatedStyles, } ) { const isDragging = useSharedValue( false ); + const isAnyTextInputFocused = useSharedValue( false ); + + const checkTextInputFocus = () => { + isAnyTextInputFocused.value = + TextInputState.currentlyFocusedInput() !== null; + }; const longPressGesture = Gesture.LongPress() + .onBegin( () => { + 'worklet'; + runOnJS( checkTextInputFocus )(); + } ) .onStart( ( ev ) => { 'worklet'; + if ( isAnyTextInputFocused.value ) { + return; + } + isDragging.value = true; if ( onDragStart ) { @@ -45,6 +60,10 @@ export default function Draggable( { } ) .onEnd( () => { 'worklet'; + if ( isAnyTextInputFocused.value ) { + return; + } + isDragging.value = false; if ( onDragEnd ) { onDragEnd(); @@ -60,7 +79,7 @@ export default function Draggable( { 'worklet'; if ( isDragging.value ) { state.activate(); - } else if ( Platform.isIOS ) { + } else if ( Platform.isIOS || isAnyTextInputFocused.value ) { state.fail(); } } ) From dfd29e2e115a4ef8044602f5fba643e73bdd33eb Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Thu, 31 Mar 2022 18:21:48 +0200 Subject: [PATCH 06/28] [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 --- .../dropping-insertion-point.native.js | 135 +++++++++++++++ .../dropping-insertion-point.native.scss | 8 + .../block-draggable/index.native.js | 17 ++ .../block-list/block-list-context.native.js | 21 +++ .../use-block-drop-zone/index.native.js | 161 ++++++++++++++++++ packages/compose/src/index.native.js | 1 + 6 files changed, 343 insertions(+) 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/use-block-drop-zone/index.native.js 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..6970493642394 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/dropping-insertion-point.native.js @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + useAnimatedReaction, + runOnJS, +} from 'react-native-reanimated'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * 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 {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, + isDragging, + targetBlockIndex, +} ) { + const { + getBlockOrder, + isBlockBeingDragged, + isDraggingBlocks, + getPreviousBlockClientId, + getNextBlockClientId, + } = useSelect( blockEditorStore ); + + const { blocksLayouts, findBlockLayoutByClientId } = useBlockListContext(); + + const blockYPosition = useSharedValue( 0 ); + const opacity = useSharedValue( 0 ); + + useAnimatedReaction( + () => isDragging.value, + ( value ) => { + if ( ! value ) { + opacity.value = 0; + blockYPosition.value = 0; + } + } + ); + + 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 nextPosition = previousElement + ? previousElement.y + previousElement.height + : nextElement?.y; + + if ( nextPosition && blockYPosition.value !== nextPosition ) { + opacity.value = 0; + blockYPosition.value = nextPosition; + opacity.value = withTiming( 1 ); + } + } + + useAnimatedReaction( + () => targetBlockIndex.value, + ( value ) => { + runOnJS( setIndicatorPosition )( value ); + } + ); + + const animatedStyles = useAnimatedStyle( () => { + return { + opacity: opacity.value, + transform: [ + { + translateY: blockYPosition.value - scroll.offsetY.value, + }, + ], + }; + } ); + + 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 index 889be86b773e2..2345294e9ca24 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -28,6 +28,8 @@ 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; @@ -106,6 +108,12 @@ const BlockDraggableWrapper = ( { children } ) => { draggingScrollHandler( event ); }; + const { + onBlockDragOver, + onBlockDragEnd, + targetBlockIndex, + } = useBlockDropZone(); + // Stop dragging blocks if the block draggable is unmounted. useEffect( () => { return () => { @@ -181,6 +189,8 @@ const BlockDraggableWrapper = ( { children } ) => { chip.x.value = dragPosition.x; chip.y.value = dragPosition.y; + runOnJS( onBlockDragOver )( { x, y: y + scroll.offsetY.value } ); + // Update scrolling velocity scrollOnDragOver( dragPosition.y ); }; @@ -190,6 +200,7 @@ const BlockDraggableWrapper = ( { children } ) => { isDragging.value = false; chip.scale.value = withTiming( 0 ); + runOnJS( onBlockDragEnd )(); runOnJS( stopDraggingBlocks )(); stopScrolling(); }; @@ -216,6 +227,12 @@ const BlockDraggableWrapper = ( { children } ) => { return ( <> + + { + 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` and `onBlockDragEnd`. + */ +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 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; + }, + targetBlockIndex, + }; +} diff --git a/packages/compose/src/index.native.js b/packages/compose/src/index.native.js index d5f6e0ea42434..f3266678b6312 100644 --- a/packages/compose/src/index.native.js +++ b/packages/compose/src/index.native.js @@ -29,4 +29,5 @@ export { default as usePreferredColorScheme } from './hooks/use-preferred-color- export { default as usePreferredColorSchemeStyle } from './hooks/use-preferred-color-scheme-style'; export { default as useResizeObserver } from './hooks/use-resize-observer'; export { default as useDebounce } from './hooks/use-debounce'; +export { default as useThrottle } from './hooks/use-throttle'; export { default as useMergeRefs } from './hooks/use-merge-refs'; From a908eceeab73ab15c8d2187ab5d34311b3c651d9 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Thu, 7 Apr 2022 16:51:58 +0200 Subject: [PATCH 07/28] [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 --- .../block-draggable/index.native.js | 27 +++- .../use-block-drop-zone/index.native.js | 14 ++- .../use-on-block-drop/index.native.js | 119 ++++++++++++++++++ 3 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 packages/block-editor/src/components/use-on-block-drop/index.native.js diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index 2345294e9ca24..2fad1e593d6bb 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -18,7 +18,7 @@ import Animated, { */ import { Draggable } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; -import { useEffect } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; /** @@ -63,6 +63,8 @@ const COLLAPSE_OPACITY_ANIMATION_CONFIG = { duration: 150 }; * @return {Function} Render function that passes `onScroll` event handler. */ const BlockDraggableWrapper = ( { children } ) => { + const currentClientId = useRef(); + const wrapperStyles = usePreferredColorSchemeStyle( styles[ 'draggable-wrapper__container' ], styles[ 'draggable-wrapper__container--dark' ] @@ -111,6 +113,7 @@ const BlockDraggableWrapper = ( { children } ) => { const { onBlockDragOver, onBlockDragEnd, + onBlockDrop, targetBlockIndex, } = useBlockDropZone(); @@ -123,13 +126,14 @@ const BlockDraggableWrapper = ( { children } ) => { }; }, [] ); - const setupDraggingBlock = ( position ) => { + const onStartDragging = ( position ) => { const blockLayout = findBlockLayoutByPosition( blocksLayouts.current, { x: position.x, y: position.y + scroll.offsetY.value, } ); const foundClientId = blockLayout?.clientId; + currentClientId.current = foundClientId; if ( foundClientId ) { startDraggingBlocks( [ foundClientId ] ); @@ -156,6 +160,19 @@ const BlockDraggableWrapper = ( { children } ) => { } }; + const onStopDragging = () => { + if ( currentClientId.current ) { + onBlockDrop( { + // Dropping is only allowed at root level + srcRootClientId: '', + srcClientIds: [ currentClientId.current ], + type: 'block', + } ); + } + onBlockDragEnd(); + stopDraggingBlocks(); + }; + // This hook is used for animating the scroll via a shared value. useAnimatedReaction( () => scrollAnimation.value, @@ -180,7 +197,7 @@ const BlockDraggableWrapper = ( { children } ) => { isDragging.value = true; chip.scale.value = withTiming( 1 ); - runOnJS( setupDraggingBlock )( dragPosition ); + runOnJS( onStartDragging )( dragPosition ); }; const updateDragging = ( { x, y } ) => { @@ -200,9 +217,8 @@ const BlockDraggableWrapper = ( { children } ) => { isDragging.value = false; chip.scale.value = withTiming( 0 ); - runOnJS( onBlockDragEnd )(); - runOnJS( stopDraggingBlocks )(); stopScrolling(); + runOnJS( onStopDragging )(); }; const chipDynamicStyles = useAnimatedStyle( () => { @@ -350,6 +366,7 @@ const BlockDraggable = ( { clientId, children } ) => { const blockStyles = useAnimatedStyle( () => { return { + display: collapseAnimation.opacity.value !== 0 ? 'none' : 'flex', opacity: 1 - collapseAnimation.opacity.value, }; } ); diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.native.js b/packages/block-editor/src/components/use-block-drop-zone/index.native.js index eef79644d22e1..e776144aa57a6 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.native.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.native.js @@ -16,6 +16,7 @@ import { useThrottle } from '@wordpress/compose'; import { store as blockEditorStore } from '../../store'; import { useBlockListContext } from '../block-list/block-list-context'; import { getDistanceToNearestEdge } from '../../utils/math'; +import useOnBlockDrop from '../use-on-block-drop'; /** @typedef {import('../../utils/math').WPPoint} WPPoint */ @@ -100,7 +101,7 @@ export function getNearestBlockIndex( * @param {WPBlockDropZoneConfig} dropZoneConfig configuration data for the drop zone. * * @return {Object} An object that contains `targetBlockIndex` and the event - * handlers `onBlockDragOver` and `onBlockDragEnd`. + * handlers `onBlockDragOver`, `onBlockDragEnd` and `onBlockDrop`. */ export default function useBlockDropZone( { // An undefined value represents a top-level block. Default to an empty @@ -123,6 +124,8 @@ export default function useBlockDropZone( { const isRTL = getSettings().isRTL; + const onBlockDrop = useOnBlockDrop(); + const throttled = useThrottle( useCallback( ( event ) => { @@ -156,6 +159,15 @@ export default function useBlockDropZone( { 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 + ); +} From 22ff474f18e370c66ad25f711c239bd207c53d71 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Fri, 8 Apr 2022 10:07:27 +0200 Subject: [PATCH 08/28] [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 --- .../block-draggable/index.native.js | 151 ++++-------------- .../block-draggable/style.native.scss | 15 -- 2 files changed, 31 insertions(+), 135 deletions(-) diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index 2fad1e593d6bb..926f102bd562a 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -7,10 +7,8 @@ import Animated, { useAnimatedRef, useAnimatedStyle, useSharedValue, + withDelay, withTiming, - scrollTo, - useAnimatedReaction, - Easing, } from 'react-native-reanimated'; /** @@ -33,18 +31,8 @@ import useBlockDropZone from '../use-block-drop-zone'; import styles from './style.scss'; const CHIP_OFFSET_TO_TOUCH_POSITION = 32; -const BLOCK_COLLAPSED_HEIGHT = 20; -const EXTRA_OFFSET_WHEN_CLOSE_TO_TOP_EDGE = 80; -const SCROLL_ANIMATION_DURATION = 350; -const COLLAPSE_HEIGHT_ANIMATION_CONFIG = { - duration: 350, - easing: Easing.out( Easing.exp ), -}; -const EXPAND_HEIGHT_ANIMATION_CONFIG = { - duration: 350, - easing: Easing.in( Easing.exp ), -}; -const COLLAPSE_OPACITY_ANIMATION_CONFIG = { duration: 150 }; +const BLOCK_OPACITY_ANIMATION_CONFIG = { duration: 350 }; +const BLOCK_OPACITY_ANIMATION_DELAY = 250; /** * Block draggable wrapper component @@ -63,7 +51,7 @@ const COLLAPSE_OPACITY_ANIMATION_CONFIG = { duration: 150 }; * @return {Function} Render function that passes `onScroll` event handler. */ const BlockDraggableWrapper = ( { children } ) => { - const currentClientId = useRef(); + const currentBlockLayout = useRef(); const wrapperStyles = usePreferredColorSchemeStyle( styles[ 'draggable-wrapper__container' ], @@ -93,7 +81,6 @@ const BlockDraggableWrapper = ( { children } ) => { scale: useSharedValue( 0 ), }; const isDragging = useSharedValue( false ); - const scrollAnimation = useSharedValue( 0 ); const [ startScrolling, @@ -133,27 +120,10 @@ const BlockDraggableWrapper = ( { children } ) => { } ); const foundClientId = blockLayout?.clientId; - currentClientId.current = foundClientId; + currentBlockLayout.current = blockLayout; if ( foundClientId ) { startDraggingBlocks( [ foundClientId ] ); - - const isBlockOutOfScrollView = blockLayout.y < scroll.offsetY.value; - // If the dragging block is out of the scroll view, we have to - // scroll the block list to show the origin position of the block. - if ( isBlockOutOfScrollView ) { - scrollAnimation.value = scroll.offsetY.value; - const scrollOffsetTarget = Math.max( - 0, - blockLayout.y - EXTRA_OFFSET_WHEN_CLOSE_TO_TOP_EDGE - ); - scrollAnimation.value = withTiming( - scrollOffsetTarget, - { duration: SCROLL_ANIMATION_DURATION }, - () => startScrolling( position.y ) - ); - } else { - runOnUI( startScrolling )( position.y ); - } + runOnUI( startScrolling )( position.y ); } else { // We stop dragging if no block is found. runOnUI( stopDragging )(); @@ -161,11 +131,12 @@ const BlockDraggableWrapper = ( { children } ) => { }; const onStopDragging = () => { - if ( currentClientId.current ) { + const currentClientId = currentBlockLayout.current?.clientId; + if ( currentClientId ) { onBlockDrop( { // Dropping is only allowed at root level srcRootClientId: '', - srcClientIds: [ currentClientId.current ], + srcClientIds: [ currentClientId ], type: 'block', } ); } @@ -173,16 +144,6 @@ const BlockDraggableWrapper = ( { children } ) => { stopDraggingBlocks(); }; - // This hook is used for animating the scroll via a shared value. - useAnimatedReaction( - () => scrollAnimation.value, - ( value ) => { - if ( isDragging.value ) { - scrollTo( animatedScrollRef, 0, value, false ); - } - } - ); - const onChipLayout = ( { nativeEvent: { layout } } ) => { chip.width.value = layout.width; chip.height.value = layout.height; @@ -281,50 +242,26 @@ const BlockDraggableWrapper = ( { children } ) => { * @return {Function} Render function which includes the parameter `isDraggable` to determine if the block can be dragged. */ const BlockDraggable = ( { clientId, children } ) => { - const { blocksLayouts, findBlockLayoutByClientId } = useBlockListContext(); + const { selectBlock } = useDispatch( blockEditorStore ); + const wasBeingDragged = useRef( false ); - const collapseAnimation = { - opacity: useSharedValue( 0 ), - height: useSharedValue( 0 ), - initialHeight: useSharedValue( 0 ), + const draggingAnimation = { + opacity: useSharedValue( 1 ), }; - const startBlockDragging = () => { - const blockLayout = findBlockLayoutByClientId( - blocksLayouts.current, - clientId + const startDraggingBlock = () => { + draggingAnimation.opacity.value = withTiming( + 0.4, + BLOCK_OPACITY_ANIMATION_CONFIG ); - if ( blockLayout?.height > 0 ) { - collapseAnimation.initialHeight.value = blockLayout.height; - collapseAnimation.height.value = blockLayout.height; - collapseAnimation.opacity.value = withTiming( - 1, - COLLAPSE_OPACITY_ANIMATION_CONFIG, - ( completed ) => { - if ( completed ) { - collapseAnimation.height.value = withTiming( - BLOCK_COLLAPSED_HEIGHT, - COLLAPSE_HEIGHT_ANIMATION_CONFIG - ); - } - } - ); - } }; - const stopBlockDragging = () => { - collapseAnimation.height.value = withTiming( - collapseAnimation.initialHeight.value, - EXPAND_HEIGHT_ANIMATION_CONFIG, - ( completed ) => { - if ( completed ) { - collapseAnimation.opacity.value = withTiming( - 0, - COLLAPSE_OPACITY_ANIMATION_CONFIG - ); - } - } + const stopDraggingBlock = () => { + draggingAnimation.opacity.value = withDelay( + BLOCK_OPACITY_ANIMATION_DELAY, + withTiming( 1, BLOCK_OPACITY_ANIMATION_CONFIG ) ); + runOnJS( selectBlock )( clientId ); }; const { isDraggable, isBeingDragged } = useSelect( @@ -349,53 +286,27 @@ const BlockDraggable = ( { clientId, children } ) => { useEffect( () => { if ( isBeingDragged ) { - startBlockDragging(); - } else { - stopBlockDragging(); + startDraggingBlock(); + wasBeingDragged.current = true; + } else if ( wasBeingDragged.current ) { + stopDraggingBlock(); + wasBeingDragged.current = false; } }, [ isBeingDragged ] ); - const containerStyles = useAnimatedStyle( () => { - const canAnimateHeight = - collapseAnimation.height.value !== 0 && - collapseAnimation.opacity.value !== 0; - return { - height: canAnimateHeight ? collapseAnimation.height.value : 'auto', - }; - } ); - - const blockStyles = useAnimatedStyle( () => { - return { - display: collapseAnimation.opacity.value !== 0 ? 'none' : 'flex', - opacity: 1 - collapseAnimation.opacity.value, - }; - } ); - - const placeholderDynamicStyles = useAnimatedStyle( () => { + const wrapperStyles = useAnimatedStyle( () => { return { - display: collapseAnimation.opacity.value === 0 ? 'none' : 'flex', - opacity: collapseAnimation.opacity.value, + opacity: draggingAnimation.opacity.value, }; } ); - const placeholderStaticStyles = usePreferredColorSchemeStyle( - styles[ 'draggable-placeholder__container' ], - styles[ 'draggable-placeholder__container--dark' ] - ); - const placeholderStyles = [ - placeholderStaticStyles, - placeholderDynamicStyles, - ]; if ( ! isDraggable ) { return children( { isDraggable: false } ); } return ( - - - { children( { isDraggable: true } ) } - - + + { children( { isDraggable: true } ) } ); }; diff --git a/packages/block-editor/src/components/block-draggable/style.native.scss b/packages/block-editor/src/components/block-draggable/style.native.scss index b8133c966e9e4..ad93c9ea17b06 100644 --- a/packages/block-editor/src/components/block-draggable/style.native.scss +++ b/packages/block-editor/src/components/block-draggable/style.native.scss @@ -17,18 +17,3 @@ .draggable-chip__container--dark { background-color: $app-background-dark-alt; } - -.draggable-placeholder__container { - position: absolute; - top: 0; - left: $solid-border-space; - right: $solid-border-space; - bottom: 0; - z-index: 10; - background-color: $gray-lighten-30; - border-radius: 8px; -} - -.draggable-placeholder__container--dark { - background-color: $gray-darken-30; -} From 2ff70a4171bf951b306fae0896b854397ea2a934 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 19 Apr 2022 11:21:47 +0200 Subject: [PATCH 09/28] [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 --- .../test/__snapshots__/index.native.js.snap | 6 +++ .../components/src/button/index.native.js | 51 ++++++++++++------- .../components/src/draggable/index.native.js | 49 ++++++++++-------- .../ReactNativeAztec/ReactAztecText.java | 11 ++++ 4 files changed, 77 insertions(+), 40 deletions(-) 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 [ } > { + if ( nativeEvent.state === State.ACTIVE && onLongPress ) { + onLongPress(); + } + }, + [ onLongPress ] + ); + const element = ( - - - { newIcon } - { newChildren } - { subscript && ( - - { subscript } - - ) } + + + + { newIcon } + { newChildren } + { subscript && ( + + { subscript } + + ) } + - + ); diff --git a/packages/components/src/draggable/index.native.js b/packages/components/src/draggable/index.native.js index 35c5ad8c99095..bc300d7e3a3c2 100644 --- a/packages/components/src/draggable/index.native.js +++ b/packages/components/src/draggable/index.native.js @@ -2,7 +2,11 @@ * External dependencies */ import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { useSharedValue, runOnJS } from 'react-native-reanimated'; +import Animated, { + useSharedValue, + runOnJS, + runOnUI, +} from 'react-native-reanimated'; import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; /** @@ -27,7 +31,7 @@ import { Platform } from '@wordpress/element'; export default function Draggable( { children, maxDistance = 1000, - minDuration = 500, + minDuration = 450, onDragEnd, onDragOver, onDragStart, @@ -37,37 +41,38 @@ export default function Draggable( { const isAnyTextInputFocused = useSharedValue( false ); const checkTextInputFocus = () => { - isAnyTextInputFocused.value = + const isTextInputFocused = TextInputState.currentlyFocusedInput() !== null; + isAnyTextInputFocused.value = isTextInputFocused; + return isTextInputFocused; + }; + + const dragStart = ( event ) => { + const isTextInputFocused = checkTextInputFocus(); + + if ( ! isTextInputFocused && onDragStart ) { + runOnUI( onDragStart )( event ); + } + }; + + const dragEnd = () => { + const isTextInputFocused = checkTextInputFocus(); + + if ( ! isTextInputFocused && onDragEnd ) { + runOnUI( onDragEnd )(); + } }; const longPressGesture = Gesture.LongPress() - .onBegin( () => { - 'worklet'; - runOnJS( checkTextInputFocus )(); - } ) .onStart( ( ev ) => { 'worklet'; - if ( isAnyTextInputFocused.value ) { - return; - } - isDragging.value = true; - - if ( onDragStart ) { - onDragStart( ev ); - } + runOnJS( dragStart )( ev ); } ) .onEnd( () => { 'worklet'; - if ( isAnyTextInputFocused.value ) { - return; - } - isDragging.value = false; - if ( onDragEnd ) { - onDragEnd(); - } + runOnJS( dragEnd )(); } ) .maxDistance( maxDistance ) .minDuration( minDuration ) diff --git a/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java index 13340484b0ab9..71df8e0c2888a 100644 --- a/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java +++ b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java @@ -18,6 +18,7 @@ import android.text.Spannable; import android.text.TextUtils; import android.text.TextWatcher; +import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.TextView; @@ -143,6 +144,16 @@ public void onSelectionChanged(int selStart, int selEnd) { } }); this.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + + setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + // The long click event can make text input gain focus, which conflicts with the drag and drop gesture. + // In order to prevent this, we consume this event unless it's already focused. + return !v.isFocused(); + } + } + ); } private void forceCaretAtStartOnTakeFocus() { From 3996c65566ad81326aec6b6b95944bbcf4850096 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 20 Apr 2022 15:55:50 +0200 Subject: [PATCH 10/28] [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 --- .../block-draggable/index.native.js | 112 ++++++---- .../block-list/block-list-item-cell.native.js | 9 +- .../src/components/block-list/block.native.js | 26 ++- .../components/block-mover/index.native.js | 28 ++- .../components/src/draggable/index.native.js | 209 ++++++++++++------ .../src/draggable/style.native.scss | 3 + packages/components/src/index.native.js | 2 +- 7 files changed, 250 insertions(+), 139 deletions(-) create mode 100644 packages/components/src/draggable/style.native.scss diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index 926f102bd562a..d0d4bbbf49eb9 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -10,14 +10,14 @@ import Animated, { withDelay, withTiming, } from 'react-native-reanimated'; +import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; /** * WordPress dependencies */ -import { Draggable } from '@wordpress/components'; +import { Draggable, DraggableTrigger } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; -import { useEffect, useRef } from '@wordpress/element'; -import { usePreferredColorSchemeStyle } from '@wordpress/compose'; +import { useEffect, useRef, useState, Platform } from '@wordpress/element'; /** * Internal dependencies @@ -33,6 +33,9 @@ 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 @@ -51,22 +54,15 @@ const BLOCK_OPACITY_ANIMATION_DELAY = 250; * @return {Function} Render function that passes `onScroll` event handler. */ const BlockDraggableWrapper = ( { children } ) => { - const currentBlockLayout = useRef(); - - const wrapperStyles = usePreferredColorSchemeStyle( - styles[ 'draggable-wrapper__container' ], - styles[ 'draggable-wrapper__container--dark' ] - ); - - const { startDraggingBlocks, stopDraggingBlocks } = useDispatch( - blockEditorStore - ); + const [ currentClientId, setCurrentClientId ] = useState(); const { - blocksLayouts, - scrollRef, - findBlockLayoutByPosition, - } = useBlockListContext(); + selectBlock, + startDraggingBlocks, + stopDraggingBlocks, + } = useDispatch( blockEditorStore ); + + const { scrollRef } = useBlockListContext(); const animatedScrollRef = useAnimatedRef(); animatedScrollRef( scrollRef ); @@ -113,16 +109,10 @@ const BlockDraggableWrapper = ( { children } ) => { }; }, [] ); - const onStartDragging = ( position ) => { - const blockLayout = findBlockLayoutByPosition( blocksLayouts.current, { - x: position.x, - y: position.y + scroll.offsetY.value, - } ); - - const foundClientId = blockLayout?.clientId; - currentBlockLayout.current = blockLayout; - if ( foundClientId ) { - startDraggingBlocks( [ foundClientId ] ); + const onStartDragging = ( { clientId, position } ) => { + if ( clientId ) { + startDraggingBlocks( [ clientId ] ); + setCurrentClientId( clientId ); runOnUI( startScrolling )( position.y ); } else { // We stop dragging if no block is found. @@ -131,7 +121,6 @@ const BlockDraggableWrapper = ( { children } ) => { }; const onStopDragging = () => { - const currentClientId = currentBlockLayout.current?.clientId; if ( currentClientId ) { onBlockDrop( { // Dropping is only allowed at root level @@ -139,6 +128,8 @@ const BlockDraggableWrapper = ( { children } ) => { srcClientIds: [ currentClientId ], type: 'block', } ); + selectBlock( currentClientId ); + setCurrentClientId( undefined ); } onBlockDragEnd(); stopDraggingBlocks(); @@ -149,7 +140,7 @@ const BlockDraggableWrapper = ( { children } ) => { chip.height.value = layout.height; }; - const startDragging = ( { x, y } ) => { + const startDragging = ( { x, y, id } ) => { 'worklet'; const dragPosition = { x, y }; chip.x.value = dragPosition.x; @@ -158,7 +149,7 @@ const BlockDraggableWrapper = ( { children } ) => { isDragging.value = true; chip.scale.value = withTiming( 1 ); - runOnJS( onStartDragging )( dragPosition ); + runOnJS( onStartDragging )( { clientId: id, position: dragPosition } ); }; const updateDragging = ( { x, y } ) => { @@ -209,12 +200,10 @@ const BlockDraggableWrapper = ( { children } ) => { isDragging={ isDragging } targetBlockIndex={ targetBlockIndex } /> - { children( { onScroll: scrollHandler } ) } @@ -235,14 +224,14 @@ const BlockDraggableWrapper = ( { children } ) => { * 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 {Object} props Component props. + * @param {JSX.Element} props.children Children to be rendered. + * @param {string[]} props.clientId Client id of the block. + * @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 } ) => { - const { selectBlock } = useDispatch( blockEditorStore ); +const BlockDraggable = ( { clientId, children, enabled = true } ) => { const wasBeingDragged = useRef( false ); const draggingAnimation = { @@ -261,53 +250,78 @@ const BlockDraggable = ( { clientId, children } ) => { BLOCK_OPACITY_ANIMATION_DELAY, withTiming( 1, BLOCK_OPACITY_ANIMATION_CONFIG ) ); - runOnJS( selectBlock )( clientId ); }; - const { isDraggable, isBeingDragged } = useSelect( + const { isDraggable, isBeingDragged, canDragBlock } = useSelect( ( select ) => { const { getBlockRootClientId, getTemplateLock, isBlockBeingDragged, + hasSelectedBlock, } = select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); const templateLock = rootClientId ? getTemplateLock( rootClientId ) : null; + const isAnyTextInputFocused = + TextInputState.currentlyFocusedInput() !== null; return { isBeingDragged: isBlockBeingDragged( clientId ), isDraggable: 'all' !== templateLock, + canDragBlock: hasSelectedBlock() + ? ! isAnyTextInputFocused + : true, }; }, [ clientId ] ); useEffect( () => { - if ( isBeingDragged ) { - startDraggingBlock(); - wasBeingDragged.current = true; - } else if ( wasBeingDragged.current ) { - stopDraggingBlock(); - wasBeingDragged.current = false; + if ( isBeingDragged !== wasBeingDragged.current ) { + if ( isBeingDragged ) { + startDraggingBlock(); + } else { + stopDraggingBlock(); + } } + wasBeingDragged.current = isBeingDragged; }, [ isBeingDragged ] ); - const wrapperStyles = useAnimatedStyle( () => { + const animatedWrapperStyles = useAnimatedStyle( () => { return { opacity: draggingAnimation.opacity.value, }; } ); + const wrapperStyles = [ + animatedWrapperStyles, + styles[ 'draggable-wrapper__container' ], + ]; if ( ! isDraggable ) { return children( { isDraggable: false } ); } return ( - - { children( { isDraggable: true } ) } - + + + { children( { isDraggable: true } ) } + + ); }; 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 index 5577b0705d686..c399643a63399 100644 --- 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 @@ -12,7 +12,6 @@ import { useEffect, useCallback } from '@wordpress/element'; * Internal dependencies */ import { useBlockListContext } from './block-list-context'; -import BlockDraggable from '../block-draggable'; function BlockListItemCell( { children, clientId, rootClientId } ) { const { blocksLayouts, updateBlocksLayouts } = useBlockListContext(); @@ -37,13 +36,7 @@ function BlockListItemCell( { children, clientId, rootClientId } ) { [ clientId, rootClientId, updateBlocksLayouts ] ); - return ( - - - { () => children } - - - ); + 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..3f02a04538855 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,7 @@ class BlockListBlock extends Component { marginHorizontal, isInnerBlockSelected, name, + rootClientId, } = this.props; if ( ! attributes || ! blockType ) { @@ -207,6 +209,7 @@ class BlockListBlock extends Component { const isScreenWidthEqual = blockWidth === screenWidth; const isScreenWidthWider = blockWidth < screenWidth; const isFullWidthToolbar = isFullWidth( align ) || isScreenWidthEqual; + const hasParent = !! rootClientId; return ( ) } - { isValid ? ( - this.getBlockForType() - ) : ( - - ) } + + { () => + isValid ? ( + this.getBlockForType() + ) : ( + + ) + } + { 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/components/src/draggable/index.native.js b/packages/components/src/draggable/index.native.js index bc300d7e3a3c2..1b8ecb05a26ec 100644 --- a/packages/components/src/draggable/index.native.js +++ b/packages/components/src/draggable/index.native.js @@ -1,108 +1,183 @@ /** * External dependencies */ -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { + Gesture, + GestureDetector, + LongPressGestureHandler, +} from 'react-native-gesture-handler'; import Animated, { useSharedValue, runOnJS, - runOnUI, + useAnimatedReaction, + useAnimatedGestureHandler, } from 'react-native-reanimated'; -import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; /** * WordPress dependencies */ -import { Platform } from '@wordpress/element'; +import { createContext, useContext, useRef, useMemo } from '@wordpress/element'; /** - * Draggable component + * Internal dependencies + */ +import styles from './style.scss'; + +const Context = createContext( {} ); +const { Provider } = Context; + +/** + * Draggable component. * - * @param {Object} props Component props. - * @param {JSX.Element} props.children Children to be rendered. - * @param {number} [props.maxDistance] Maximum distance, that defines how far the finger is allowed to travel during a long press gesture. - * @param {number} [props.minDuration] Minimum time, that a finger must remain pressed on the corresponding view. - * @param {Function} [props.onDragEnd] Callback when dragging ends. - * @param {Function} [props.onDragOver] Callback when dragging happens over an element. - * @param {Function} [props.onDragStart] Callback when dragging starts. - * @param {import('react-native-reanimated').StyleProp} [props.wrapperAnimatedStyles] Animated styles for the wrapper component. + * @param {Object} props Component props. + * @param {JSX.Element} props.children Children to be rendered. + * @param {Function} [props.onDragEnd] Callback when dragging ends. + * @param {Function} [props.onDragOver] Callback when dragging happens over an element. + * @param {Function} [props.onDragStart] Callback when dragging starts. * * @return {JSX.Element} The component to be rendered. */ -export default function Draggable( { - children, - maxDistance = 1000, - minDuration = 450, - onDragEnd, - onDragOver, - onDragStart, - wrapperAnimatedStyles, -} ) { +const Draggable = ( { children, onDragEnd, onDragOver, onDragStart } ) => { const isDragging = useSharedValue( false ); - const isAnyTextInputFocused = useSharedValue( false ); + const isPanActive = useSharedValue( false ); + const draggingId = useSharedValue( '' ); + const panGestureRef = useRef(); - const checkTextInputFocus = () => { - const isTextInputFocused = - TextInputState.currentlyFocusedInput() !== null; - isAnyTextInputFocused.value = isTextInputFocused; - return isTextInputFocused; + const initialPosition = { + x: useSharedValue( 0 ), + y: useSharedValue( 0 ), }; - - const dragStart = ( event ) => { - const isTextInputFocused = checkTextInputFocus(); - - if ( ! isTextInputFocused && onDragStart ) { - runOnUI( onDragStart )( event ); - } + const lastPosition = { + x: useSharedValue( 0 ), + y: useSharedValue( 0 ), }; - const dragEnd = () => { - const isTextInputFocused = checkTextInputFocus(); + useAnimatedReaction( + () => isDragging.value, + ( result, previous ) => { + if ( result === previous || previous === null ) { + return; + } - if ( ! isTextInputFocused && onDragEnd ) { - runOnUI( onDragEnd )(); + if ( result ) { + if ( onDragStart ) { + onDragStart( { + x: initialPosition.x.value, + y: initialPosition.y.value, + id: draggingId.value, + } ); + } + } else if ( onDragEnd ) { + onDragEnd( { + x: lastPosition.x.value, + y: lastPosition.y.value, + id: draggingId.value, + } ); + } } - }; - - const longPressGesture = Gesture.LongPress() - .onStart( ( ev ) => { - 'worklet'; - isDragging.value = true; - runOnJS( dragStart )( ev ); - } ) - .onEnd( () => { - 'worklet'; - isDragging.value = false; - runOnJS( dragEnd )(); - } ) - .maxDistance( maxDistance ) - .minDuration( minDuration ) - .shouldCancelWhenOutside( false ); + ); const panGesture = Gesture.Pan() .manualActivation( true ) + .onTouchesDown( ( event ) => { + const { x = 0, y = 0 } = event.allTouches[ 0 ]; + initialPosition.x.value = x; + initialPosition.y.value = y; + } ) .onTouchesMove( ( _, state ) => { - 'worklet'; - if ( isDragging.value ) { + if ( ! isPanActive.value && isDragging.value ) { + isPanActive.value = true; state.activate(); - } else if ( Platform.isIOS || isAnyTextInputFocused.value ) { - state.fail(); } } ) - .onUpdate( ( ev ) => { - 'worklet'; + .onUpdate( ( event ) => { + lastPosition.x.value = event.x; + lastPosition.y.value = event.y; + if ( onDragOver ) { - onDragOver( ev ); + onDragOver( event ); } } ) + .onEnd( () => { + isPanActive.value = false; + isDragging.value = false; + } ) + .withRef( panGestureRef ) .shouldCancelWhenOutside( false ); - const dragHandler = Gesture.Simultaneous( panGesture, longPressGesture ); + const providerValue = useMemo( () => { + return { panGestureRef, isDragging, draggingId }; + }, [] ); return ( - - - { children } + + + { children } ); -} +}; + +/** + * Draggable trigger component. + * + * This component acts as the trigger for the dragging functionality. + * + * @param {Object} props Component props. + * @param {JSX.Element} props.children Children to be rendered. + * @param {*} props.id Identifier passed within the event callbacks. + * @param {boolean} [props.enabled] Enables the long-press gesture. + * @param {number} [props.maxDistance] Maximum distance, that defines how far the finger is allowed to travel during a long press gesture. + * @param {number} [props.minDuration] Minimum time, that a finger must remain pressed on the corresponding view. + * @param {Function} [props.onLongPress] Callback when long-press gesture is triggered over an element. + * @param {Function} [props.onLongPressEnd] Callback when long-press gesture ends. + * + * @return {JSX.Element} The component to be rendered. + */ +const DraggableTrigger = ( { + children, + enabled = true, + id, + maxDistance = 1000, + minDuration = 500, + onLongPress, + onLongPressEnd, +} ) => { + const { panGestureRef, isDragging, draggingId } = useContext( Context ); + + const gestureHandler = useAnimatedGestureHandler( { + onActive: () => { + if ( isDragging.value ) { + return; + } + + isDragging.value = true; + draggingId.value = id; + if ( onLongPress ) { + runOnJS( onLongPress )( id ); + } + }, + onEnd: () => { + isDragging.value = false; + if ( onLongPressEnd ) { + runOnJS( onLongPressEnd )( id ); + } + }, + } ); + + return ( + + { children } + + ); +}; + +export { DraggableTrigger }; +export default Draggable; diff --git a/packages/components/src/draggable/style.native.scss b/packages/components/src/draggable/style.native.scss new file mode 100644 index 0000000000000..a3165cbe080ca --- /dev/null +++ b/packages/components/src/draggable/style.native.scss @@ -0,0 +1,3 @@ +.draggable__container { + flex: 1; +} diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index 108fb95b094be..140ad940ace6e 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -61,7 +61,7 @@ export { filterUnitsWithSettings as filterUnitsWithSettings, } from './unit-control/utils'; export { default as Disabled } from './disabled'; -export { default as Draggable } from './draggable'; +export { default as Draggable, DraggableTrigger } from './draggable'; // Higher-Order Components. export { default as withConstrainedTabbing } from './higher-order/with-constrained-tabbing'; From 48da04590b305a24c5049a2c6d36bc8c5621a07c Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 20 Apr 2022 17:06:49 +0200 Subject: [PATCH 11/28] [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 --- .../block-draggable/draggable-chip.native.js | 21 ++----- .../block-draggable/index.native.js | 57 ++++++++++++------- 2 files changed, 41 insertions(+), 37 deletions(-) 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 index 2559b2089252b..682c4f5b2cd49 100644 --- a/packages/block-editor/src/components/block-draggable/draggable-chip.native.js +++ b/packages/block-editor/src/components/block-draggable/draggable-chip.native.js @@ -7,8 +7,6 @@ import { View } from 'react-native'; * WordPress dependencies */ import { dragHandle } from '@wordpress/icons'; -import { useSelect } from '@wordpress/data'; -import { getBlockType } from '@wordpress/blocks'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; /** @@ -16,7 +14,6 @@ import { usePreferredColorSchemeStyle } from '@wordpress/compose'; */ import BlockIcon from '../block-icon'; import styles from './style.scss'; -import { store as blockEditorStore } from '../../store'; const shadowStyle = { shadowColor: '#000', @@ -33,30 +30,20 @@ const shadowStyle = { /** * Block draggable chip component * + * @param {Object} props Component props. + * @param {Object} [props.icon] Block icon. * @return {JSX.Element} Chip component. */ -export default function BlockDraggableChip() { +export default function BlockDraggableChip( { icon } ) { const containerStyle = usePreferredColorSchemeStyle( styles[ 'draggable-chip__container' ], styles[ 'draggable-chip__container--dark' ] ); - const { blockIcon } = useSelect( ( select ) => { - const { getBlockName, getDraggedBlockClientIds } = select( - blockEditorStore - ); - const draggedBlockClientIds = getDraggedBlockClientIds(); - const blockName = getBlockName( draggedBlockClientIds[ 0 ] ); - - return { - blockIcon: getBlockType( blockName )?.icon, - }; - } ); - return ( - + { icon && } ); } diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index d0d4bbbf49eb9..7605025ef59ff 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -9,6 +9,8 @@ import Animated, { useSharedValue, withDelay, withTiming, + ZoomInEasyDown, + ZoomOutEasyDown, } from 'react-native-reanimated'; import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; @@ -16,8 +18,9 @@ import TextInputState from 'react-native/Libraries/Components/TextInput/TextInpu * WordPress dependencies */ import { Draggable, DraggableTrigger } from '@wordpress/components'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { select, useSelect, useDispatch } from '@wordpress/data'; import { useEffect, useRef, useState, Platform } from '@wordpress/element'; +import { getBlockType } from '@wordpress/blocks'; /** * Internal dependencies @@ -54,7 +57,7 @@ const DEFAULT_IOS_LONG_PRESS_MIN_DURATION = * @return {Function} Render function that passes `onScroll` event handler. */ const BlockDraggableWrapper = ( { children } ) => { - const [ currentClientId, setCurrentClientId ] = useState(); + const [ draggedBlockIcon, setDraggedBlockIcon ] = useState(); const { selectBlock, @@ -74,7 +77,6 @@ const BlockDraggableWrapper = ( { children } ) => { y: useSharedValue( 0 ), width: useSharedValue( 0 ), height: useSharedValue( 0 ), - scale: useSharedValue( 0 ), }; const isDragging = useSharedValue( false ); @@ -109,10 +111,18 @@ const BlockDraggableWrapper = ( { children } ) => { }; }, [] ); + 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 ] ); - setCurrentClientId( clientId ); + setDraggedBlockIconByClientId( clientId ); runOnUI( startScrolling )( position.y ); } else { // We stop dragging if no block is found. @@ -120,24 +130,28 @@ const BlockDraggableWrapper = ( { children } ) => { } }; - const onStopDragging = () => { - if ( currentClientId ) { + const onStopDragging = ( { clientId } ) => { + if ( clientId ) { onBlockDrop( { // Dropping is only allowed at root level srcRootClientId: '', - srcClientIds: [ currentClientId ], + srcClientIds: [ clientId ], type: 'block', } ); - selectBlock( currentClientId ); - setCurrentClientId( undefined ); + selectBlock( clientId ); + setDraggedBlockIcon( undefined ); } onBlockDragEnd(); stopDraggingBlocks(); }; const onChipLayout = ( { nativeEvent: { layout } } ) => { - chip.width.value = layout.width; - chip.height.value = layout.height; + if ( layout.width > 0 ) { + chip.width.value = layout.width; + } + if ( layout.height > 0 ) { + chip.height.value = layout.height; + } }; const startDragging = ( { x, y, id } ) => { @@ -148,7 +162,6 @@ const BlockDraggableWrapper = ( { children } ) => { isDragging.value = true; - chip.scale.value = withTiming( 1 ); runOnJS( onStartDragging )( { clientId: id, position: dragPosition } ); }; @@ -164,13 +177,12 @@ const BlockDraggableWrapper = ( { children } ) => { scrollOnDragOver( dragPosition.y ); }; - const stopDragging = () => { + const stopDragging = ( { id } ) => { 'worklet'; isDragging.value = false; - chip.scale.value = withTiming( 0 ); stopScrolling(); - runOnJS( onStopDragging )(); + runOnJS( onStopDragging )( { clientId: id } ); }; const chipDynamicStyles = useAnimatedStyle( () => { @@ -183,8 +195,6 @@ const BlockDraggableWrapper = ( { children } ) => { chip.height.value - CHIP_OFFSET_TO_TOUCH_POSITION, }, - { scaleX: chip.scale.value }, - { scaleY: chip.scale.value }, ], }; } ); @@ -212,7 +222,14 @@ const BlockDraggableWrapper = ( { children } ) => { style={ chipStyles } pointerEvents="none" > - + { draggedBlockIcon && ( + + + + ) } ); @@ -253,13 +270,13 @@ const BlockDraggable = ( { clientId, children, enabled = true } ) => { }; const { isDraggable, isBeingDragged, canDragBlock } = useSelect( - ( select ) => { + ( _select ) => { const { getBlockRootClientId, getTemplateLock, isBlockBeingDragged, hasSelectedBlock, - } = select( blockEditorStore ); + } = _select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); const templateLock = rootClientId ? getTemplateLock( rootClientId ) From 8547f80690ced6bbceb23355cd38bcd095dd75b8 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Fri, 22 Apr 2022 13:40:56 +0200 Subject: [PATCH 12/28] 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 --- .../dropping-insertion-point.native.js | 32 +++++++++++++++++-- .../block-draggable/index.native.js | 4 +++ 2 files changed, 33 insertions(+), 3 deletions(-) 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 index 6970493642394..db58b95877b12 100644 --- 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 @@ -28,6 +28,7 @@ import styles from './dropping-insertion-point.scss'; * * @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. * @@ -35,6 +36,7 @@ import styles from './dropping-insertion-point.scss'; */ export default function DroppingInsertionPoint( { scroll, + currentYPosition, isDragging, targetBlockIndex, } ) { @@ -61,6 +63,16 @@ export default function DroppingInsertionPoint( { } ); + 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(); @@ -95,9 +107,23 @@ export default function DroppingInsertionPoint( { ? findBlockLayoutByClientId( blocksLayouts.current, nextClientId ) : null; - const nextPosition = previousElement - ? previousElement.y + previousElement.height - : nextElement?.y; + const elementsPositions = { + top: Math.floor( + previousElement + ? previousElement.y + previousElement.height + : nextElement?.y + ), + bottom: Math.floor( + nextElement + ? nextElement.y + : previousElement.y + previousElement.height + ), + }; + + const nextPosition = + elementsPositions.top !== elementsPositions.bottom + ? getSelectedBlockIndicatorPosition( elementsPositions ) + : elementsPositions.top; if ( nextPosition && blockYPosition.value !== nextPosition ) { opacity.value = 0; diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index 7605025ef59ff..660f4bc1d9e7d 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -78,6 +78,7 @@ const BlockDraggableWrapper = ( { children } ) => { width: useSharedValue( 0 ), height: useSharedValue( 0 ), }; + const currentYPosition = useSharedValue( 0 ); const isDragging = useSharedValue( false ); const [ @@ -159,6 +160,7 @@ const BlockDraggableWrapper = ( { children } ) => { const dragPosition = { x, y }; chip.x.value = dragPosition.x; chip.y.value = dragPosition.y; + currentYPosition.value = dragPosition.y; isDragging.value = true; @@ -170,6 +172,7 @@ const BlockDraggableWrapper = ( { children } ) => { 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 } ); @@ -207,6 +210,7 @@ const BlockDraggableWrapper = ( { children } ) => { <> From a4f52e32dd3f2a38b6fa9930859c0d28380cf033 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Fri, 22 Apr 2022 15:20:07 +0200 Subject: [PATCH 13/28] [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 --- .../dropping-insertion-point.native.js | 8 ++++++-- .../block-draggable/index.native.js | 2 ++ .../src/main/AndroidManifest.xml | 3 +++ .../RNReactNativeGutenbergBridgeModule.java | 20 +++++++++++++++++++ packages/react-native-bridge/index.js | 7 +++++++ .../ios/RNReactNativeGutenbergBridge.m | 1 + .../ios/RNReactNativeGutenbergBridge.swift | 5 +++++ 7 files changed, 44 insertions(+), 2 deletions(-) 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 index db58b95877b12..d0c42f6bf1d00 100644 --- 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 @@ -13,6 +13,7 @@ import Animated, { * WordPress dependencies */ import { useSelect } from '@wordpress/data'; +import { generateHapticFeedback } from '@wordpress/react-native-bridge'; /** * Internal dependencies @@ -129,13 +130,16 @@ export default function DroppingInsertionPoint( { opacity.value = 0; blockYPosition.value = nextPosition; opacity.value = withTiming( 1 ); + generateHapticFeedback(); } } useAnimatedReaction( () => targetBlockIndex.value, - ( value ) => { - runOnJS( setIndicatorPosition )( value ); + ( value, previous ) => { + if ( value !== previous ) { + runOnJS( setIndicatorPosition )( value ); + } } ); diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index 660f4bc1d9e7d..b50122764e54e 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -21,6 +21,7 @@ import { Draggable, DraggableTrigger } from '@wordpress/components'; import { select, useSelect, useDispatch } from '@wordpress/data'; import { useEffect, useRef, useState, Platform } from '@wordpress/element'; import { getBlockType } from '@wordpress/blocks'; +import { generateHapticFeedback } from '@wordpress/react-native-bridge'; /** * Internal dependencies @@ -125,6 +126,7 @@ const BlockDraggableWrapper = ( { children } ) => { startDraggingBlocks( [ clientId ] ); setDraggedBlockIconByClientId( clientId ); runOnUI( startScrolling )( position.y ); + generateHapticFeedback(); } else { // We stop dragging if no block is found. runOnUI( stopDragging )(); diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml b/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml index 841683d44dc8b..c4d5efc749611 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/AndroidManifest.xml @@ -13,6 +13,9 @@ + + + = Build.VERSION_CODES.Q) { + int hapticFeedbackEnabled = Settings.System.getInt(mReactContext.getContentResolver(), Settings.System.HAPTIC_FEEDBACK_ENABLED, 0); + if (hapticFeedbackEnabled == 0) { + return; + } + VibrationEffect tickEffect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK); + Vibrator vibrator = (Vibrator) mReactContext.getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator != null) { + vibrator.vibrate(tickEffect); + } + } + } } diff --git a/packages/react-native-bridge/index.js b/packages/react-native-bridge/index.js index 052a75789096b..2acba1a7bbb9f 100644 --- a/packages/react-native-bridge/index.js +++ b/packages/react-native-bridge/index.js @@ -436,4 +436,11 @@ export function sendEventToHost( eventName, properties ) { ); } +/** + * Generate haptic feedback. + */ +export function generateHapticFeedback() { + RNReactNativeGutenbergBridge.generateHapticFeedback(); +} + export default RNReactNativeGutenbergBridge; diff --git a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m index bc1a66db4ac09..f062664d939b5 100644 --- a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m +++ b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m @@ -38,5 +38,6 @@ @interface RCT_EXTERN_MODULE(RNReactNativeGutenbergBridge, NSObject) RCT_EXTERN_METHOD(requestContactCustomerSupport) RCT_EXTERN_METHOD(requestGotoCustomerSupportOptions) RCT_EXTERN_METHOD(sendEventToHost:(NSString)eventName properties:(NSDictionary *)properties) +RCT_EXTERN_METHOD(generateHapticFeedback) @end diff --git a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift index 83f9dcd647293..162bdc3fec7d7 100644 --- a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift +++ b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift @@ -392,6 +392,11 @@ public class RNReactNativeGutenbergBridge: RCTEventEmitter { func sendEventToHost(_ eventName: String, properties: [AnyHashable: Any]) { self.delegate?.gutenbergDidRequestSendEventToHost(eventName, properties: properties) } + + @objc + func generateHapticFeedback() { + UISelectionFeedbackGenerator().selectionChanged() + } } // MARK: - RCTBridgeModule delegate From 1320c03e700a0e6a0e6cdc01d97987e4e35403ab Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Wed, 27 Apr 2022 13:23:12 +0200 Subject: [PATCH 14/28] [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 --- .../block-draggable/index.native.js | 49 ++++++-- packages/react-native-aztec/CHANGELOG.md | 1 + .../react-native-aztec/src/AztecInputState.js | 111 ++++++++++++++++++ packages/react-native-aztec/src/AztecView.js | 23 +++- .../src/test/AztecInputState.test.js | 101 ++++++++++++++++ .../@wordpress/react-native-aztec/index.js | 14 ++- 6 files changed, 282 insertions(+), 17 deletions(-) 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/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index b50122764e54e..f291a20edb2f1 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -12,16 +12,22 @@ import Animated, { ZoomInEasyDown, ZoomOutEasyDown, } from 'react-native-reanimated'; -import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; /** * WordPress dependencies */ import { Draggable, DraggableTrigger } from '@wordpress/components'; import { select, useSelect, useDispatch } from '@wordpress/data'; -import { useEffect, useRef, useState, Platform } from '@wordpress/element'; +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 @@ -256,6 +262,9 @@ const BlockDraggableWrapper = ( { children } ) => { */ const BlockDraggable = ( { clientId, children, enabled = true } ) => { const wasBeingDragged = useRef( false ); + const [ isEditingText, setIsEditingText ] = useState( + RCTAztecView.InputState.isFocused() + ); const draggingAnimation = { opacity: useSharedValue( 1 ), @@ -275,27 +284,28 @@ const BlockDraggable = ( { clientId, children, enabled = true } ) => { ); }; - const { isDraggable, isBeingDragged, canDragBlock } = useSelect( + const { isDraggable, isBeingDragged, isBlockSelected } = useSelect( ( _select ) => { const { getBlockRootClientId, getTemplateLock, isBlockBeingDragged, - hasSelectedBlock, + getSelectedBlockClientId, + hasSelectedInnerBlock, } = _select( blockEditorStore ); const rootClientId = getBlockRootClientId( clientId ); const templateLock = rootClientId ? getTemplateLock( rootClientId ) : null; - const isAnyTextInputFocused = - TextInputState.currentlyFocusedInput() !== null; + const selectedBlockClientId = getSelectedBlockClientId(); return { isBeingDragged: isBlockBeingDragged( clientId ), isDraggable: 'all' !== templateLock, - canDragBlock: hasSelectedBlock() - ? ! isAnyTextInputFocused - : true, + isBlockSelected: + selectedBlockClientId && + ( selectedBlockClientId === clientId || + hasSelectedInnerBlock( clientId, true ) ), }; }, [ clientId ] @@ -312,6 +322,24 @@ const BlockDraggable = ( { clientId, children, enabled = true } ) => { wasBeingDragged.current = isBeingDragged; }, [ isBeingDragged ] ); + const onFocusChangeAztec = useCallback( ( { isFocused } ) => { + setIsEditingText( isFocused ); + }, [] ); + + useEffect( () => { + RCTAztecView.InputState.addFocusChangeListener( onFocusChangeAztec ); + return () => { + RCTAztecView.InputState.removeFocusChangeListener( + onFocusChangeAztec + ); + }; + }, [] ); + + 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, @@ -322,6 +350,8 @@ const BlockDraggable = ( { clientId, children, enabled = true } ) => { styles[ 'draggable-wrapper__container' ], ]; + const canDragBlock = enabled && ( ! isBlockSelected || ! isEditingText ); + if ( ! isDraggable ) { return children( { isDraggable: false } ); } @@ -340,6 +370,7 @@ const BlockDraggable = ( { clientId, children, enabled = true } ) => { : DEFAULT_LONG_PRESS_MIN_DURATION, android: DEFAULT_LONG_PRESS_MIN_DURATION, } ) } + onLongPress={ onLongPressDraggable } > { children( { isDraggable: true } ) } diff --git a/packages/react-native-aztec/CHANGELOG.md b/packages/react-native-aztec/CHANGELOG.md index 5973442f6f736..5646165151470 100644 --- a/packages/react-native-aztec/CHANGELOG.md +++ b/packages/react-native-aztec/CHANGELOG.md @@ -11,6 +11,7 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [*] Bump Aztec-Android version to v1.5.1 [#36861] +- [*] Add text input state to Aztec view [#40480] ## 1.50.0 - [*] Block split/merge fix for a (never shipped) regression (Android only) [#29683] diff --git a/packages/react-native-aztec/src/AztecInputState.js b/packages/react-native-aztec/src/AztecInputState.js new file mode 100644 index 0000000000000..b0b184c2dd5c6 --- /dev/null +++ b/packages/react-native-aztec/src/AztecInputState.js @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; + +/** @typedef {import('@wordpress/element').RefObject} RefObject */ + +const focusChangeListeners = []; + +let currentFocusedElement = null; + +/** + * Adds a listener that will be called in the following cases: + * + * - An Aztec view is being focused and all were previously unfocused. + * - An Aztec view is being unfocused and none will be focused. + * + * Note that this listener won't be called when switching focus between Aztec views. + * + * @param {Function} listener + */ +export const addFocusChangeListener = ( listener ) => { + focusChangeListeners.push( listener ); +}; + +/** + * Removes a listener from the focus change listeners list. + * + * @param {Function} listener + */ +export const removeFocusChangeListener = ( listener ) => { + const itemIndex = focusChangeListeners.indexOf( listener ); + if ( itemIndex !== -1 ) { + focusChangeListeners.splice( itemIndex, 1 ); + } +}; + +/** + * Notifies listeners about changes in focus. + * + * @param {Object} event Event data to be notified to listeners. + * @param {boolean} event.isFocused True if any Aztec view is currently focused. + */ +const notifyListeners = ( { isFocused } ) => { + focusChangeListeners.forEach( ( listener ) => { + listener( { isFocused } ); + } ); +}; + +/** + * Determines if any Aztec view is focused. + * + * @return {boolean} True if focused. + */ +export const isFocused = () => { + return currentFocusedElement !== null; +}; + +/** + * Returns the current focused element. + * + * @return {RefObject} Ref of the current focused element or `null` otherwise. + */ +export const getCurrentFocusedElement = () => { + return currentFocusedElement; +}; + +/** + * Notifies that an Aztec view is being focused or unfocused. + */ +export const notifyInputChange = () => { + const focusedInput = TextInputState.currentlyFocusedInput(); + const hasAnyFocusedInput = focusedInput !== null; + + if ( hasAnyFocusedInput ) { + if ( ! currentFocusedElement ) { + notifyListeners( { isFocused: true } ); + } + currentFocusedElement = focusedInput; + } else if ( currentFocusedElement ) { + notifyListeners( { isFocused: false } ); + currentFocusedElement = null; + } +}; + +/** + * Focuses the specified element. + * + * @param {RefObject} element Element to be focused. + */ +export const focus = ( element ) => { + TextInputState.focusTextInput( element ); +}; + +/** + * Unfocuses the specified element. + * + * @param {RefObject} element Element to be unfocused. + */ +export const blur = ( element ) => { + TextInputState.blurTextInput( element ); +}; + +/** + * Unfocuses the current focused element. + */ +export const blurCurrentFocusedElement = () => { + if ( isFocused() ) { + blur( getCurrentFocusedElement() ); + } +}; diff --git a/packages/react-native-aztec/src/AztecView.js b/packages/react-native-aztec/src/AztecView.js index 96af6bfa366b0..3e6193bf10a4b 100644 --- a/packages/react-native-aztec/src/AztecView.js +++ b/packages/react-native-aztec/src/AztecView.js @@ -7,13 +7,18 @@ import { TouchableWithoutFeedback, Platform, } from 'react-native'; -import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; + /** * WordPress dependencies */ import { Component, createRef } from '@wordpress/element'; import { ENTER, BACKSPACE } from '@wordpress/keycodes'; +/** + * Internal dependencies + */ +import * as AztecInputState from './AztecInputState'; + const AztecManager = UIManager.getViewManagerConfig( 'RCTAztecView' ); class AztecView extends Component { @@ -117,6 +122,8 @@ class AztecView extends Component { } _onFocus( event ) { + AztecInputState.notifyInputChange(); + if ( ! this.props.onFocus ) { return; } @@ -127,7 +134,9 @@ class AztecView extends Component { _onBlur( event ) { this.selectionEndCaretY = null; - TextInputState.blurTextInput( this.aztecViewRef.current ); + + AztecInputState.blur( this.aztecViewRef.current ); + AztecInputState.notifyInputChange(); if ( ! this.props.onBlur ) { return; @@ -179,16 +188,16 @@ class AztecView extends Component { } blur() { - TextInputState.blurTextInput( this.aztecViewRef.current ); + AztecInputState.blur( this.aztecViewRef.current ); } focus() { - TextInputState.focusTextInput( this.aztecViewRef.current ); + AztecInputState.focus( this.aztecViewRef.current ); } isFocused() { - const focusedField = TextInputState.currentlyFocusedInput(); - return focusedField && focusedField === this.aztecViewRef.current; + const focusedElement = AztecInputState.getCurrentFocusedElement(); + return focusedElement && focusedElement === this.aztecViewRef.current; } _onPress( event ) { @@ -251,4 +260,6 @@ class AztecView extends Component { const RCTAztecView = requireNativeComponent( 'RCTAztecView', AztecView ); +AztecView.InputState = AztecInputState; + export default AztecView; diff --git a/packages/react-native-aztec/src/test/AztecInputState.test.js b/packages/react-native-aztec/src/test/AztecInputState.test.js new file mode 100644 index 0000000000000..4e5a59c6caf23 --- /dev/null +++ b/packages/react-native-aztec/src/test/AztecInputState.test.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; + +/** + * Internal dependencies + */ +import { + addFocusChangeListener, + getCurrentFocusedElement, + isFocused, + focus, + blur, + notifyInputChange, + removeFocusChangeListener, +} from '../AztecInputState'; + +jest.mock( + 'react-native/Libraries/Components/TextInput/TextInputState', + () => ( { + focusTextInput: jest.fn(), + blurTextInput: jest.fn(), + currentlyFocusedInput: jest.fn(), + } ) +); + +const ref = { current: null }; + +const updateCurrentFocusedInput = ( value ) => { + TextInputState.currentlyFocusedInput.mockReturnValue( value ); + notifyInputChange(); +}; + +describe( 'Aztec Input State', () => { + it( 'listens to focus change event', () => { + const listener = jest.fn(); + const anotherRef = { current: null }; + addFocusChangeListener( listener ); + + updateCurrentFocusedInput( ref ); + + expect( listener ).toHaveBeenCalledWith( { isFocused: true } ); + + updateCurrentFocusedInput( anotherRef ); + + expect( listener ).toHaveBeenCalledTimes( 1 ); + + updateCurrentFocusedInput( null ); + + expect( listener ).toHaveBeenCalledWith( { isFocused: false } ); + } ); + + it( 'does not call focus change listener if removed', () => { + const listener = jest.fn(); + addFocusChangeListener( listener ); + removeFocusChangeListener( listener ); + + updateCurrentFocusedInput( ref ); + + expect( listener ).not.toHaveBeenCalledWith( { isFocused: true } ); + + updateCurrentFocusedInput( null ); + + expect( listener ).not.toHaveBeenCalledWith( { isFocused: false } ); + } ); + + it( 'returns true if an element is focused', () => { + updateCurrentFocusedInput( ref ); + expect( isFocused() ).toBeTruthy(); + } ); + + it( 'returns false if an element is unfocused', () => { + updateCurrentFocusedInput( null ); + expect( isFocused() ).toBeFalsy(); + } ); + + it( 'returns current focused element', () => { + const anotherRef = { current: null }; + updateCurrentFocusedInput( ref ); + expect( getCurrentFocusedElement() ).toBe( ref ); + + updateCurrentFocusedInput( anotherRef ); + expect( getCurrentFocusedElement() ).toBe( anotherRef ); + } ); + + it( 'returns null if focused element is unfocused', () => { + updateCurrentFocusedInput( null ); + expect( getCurrentFocusedElement() ).toBe( null ); + } ); + + it( 'focus an element', () => { + focus( ref ); + expect( TextInputState.focusTextInput ).toHaveBeenCalledWith( ref ); + } ); + + it( 'unfocuses an element', () => { + blur( ref ); + expect( TextInputState.blurTextInput ).toHaveBeenCalledWith( ref ); + } ); +} ); diff --git a/test/native/__mocks__/@wordpress/react-native-aztec/index.js b/test/native/__mocks__/@wordpress/react-native-aztec/index.js index 49ebf60786dc4..dfd2f21c0504b 100644 --- a/test/native/__mocks__/@wordpress/react-native-aztec/index.js +++ b/test/native/__mocks__/@wordpress/react-native-aztec/index.js @@ -9,9 +9,15 @@ import { omit } from 'lodash'; */ import { forwardRef } from '@wordpress/element'; +const reactNativeAztecMock = jest.createMockFromModule( + '@wordpress/react-native-aztec' +); +// Preserve the mock of AztecInputState to be exported with the AztecView mock. +const AztecInputState = reactNativeAztecMock.default.InputState; + const UNSUPPORTED_PROPS = [ 'style' ]; -const RCTAztecView = ( { accessibilityLabel, text, ...rest }, ref ) => { +const AztecView = ( { accessibilityLabel, text, ...rest }, ref ) => { return ( { ); }; -export default forwardRef( RCTAztecView ); +// Replace default mock of AztecView component with custom implementation. +reactNativeAztecMock.default = forwardRef( AztecView ); +reactNativeAztecMock.default.InputState = AztecInputState; + +module.exports = reactNativeAztecMock; From d147f60e390adef63a598aeb96123543cd2e4023 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Wed, 27 Apr 2022 16:41:01 +0200 Subject: [PATCH 15/28] 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. --- .../components/src/draggable/index.native.js | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/components/src/draggable/index.native.js b/packages/components/src/draggable/index.native.js index 1b8ecb05a26ec..9add347e58581 100644 --- a/packages/components/src/draggable/index.native.js +++ b/packages/components/src/draggable/index.native.js @@ -42,6 +42,7 @@ const Draggable = ( { children, onDragEnd, onDragOver, onDragStart } ) => { const isPanActive = useSharedValue( false ); const draggingId = useSharedValue( '' ); const panGestureRef = useRef(); + const currentFirstTouchId = useSharedValue( null ); const initialPosition = { x: useSharedValue( 0 ), @@ -77,28 +78,51 @@ const Draggable = ( { children, onDragEnd, onDragOver, onDragStart } ) => { } ); + function getFirstTouchEvent( event ) { + 'worklet'; + + return event.allTouches.find( + ( touch ) => touch.id === currentFirstTouchId.value + ); + } + const panGesture = Gesture.Pan() .manualActivation( true ) .onTouchesDown( ( event ) => { - const { x = 0, y = 0 } = event.allTouches[ 0 ]; - initialPosition.x.value = x; - initialPosition.y.value = y; + if ( ! currentFirstTouchId.value ) { + const firstEvent = event.allTouches[ 0 ]; + const { x = 0, y = 0 } = firstEvent; + + currentFirstTouchId.value = firstEvent.id; + + initialPosition.x.value = x; + initialPosition.y.value = y; + } } ) - .onTouchesMove( ( _, state ) => { + .onTouchesMove( ( event, state ) => { if ( ! isPanActive.value && isDragging.value ) { isPanActive.value = true; state.activate(); } - } ) - .onUpdate( ( event ) => { - lastPosition.x.value = event.x; - lastPosition.y.value = event.y; - if ( onDragOver ) { - onDragOver( event ); + if ( isPanActive.value && isDragging.value ) { + const firstEvent = getFirstTouchEvent( event ); + + if ( ! firstEvent ) { + state.end(); + return; + } + + lastPosition.x.value = firstEvent.x; + lastPosition.y.value = firstEvent.y; + + if ( onDragOver ) { + onDragOver( firstEvent ); + } } } ) .onEnd( () => { + currentFirstTouchId.value = null; isPanActive.value = false; isDragging.value = false; } ) @@ -106,7 +130,7 @@ const Draggable = ( { children, onDragEnd, onDragOver, onDragStart } ) => { .shouldCancelWhenOutside( false ); const providerValue = useMemo( () => { - return { panGestureRef, isDragging, draggingId }; + return { panGestureRef, isDragging, isPanActive, draggingId }; }, [] ); return ( @@ -143,7 +167,9 @@ const DraggableTrigger = ( { onLongPress, onLongPressEnd, } ) => { - const { panGestureRef, isDragging, draggingId } = useContext( Context ); + const { panGestureRef, isDragging, isPanActive, draggingId } = useContext( + Context + ); const gestureHandler = useAnimatedGestureHandler( { onActive: () => { @@ -158,7 +184,10 @@ const DraggableTrigger = ( { } }, onEnd: () => { - isDragging.value = false; + if ( ! isPanActive.value ) { + isDragging.value = false; + } + if ( onLongPressEnd ) { runOnJS( onLongPressEnd )( id ); } From fa3d10bebae4bbca8c05681f16ededaf4c30d756 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Thu, 28 Apr 2022 15:24:48 +0200 Subject: [PATCH 16/28] 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 --- .../dropping-insertion-point.native.js | 19 +++++++++++++++++-- .../src/components/provider/index.native.js | 3 ++- test/native/__mocks__/styleMock.js | 3 +++ test/native/setup.js | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) 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 index d0c42f6bf1d00..327d9d2ebdd43 100644 --- 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 @@ -8,6 +8,10 @@ import Animated, { useAnimatedReaction, runOnJS, } from 'react-native-reanimated'; +import { + useSafeAreaInsets, + useSafeAreaFrame, +} from 'react-native-safe-area-context'; /** * WordPress dependencies @@ -50,6 +54,12 @@ export default function DroppingInsertionPoint( { } = 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 ); @@ -144,11 +154,16 @@ export default function DroppingInsertionPoint( { ); const animatedStyles = useAnimatedStyle( () => { + const translationY = blockYPosition.value - scroll.offsetY.value; + // Prevents overflowing behind the header/footer + const shouldHideIndicator = + translationY < 0 || translationY > maxHeight; + return { - opacity: opacity.value, + opacity: shouldHideIndicator ? 0 : opacity.value, transform: [ { - translateY: blockYPosition.value - scroll.offsetY.value, + translateY: translationY, }, ], }; diff --git a/packages/editor/src/components/provider/index.native.js b/packages/editor/src/components/provider/index.native.js index 3260d9b06f38e..f7e211e251674 100644 --- a/packages/editor/src/components/provider/index.native.js +++ b/packages/editor/src/components/provider/index.native.js @@ -2,6 +2,7 @@ * External dependencies */ import memize from 'memize'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; /** * WordPress dependencies @@ -333,7 +334,7 @@ class NativeEditorProvider extends Component { settings={ editorSettings } { ...props } > - { children } + { children } { }; } ); +// To be replaced with built in mocks when we upgrade to the latest version +jest.mock( 'react-native-safe-area-context', () => { + const inset = { top: 0, right: 0, bottom: 0, left: 0 }; + const frame = { x: 0, y: 0, width: 0, height: 0 }; + return { + SafeAreaProvider: jest + .fn() + .mockImplementation( ( { children } ) => children ), + SafeAreaConsumer: jest + .fn() + .mockImplementation( ( { children } ) => children( inset ) ), + useSafeAreaInsets: jest.fn().mockImplementation( () => inset ), + useSafeAreaFrame: jest.fn().mockImplementation( () => frame ), + }; +} ); + jest.mock( '@react-native-community/slider', () => { From d0f14ba6986f55d15d288c12876eb1bd5b06dc18 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Thu, 28 Apr 2022 15:30:54 +0200 Subject: [PATCH 17/28] 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 --- packages/block-library/src/cover/controls.native.js | 1 - packages/block-library/src/cover/edit.native.js | 1 - packages/block-library/src/file/edit.native.js | 1 - packages/block-library/src/image/edit.native.js | 1 - packages/block-library/src/media-text/edit.native.js | 4 +++- .../block-library/src/media-text/media-container.native.js | 6 ++---- 6 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/block-library/src/cover/controls.native.js b/packages/block-library/src/cover/controls.native.js index 6b967548a23d4..539d263855315 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 87ef3a07cf141..01773bd6119e2 100644 --- a/packages/block-library/src/cover/edit.native.js +++ b/packages/block-library/src/cover/edit.native.js @@ -375,7 +375,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 && (