diff --git a/packages/base-styles/_colors.native.scss b/packages/base-styles/_colors.native.scss index 059843c955bca0..a34e5012eecf9c 100644 --- a/packages/base-styles/_colors.native.scss +++ b/packages/base-styles/_colors.native.scss @@ -54,6 +54,7 @@ $gray-lighten-10: lighten($gray, 10%); // #a8bece $gray-lighten-20: lighten($gray, 20%); // #c8d7e1 $gray-lighten-30: lighten($gray, 30%); // #e9eff3 $gray-darken-20: darken($gray, 20%); // #4f748e +$gray-darken-30: darken($gray, 30%); // #3d596d // Custom $toolbar-button: #7b9ab1; diff --git a/packages/block-editor/src/components/autocomplete/index.native.js b/packages/block-editor/src/components/autocomplete/index.native.js deleted file mode 100644 index 461f67a0a4bcbe..00000000000000 --- a/packages/block-editor/src/components/autocomplete/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export default () => null; diff --git a/packages/components/src/autocomplete/autocompleter-ui.js b/packages/components/src/autocomplete/autocompleter-ui.js new file mode 100644 index 00000000000000..f3ae2937acd6de --- /dev/null +++ b/packages/components/src/autocomplete/autocompleter-ui.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { map } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useLayoutEffect } from '@wordpress/element'; +import { useAnchorRef } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import getDefaultUseItems from './get-default-use-items'; +import Button from '../button'; +import Popover from '../popover'; + +export function getAutoCompleterUI( autocompleter ) { + const useItems = autocompleter.useItems + ? autocompleter.useItems + : getDefaultUseItems( autocompleter ); + + function AutocompleterUI( { + filterValue, + instanceId, + listBoxId, + className, + selectedIndex, + onChangeOptions, + onSelect, + onReset, + value, + contentRef, + } ) { + const [ items ] = useItems( filterValue ); + const anchorRef = useAnchorRef( { ref: contentRef, value } ); + + useLayoutEffect( () => { + onChangeOptions( items ); + }, [ items ] ); + + if ( ! items.length > 0 ) { + return null; + } + + return ( + +
+ { map( items, ( option, index ) => ( + + ) ) } +
+
+ ); + } + + return AutocompleterUI; +} diff --git a/packages/components/src/autocomplete/autocompleter-ui.native.js b/packages/components/src/autocomplete/autocompleter-ui.native.js new file mode 100644 index 00000000000000..1224ca9287b68c --- /dev/null +++ b/packages/components/src/autocomplete/autocompleter-ui.native.js @@ -0,0 +1,213 @@ +/** + * External dependencies + */ +import { + View, + Animated, + StyleSheet, + Text, + TouchableOpacity, + ScrollView, +} from 'react-native'; + +/** + * WordPress dependencies + */ +import { + useLayoutEffect, + useEffect, + useRef, + useState, + useCallback, +} from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { + Icon, + __unstableAutocompletionItemsFill as AutocompletionItemsFill, +} from '@wordpress/components'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import BackgroundView from './background-view'; +import getDefaultUseItems from './get-default-use-items'; +import styles from './style.scss'; + +const { compose: stylesCompose } = StyleSheet; + +export function getAutoCompleterUI( autocompleter ) { + const useItems = autocompleter.useItems + ? autocompleter.useItems + : getDefaultUseItems( autocompleter ); + + function AutocompleterUI( { + filterValue, + selectedIndex, + onChangeOptions, + onSelect, + value, + reset, + } ) { + const [ items ] = useItems( filterValue ); + const scrollViewRef = useRef(); + const animationValue = useRef( new Animated.Value( 0 ) ).current; + const [ isVisible, setIsVisible ] = useState( false ); + const { text } = value; + + useEffect( () => { + if ( ! isVisible && text.length > 0 ) { + setIsVisible( true ); + } + }, [ isVisible, text ] ); + + useLayoutEffect( () => { + onChangeOptions( items ); + scrollViewRef.current?.scrollTo( { x: 0, animated: false } ); + + if ( isVisible && text.length > 0 ) { + startAnimation( true ); + } else if ( isVisible && text.length === 0 ) { + startAnimation( false ); + } + }, [ items, isVisible, text ] ); + + const activeItemStyles = usePreferredColorSchemeStyle( + styles[ 'components-autocomplete__item-active' ], + styles[ 'components-autocomplete__item-active-dark' ] + ); + + const iconStyles = usePreferredColorSchemeStyle( + styles[ 'components-autocomplete__icon' ], + styles[ 'components-autocomplete__icon-active-dark' ] + ); + + const activeIconStyles = usePreferredColorSchemeStyle( + styles[ 'components-autocomplete__icon-active ' ], + styles[ 'components-autocomplete__icon-active-dark' ] + ); + + const textStyles = usePreferredColorSchemeStyle( + styles[ 'components-autocomplete__text' ], + styles[ 'components-autocomplete__text-dark' ] + ); + + const activeTextStyles = usePreferredColorSchemeStyle( + styles[ 'components-autocomplete__text-active' ], + styles[ 'components-autocomplete__text-active-dark' ] + ); + + const startAnimation = useCallback( + ( show ) => { + Animated.timing( animationValue, { + toValue: show ? 1 : 0, + duration: show ? 200 : 100, + useNativeDriver: true, + } ).start( ( { finished } ) => { + if ( finished && ! show && isVisible ) { + setIsVisible( false ); + reset(); + } + } ); + }, + [ isVisible ] + ); + + const contentStyles = { + transform: [ + { + translateY: animationValue.interpolate( { + inputRange: [ 0, 1 ], + outputRange: [ + styles[ 'components-autocomplete' ].height, + 0, + ], + } ), + }, + ], + }; + + if ( ! items.length > 0 || ! isVisible ) { + return null; + } + + return ( + + + + + + { items.map( ( option, index ) => { + const isActive = index === selectedIndex; + const itemStyle = stylesCompose( + styles[ + 'components-autocomplete__item' + ], + isActive && activeItemStyles + ); + const textStyle = stylesCompose( + textStyles, + isActive && activeTextStyles + ); + const iconStyle = stylesCompose( + iconStyles, + isActive && activeIconStyles + ); + + return ( + onSelect( option ) } + accessibilityLabel={ sprintf( + // translators: %s: Block name e.g. "Image block" + __( '%s block' ), + option?.value?.title + ) } + > + + + + + { option?.value?.title } + + + ); + } ) } + + + + + + ); + } + + return AutocompleterUI; +} + +export default getAutoCompleterUI; diff --git a/packages/components/src/autocomplete/background-view.android.js b/packages/components/src/autocomplete/background-view.android.js new file mode 100644 index 00000000000000..1b8ea61508a07e --- /dev/null +++ b/packages/components/src/autocomplete/background-view.android.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +const BackgroundView = ( { children } ) => { + const backgroundStyles = usePreferredColorSchemeStyle( + styles[ 'components-autocomplete__background' ], + styles[ 'components-autocomplete__background-dark' ] + ); + + return { children }; +}; + +export default BackgroundView; diff --git a/packages/components/src/autocomplete/background-view.ios.js b/packages/components/src/autocomplete/background-view.ios.js new file mode 100644 index 00000000000000..6c093ed4768013 --- /dev/null +++ b/packages/components/src/autocomplete/background-view.ios.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { BlurView } from '@react-native-community/blur'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; + +const BackgroundView = ( { children } ) => { + return ( + + { children } + + ); +}; + +export default BackgroundView; diff --git a/packages/components/src/autocomplete/get-default-use-items.js b/packages/components/src/autocomplete/get-default-use-items.js new file mode 100644 index 00000000000000..3ad41f94263d6e --- /dev/null +++ b/packages/components/src/autocomplete/get-default-use-items.js @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import { debounce, deburr, escapeRegExp } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useLayoutEffect, useState } from '@wordpress/element'; + +function filterOptions( search, options = [], maxResults = 10 ) { + const filtered = []; + for ( let i = 0; i < options.length; i++ ) { + const option = options[ i ]; + + // Merge label into keywords + let { keywords = [] } = option; + if ( 'string' === typeof option.label ) { + keywords = [ ...keywords, option.label ]; + } + + const isMatch = keywords.some( ( keyword ) => + search.test( deburr( keyword ) ) + ); + if ( ! isMatch ) { + continue; + } + + filtered.push( option ); + + // Abort early if max reached + if ( filtered.length === maxResults ) { + break; + } + } + + return filtered; +} + +export default function getDefaultUseItems( autocompleter ) { + return ( filterValue ) => { + const [ items, setItems ] = useState( [] ); + /* + * We support both synchronous and asynchronous retrieval of completer options + * but internally treat all as async so we maintain a single, consistent code path. + * + * Because networks can be slow, and the internet is wonderfully unpredictable, + * we don't want two promises updating the state at once. This ensures that only + * the most recent promise will act on `optionsData`. This doesn't use the state + * because `setState` is batched, and so there's no guarantee that setting + * `activePromise` in the state would result in it actually being in `this.state` + * before the promise resolves and we check to see if this is the active promise or not. + */ + useLayoutEffect( () => { + const { options, isDebounced } = autocompleter; + const loadOptions = debounce( + () => { + const promise = Promise.resolve( + typeof options === 'function' + ? options( filterValue ) + : options + ).then( ( optionsData ) => { + if ( promise.canceled ) { + return; + } + const keyedOptions = optionsData.map( + ( optionData, optionIndex ) => ( { + key: `${ autocompleter.name }-${ optionIndex }`, + value: optionData, + label: autocompleter.getOptionLabel( + optionData + ), + keywords: autocompleter.getOptionKeywords + ? autocompleter.getOptionKeywords( + optionData + ) + : [], + isDisabled: autocompleter.isOptionDisabled + ? autocompleter.isOptionDisabled( + optionData + ) + : false, + } ) + ); + + // create a regular expression to filter the options + const search = new RegExp( + '(?:\\b|\\s|^)' + escapeRegExp( filterValue ), + 'i' + ); + setItems( filterOptions( search, keyedOptions ) ); + } ); + + return promise; + }, + isDebounced ? 250 : 0 + ); + + const promise = loadOptions(); + + return () => { + loadOptions.cancel(); + if ( promise ) { + promise.canceled = true; + } + }; + }, [ filterValue ] ); + + return [ items ]; + }; +} diff --git a/packages/components/src/autocomplete/index.js b/packages/components/src/autocomplete/index.js index 87dd92eac26332..f801d4be21e252 100644 --- a/packages/components/src/autocomplete/index.js +++ b/packages/components/src/autocomplete/index.js @@ -1,8 +1,7 @@ /** * External dependencies */ -import classnames from 'classnames'; -import { escapeRegExp, find, map, debounce, deburr } from 'lodash'; +import { escapeRegExp, find, deburr } from 'lodash'; /** * WordPress dependencies @@ -10,7 +9,6 @@ import { escapeRegExp, find, map, debounce, deburr } from 'lodash'; import { renderToString, useEffect, - useLayoutEffect, useState, useRef, } from '@wordpress/element'; @@ -36,15 +34,13 @@ import { insert, isCollapsed, getTextContent, - useAnchorRef, } from '@wordpress/rich-text'; import { speak } from '@wordpress/a11y'; /** * Internal dependencies */ -import Button from '../button'; -import Popover from '../popover'; +import { getAutoCompleterUI } from './autocompleter-ui'; /** * A raw completer option. @@ -122,175 +118,6 @@ import Popover from '../popover'; * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. */ -function filterOptions( search, options = [], maxResults = 10 ) { - const filtered = []; - for ( let i = 0; i < options.length; i++ ) { - const option = options[ i ]; - - // Merge label into keywords - let { keywords = [] } = option; - if ( 'string' === typeof option.label ) { - keywords = [ ...keywords, option.label ]; - } - - const isMatch = keywords.some( ( keyword ) => - search.test( deburr( keyword ) ) - ); - if ( ! isMatch ) { - continue; - } - - filtered.push( option ); - - // Abort early if max reached - if ( filtered.length === maxResults ) { - break; - } - } - - return filtered; -} - -const getAutoCompleterUI = ( autocompleter ) => { - const useItems = autocompleter.useItems - ? autocompleter.useItems - : ( filterValue ) => { - const [ items, setItems ] = useState( [] ); - /* - * We support both synchronous and asynchronous retrieval of completer options - * but internally treat all as async so we maintain a single, consistent code path. - * - * Because networks can be slow, and the internet is wonderfully unpredictable, - * we don't want two promises updating the state at once. This ensures that only - * the most recent promise will act on `optionsData`. This doesn't use the state - * because `setState` is batched, and so there's no guarantee that setting - * `activePromise` in the state would result in it actually being in `this.state` - * before the promise resolves and we check to see if this is the active promise or not. - */ - useLayoutEffect( () => { - const { options, isDebounced } = autocompleter; - const loadOptions = debounce( - () => { - const promise = Promise.resolve( - typeof options === 'function' - ? options( filterValue ) - : options - ).then( ( optionsData ) => { - if ( promise.canceled ) { - return; - } - const keyedOptions = optionsData.map( - ( optionData, optionIndex ) => ( { - key: `${ autocompleter.name }-${ optionIndex }`, - value: optionData, - label: autocompleter.getOptionLabel( - optionData - ), - keywords: autocompleter.getOptionKeywords - ? autocompleter.getOptionKeywords( - optionData - ) - : [], - isDisabled: autocompleter.isOptionDisabled - ? autocompleter.isOptionDisabled( - optionData - ) - : false, - } ) - ); - - // create a regular expression to filter the options - const search = new RegExp( - '(?:\\b|\\s|^)' + - escapeRegExp( filterValue ), - 'i' - ); - setItems( - filterOptions( search, keyedOptions ) - ); - } ); - - return promise; - }, - isDebounced ? 250 : 0 - ); - - const promise = loadOptions(); - - return () => { - loadOptions.cancel(); - if ( promise ) { - promise.canceled = true; - } - }; - }, [ filterValue ] ); - - return [ items ]; - }; - - function AutocompleterUI( { - filterValue, - instanceId, - listBoxId, - className, - selectedIndex, - onChangeOptions, - onSelect, - onReset, - value, - contentRef, - } ) { - const [ items ] = useItems( filterValue ); - const anchorRef = useAnchorRef( { ref: contentRef, value } ); - - useLayoutEffect( () => { - onChangeOptions( items ); - }, [ items ] ); - - if ( ! items.length > 0 ) { - return null; - } - - return ( - -
- { map( items, ( option, index ) => ( - - ) ) } -
-
- ); - } - - return AutocompleterUI; -}; - function useAutocomplete( { record, onChange, @@ -563,6 +390,7 @@ function useAutocomplete( { onSelect={ select } value={ record } contentRef={ contentRef } + reset={ reset } /> ), }; diff --git a/packages/components/src/autocomplete/style.android.scss b/packages/components/src/autocomplete/style.android.scss new file mode 100644 index 00000000000000..b20905b0d393d7 --- /dev/null +++ b/packages/components/src/autocomplete/style.android.scss @@ -0,0 +1,7 @@ +@import "./style.native.scss"; + +.components-autocomplete { + width: 100%; + height: $mobile-header-toolbar-height; + bottom: $mobile-header-toolbar-height; +} diff --git a/packages/components/src/autocomplete/style.native.scss b/packages/components/src/autocomplete/style.native.scss new file mode 100644 index 00000000000000..fb0f046adf0927 --- /dev/null +++ b/packages/components/src/autocomplete/style.native.scss @@ -0,0 +1,74 @@ +.components-autocomplete { + width: 100%; + height: $mobile-header-toolbar-height; + overflow: hidden; +} + +.components-autocomplete__background { + height: $mobile-header-toolbar-height; + background-color: $gray-0; +} + +.components-autocomplete__background-dark { + background-color: $app-background-dark-alt; +} + +.components-autocomplete__background-blur { + width: 100%; + height: $mobile-header-toolbar-height; +} + +.components-autocomplete__content { + flex-grow: 1; + padding-left: 6px; +} + +.components-autocomplete__item { + flex-direction: row; + align-items: center; + padding: 5px 12px 5px 8px; + margin-right: 2px; + margin-top: 3px; + margin-bottom: 3px; +} + +.components-autocomplete__icon { + margin-right: 4px; + color: $gray-darken-30; +} + +.components-autocomplete__icon-active { + color: $gray-dark; +} + +.components-autocomplete__icon-active-dark { + color: $gray-20; +} + +.components-autocomplete__text { + color: $gray-darken-30; +} + +.components-autocomplete__text-dark { + color: $gray-20; +} + +.components-autocomplete__text-active { + color: $gray-dark; + text-decoration: underline; + text-decoration-color: $gray-darken-30; +} + +.components-autocomplete__text-active-dark { + color: $gray-20; + text-decoration-color: $gray-20; +} + +.components-autocomplete__item-active { + border-radius: 22px; + background-color: transparentize($color: $gray-darken-20, $amount: 0.9); +} + +.components-autocomplete__item-active-dark { + background-color: $dark-ultra-dim; +} diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index 2e85b124fe2735..fbd22606693ce7 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -65,6 +65,11 @@ export { default as withSpokenMessages } from './higher-order/with-spoken-messag export * from './text'; // Mobile Components +export { + __unstableAutocompletionItemsFill, + __unstableAutocompletionItemsSlot, +} from './mobile/autocompletion-items'; +export { default as Autocomplete } from './autocomplete'; export { default as BottomSheet } from './mobile/bottom-sheet'; export { BottomSheetConsumer, diff --git a/packages/components/src/mobile/autocompletion-items.native.js b/packages/components/src/mobile/autocompletion-items.native.js new file mode 100644 index 00000000000000..320dc6758ba5dd --- /dev/null +++ b/packages/components/src/mobile/autocompletion-items.native.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +const { Fill, Slot } = createSlotFill( '__unstableAutocompletionItemsSlot' ); + +export { + Fill as __unstableAutocompletionItemsFill, + Slot as __unstableAutocompletionItemsSlot, +}; diff --git a/packages/edit-post/src/components/layout/index.native.js b/packages/edit-post/src/components/layout/index.native.js index f52798d87dc537..2e8d589e746e71 100644 --- a/packages/edit-post/src/components/layout/index.native.js +++ b/packages/edit-post/src/components/layout/index.native.js @@ -15,6 +15,7 @@ import { HTMLTextInput, KeyboardAvoidingView, NoticeList, + __unstableAutocompletionItemsSlot as AutocompletionItemsSlot, } from '@wordpress/components'; import { AutosaveMonitor } from '@wordpress/editor'; import { sendNativeEditorDidLayout } from '@wordpress/react-native-bridge'; @@ -150,11 +151,17 @@ class Layout extends Component { style={ toolbarKeyboardAvoidingViewStyle } withAnimatedHeight > - { Platform.OS === 'ios' && } + { Platform.OS === 'ios' && ( + <> + + + + ) }
) } + { Platform.OS === 'android' && } ); } diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index ae0619dac26ca8..b27d1243e04c3a 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,6 +10,7 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased +- [***] Slash inserter ## 1.53.0 diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-slash-inserter-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-slash-inserter-@canary.test.js new file mode 100644 index 00000000000000..b98c43e3bb5bd2 --- /dev/null +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-slash-inserter-@canary.test.js @@ -0,0 +1,141 @@ +/** + * Internal dependencies + */ +import { blockNames } from './pages/editor-page'; +import { isAndroid } from './helpers/utils'; +import { slashInserter, shortText } from './helpers/test-data'; + +const ANIMATION_TIME = 200; + +// helper function for asserting slash inserter presence +async function assertSlashInserterPresent() { + const slashInserterElement = await editorPage.driver.elementByAccessibilityId( + 'Slash inserter results' + ); + expect( slashInserterElement ).toBeTruthy(); +} + +describe( 'Gutenberg Editor Slash Inserter tests', () => { + it( 'should show the menu after typing /', async () => { + await editorPage.addNewBlock( blockNames.paragraph ); + const paragraphBlockElement = await editorPage.getBlockAtPosition( + blockNames.paragraph + ); + if ( isAndroid() ) { + await paragraphBlockElement.click(); + } + + await editorPage.typeTextToParagraphBlock( + paragraphBlockElement, + slashInserter + ); + await editorPage.driver.sleep( ANIMATION_TIME ); + + assertSlashInserterPresent(); + await editorPage.removeBlockAtPosition( blockNames.paragraph ); + } ); + + it( 'should hide the menu after deleting the / character', async () => { + await editorPage.addNewBlock( blockNames.paragraph ); + const paragraphBlockElement = await editorPage.getBlockAtPosition( + blockNames.paragraph + ); + if ( isAndroid() ) { + await paragraphBlockElement.click(); + } + + await editorPage.typeTextToParagraphBlock( + paragraphBlockElement, + slashInserter + ); + await editorPage.driver.sleep( ANIMATION_TIME ); + + assertSlashInserterPresent(); + + // Remove / character + if ( isAndroid() ) { + await editorPage.typeTextToParagraphBlock( + paragraphBlockElement, + `${ shortText }`, + true + ); + } else { + await editorPage.typeTextToParagraphBlock( + paragraphBlockElement, + `\b ${ shortText }`, + false + ); + } + await editorPage.driver.sleep( ANIMATION_TIME ); + + // Check if the slash inserter UI exists + const foundElements = await editorPage.driver.elementsByAccessibilityId( + 'Slash inserter results' + ); + expect( foundElements.length ).toBe( 0 ); + + await editorPage.removeBlockAtPosition( blockNames.paragraph ); + } ); + + it( 'should add an Image block after tying /image and tapping on the Image block button', async () => { + await editorPage.addNewBlock( blockNames.paragraph ); + const paragraphBlockElement = await editorPage.getBlockAtPosition( + blockNames.paragraph + ); + if ( isAndroid() ) { + await paragraphBlockElement.click(); + } + + await editorPage.typeTextToParagraphBlock( + paragraphBlockElement, + `${ slashInserter }image` + ); + await editorPage.driver.sleep( ANIMATION_TIME ); + + assertSlashInserterPresent(); + + // Find Image block button + const imageButtonElement = await editorPage.driver.elementByAccessibilityId( + 'Image block' + ); + expect( imageButtonElement ).toBeTruthy(); + + // Add image block + await imageButtonElement.click(); + + // Check image exists in the editor + expect( + await editorPage.hasBlockAtPosition( 1, blockNames.image ) + ).toBe( true ); + + // Slash inserter UI should not be present after adding a block + const foundElements = await editorPage.driver.elementsByAccessibilityId( + 'Slash inserter results' + ); + expect( foundElements.length ).toBe( 0 ); + + // Remove image block + await editorPage.removeBlockAtPosition( blockNames.image ); + } ); + + it( 'should insert an image block with "/img" + enter', async () => { + await editorPage.addNewBlock( blockNames.paragraph ); + const paragraphBlockElement = await editorPage.getBlockAtPosition( + blockNames.paragraph + ); + if ( isAndroid() ) { + await paragraphBlockElement.click(); + } + + await editorPage.typeTextToParagraphBlock( + paragraphBlockElement, + '/img\n', + false + ); + expect( + await editorPage.hasBlockAtPosition( 1, blockNames.image ) + ).toBe( true ); + + await editorPage.removeBlockAtPosition( blockNames.image ); + } ); +} ); diff --git a/packages/react-native-editor/__device-tests__/helpers/test-data.js b/packages/react-native-editor/__device-tests__/helpers/test-data.js index b4c5772164ae34..d0bcea763a7c0b 100644 --- a/packages/react-native-editor/__device-tests__/helpers/test-data.js +++ b/packages/react-native-editor/__device-tests__/helpers/test-data.js @@ -4,6 +4,8 @@ exports.shortText = `Rock music approaches at high velocity.`; exports.mediumText = `The finer continuum interprets the polynomial rabbit. When can the geology runs? An astronomer runs. Should a communist consent?`; +exports.slashInserter = '/'; + exports.longText = `Beneath the busy continuum blinks the ineffective husband. Why a metric now outside the official subway? How can the prompt crop exhaust his tree Does this chord crowd my emptied search? A theory bubbles under the cartoon. The discontinued speaker cracks every thick epic. extraordinary twin shifts behind The finer continuum interprets the polynomial rabbit. When can the geology runs? An astronomer runs. Should a communist consent?`; diff --git a/packages/rich-text/src/component/index.native.js b/packages/rich-text/src/component/index.native.js index a4eded3ce3e793..195de53e802fed 100644 --- a/packages/rich-text/src/component/index.native.js +++ b/packages/rich-text/src/component/index.native.js @@ -351,6 +351,13 @@ export class RichText extends Component { return; } + // Add stubs for conformance in downstream autocompleters logic + this.customEditableOnKeyDown?.( { + preventDefault: () => undefined, + stopPropagation: () => undefined, + ...event, + } ); + this.handleDelete( event ); this.handleEnter( event ); this.handleTriggerKeyCodes( event ); @@ -841,6 +848,15 @@ export class RichText extends Component { return value; } + getEditableProps() { + return { + // Overridable props. + style: {}, + className: 'rich-text', + onKeyDown: () => null, + }; + } + render() { const { tagName, @@ -858,6 +874,7 @@ export class RichText extends Component { const record = this.getRecord(); const html = this.getHtmlToRender( record, tagName ); + const editableProps = this.getEditableProps(); const placeholderStyle = getStylesFromColorScheme( styles.richTextPlaceholder, @@ -927,6 +944,12 @@ export class RichText extends Component { backgroundColor: style.backgroundColor, }; + const EditableView = ( props ) => { + this.customEditableOnKeyDown = props?.onKeyDown; + + return <>; + }; + return ( { children && @@ -935,6 +958,8 @@ export class RichText extends Component { value: record, onChange: this.onFormatChange, onFocus: () => {}, + editableProps, + editableTagName: EditableView, } ) }