diff --git a/package-lock.json b/package-lock.json index 811af9f2c77b3..6c8dfd26b78c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18030,7 +18030,6 @@ "react-native-get-random-values": "1.4.0", "react-native-hr": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hr/1.1.3-wp-1/react-native-hr-1.1.3.tgz", "react-native-hsv-color-picker": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hsv-color-picker/v1.0.1-wp-3/react-native-hsv-color-picker-1.0.1-wp-3.tgz", - "react-native-keyboard-aware-scroll-view": "https://raw.githubusercontent.com/wordpress-mobile/react-native-keyboard-aware-scroll-view/v0.8.8-wp-1/react-native-keyboard-aware-scroll-view-0.8.8-wp-1.tgz", "react-native-linear-gradient": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-3/react-native-linear-gradient-2.5.6-wp-3.tgz", "react-native-modal": "^11.10.0", "react-native-prompt-android": "https://raw.githubusercontent.com/wordpress-mobile/react-native-prompt-android/v1.0.0-wp-3/react-native-prompt-android-1.0.0-wp-3.tgz", @@ -49374,14 +49373,6 @@ "resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz", "integrity": "sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg==" }, - "react-native-keyboard-aware-scroll-view": { - "version": "https://raw.githubusercontent.com/wordpress-mobile/react-native-keyboard-aware-scroll-view/v0.8.8-wp-1/react-native-keyboard-aware-scroll-view-0.8.8-wp-1.tgz", - "integrity": "sha512-gp0xGZvr4TKFW5K5HM2uPsSsbSdONOlajEZohWVxLJJyVSToyaptt/amJrvkpWFqlqtcE9k22iEO/vzpnAgNRw==", - "requires": { - "prop-types": "^15.6.2", - "react-native-iphone-x-helper": "^1.0.3" - } - }, "react-native-linear-gradient": { "version": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-3/react-native-linear-gradient-2.5.6-wp-3.tgz", "integrity": "sha512-Xq/ABki6/zz6ejut2wPWrh2ZV9Cw5NhHsFcB1adhY/Z2YIVyAVnpApwhMWVV6BxbtKcl17eMPR6vpOI5Q76BjA==" 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 b562daedcf30c..02c79360b567a 100644 --- a/packages/block-editor/src/components/block-list/index.native.js +++ b/packages/block-editor/src/components/block-list/index.native.js @@ -6,7 +6,7 @@ import { View, Platform, TouchableWithoutFeedback } from 'react-native'; /** * WordPress dependencies */ -import { Component, createContext } from '@wordpress/element'; +import { Component } from '@wordpress/element'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose, withPreferredColorScheme } from '@wordpress/compose'; import { createBlock } from '@wordpress/blocks'; @@ -33,7 +33,6 @@ import { import { BlockDraggableWrapper } from '../block-draggable'; import { store as blockEditorStore } from '../../store'; -export const OnCaretVerticalPositionChange = createContext(); const identity = ( x ) => x; const stylesMemo = {}; @@ -70,8 +69,6 @@ export class BlockList extends Component { }; this.renderItem = this.renderItem.bind( this ); this.renderBlockListFooter = this.renderBlockListFooter.bind( this ); - this.onCaretVerticalPositionChange = - this.onCaretVerticalPositionChange.bind( this ); this.scrollViewInnerRef = this.scrollViewInnerRef.bind( this ); this.addBlockToEndOfPost = this.addBlockToEndOfPost.bind( this ); this.shouldFlatListPreventAutomaticScroll = @@ -94,15 +91,6 @@ export class BlockList extends Component { this.props.insertBlock( newBlock, this.props.blockCount ); } - onCaretVerticalPositionChange( targetId, caretY, previousCaretY ) { - KeyboardAwareFlatList.handleCaretVerticalPositionChange( - this.scrollViewRef, - targetId, - caretY, - previousCaretY - ); - } - scrollViewInnerRef( ref ) { this.scrollViewRef = ref; } @@ -209,13 +197,7 @@ export class BlockList extends Component { ); - return ( - - { blockList } - - ); + return blockList; } renderList( extraProps = {} ) { @@ -237,8 +219,7 @@ export class BlockList extends Component { } = this.props; const { parentScrollRef, onScroll } = extraProps; - const { blockToolbar, blockBorder, headerToolbar, floatingToolbar } = - styles; + const { blockToolbar, headerToolbar, floatingToolbar } = styles; const containerStyle = { flex: isRootList ? 1 : 0, @@ -250,6 +231,15 @@ export class BlockList extends Component { const isContentStretch = contentResizeMode === 'stretch'; const isMultiBlocks = blockClientIds.length > 1; const { isWider } = alignmentHelpers; + const extraScrollHeight = + headerToolbar.height + + blockToolbar.height + + ( isFloatingToolbarVisible ? floatingToolbar.height : 0 ); + + const scrollViewStyle = [ + { flex: isRootList ? 1 : 0 }, + ! isRootList && styles.overflowVisible, + ]; return ( { this.scrollViewInnerRef( parentScrollRef || ref ); } } - extraScrollHeight={ - blockToolbar.height + blockBorder.width - } - inputAccessoryViewHeight={ - headerToolbar.height + - ( isFloatingToolbarVisible - ? floatingToolbar.height - : 0 ) - } + extraScrollHeight={ extraScrollHeight } keyboardShouldPersistTaps="always" - scrollViewStyle={ [ - { flex: isRootList ? 1 : 0 }, - ! isRootList && styles.overflowVisible, - ] } + scrollViewStyle={ scrollViewStyle } extraData={ this.getExtraData() } scrollEnabled={ isRootList } contentContainerStyle={ [ @@ -407,6 +385,7 @@ export default compose( [ ( select, { rootClientId, orientation, filterInnerBlocks } ) => { const { getBlockCount, + getBlockHierarchyRootClientId, getBlockOrder, getSelectedBlockClientId, isBlockInsertionPointVisible, @@ -427,10 +406,12 @@ export default compose( [ const isReadOnly = getSettings().readOnly; const blockCount = getBlockCount(); - const hasRootInnerBlocks = !! blockCount; + const rootBlockId = getBlockHierarchyRootClientId( + selectedBlockClientId + ); const isFloatingToolbarVisible = - !! selectedBlockClientId && hasRootInnerBlocks; + !! selectedBlockClientId && !! getBlockCount( rootBlockId ); const isRTL = getSettings().isRTL; return { diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index 8aacaad95b296..82e7a96ec8733 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -46,7 +46,6 @@ import { useBlockEditContext } from '../block-edit'; import { RemoveBrowserShortcuts } from './remove-browser-shortcuts'; import { filePasteHandler } from './file-paste-handler'; import FormatToolbarContainer from './format-toolbar-container'; -import { useNativeProps } from './use-native-props'; import { store as blockEditorStore } from '../../store'; import { addActiveFormats, @@ -120,7 +119,6 @@ function RichTextWrapper( const fallbackRef = useRef(); const { clientId, isSelected: blockIsSelected } = useBlockEditContext(); - const nativeProps = useNativeProps(); const embedHandlerPickerRef = useRef(); const selector = ( select ) => { const { @@ -219,6 +217,7 @@ function RichTextWrapper( selectionChangeEnd ); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ clientId, identifier ] ); @@ -372,6 +371,7 @@ function RichTextWrapper( } } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ onReplace, onSplit, @@ -614,7 +614,6 @@ function RichTextWrapper( } __unstableMultilineRootTag={ __unstableMultilineRootTag } // Native props. - { ...nativeProps } blockIsSelected={ originalIsSelected !== undefined ? originalIsSelected diff --git a/packages/block-editor/src/components/rich-text/use-native-props.js b/packages/block-editor/src/components/rich-text/use-native-props.js deleted file mode 100644 index 04343773a04c6..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-native-props.js +++ /dev/null @@ -1,3 +0,0 @@ -export function useNativeProps() { - return {}; -} diff --git a/packages/block-editor/src/components/rich-text/use-native-props.native.js b/packages/block-editor/src/components/rich-text/use-native-props.native.js deleted file mode 100644 index 41f4e2ea9ac2c..0000000000000 --- a/packages/block-editor/src/components/rich-text/use-native-props.native.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * WordPress dependencies - */ -import { useContext } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { OnCaretVerticalPositionChange } from '../block-list'; - -export function useNativeProps() { - return { - onCaretVerticalPositionChange: useContext( - OnCaretVerticalPositionChange - ), - }; -} diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index bb5597178f0de..49d10aacb9fb1 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Internal + +- `Mobile` Refactor of the KeyboardAwareFlatList component. + ### Enhancements - `DropZone`: Smooth animation ([#49517](https://github.com/WordPress/gutenberg/pull/49517)). 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 ffdd97dd5acbb..eccb80f3903e5 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 @@ -24,8 +24,4 @@ export const KeyboardAwareFlatList = ( { innerRef, onScroll, ...props } ) => { ); }; -KeyboardAwareFlatList.handleCaretVerticalPositionChange = () => { - // no need to handle on Android, it is system managed -}; - export default KeyboardAwareFlatList; 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 05ff4c8bb6519..90fda81d05b2f 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 @@ -1,9 +1,8 @@ /** * External dependencies */ -import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; -import { FlatList } from 'react-native'; -import fastDeepEqual from 'fast-deep-equal/es6'; + +import { ScrollView, FlatList, useWindowDimensions } from 'react-native'; import Animated, { useAnimatedScrollHandler, useSharedValue, @@ -12,36 +11,123 @@ import Animated, { /** * WordPress dependencies */ -import { memo, useCallback, useRef } from '@wordpress/element'; +import { useCallback, useEffect, useRef } from '@wordpress/element'; +import { useThrottle } from '@wordpress/compose'; -const List = memo( FlatList, fastDeepEqual ); -const AnimatedKeyboardAwareScrollView = Animated.createAnimatedComponent( - KeyboardAwareScrollView -); +/** + * Internal dependencies + */ +import useTextInputOffset from './use-text-input-offset'; +import useKeyboardOffset from './use-keyboard-offset'; +import useScrollToTextInput from './use-scroll-to-text-input'; +import useTextInputCaretPosition from './use-text-input-caret-position'; +const AnimatedScrollView = Animated.createAnimatedComponent( ScrollView ); + +/** + * React component that provides a FlatList that is aware of the keyboard state and can scroll + * to the currently focused TextInput. + * + * @param {Object} props Component props. + * @param {number} props.extraScrollHeight Extra scroll height for the content. + * @param {Function} props.innerRef Function to pass the ScrollView ref to the parent component. + * @param {Function} props.onScroll Function to be called when the list is scrolled. + * @param {boolean} props.scrollEnabled Whether the list can be scrolled. + * @param {Object} props.scrollViewStyle Additional style for the ScrollView component. + * @param {boolean} props.shouldPreventAutomaticScroll Whether to prevent scrolling when there's a Keyboard offset set. + * @param {Object} props... Other props to pass to the FlatList component. + * @return {WPComponent} KeyboardAwareFlatList component. + */ export const KeyboardAwareFlatList = ( { extraScrollHeight, - shouldPreventAutomaticScroll, innerRef, - autoScroll, - scrollViewStyle, - inputAccessoryViewHeight, onScroll, - ...listProps + scrollEnabled, + scrollViewStyle, + shouldPreventAutomaticScroll, + ...props } ) => { const scrollViewRef = useRef(); - const keyboardWillShowIndicator = useRef(); + const scrollViewMeasurements = useRef(); + const scrollViewYOffset = useSharedValue( -1 ); + + const { height: windowHeight, width: windowWidth } = useWindowDimensions(); + const isLandscape = windowWidth >= windowHeight; + + const [ keyboardOffset ] = useKeyboardOffset( + scrollEnabled, + shouldPreventAutomaticScroll + ); + + const [ currentCaretData ] = useTextInputCaretPosition( scrollEnabled ); + + const [ getTextInputOffset ] = useTextInputOffset( + scrollEnabled, + scrollViewRef + ); + + const [ scrollToTextInputOffset ] = useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ); - const latestContentOffsetY = useSharedValue( -1 ); + const onScrollToTextInput = useThrottle( + useCallback( + async ( caret ) => { + const textInputOffset = await getTextInputOffset( caret ); + const hasTextInputOffset = textInputOffset !== null; + + if ( hasTextInputOffset ) { + scrollToTextInputOffset( caret, textInputOffset ); + } + }, + [ getTextInputOffset, scrollToTextInputOffset ] + ), + 200, + { leading: false } + ); + + useEffect( () => { + onScrollToTextInput( currentCaretData ); + }, [ currentCaretData, onScrollToTextInput ] ); + + // When the orientation changes, the ScrollView measurements + // need to be re-calculated. + useEffect( () => { + scrollViewMeasurements.current = null; + }, [ isLandscape ] ); const scrollHandler = useAnimatedScrollHandler( { onScroll: ( event ) => { const { contentOffset } = event; - latestContentOffsetY.value = contentOffset.y; + scrollViewYOffset.value = contentOffset.y; onScroll( event ); }, } ); + const measureScrollView = useCallback( () => { + if ( scrollViewRef.current ) { + const scrollRef = scrollViewRef.current.getNativeScrollRef(); + + scrollRef.measureInWindow( ( _x, y, width, height ) => { + scrollViewMeasurements.current = { y, width, height }; + } ); + } + }, [] ); + + const onContentSizeChange = useCallback( () => { + onScrollToTextInput( currentCaretData ); + + // Sets the first values when the content size changes. + if ( ! scrollViewMeasurements.current ) { + measureScrollView(); + } + }, [ measureScrollView, onScrollToTextInput, currentCaretData ] ); + const getRef = useCallback( ( ref ) => { scrollViewRef.current = ref; @@ -49,63 +135,28 @@ export const KeyboardAwareFlatList = ( { }, [ 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; - }, [] ); + + // Adds content insets when the keyboard is opened to have + // extra padding at the bottom. + const contentInset = { bottom: keyboardOffset }; + + const style = [ { flex: 1 }, scrollViewStyle ]; return ( - - - + + ); }; -KeyboardAwareFlatList.handleCaretVerticalPositionChange = ( - scrollView, - targetId, - caretY, - previousCaretY -) => { - if ( previousCaretY ) { - // If this is not the first tap. - scrollView.refreshScrollForField( targetId ); - } -}; - export default KeyboardAwareFlatList; diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-keyboard-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-keyboard-offset.native.js new file mode 100644 index 0000000000000..18265682b305a --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-keyboard-offset.native.js @@ -0,0 +1,203 @@ +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react-native'; +import { Keyboard } from 'react-native'; +import RCTDeviceEventEmitter from 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter'; + +/** + * Internal dependencies + */ +import useKeyboardOffset from '../use-keyboard-offset'; + +jest.useFakeTimers(); + +describe( 'useKeyboardOffset', () => { + beforeEach( () => { + Keyboard.removeAllListeners( 'keyboardDidShow' ); + Keyboard.removeAllListeners( 'keyboardDidHide' ); + Keyboard.removeAllListeners( 'keyboardWillShow' ); + } ); + + it( 'returns the initial state', () => { + // Arrange + const { result } = renderHook( () => useKeyboardOffset( true ) ); + const [ keyboardOffset ] = result.current; + + // Assert + expect( keyboardOffset ).toBe( 0 ); + } ); + + it( 'updates keyboard visibility and offset when the keyboard is shown', () => { + // Arrange + const { result } = renderHook( () => useKeyboardOffset( true ) ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + // Assert + const [ keyboardOffset ] = result.current; + expect( keyboardOffset ).toBe( 250 ); + } ); + + it( 'updates keyboard visibility and offset when the keyboard is hidden', () => { + // Arrange + const shouldPreventAutomaticScroll = jest.fn().mockReturnValue( false ); + const { result } = renderHook( () => + useKeyboardOffset( true, shouldPreventAutomaticScroll ) + ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidHide' ); + jest.runAllTimers(); + } ); + + // Assert + const [ keyboardOffset ] = result.current; + expect( keyboardOffset ).toBe( 0 ); + } ); + + it( 'removes all keyboard listeners when scrollEnabled changes to false', () => { + // Arrange + const { result, rerender } = renderHook( + ( { scrollEnabled } ) => useKeyboardOffset( scrollEnabled ), + { + initialProps: { scrollEnabled: true }, + } + ); + const [ keyboardOffset ] = result.current; + + // Act + rerender( { scrollEnabled: false } ); + + // Assert + expect( keyboardOffset ).toBe( 0 ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidHide' ) ).toBe( + 0 + ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidShow' ) ).toBe( + 0 + ); + } ); + + it( 'adds all keyboard listeners when scrollEnabled changes to true', () => { + // Arrange + const { result, rerender } = renderHook( + ( { scrollEnabled } ) => useKeyboardOffset( scrollEnabled ), + { + initialProps: { scrollEnabled: false }, + } + ); + // Act + act( () => { + rerender( { scrollEnabled: true } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + const [ keyboardOffset ] = result.current; + + // Assert + expect( keyboardOffset ).toBe( 250 ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidShow' ) ).toBe( + 1 + ); + expect( RCTDeviceEventEmitter.listenerCount( 'keyboardDidHide' ) ).toBe( + 1 + ); + } ); + + it( 'does not set keyboard offset to 0 when keyboard is hidden and shouldPreventAutomaticScroll is true', () => { + // Arrange + const shouldPreventAutomaticScroll = jest.fn().mockReturnValue( true ); + const { result } = renderHook( () => + useKeyboardOffset( true, shouldPreventAutomaticScroll ) + ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidHide' ); + jest.runAllTimers(); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 250 ); + } ); + + it( 'handles updates to shouldPreventAutomaticScroll', () => { + // Arrange + const preventScrollTrue = jest.fn( () => true ); + const preventScrollFalse = jest.fn( () => false ); + + // Act + const { result, rerender } = renderHook( + ( { shouldPreventAutomaticScroll } ) => + useKeyboardOffset( true, shouldPreventAutomaticScroll ), + { + initialProps: { + shouldPreventAutomaticScroll: preventScrollFalse, + }, + } + ); + + // Assert + expect( result.current[ 0 ] ).toBe( 0 ); + + // Act + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 150 }, + } ); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 150 ); + + // Act + act( () => { + rerender( { shouldPreventAutomaticScroll: preventScrollTrue } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidHide' ); + jest.runAllTimers(); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 150 ); + + // Act + act( () => { + rerender( { shouldPreventAutomaticScroll: preventScrollFalse } ); + } ); + + act( () => { + RCTDeviceEventEmitter.emit( 'keyboardDidShow', { + endCoordinates: { height: 250 }, + } ); + } ); + + // Assert + expect( result.current[ 0 ] ).toBe( 250 ); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll-to-text-input.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll-to-text-input.native.js new file mode 100644 index 0000000000000..98c33cd4750fb --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-scroll-to-text-input.native.js @@ -0,0 +1,140 @@ +/** + * External dependencies + */ + +import { renderHook } from '@testing-library/react-native'; + +/** + * Internal dependencies + */ +import useScrollToTextInput from '../use-scroll-to-text-input'; + +describe( 'useScrollToTextInput', () => { + it( 'scrolls up to the current TextInput offset', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = true; + const scrollViewRef = { current: { scrollTo: jest.fn() } }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 150 }; + const textInputOffset = 50; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + expect( scrollViewRef.current.scrollTo ).toHaveBeenCalledWith( { + y: textInputOffset, + animated: true, + } ); + } ); + + it( 'scrolls down to the current TextInput offset', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = true; + const scrollViewRef = { current: { scrollTo: jest.fn() } }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 250 }; + const textInputOffset = 750; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + const expectedYOffset = + textInputOffset - + ( scrollViewMeasurements.current.height - + ( keyboardOffset + + extraScrollHeight + + currentCaretData.caretHeight ) ); + expect( scrollViewRef.current.scrollTo ).toHaveBeenCalledWith( { + y: expectedYOffset, + animated: true, + } ); + } ); + + it( 'does not scroll when the ScrollView ref is not available', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = true; + const scrollViewRef = { current: null }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 0 }; + const textInputOffset = 50; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + expect( scrollViewRef.current ).toBeNull(); + } ); + + it( 'does not scroll when the scroll is not enabled', () => { + // Arrange + const currentCaretData = { caretHeight: 10 }; + const extraScrollHeight = 50; + const keyboardOffset = 100; + const scrollEnabled = false; + const scrollViewRef = { current: { scrollTo: jest.fn() } }; + const scrollViewMeasurements = { current: { height: 600 } }; + const scrollViewYOffset = { value: 0 }; + const textInputOffset = 50; + + const { result } = renderHook( () => + useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset + ) + ); + + // Act + result.current[ 0 ]( currentCaretData, textInputOffset ); + + // Assert + expect( scrollViewRef.current.scrollTo ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-caret-position.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-caret-position.native.js new file mode 100644 index 0000000000000..6cb9bd5be81aa --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-caret-position.native.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-native'; + +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; + +/** + * Internal dependencies + */ +import useTextInputCaretPosition from '../use-text-input-caret-position'; + +describe( 'useTextInputCaretPosition', () => { + let addCaretChangeListenerSpy; + let removeCaretChangeListenerSpy; + + beforeAll( () => { + addCaretChangeListenerSpy = jest.spyOn( + RCTAztecView.InputState, + 'addCaretChangeListener' + ); + removeCaretChangeListenerSpy = jest.spyOn( + RCTAztecView.InputState, + 'removeCaretChangeListener' + ); + } ); + + beforeEach( () => { + addCaretChangeListenerSpy.mockClear(); + removeCaretChangeListenerSpy.mockClear(); + } ); + + it( 'should add and remove caret change listener correctly', () => { + // Arrange + const scrollEnabled = true; + + // Act + const { unmount } = renderHook( () => + useTextInputCaretPosition( scrollEnabled ) + ); + unmount(); + + // Assert + expect( addCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + expect( removeCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should add caret change listener when scroll is enabled', () => { + // Arrange + const scrollEnabled = true; + + // Act + renderHook( () => useTextInputCaretPosition( scrollEnabled ) ); + + // Assert + expect( addCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + expect( removeCaretChangeListenerSpy ).not.toHaveBeenCalled(); + } ); + + it( 'should remove caret change listener when scroll is enabled and then changed to disabled', () => { + // Arrange + const { rerender } = renderHook( + ( props ) => useTextInputCaretPosition( props.scrollEnabled ), + { + initialProps: { scrollEnabled: true }, + } + ); + + // Assert + expect( addCaretChangeListenerSpy ).toHaveBeenCalled(); + + // Act + rerender( { scrollEnabled: false } ); + + // Assert + expect( removeCaretChangeListenerSpy ).toHaveBeenCalled(); + expect( addCaretChangeListenerSpy ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-offset.native.js new file mode 100644 index 0000000000000..850b8c09a03b9 --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/test/use-text-input-offset.native.js @@ -0,0 +1,147 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-native'; + +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; + +/** + * Internal dependencies + */ +import useTextInputOffset from '../use-text-input-offset'; + +jest.mock( '@wordpress/react-native-aztec', () => ( { + InputState: { + getCurrentFocusedElement: jest.fn(), + }, +} ) ); + +describe( 'useTextInputOffset', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should return a function', () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + + // Assert + expect( result.current[ 0 ] ).toBeInstanceOf( Function ); + } ); + + it( 'should return null when scrollViewRef.current is null', async () => { + // Arrange + const scrollViewRef = { current: null }; + const scrollEnabled = true; + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset(); + expect( offset ).toBeNull(); + } ); + + it( 'should return null when textInput is null', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + RCTAztecView.InputState.getCurrentFocusedElement.mockReturnValue( + null + ); + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset(); + expect( offset ).toBeNull(); + } ); + + it( 'should return null when scroll is not enabled', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = false; + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset(); + expect( offset ).toBeNull(); + } ); + + it( 'should return correct offset value when caretY is not null', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + const x = 0; + const y = 10; + const width = 0; + const height = 100; + const textInput = { + measureLayout: jest.fn( ( _, callback ) => { + callback( x, y, width, height ); + } ), + }; + RCTAztecView.InputState.getCurrentFocusedElement.mockReturnValue( + textInput + ); + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset( { caretY: 10 } ); + expect( offset ).toBe( 20 ); + } ); + + it( 'should return correct offset value when caretY is -1', async () => { + // Arrange + const scrollViewRef = { current: {} }; + const scrollEnabled = true; + const x = 0; + const y = 10; + const width = 0; + const height = 100; + const textInput = { + measureLayout: jest.fn( ( _, callback ) => { + callback( x, y, width, height ); + } ), + }; + RCTAztecView.InputState.getCurrentFocusedElement.mockReturnValue( + textInput + ); + + // Act + const { result } = renderHook( () => + useTextInputOffset( scrollEnabled, scrollViewRef ) + ); + const getTextInputOffset = result.current[ 0 ]; + + // Assert + const offset = await getTextInputOffset( { caretY: -1 } ); + expect( offset ).toBe( 110 ); + } ); +} ); diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-keyboard-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-keyboard-offset.native.js new file mode 100644 index 0000000000000..f12b254dd9469 --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-keyboard-offset.native.js @@ -0,0 +1,87 @@ +/** + * External dependencies + */ + +import { Keyboard } from 'react-native'; + +/** + * WordPress dependencies + */ +import { useEffect, useCallback, useState, useRef } from '@wordpress/element'; + +/** + * Hook that adds Keyboard listeners to get the offset space + * when the keyboard is opened, taking into account focused AztecViews. + * + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @param {Function} shouldPreventAutomaticScroll Whether to prevent scrolling when there's a Keyboard offset set. + * @return {[number]} Keyboard offset. + */ +export default function useKeyboardOffset( + scrollEnabled, + shouldPreventAutomaticScroll +) { + const [ keyboardOffset, setKeyboardOffset ] = useState( 0 ); + const timeoutRef = useRef(); + + const onKeyboardDidHide = useCallback( () => { + if ( shouldPreventAutomaticScroll() ) { + clearTimeout( timeoutRef.current ); + return; + } + + // A timeout is being used to delay resetting the offset in cases + // where the focus is changed to a different TextInput. + clearTimeout( timeoutRef.current ); + timeoutRef.current = setTimeout( () => { + setKeyboardOffset( 0 ); + }, 200 ); + }, [ shouldPreventAutomaticScroll ] ); + + const onKeyboardDidShow = useCallback( ( { endCoordinates } ) => { + clearTimeout( timeoutRef.current ); + setKeyboardOffset( endCoordinates.height ); + }, [] ); + + const onKeyboardWillShow = useCallback( () => { + clearTimeout( timeoutRef.current ); + }, [] ); + + useEffect( () => { + let willShowSubscription; + let showSubscription; + let hideSubscription; + + if ( scrollEnabled ) { + willShowSubscription = Keyboard.addListener( + 'keyboardWillShow', + onKeyboardWillShow + ); + showSubscription = Keyboard.addListener( + 'keyboardDidShow', + onKeyboardDidShow + ); + hideSubscription = Keyboard.addListener( + 'keyboardDidHide', + onKeyboardDidHide + ); + } else { + willShowSubscription?.remove(); + showSubscription?.remove(); + hideSubscription?.remove(); + } + + return () => { + clearTimeout( timeoutRef.current ); + willShowSubscription?.remove(); + showSubscription?.remove(); + hideSubscription?.remove(); + }; + }, [ + onKeyboardDidHide, + onKeyboardDidShow, + onKeyboardWillShow, + scrollEnabled, + ] ); + return [ keyboardOffset ]; +} diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js new file mode 100644 index 0000000000000..3bdaba837a60b --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-scroll-to-text-input.native.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; + +const DEFAULT_FONT_SIZE = 16; + +/** @typedef {import('@wordpress/element').RefObject} RefObject */ +/** @typedef {import('react-native-reanimated').SharedValue} SharedValue */ +/** + * Hook to scroll to the currently focused TextInput + * depending on where the caret is placed taking into + * account the Keyboard and the Header. + * + * @param {number} extraScrollHeight Extra space to not overlap the content. + * @param {number} keyboardOffset Keyboard space offset. + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @param {RefObject} scrollViewMeasurements ScrollView Layout measurements. + * @param {RefObject} scrollViewRef ScrollView reference. + * @param {SharedValue} scrollViewYOffset Current offset position of the ScrollView. + * @return {Function[]} Function to scroll to the current TextInput's offset. + */ +export default function useScrollToTextInput( + extraScrollHeight, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset +) { + const { top, bottom } = useSafeAreaInsets(); + const insets = top + bottom; + + /** + * Function to scroll to the current TextInput's offset. + * + * @param {Object} caret The caret position data of the currently focused TextInput. + * @param {number} caret.caretHeight The height of the caret. + * @param {number} textInputOffset The offset calculated with the caret's Y coordinate + the + * TextInput's Y coord or height value. + */ + const scrollToTextInputOffset = useCallback( + ( caret, textInputOffset ) => { + const { caretHeight = DEFAULT_FONT_SIZE } = caret ?? {}; + + if ( + ! scrollViewRef.current || + ! scrollEnabled || + ! scrollViewMeasurements.current + ) { + return; + } + const currentScrollViewYOffset = Math.max( + 0, + scrollViewYOffset.value + ); + + // Scroll up. + if ( textInputOffset < currentScrollViewYOffset ) { + scrollViewRef.current.scrollTo( { + y: textInputOffset, + animated: true, + } ); + return; + } + + const availableScreenSpace = Math.abs( + Math.floor( + scrollViewMeasurements.current.height - + ( keyboardOffset + extraScrollHeight + caretHeight ) + ) + ); + const maxOffset = Math.floor( + currentScrollViewYOffset + availableScreenSpace + ); + + const isAtTheTop = + textInputOffset < scrollViewMeasurements.current.y + insets; + + // Scroll down. + if ( textInputOffset > maxOffset && ! isAtTheTop ) { + scrollViewRef.current.scrollTo( { + y: textInputOffset - availableScreenSpace, + animated: true, + } ); + } + }, + [ + extraScrollHeight, + insets, + keyboardOffset, + scrollEnabled, + scrollViewMeasurements, + scrollViewRef, + scrollViewYOffset, + ] + ); + + return [ scrollToTextInputOffset ]; +} diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-caret-position.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-caret-position.native.js new file mode 100644 index 0000000000000..49aa873fc66ef --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-caret-position.native.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; +import { useCallback, useEffect, useState } from '@wordpress/element'; + +/** + * Hook that listens to caret changes from AztecView TextInputs. + * + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @return {[number]} Current caret's data. + */ +export default function useTextInputCaretPosition( scrollEnabled ) { + const [ currentCaretData, setCurrentCaretData ] = useState(); + + const onCaretChange = useCallback( ( caret ) => { + setCurrentCaretData( caret ); + }, [] ); + + useEffect( () => { + if ( scrollEnabled ) { + RCTAztecView.InputState.addCaretChangeListener( onCaretChange ); + } else { + RCTAztecView.InputState.removeCaretChangeListener( onCaretChange ); + } + + return () => { + if ( scrollEnabled ) { + RCTAztecView.InputState.removeCaretChangeListener( + onCaretChange + ); + } + }; + }, [ scrollEnabled, onCaretChange ] ); + return [ currentCaretData ]; +} diff --git a/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-offset.native.js b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-offset.native.js new file mode 100644 index 0000000000000..1c69cbcc48c45 --- /dev/null +++ b/packages/components/src/mobile/keyboard-aware-flat-list/use-text-input-offset.native.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; +import { useCallback } from '@wordpress/element'; + +/** @typedef {import('@wordpress/element').RefObject} RefObject */ +/** + * Hook that calculates the currently focused TextInput's current + * caret Y coordinate position. + * + * @param {boolean} scrollEnabled Whether the scroll is enabled or not. + * @param {RefObject} scrollViewRef ScrollView reference. + * @return {[Function]} Function to get the current TextInput's offset. + */ +export default function useTextInputOffset( scrollEnabled, scrollViewRef ) { + const getTextInputOffset = useCallback( + async ( caret ) => { + const { caretY = null } = caret ?? {}; + const textInput = + RCTAztecView.InputState.getCurrentFocusedElement(); + + return new Promise( ( resolve ) => { + if ( + scrollViewRef.current && + textInput && + scrollEnabled && + caretY !== null + ) { + textInput.measureLayout( + scrollViewRef.current, + ( _x, y, _width, height ) => { + const caretYOffset = + // For cases where the caretY value is -1 + // we use the y + height value, e.g the current + // character index is not valid or out of bounds + // see https://github.com/wordpress-mobile/AztecEditor-iOS/blob/4d0522d67b0056ac211466caaa76936cc5b4f947/Aztec/Classes/TextKit/TextView.swift#L762 + caretY >= 0 && caretY < height + ? y + caretY + : y + height; + resolve( Math.round( Math.abs( caretYOffset ) ) ); + }, + () => resolve( null ) + ); + } else { + resolve( null ); + } + } ); + }, + [ scrollEnabled, scrollViewRef ] + ); + + return [ getTextInputOffset ]; +} diff --git a/packages/edit-post/src/components/visual-editor/index.native.js b/packages/edit-post/src/components/visual-editor/index.native.js index 886e56e493938..b23b73c334e75 100644 --- a/packages/edit-post/src/components/visual-editor/index.native.js +++ b/packages/edit-post/src/components/visual-editor/index.native.js @@ -3,10 +3,7 @@ */ import { Component } from '@wordpress/element'; import { BlockList } from '@wordpress/block-editor'; -/** - * External dependencies - */ -import { Keyboard } from 'react-native'; + /** * Internal dependencies */ @@ -16,36 +13,6 @@ export default class VisualEditor extends Component { constructor( props ) { super( props ); this.renderHeader = this.renderHeader.bind( this ); - this.keyboardDidShow = this.keyboardDidShow.bind( this ); - this.keyboardDidHide = this.keyboardDidHide.bind( this ); - - this.state = { - isAutoScrollEnabled: true, - }; - } - - componentDidMount() { - this.keyboardDidShow = Keyboard.addListener( - 'keyboardDidShow', - this.keyboardDidShow - ); - this.keyboardDidHideListener = Keyboard.addListener( - 'keyboardDidHide', - this.keyboardDidHide - ); - } - - componentWillUnmount() { - this.keyboardDidShow.remove(); - this.keyboardDidHideListener.remove(); - } - - keyboardDidShow() { - this.setState( { isAutoScrollEnabled: false } ); - } - - keyboardDidHide() { - this.setState( { isAutoScrollEnabled: true } ); } renderHeader() { @@ -55,13 +22,11 @@ export default class VisualEditor extends Component { render() { const { safeAreaBottomInset } = this.props; - const { isAutoScrollEnabled } = this.state; return ( ); } diff --git a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift index 166d86834bc13..c6e928b396440 100644 --- a/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift +++ b/packages/react-native-aztec/ios/RNTAztecView/RCTAztecView.swift @@ -234,7 +234,10 @@ class RCTAztecView: Aztec.TextView { previousContentSize = newSize let body = packForRN(newSize, withName: "contentSize") - onContentSizeChange(body) + let caretData = packCaretDataForRN() + var result = body + result.merge(caretData) { (_, new) in new } + onContentSizeChange(result) } // MARK: - Paste handling @@ -479,6 +482,7 @@ class RCTAztecView: Aztec.TextView { if !(caretEndRect.isInfinite || caretEndRect.isNull) { result["selectionEndCaretX"] = caretEndRect.origin.x result["selectionEndCaretY"] = caretEndRect.origin.y + result["selectionEndCaretHeight"] = caretEndRect.size.height } } @@ -792,7 +796,8 @@ extension RCTAztecView: UITextViewDelegate { override func becomeFirstResponder() -> Bool { if !isFirstResponder && canBecomeFirstResponder { - onFocus?([:]) + let caretData = packCaretDataForRN() + onFocus?(caretData) } return super.becomeFirstResponder() } diff --git a/packages/react-native-aztec/src/AztecInputState.js b/packages/react-native-aztec/src/AztecInputState.js index 973b8179ea5ec..8a1737118d1d1 100644 --- a/packages/react-native-aztec/src/AztecInputState.js +++ b/packages/react-native-aztec/src/AztecInputState.js @@ -6,8 +6,10 @@ import TextInputState from 'react-native/Libraries/Components/TextInput/TextInpu /** @typedef {import('@wordpress/element').RefObject} RefObject */ const focusChangeListeners = []; +const caretChangeListeners = []; let currentFocusedElement = null; +let currentCaretData = null; /** * Adds a listener that will be called in the following cases: @@ -47,6 +49,37 @@ const notifyListeners = ( { isFocused } ) => { } ); }; +/** + * Adds a listener that will be called when the caret's Y position + * changes for the focused Aztec view. + * + * @param {Function} listener + */ +export const addCaretChangeListener = ( listener ) => { + caretChangeListeners.push( listener ); +}; + +/** + * Removes a listener from the caret change listeners list. + * + * @param {Function} listener + */ +export const removeCaretChangeListener = ( listener ) => { + const itemIndex = caretChangeListeners.indexOf( listener ); + if ( itemIndex !== -1 ) { + caretChangeListeners.splice( itemIndex, 1 ); + } +}; + +/** + * Notifies listeners about caret changes in focused Aztec view. + */ +const notifyCaretChangeListeners = () => { + caretChangeListeners.forEach( ( listener ) => { + listener( getCurrentCaretData() ); + } ); +}; + /** * Determines if any Aztec view is focused. * @@ -100,6 +133,7 @@ export const focus = ( element ) => { */ export const blur = ( element ) => { TextInputState.blurTextInput( element ); + setCurrentCaretData( null ); notifyInputChange(); }; @@ -111,3 +145,24 @@ export const blurCurrentFocusedElement = () => { blur( getCurrentFocusedElement() ); } }; + +/** + * Sets the current focused element caret's data. + * + * @param {Object} caret Caret's data. + */ +export const setCurrentCaretData = ( caret ) => { + if ( isFocused() && caret ) { + currentCaretData = caret; + notifyCaretChangeListeners(); + } +}; + +/** + * Get the current focused element caret's data. + * + * @return {Object} Current caret's data. + */ +export const getCurrentCaretData = () => { + return currentCaretData; +}; diff --git a/packages/react-native-aztec/src/AztecView.js b/packages/react-native-aztec/src/AztecView.js index 8ac7884dd89c6..4d90d13974c8e 100644 --- a/packages/react-native-aztec/src/AztecView.js +++ b/packages/react-native-aztec/src/AztecView.js @@ -79,6 +79,8 @@ class AztecView extends Component { } _onContentSizeChange( event ) { + this.updateCaretData( event ); + if ( ! this.props.onContentSizeChange ) { return; } @@ -197,16 +199,19 @@ class AztecView extends Component { onSelectionChange( selectionStart, selectionEnd, text, event ); } + this.updateCaretData( event ); + } + + updateCaretData( event ) { if ( - this.props.onCaretVerticalPositionChange && - this.selectionEndCaretY !== event.nativeEvent.selectionEndCaretY + this.isFocused() && + this.selectionEndCaretY !== event?.nativeEvent?.selectionEndCaretY ) { const caretY = event.nativeEvent.selectionEndCaretY; - this.props.onCaretVerticalPositionChange( - event.nativeEvent.target, + AztecInputState.setCurrentCaretData( { caretY, - this.selectionEndCaretY - ); + caretHeight: event.nativeEvent?.selectionEndCaretHeight, + } ); this.selectionEndCaretY = caretY; } } @@ -237,6 +242,8 @@ class AztecView extends Component { // combination generate an infinite loop as described in https://github.com/wordpress-mobile/gutenberg-mobile/issues/302 // For iOS, this is necessary to let the system know when Aztec was focused programatically. if ( Platform.OS === 'ios' ) { + this.updateCaretData( event ); + this._onPress( event ); } } diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index e425af19ec089..1eedaf1467ddc 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -12,6 +12,7 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [*] Support POST requests [#49371] - [*] Avoid empty Gallery block error [#49557] +- [***] [iOS] Fixed iOS scroll jumping issue by refactoring KeyboardAwareFlatList improving writing flow and caret focus handling. [#48791] ## 1.92.0 * No User facing changes * diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js index e5b34078a0e45..b413f3b6a42ce 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js @@ -65,14 +65,14 @@ describe( 'Gutenberg Editor tests for Block insertion', () => { } ); await titleElement.click(); + // Wait for editor to finish scrolling to the title + await editorPage.driver.sleep( 2000 ); + await editorPage.addNewBlock( blockNames.paragraph ); const emptyParagraphBlock = await editorPage.getBlockAtPosition( blockNames.paragraph ); expect( emptyParagraphBlock ).toBeTruthy(); - const emptyParagraphBlockElement = - await editorPage.getTextBlockAtPosition( blockNames.paragraph ); - expect( emptyParagraphBlockElement ).toBeTruthy(); await editorPage.sendTextToParagraphBlock( 1, testData.mediumText ); const html = await editorPage.getHtmlContent(); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js index 6604c9ba6f32b..aa398d1f24e26 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js @@ -86,6 +86,9 @@ describe( 'Gutenberg Editor tests for Paragraph Block', () => { await editorPage.sendTextToParagraphBlock( 1, testData.longText ); for ( let i = 3; i > 0; i-- ) { + const paragraphBlockElement = + await editorPage.getTextBlockAtPosition( blockNames.paragraph ); + await paragraphBlockElement.click(); await editorPage.removeBlock(); } } ); diff --git a/packages/react-native-editor/__device-tests__/pages/editor-page.js b/packages/react-native-editor/__device-tests__/pages/editor-page.js index cbedc7fc8a955..6cad729d26ecb 100644 --- a/packages/react-native-editor/__device-tests__/pages/editor-page.js +++ b/packages/react-native-editor/__device-tests__/pages/editor-page.js @@ -200,14 +200,19 @@ class EditorPage { const titleElement = isAndroid() ? 'Post title. Welcome to Gutenberg!, Updates the title.' : 'post-title'; + + if ( options.autoscroll ) { + await swipeDown( this.driver ); + } + const elements = await this.driver.elementsByAccessibilityId( titleElement ); - if ( elements.length === 0 || ! elements[ 0 ].isDisplayed() ) { - if ( options.autoscroll ) { - await swipeDown( this.driver ); - } + if ( + elements.length === 0 || + ! ( await elements[ 0 ].isDisplayed() ) + ) { return await this.getTitleElement( options ); } return elements[ 0 ]; diff --git a/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj b/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj index 010f3add38639..79a27e1e61846 100644 --- a/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj +++ b/packages/react-native-editor/ios/GutenbergDemo.xcodeproj/project.pbxproj @@ -370,7 +370,6 @@ "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", "${BUILT_PRODUCTS_DIR}/react-native-blur/react_native_blur.framework", "${BUILT_PRODUCTS_DIR}/react-native-get-random-values/react_native_get_random_values.framework", - "${BUILT_PRODUCTS_DIR}/react-native-keyboard-aware-scroll-view/react_native_keyboard_aware_scroll_view.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area/react_native_safe_area.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area-context/react_native_safe_area_context.framework", "${BUILT_PRODUCTS_DIR}/react-native-slider/react_native_slider.framework", @@ -419,7 +418,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_blur.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_get_random_values.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_keyboard_aware_scroll_view.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area_context.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_slider.framework", @@ -500,7 +498,6 @@ "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", "${BUILT_PRODUCTS_DIR}/react-native-blur/react_native_blur.framework", "${BUILT_PRODUCTS_DIR}/react-native-get-random-values/react_native_get_random_values.framework", - "${BUILT_PRODUCTS_DIR}/react-native-keyboard-aware-scroll-view/react_native_keyboard_aware_scroll_view.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area/react_native_safe_area.framework", "${BUILT_PRODUCTS_DIR}/react-native-safe-area-context/react_native_safe_area_context.framework", "${BUILT_PRODUCTS_DIR}/react-native-slider/react_native_slider.framework", @@ -549,7 +546,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_blur.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_get_random_values.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_keyboard_aware_scroll_view.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_safe_area_context.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/react_native_slider.framework", diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index 562fa507f3f94..af952e19906d1 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -240,8 +240,6 @@ PODS: - React-Core - react-native-get-random-values (1.4.0): - React-Core - - react-native-keyboard-aware-scroll-view (0.8.8-wp-1): - - React-Core - react-native-safe-area (0.5.1): - React-Core - react-native-safe-area-context (3.2.0): @@ -399,7 +397,6 @@ DEPENDENCIES: - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) - "react-native-blur (from `../../../node_modules/@react-native-community/blur`)" - react-native-get-random-values (from `../../../node_modules/react-native-get-random-values`) - - react-native-keyboard-aware-scroll-view (from `../../../node_modules/react-native-keyboard-aware-scroll-view`) - react-native-safe-area (from `../../../node_modules/react-native-safe-area`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - "react-native-slider (from `../../../node_modules/@react-native-community/slider`)" @@ -482,8 +479,6 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@react-native-community/blur" react-native-get-random-values: :path: "../../../node_modules/react-native-get-random-values" - react-native-keyboard-aware-scroll-view: - :path: "../../../node_modules/react-native-keyboard-aware-scroll-view" react-native-safe-area: :path: "../../../node_modules/react-native-safe-area" react-native-safe-area-context: @@ -563,7 +558,6 @@ SPEC CHECKSUMS: React-logger: 1088859f145b8f6dd0d3ed051a647ef0e3e80fad react-native-blur: 3e9c8e8e9f7d17fa1b94e1a0ae9fd816675f5382 react-native-get-random-values: b6fb85e7169b9822976793e467458c151c3e8b69 - react-native-keyboard-aware-scroll-view: 0bc6c2dfe9056935a40dc1a70e764b7a1bbf6568 react-native-safe-area: c9cf765aa2dd96159476a99633e7d462ce5bb94f react-native-safe-area-context: f0906bf8bc9835ac9a9d3f97e8bde2a997d8da79 react-native-slider: a433f1c13c5da3c17a587351bff7371f65cc9a07 diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index 9c979c300f1c0..8a143fe97b288 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -62,7 +62,6 @@ "react-native-get-random-values": "1.4.0", "react-native-hr": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hr/1.1.3-wp-1/react-native-hr-1.1.3.tgz", "react-native-hsv-color-picker": "https://raw.githubusercontent.com/wordpress-mobile/react-native-hsv-color-picker/v1.0.1-wp-3/react-native-hsv-color-picker-1.0.1-wp-3.tgz", - "react-native-keyboard-aware-scroll-view": "https://raw.githubusercontent.com/wordpress-mobile/react-native-keyboard-aware-scroll-view/v0.8.8-wp-1/react-native-keyboard-aware-scroll-view-0.8.8-wp-1.tgz", "react-native-linear-gradient": "https://raw.githubusercontent.com/wordpress-mobile/react-native-linear-gradient/v2.5.6-wp-3/react-native-linear-gradient-2.5.6-wp-3.tgz", "react-native-modal": "^11.10.0", "react-native-prompt-android": "https://raw.githubusercontent.com/wordpress-mobile/react-native-prompt-android/v1.0.0-wp-3/react-native-prompt-android-1.0.0-wp-3.tgz", diff --git a/packages/rich-text/src/component/index.native.js b/packages/rich-text/src/component/index.native.js index 134a3fe6666ba..2d48d1263cb61 100644 --- a/packages/rich-text/src/component/index.native.js +++ b/packages/rich-text/src/component/index.native.js @@ -1218,9 +1218,6 @@ export class RichText extends Component { onPaste={ this.onPaste } activeFormats={ this.getActiveFormatNames( record ) } onContentSizeChange={ this.onContentSizeChange } - onCaretVerticalPositionChange={ - this.props.onCaretVerticalPositionChange - } onSelectionChange={ this.onSelectionChangeFromAztec } blockType={ { tag: tagName } } color={ diff --git a/patches/react-native-keyboard-aware-scroll-view+0.8.8-wp-1.patch b/patches/react-native-keyboard-aware-scroll-view+0.8.8-wp-1.patch deleted file mode 100644 index fab58b9d3157d..0000000000000 --- a/patches/react-native-keyboard-aware-scroll-view+0.8.8-wp-1.patch +++ /dev/null @@ -1,76 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js b/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js -index 30f62c9..83a6920 100644 ---- a/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js -+++ b/node_modules/react-native-keyboard-aware-scroll-view/lib/KeyboardAwareHOC.js -@@ -264,9 +264,13 @@ function KeyboardAwareHOC( - }) - } - -- componentWillReceiveProps(nextProps: KeyboardAwareHOCProps) { -- if (nextProps.viewIsInsideTabBar !== this.props.viewIsInsideTabBar) { -- const keyboardSpace: number = nextProps.viewIsInsideTabBar -+ // This patch changed from the deprecated `componentWillReceiveProps` to -+ // `componentDidUpdate`. We can remove this patch when we upgrade to -+ // `react-native-keyboard-aware-scroll-view@^0.9.2` -+ // https://git.io/JPbOK -+ componentDidUpdate(prevProps: KeyboardAwareHOCProps) { -+ if (this.props.viewIsInsideTabBar !== prevProps.viewIsInsideTabBar) { -+ const keyboardSpace: number = this.props.viewIsInsideTabBar - ? _KAM_DEFAULT_TAB_BAR_HEIGHT - : 0 - if (this.state.keyboardSpace !== keyboardSpace) { -@@ -293,12 +297,33 @@ function KeyboardAwareHOC( - - scrollToPosition = (x: number, y: number, animated: boolean = true) => { - const responder = this.getScrollResponder() -- responder && responder.scrollResponderScrollTo({ x, y, animated }) -+ // Patch applied to avoid invoking the removed `scrollResponderScrollTo` -+ // method. This patch could be removed if we upgrade to -+ // `react-native-keyboard-aware-view@^0.9.5` https://git.io/JPb6a -+ if (!responder) return; -+ if (responder.scrollResponderScrollTo) { -+ // React Native < 0.65 -+ responder.scrollResponderScrollTo({ x, y, animated }) -+ } else if (responder.scrollTo) { -+ // React Native >= 0.65 -+ responder.scrollTo({ x, y, animated }) -+ } - } - - scrollToEnd = (animated?: boolean = true) => { - const responder = this.getScrollResponder() -- responder && responder.scrollResponderScrollToEnd({ animated }) -+ // Patch applied to avoid invoking the removed -+ // `scrollResponderScrollToEnd` method. This patch could be removed if we -+ // upgrade to `react-native-keyboard-aware-view@^0.9.5` -+ // https://git.io/JPb6a -+ if (!responder) return; -+ if (responder.scrollResponderScrollToEnd) { -+ // React Native < 0.65 -+ responder.scrollResponderScrollToEnd({ animated }) -+ } else if (responder.scrollToEnd) { -+ // React Native >= 0.65 -+ responder.scrollToEnd({ animated }) -+ } - } - - scrollForExtraHeightOnAndroid = (extraHeight: number) => { -@@ -553,7 +578,17 @@ function KeyboardAwareHOC( - - scrollOffsetY = Math.max(0, scrollOffsetY); //prevent negative scroll offset - const responder = this.getScrollResponder(); -- responder && responder.scrollResponderScrollTo( { x: 0, y: scrollOffsetY, animated: true } ); -+ // Patch applied to avoid invoking the removed `scrollResponderScrollTo` -+ // method. This patch could be removed if we upgrade to -+ // `react-native-keyboard-aware-view@^0.9.5` https://git.io/JPb6a -+ if (!responder) return; -+ if (responder.scrollResponderScrollTo) { -+ // React Native < 0.65 -+ responder.scrollResponderScrollTo( { x: 0, y: scrollOffsetY, animated: true } ) -+ } else if (responder.scrollTo) { -+ // React Native >= 0.65 -+ responder.scrollTo( { x: 0, y: scrollOffsetY, animated: true } ) -+ } - } - - const measureLayoutErrorHandler = ( e: Object ) => {