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 697a9ffc19daa6..bdea027b116f14 100644 --- a/packages/block-editor/src/components/block-list/index.native.js +++ b/packages/block-editor/src/components/block-list/index.native.js @@ -24,7 +24,7 @@ import { __ } from '@wordpress/i18n'; */ import styles from './style.scss'; import BlockListAppender from '../block-list-appender'; -import BlockListItem from './block-list-item.native'; +import BlockListItem from './block-list-item'; import { store as blockEditorStore } from '../../store'; const BlockListContext = createContext(); diff --git a/packages/block-editor/src/components/block-settings/container.native.js b/packages/block-editor/src/components/block-settings/container.native.js index 7f9d8a9073d398..0ebc357e55b97d 100644 --- a/packages/block-editor/src/components/block-settings/container.native.js +++ b/packages/block-editor/src/components/block-settings/container.native.js @@ -6,6 +6,7 @@ import { BottomSheet, ColorSettings, FocalPointSettingsPanel, + ImageLinkDestinationsScreen, LinkPickerScreen, } from '@wordpress/components'; import { compose } from '@wordpress/compose'; @@ -21,6 +22,7 @@ export const blockSettingsScreens = { color: 'Color', focalPoint: 'FocalPoint', linkPicker: 'linkPicker', + imageLinkDestinations: 'imageLinkDestinations', }; function BottomSheetSettings( { @@ -75,6 +77,11 @@ function BottomSheetSettings( { returnScreenName={ blockSettingsScreens.settings } /> + + + ); diff --git a/packages/block-editor/src/components/block-styles/preview.native.js b/packages/block-editor/src/components/block-styles/preview.native.js index c017331ad38a32..1fce3ddc0acc5e 100644 --- a/packages/block-editor/src/components/block-styles/preview.native.js +++ b/packages/block-editor/src/components/block-styles/preview.native.js @@ -67,11 +67,11 @@ function StylePreview( { onPress, isActive, style, url } ) { ); const getOutline = ( outlineStyles ) => - outlineStyles.map( ( outlineStyle ) => { + outlineStyles.map( ( outlineStyle, index ) => { return ( ); } ); diff --git a/packages/block-library/src/image/edit.native.js b/packages/block-library/src/image/edit.native.js index 6468b9f7198622..82fd9f638a20c0 100644 --- a/packages/block-library/src/image/edit.native.js +++ b/packages/block-library/src/image/edit.native.js @@ -2,11 +2,12 @@ * External dependencies */ import { View, TouchableWithoutFeedback } from 'react-native'; +import { useRoute } from '@react-navigation/native'; /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; +import { Component, useEffect } from '@wordpress/element'; import { requestMediaImport, mediaUploadSync, @@ -41,6 +42,7 @@ import { BlockAlignmentToolbar, BlockStyles, store as blockEditorStore, + blockSettingsScreens, } from '@wordpress/block-editor'; import { __, _x, sprintf } from '@wordpress/i18n'; import { getProtocol, hasQueryArg } from '@wordpress/url'; @@ -63,6 +65,7 @@ import styles from './styles.scss'; import { getUpdatedLinkTargetSettings } from './utils'; import { + LINK_DESTINATION_NONE, LINK_DESTINATION_CUSTOM, LINK_DESTINATION_ATTACHMENT, LINK_DESTINATION_MEDIA, @@ -76,6 +79,101 @@ const getUrlForSlug = ( image, sizeSlug ) => { return image?.media_details?.sizes?.[ sizeSlug ]?.source_url; }; +function LinkSettings( { + attributes, + image, + isLinkSheetVisible, + setMappedAttributes, +} ) { + const route = useRoute(); + const { href: url, label, linkDestination, linkTarget, rel } = attributes; + + // Persist attributes passed from child screen + useEffect( () => { + const { inputValue: newUrl } = route.params || {}; + + let newLinkDestination; + switch ( newUrl ) { + case attributes.url: + newLinkDestination = LINK_DESTINATION_MEDIA; + break; + case image?.link: + newLinkDestination = LINK_DESTINATION_ATTACHMENT; + break; + case '': + newLinkDestination = LINK_DESTINATION_NONE; + break; + default: + newLinkDestination = LINK_DESTINATION_CUSTOM; + break; + } + + setMappedAttributes( { + url: newUrl, + linkDestination: newLinkDestination, + } ); + }, [ route.params?.inputValue ] ); + + let valueMask; + switch ( linkDestination ) { + case LINK_DESTINATION_MEDIA: + valueMask = __( 'Media File' ); + break; + case LINK_DESTINATION_ATTACHMENT: + valueMask = __( 'Attachment Page' ); + break; + case LINK_DESTINATION_CUSTOM: + valueMask = __( 'Custom URL' ); + break; + default: + valueMask = __( 'None' ); + break; + } + + const linkSettingsOptions = { + url: { + valueMask, + autoFocus: false, + autoFill: true, + }, + openInNewTab: { + label: __( 'Open in new tab' ), + }, + linkRel: { + label: __( 'Link Rel' ), + placeholder: _x( 'None', 'Link rel attribute value placeholder' ), + }, + }; + + return ( + + { + navigation.navigate( + blockSettingsScreens.imageLinkDestinations, + { + inputValue: attributes.href, + linkDestination: attributes.linkDestination, + imageUrl: attributes.url, + attachmentPageUrl: image?.link, + } + ); + } } + /> + + ); +} + export class ImageEdit extends Component { constructor( props ) { super( props ); @@ -96,7 +194,6 @@ export class ImageEdit extends Component { ); this.updateMediaProgress = this.updateMediaProgress.bind( this ); this.updateImageURL = this.updateImageURL.bind( this ); - this.onSetLinkDestination = this.onSetLinkDestination.bind( this ); this.onSetNewTab = this.onSetNewTab.bind( this ); this.onSetSizeSlug = this.onSetSizeSlug.bind( this ); this.onImagePressed = this.onImagePressed.bind( this ); @@ -108,25 +205,6 @@ export class ImageEdit extends Component { ); this.setMappedAttributes = this.setMappedAttributes.bind( this ); this.onSizeChangeValue = this.onSizeChangeValue.bind( this ); - - this.linkSettingsOptions = { - url: { - label: __( 'Image Link URL' ), - placeholder: __( 'Add URL' ), - autoFocus: false, - autoFill: true, - }, - openInNewTab: { - label: __( 'Open in new tab' ), - }, - linkRel: { - label: __( 'Link Rel' ), - placeholder: _x( - 'None', - 'Link rel attribute value placeholder' - ), - }, - }; } componentDidMount() { @@ -282,13 +360,6 @@ export class ImageEdit extends Component { } ); } - onSetLinkDestination( href ) { - this.props.setAttributes( { - linkDestination: LINK_DESTINATION_CUSTOM, - href, - } ); - } - onSetNewTab( value ) { const updatedLinkTarget = getUpdatedLinkTargetSettings( value, @@ -383,45 +454,23 @@ export class ImageEdit extends Component { : width; } - setMappedAttributes( { url: href, ...restAttributes } ) { + setMappedAttributes( { url: href, linkDestination, ...restAttributes } ) { const { setAttributes } = this.props; + if ( ! href && ! linkDestination ) { + linkDestination = LINK_DESTINATION_NONE; + } else if ( ! linkDestination ) { + linkDestination = LINK_DESTINATION_CUSTOM; + } - return href === undefined - ? setAttributes( { - ...restAttributes, - linkDestination: LINK_DESTINATION_CUSTOM, - } ) + return href === undefined || href === this.props.attributes.href + ? setAttributes( restAttributes ) : setAttributes( { ...restAttributes, + linkDestination, href, - linkDestination: LINK_DESTINATION_CUSTOM, } ); } - getLinkSettings() { - const { isLinkSheetVisible } = this.state; - const { - attributes: { href: url, ...unMappedAttributes }, - } = this.props; - const mappedAttributes = { ...unMappedAttributes, url }; - - return ( - - ); - } - getAltTextSettings() { const { attributes: { alt }, @@ -583,9 +632,12 @@ export class ImageEdit extends Component { ) } { this.getAltTextSettings() } - - { this.getLinkSettings( true ) } - + { + const dataControls = jest.requireActual( '@wordpress/data-controls' ); + return { + ...dataControls, + apiFetch: jest.fn(), + }; +} ); -const getStylesFromColorScheme = () => { - return { color: 'white' }; -}; +const apiFetchPromise = Promise.resolve( {} ); +apiFetch.mockImplementation( () => apiFetchPromise ); -const setAttributes = jest.fn(); +beforeAll( () => { + registerCoreBlocks(); + + // Mock Image.getSize to avoid failed attempt to size non-existant image + const getSizeSpy = jest.spyOn( Image, 'getSize' ); + getSizeSpy.mockImplementation( ( _url, callback ) => callback( 300, 200 ) ); +} ); + +afterAll( () => { + getBlockTypes().forEach( ( { name } ) => { + unregisterBlockType( name ); + } ); -const getImageComponent = ( attributes = {} ) => ( - -); + // Restore mocks + Image.getSize.mockRestore(); +} ); describe( 'Image Block', () => { - beforeEach( () => { - setAttributes.mockReset(); + it( 'sets link to None', async () => { + const initialHtml = ` + +
+ + + +
Mountain
+ `; + const screen = await initializeEditor( { initialHtml } ); + // We must await the image fetch via `getMedia` + await act( () => apiFetchPromise ); + + fireEvent.press( screen.getByA11yLabel( /Image Block/ ) ); + // Awaiting navigation event seemingly required due to React Navigation bug + // https://git.io/Ju35Z + await act( () => + fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) ) + ); + fireEvent.press( screen.getByText( 'Media File' ) ); + fireEvent.press( screen.getByText( 'None' ) ); + + const expectedHtml = ` +
Mountain
+`; + expect( getEditorHtml() ).toBe( expectedHtml ); + } ); + + it( 'sets link to Media File', async () => { + const initialHtml = ` + +
+ +
Mountain
+ `; + const screen = await initializeEditor( { initialHtml } ); + // We must await the image fetch via `getMedia` + await act( () => apiFetchPromise ); + + fireEvent.press( screen.getByA11yLabel( /Image Block/ ) ); + // Awaiting navigation event seemingly required due to React Navigation bug + // https://git.io/Ju35Z + await act( () => + fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) ) + ); + fireEvent.press( screen.getByText( 'None' ) ); + fireEvent.press( screen.getByText( 'Media File' ) ); + + const expectedHtml = ` +
Mountain
+`; + expect( getEditorHtml() ).toBe( expectedHtml ); } ); - it( 'renders without crashing', () => { - const component = renderer.create( getImageComponent() ); - const rendered = component.toJSON(); - expect( rendered ).toBeTruthy(); + it( 'sets link to Custom URL', async () => { + const initialHtml = ` + +
+ +
Mountain
+ `; + const screen = await initializeEditor( { initialHtml } ); + // We must await the image fetch via `getMedia` + await act( () => apiFetchPromise ); + + fireEvent.press( screen.getByA11yLabel( /Image Block/ ) ); + // Awaiting navigation event seemingly required due to React Navigation bug + // https://git.io/Ju35Z + await act( () => + fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) ) + ); + fireEvent.press( screen.getByText( 'None' ) ); + fireEvent.press( screen.getByText( 'Custom URL' ) ); + fireEvent.changeText( + screen.getByPlaceholderText( 'Search or type URL' ), + 'wordpress.org' + ); + fireEvent.press( screen.getByA11yLabel( 'Apply' ) ); + + const expectedHtml = ` +
Mountain
+`; + expect( getEditorHtml() ).toBe( expectedHtml ); } ); - it( 'sets link target', () => { - const component = renderer.create( getImageComponent() ); - const instance = component.getInstance(); + it( 'swaps the link between destinations', async () => { + const initialHtml = ` + +
+ +
Mountain
+ `; + const screen = await initializeEditor( { initialHtml } ); + // We must await the image fetch via `getMedia` + await act( () => apiFetchPromise ); - instance.onSetNewTab( true ); + fireEvent.press( screen.getByA11yLabel( /Image Block/ ) ); + // Awaiting navigation event seemingly required due to React Navigation bug + // https://git.io/Ju35Z + await act( () => + fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) ) + ); + fireEvent.press( screen.getByText( 'None' ) ); + fireEvent.press( screen.getByText( 'Media File' ) ); + fireEvent.press( screen.getByText( 'Custom URL' ) ); + fireEvent.changeText( + screen.getByPlaceholderText( 'Search or type URL' ), + 'wordpress.org' + ); + fireEvent.press( screen.getByA11yLabel( 'Apply' ) ); + fireEvent.press( screen.getByText( 'Custom URL' ) ); + fireEvent.press( screen.getByText( 'Media File' ) ); - expect( setAttributes ).toHaveBeenCalledWith( { - linkTarget: '_blank', - rel: undefined, - } ); + const expectedHtml = ` +
Mountain
+`; + expect( getEditorHtml() ).toBe( expectedHtml ); } ); - it( 'unset link target', () => { - const component = renderer.create( - getImageComponent( { - linkTarget: '_blank', - rel: NEW_TAB_REL.join( ' ' ), - } ) + it( 'does not display the Link To URL within the Custom URL input when set to Media File and query parameters are present', async () => { + const initialHtml = ` + +
+ + + +
Mountain
+ `; + const screen = await initializeEditor( { initialHtml } ); + // We must await the image fetch via `getMedia` + await act( () => apiFetchPromise ); + + fireEvent.press( screen.getByA11yLabel( /Image Block/ ) ); + // Awaiting navigation event seemingly required due to React Navigation bug + // https://git.io/Ju35Z + await act( () => + fireEvent.press( screen.getByA11yLabel( 'Open Settings' ) ) ); - const instance = component.getInstance(); + fireEvent.press( screen.getByText( 'Media File' ) ); + + expect( screen.queryByA11yLabel( /https:\/\/cldup\.com/ ) ).toBeNull(); + } ); + + it( 'sets link target', async () => { + const initialHtml = ` + +
+ + + +
Mountain
+ `; + const screen = await initializeEditor( { initialHtml } ); + // We must await the image fetch via `getMedia` + await act( () => apiFetchPromise ); + + const imageBlock = screen.getByA11yLabel( /Image Block/ ); + fireEvent.press( imageBlock ); + + const settingsButton = screen.getByA11yLabel( 'Open Settings' ); + // Awaiting navigation event seemingly required due to React Navigation bug + // https://git.io/Ju35Z + await act( () => fireEvent.press( settingsButton ) ); + + const linkTargetButton = screen.getByText( 'Open in new tab' ); + fireEvent.press( linkTargetButton ); + + const expectedHtml = ` +
Mountain
+`; + expect( getEditorHtml() ).toBe( expectedHtml ); + } ); + + it( 'unset link target', async () => { + const initialHtml = ` + +
+ + + +
Mountain
+
+ `; + const screen = await initializeEditor( { initialHtml } ); + // We must await the image fetch via `getMedia` + await act( () => apiFetchPromise ); + + const imageBlock = screen.getByA11yLabel( /Image Block/ ); + fireEvent.press( imageBlock ); + + const settingsButton = screen.getByA11yLabel( 'Open Settings' ); + // Awaiting navigation event seemingly required due to React Navigation bug + // https://git.io/Ju35Z + await act( () => fireEvent.press( settingsButton ) ); - instance.onSetNewTab( false ); + const linkTargetButton = screen.getByText( 'Open in new tab' ); + fireEvent.press( linkTargetButton ); - expect( setAttributes ).toHaveBeenCalledWith( { - linkTarget: undefined, - rel: undefined, - } ); + const expectedHtml = ` +
Mountain
+`; + expect( getEditorHtml() ).toBe( expectedHtml ); } ); } ); diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index d733021cba85dc..5b42724130a97f 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -103,6 +103,7 @@ export { default as LinkPickerScreen } from './mobile/link-picker/link-picker-sc export { default as LinkSettings } from './mobile/link-settings'; export { default as LinkSettingsScreen } from './mobile/link-settings/link-settings-screen'; export { default as LinkSettingsNavigation } from './mobile/link-settings/link-settings-navigation'; +export { default as ImageLinkDestinationsScreen } from './mobile/link-settings/image-link-destinations-screen'; export { default as SegmentedControl } from './mobile/segmented-control'; export { default as Image, IMAGE_DEFAULT_FOCAL_POINT } from './mobile/image'; export { default as ImageEditingButton } from './mobile/image/image-editing-button'; diff --git a/packages/components/src/mobile/bottom-sheet/cell.native.js b/packages/components/src/mobile/bottom-sheet/cell.native.js index 136e4daef1b220..ac780d9e0f65d5 100644 --- a/packages/components/src/mobile/bottom-sheet/cell.native.js +++ b/packages/components/src/mobile/bottom-sheet/cell.native.js @@ -106,6 +106,7 @@ class BottomSheetCell extends Component { valuePlaceholder = '', icon, leftAlign, + iconStyle = {}, labelStyle = {}, valueStyle = {}, cellContainerStyle = {}, @@ -307,7 +308,7 @@ class BottomSheetCell extends Component { ); }; - const iconStyle = getStylesFromColorScheme( + const iconStyleBase = getStylesFromColorScheme( styles.icon, styles.iconDark ); @@ -362,7 +363,11 @@ class BottomSheetCell extends Component { diff --git a/packages/components/src/mobile/link-settings/image-link-destinations-screen.native.js b/packages/components/src/mobile/link-settings/image-link-destinations-screen.native.js new file mode 100644 index 00000000000000..6cdfc5a781c6a0 --- /dev/null +++ b/packages/components/src/mobile/link-settings/image-link-destinations-screen.native.js @@ -0,0 +1,148 @@ +/** + * External dependencies + */ +import { useNavigation, useRoute } from '@react-navigation/native'; +import { StyleSheet } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Icon, check, chevronRight } from '@wordpress/icons'; +import { blockSettingsScreens } from '@wordpress/block-editor'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; +import PanelBody from '../../panel/body'; +import BottomSheet from '../bottom-sheet'; + +const LINK_DESTINATION_NONE = 'none'; +const LINK_DESTINATION_MEDIA = 'media'; +const LINK_DESTINATION_ATTACHMENT = 'attachment'; +const LINK_DESTINATION_CUSTOM = 'custom'; + +function LinkDestination( { + children, + isSelected, + label, + onPress, + value, + valueStyle, +} ) { + const optionIcon = usePreferredColorSchemeStyle( + styles.optionIcon, + styles.optionIconDark + ); + return ( + + { children } + + ); +} + +function ImageLinkDestinationsScreen( props ) { + const navigation = useNavigation(); + const route = useRoute(); + const { url = '' } = props; + const { inputValue = url, imageUrl, attachmentPageUrl, linkDestination } = + route.params || {}; + + function goToLinkPicker() { + navigation.navigate( blockSettingsScreens.linkPicker, { + inputValue: + linkDestination === LINK_DESTINATION_CUSTOM ? inputValue : '', + } ); + } + + const setLinkDestination = ( newLinkDestination ) => () => { + let newUrl; + switch ( newLinkDestination ) { + case LINK_DESTINATION_MEDIA: + newUrl = imageUrl; + break; + case LINK_DESTINATION_ATTACHMENT: + newUrl = attachmentPageUrl; + break; + default: + newUrl = ''; + break; + } + + navigation.navigate( blockSettingsScreens.settings, { + // The `inputValue` name is reused from LinkPicker, as it helps avoid + // bugs from stale values remaining in the React Navigation route + // parameters + inputValue: newUrl, + // Clear link text value that may be set from LinkPicker + text: '', + } ); + }; + + return ( + <> + + + + { __( 'Link To' ) } + + + + + + { !! attachmentPageUrl && ( + + ) } + + + + + + ); +} + +export default ImageLinkDestinationsScreen; diff --git a/packages/components/src/mobile/link-settings/index.native.js b/packages/components/src/mobile/link-settings/index.native.js index eaa00bf26ba259..70b5a3b6e9201f 100644 --- a/packages/components/src/mobile/link-settings/index.native.js +++ b/packages/components/src/mobile/link-settings/index.native.js @@ -86,9 +86,9 @@ function LinkSettings( { urlValue, // Attributes properties url, - label, + label = '', linkTarget, - rel, + rel = '', } ) { const [ urlInputValue, setUrlInputValue ] = useState( '' ); const [ labelInputValue, setLabelInputValue ] = useState( '' ); @@ -226,6 +226,7 @@ function LinkSettings( { ) : ( diff --git a/packages/components/src/mobile/link-settings/link-settings-screen.native.js b/packages/components/src/mobile/link-settings/link-settings-screen.native.js index cf81843d6ee16f..65c95187d2fcb8 100644 --- a/packages/components/src/mobile/link-settings/link-settings-screen.native.js +++ b/packages/components/src/mobile/link-settings/link-settings-screen.native.js @@ -20,17 +20,21 @@ const LinkSettingsScreen = ( props ) => { const { inputValue = url } = route.params || {}; const onLinkCellPressed = () => { - navigation.navigate( 'linkPicker', { inputValue } ); + if ( props.onLinkCellPressed ) { + props.onLinkCellPressed( { navigation } ); + } else { + navigation.navigate( 'linkPicker', { inputValue } ); + } }; return useMemo( () => { return ( ); }, [ props, inputValue, navigation, route ] ); diff --git a/packages/components/src/mobile/link-settings/style.native.scss b/packages/components/src/mobile/link-settings/style.native.scss index b9545b80ec4ab1..137c2e32dfc768 100644 --- a/packages/components/src/mobile/link-settings/style.native.scss +++ b/packages/components/src/mobile/link-settings/style.native.scss @@ -2,3 +2,20 @@ padding-left: 0; padding-right: 0; } + +// used in both light and dark modes +.placeholderTextColor { + color: #87a6bc; +} + +.optionIcon { + color: $blue-50; +} + +.optionIconDark { + color: $blue-30; +} + +.unselectedOptionIcon { + opacity: 0; +} diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 27442a817942b2..a055276d776242 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 +- [**] [Image block] Add ability to quickly link images to Media Files and Attachment Pages [#34846] ## 1.65.0 - [**] Search block - Text and background color support [#35511] diff --git a/test/native/setup.js b/test/native/setup.js index 318fbe03ff46ba..ffd651680b45b2 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import 'react-native-gesture-handler/jestSetup'; import { NativeModules as RNNativeModules } from 'react-native'; RNNativeModules.UIManager = RNNativeModules.UIManager || {};