From 1063573c5f1c95f05b4ab8e42b763158c7c79cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Sat, 23 May 2020 17:39:23 +0200 Subject: [PATCH] Image block: use hooks (#22499) * Image block: use hooks * Use data hooks * Formatting * Fix e2e tests * Fix useSelect * Clean up * Fix select --- packages/block-library/src/image/edit.js | 917 +++++++++++------------ 1 file changed, 428 insertions(+), 489 deletions(-) diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index c947b7c1a62447..cb151819f4c3ad 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { get, filter, map, last, omit, pick } from 'lodash'; +import { get, filter, map, last, omit, pick, includes } from 'lodash'; /** * WordPress dependencies @@ -18,8 +18,8 @@ import { ToolbarGroup, withNotices, } from '@wordpress/components'; -import { compose } from '@wordpress/compose'; -import { withSelect, withDispatch } from '@wordpress/data'; +import { useViewportMatch } from '@wordpress/compose'; +import { useSelect, useDispatch } from '@wordpress/data'; import { BlockAlignmentToolbar, BlockControls, @@ -33,10 +33,9 @@ import { __experimentalImageSizeControl as ImageSizeControl, __experimentalImageURLInputUI as ImageURLInputUI, } from '@wordpress/block-editor'; -import { Component } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { getPath } from '@wordpress/url'; -import { withViewportMatch } from '@wordpress/viewport'; import { image as icon } from '@wordpress/icons'; import { createBlock } from '@wordpress/blocks'; @@ -45,6 +44,7 @@ import { createBlock } from '@wordpress/blocks'; */ import { createUpgradedEmbedBlock } from '../embed/util'; import ImageSize from './image-size'; + /** * Module constants */ @@ -87,80 +87,75 @@ const isTemporaryImage = ( id, url ) => ! id && isBlobURL( url ); */ const isExternalImage = ( id, url ) => url && ! id && ! isBlobURL( url ); -export class ImageEdit extends Component { - constructor() { - super( ...arguments ); - this.updateAlt = this.updateAlt.bind( this ); - this.updateAlignment = this.updateAlignment.bind( this ); - this.onFocusCaption = this.onFocusCaption.bind( this ); - this.onImageClick = this.onImageClick.bind( this ); - this.onSelectImage = this.onSelectImage.bind( this ); - this.onSelectURL = this.onSelectURL.bind( this ); - this.updateImage = this.updateImage.bind( this ); - this.onSetHref = this.onSetHref.bind( this ); - this.onSetTitle = this.onSetTitle.bind( this ); - this.getFilename = this.getFilename.bind( this ); - this.onUploadError = this.onUploadError.bind( this ); - this.onImageError = this.onImageError.bind( this ); - - this.state = { - captionFocused: false, - }; +function getFilename( url ) { + const path = getPath( url ); + if ( path ) { + return last( path.split( '/' ) ); } +} - componentDidMount() { - const { attributes, mediaUpload, noticeOperations } = this.props; - const { id, url = '' } = attributes; - - if ( isTemporaryImage( id, url ) ) { - const file = getBlobByURL( url ); - - if ( file ) { - mediaUpload( { - filesList: [ file ], - onFileChange: ( [ image ] ) => { - this.onSelectImage( image ); - }, - allowedTypes: ALLOWED_MEDIA_TYPES, - onError: ( message ) => { - noticeOperations.createErrorNotice( message ); - }, - } ); - } - } +export function ImageEdit( { + attributes: { + url = '', + alt, + caption, + align, + id, + href, + rel, + linkClass, + linkDestination, + title, + width, + height, + linkTarget, + sizeSlug, + }, + setAttributes, + isSelected, + className, + noticeUI, + insertBlocksAfter, + noticeOperations, + onReplace, +} ) { + const { image, maxWidth, isRTL, imageSizes, mediaUpload } = useSelect( + ( select ) => { + const { getMedia } = select( 'core' ); + const { getSettings } = select( 'core/block-editor' ); + return { + ...pick( getSettings(), [ + 'mediaUpload', + 'imageSizes', + 'isRTL', + 'maxWidth', + ] ), + image: id && isSelected ? getMedia( id ) : null, + }; + }, + [ id, isSelected ] + ); + const { toggleSelection } = useDispatch( 'core/block-editor' ); + const isLargeViewport = useViewportMatch( 'medium' ); + const [ captionFocused, setCaptionFocused ] = useState( false ); + const isWideAligned = includes( [ 'wide', 'full' ], align ); + + function onResizeStart() { + toggleSelection( false ); } - componentDidUpdate( prevProps ) { - const { id: prevID, url: prevURL = '' } = prevProps.attributes; - const { id, url = '' } = this.props.attributes; - - if ( - isTemporaryImage( prevID, prevURL ) && - ! isTemporaryImage( id, url ) - ) { - revokeBlobURL( url ); - } - - if ( - ! this.props.isSelected && - prevProps.isSelected && - this.state.captionFocused - ) { - this.setState( { - captionFocused: false, - } ); - } + function onResizeStop() { + toggleSelection( true ); } - onUploadError( message ) { - const { noticeOperations } = this.props; + function onUploadError( message ) { noticeOperations.removeAllNotices(); noticeOperations.createErrorNotice( message ); } - onSelectImage( media ) { + function onSelectImage( media ) { if ( ! media || ! media.url ) { - this.props.setAttributes( { + setAttributes( { url: undefined, alt: undefined, id: undefined, @@ -170,14 +165,6 @@ export class ImageEdit extends Component { return; } - const { - id, - url, - alt, - caption, - linkDestination, - } = this.props.attributes; - let mediaAttributes = pickRelevantMediaFiles( media ); // If the current image is temporary but an alt text was meanwhile written by the user, @@ -189,7 +176,7 @@ export class ImageEdit extends Component { } // If a caption text was meanwhile written by the user, - // make sure the text is not overwritten by empty captions + // make sure the text is not overwritten by empty captions. if ( caption && ! get( mediaAttributes, [ 'caption' ] ) ) { mediaAttributes = omit( mediaAttributes, [ 'caption' ] ); } @@ -203,7 +190,8 @@ export class ImageEdit extends Component { sizeSlug: DEFAULT_SIZE_SLUG, }; } else { - // Keep the same url when selecting the same file, so "Image Size" option is not changed. + // Keep the same url when selecting the same file, so "Image Size" + // option is not changed. additionalAttributes = { url }; } @@ -219,17 +207,15 @@ export class ImageEdit extends Component { mediaAttributes.href = media.link; } - this.props.setAttributes( { + setAttributes( { ...mediaAttributes, ...additionalAttributes, } ); } - onSelectURL( newURL ) { - const { url } = this.props.attributes; - + function onSelectURL( newURL ) { if ( newURL !== url ) { - this.props.setAttributes( { + setAttributes( { url: newURL, id: undefined, sizeSlug: DEFAULT_SIZE_SLUG, @@ -237,84 +223,70 @@ export class ImageEdit extends Component { } } - onImageError( url ) { + function onImageError() { // Check if there's an embed block that handles this URL. const embedBlock = createUpgradedEmbedBlock( { attributes: { url } } ); if ( undefined !== embedBlock ) { - this.props.onReplace( embedBlock ); + onReplace( embedBlock ); } } - onSetHref( props ) { - this.props.setAttributes( props ); + function onSetHref( props ) { + setAttributes( props ); } - onSetTitle( value ) { - // This is the HTML title attribute, separate from the media object title - this.props.setAttributes( { title: value } ); + function onSetTitle( value ) { + // This is the HTML title attribute, separate from the media object + // title. + setAttributes( { title: value } ); } - onFocusCaption() { - if ( ! this.state.captionFocused ) { - this.setState( { - captionFocused: true, - } ); + function onFocusCaption() { + if ( ! captionFocused ) { + setCaptionFocused( true ); } } - onImageClick() { - if ( this.state.captionFocused ) { - this.setState( { - captionFocused: false, - } ); + function onImageClick() { + if ( captionFocused ) { + setCaptionFocused( false ); } } - updateAlt( newAlt ) { - this.props.setAttributes( { alt: newAlt } ); + function updateAlt( newAlt ) { + setAttributes( { alt: newAlt } ); } - updateAlignment( nextAlign ) { - const extraUpdatedAttributes = - [ 'wide', 'full' ].indexOf( nextAlign ) !== -1 - ? { width: undefined, height: undefined } - : {}; - this.props.setAttributes( { + function updateAlignment( nextAlign ) { + const extraUpdatedAttributes = isWideAligned + ? { width: undefined, height: undefined } + : {}; + setAttributes( { ...extraUpdatedAttributes, align: nextAlign, } ); } - updateImage( sizeSlug ) { - const { image } = this.props; - - const url = get( image, [ + function updateImage( newSizeSlug ) { + const newUrl = get( image, [ 'media_details', 'sizes', - sizeSlug, + newSizeSlug, 'source_url', ] ); - if ( ! url ) { + if ( ! newUrl ) { return null; } - this.props.setAttributes( { + setAttributes( { url, width: undefined, height: undefined, - sizeSlug, + sizeSlug: newSizeSlug, } ); } - getFilename( url ) { - const path = getPath( url ); - if ( path ) { - return last( path.split( '/' ) ); - } - } - - getImageSizeOptions() { - const { imageSizes, image } = this.props; + function getImageSizeOptions() { return map( filter( imageSizes, ( { slug } ) => get( image, [ 'media_details', 'sizes', slug, 'source_url' ] ) @@ -323,399 +295,366 @@ export class ImageEdit extends Component { ); } - render() { - const { - attributes, - setAttributes, - isLargeViewport, - isSelected, - className, - maxWidth, - noticeUI, - isRTL, - onResizeStart, - onResizeStop, - insertBlocksAfter, - } = this.props; - const { - url, - alt, - caption, - align, - id, - href, - rel, - linkClass, - linkDestination, - title, - width, - height, - linkTarget, - sizeSlug, - } = attributes; - - const isExternal = isExternalImage( id, url ); - const controls = ( - - - { url && ( - - ) } - { url && ( - - - - ) } - - ); - const src = isExternal ? url : undefined; - const mediaPreview = !! url && ( - { - ); - - const mediaPlaceholder = ( - } - onSelect={ this.onSelectImage } - onSelectURL={ this.onSelectURL } - notices={ noticeUI } - onError={ this.onUploadError } - accept="image/*" - allowedTypes={ ALLOWED_MEDIA_TYPES } - value={ { id, src } } - mediaPreview={ mediaPreview } - disableMediaButtons={ url } - /> - ); + const isTemp = isTemporaryImage( id, url ); - if ( ! url ) { - return ( - <> - { controls } - { mediaPlaceholder } - - ); + // Upload a temporary image on mount. + useEffect( () => { + if ( ! isTemp ) { + return; } - const classes = classnames( className, { - 'is-transient': isBlobURL( url ), - 'is-resized': !! width || !! height, - 'is-focused': isSelected, - [ `size-${ sizeSlug }` ]: sizeSlug, - } ); + const file = getBlobByURL( url ); + + if ( file ) { + mediaUpload( { + filesList: [ file ], + onFileChange: ( [ img ] ) => { + onSelectImage( img ); + }, + allowedTypes: ALLOWED_MEDIA_TYPES, + onError: ( message ) => { + noticeOperations.createErrorNotice( message ); + }, + } ); + } + }, [] ); - const isResizable = - [ 'wide', 'full' ].indexOf( align ) === -1 && isLargeViewport; + // If an image is temporary, revoke the Blob url when it is uploaded (and is + // no longer temporary). + useEffect( () => { + if ( ! isTemp ) { + return; + } - const imageSizeOptions = this.getImageSizeOptions(); + return () => { + revokeBlobURL( url ); + }; + }, [ isTemp ] ); - const getInspectorControls = ( imageWidth, imageHeight ) => ( + useEffect( () => { + if ( ! isSelected ) { + setCaptionFocused( false ); + } + }, [ isSelected ] ); + + const isExternal = isExternalImage( id, url ); + const controls = ( + + + { url && ( + + ) } + { url && ( + + + + ) } + + ); + const src = isExternal ? url : undefined; + const mediaPreview = !! url && ( + { + ); + + const mediaPlaceholder = ( + } + onSelect={ onSelectImage } + onSelectURL={ onSelectURL } + notices={ noticeUI } + onError={ onUploadError } + accept="image/*" + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ { id, src } } + mediaPreview={ mediaPreview } + disableMediaButtons={ url } + /> + ); + + if ( ! url ) { + return ( <> - - - - - { __( - 'Describe the purpose of the image' - ) } - - { __( - 'Leave empty if the image is purely decorative.' - ) } - - } - /> - setAttributes( value ) } - slug={ sizeSlug } - width={ width } - height={ height } - imageSizeOptions={ imageSizeOptions } - isResizable={ isResizable } - imageWidth={ imageWidth } - imageHeight={ imageHeight } - /> - - - - { mediaPlaceholder } + + ); + } + + const classes = classnames( className, { + 'is-transient': isBlobURL( url ), + 'is-resized': !! width || !! height, + 'is-focused': isSelected, + [ `size-${ sizeSlug }` ]: sizeSlug, + } ); + + const isResizable = ! isWideAligned && isLargeViewport; + const imageSizeOptions = getImageSizeOptions(); + + const getInspectorControls = ( imageWidth, imageHeight ) => ( + <> + + + - { __( - 'Describe the role of this image on the page.' - ) } - + { __( - '(Note: many devices and browsers do not display this text.)' + 'Describe the purpose of the image' ) } + { __( + 'Leave empty if the image is purely decorative.' + ) } } /> - - - ); - - // Disable reason: Each block can be selected by clicking on it - /* eslint-disable jsx-a11y/click-events-have-key-events */ - return ( - <> - { controls } - - - { ( sizes ) => { - const { - imageWidthWithinContainer, - imageHeightWithinContainer, - imageWidth, - imageHeight, - } = sizes; - - const filename = this.getFilename( url ); - let defaultedAlt; - if ( alt ) { - defaultedAlt = alt; - } else if ( filename ) { - defaultedAlt = sprintf( - /* translators: %s: file name */ - __( - 'This image has an empty alt attribute; its file name is %s' - ), - filename - ); - } else { - defaultedAlt = __( - 'This image has an empty alt attribute' - ); - } - - const img = ( - // Disable reason: Image itself is not meant to be interactive, but - // should direct focus to block. - /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ - <> - { - this.onImageError( url ) - } - /> - { isBlobURL( url ) && } - - /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + setAttributes( value ) } + slug={ sizeSlug } + width={ width } + height={ height } + imageSizeOptions={ imageSizeOptions } + isResizable={ isResizable } + imageWidth={ imageWidth } + imageHeight={ imageHeight } + /> + + + + + { __( + 'Describe the role of this image on the page.' + ) } + + { __( + '(Note: many devices and browsers do not display this text.)' + ) } + + + } + /> + + + ); + + // Disable reason: Each block can be selected by clicking on it. + /* eslint-disable jsx-a11y/click-events-have-key-events */ + return ( + <> + { controls } + + + { ( sizes ) => { + const { + imageWidthWithinContainer, + imageHeightWithinContainer, + imageWidth, + imageHeight, + } = sizes; + + const filename = getFilename( url ); + let defaultedAlt; + if ( alt ) { + defaultedAlt = alt; + } else if ( filename ) { + defaultedAlt = sprintf( + /* translators: %s: file name */ + __( + 'This image has an empty alt attribute; its file name is %s' + ), + filename ); + } else { + defaultedAlt = __( + 'This image has an empty alt attribute' + ); + } - if ( - ! isResizable || - ! imageWidthWithinContainer - ) { - return ( - <> - { getInspectorControls( - imageWidth, - imageHeight - ) } -
- { img } -
- - ); - } - - const currentWidth = - width || imageWidthWithinContainer; - const currentHeight = - height || imageHeightWithinContainer; - - const ratio = imageWidth / imageHeight; - const minWidth = - imageWidth < imageHeight - ? MIN_SIZE - : MIN_SIZE * ratio; - const minHeight = - imageHeight < imageWidth - ? MIN_SIZE - : MIN_SIZE / ratio; - - // With the current implementation of ResizableBox, an image needs an explicit pixel value for the max-width. - // In absence of being able to set the content-width, this max-width is currently dictated by the vanilla editor style. - // The following variable adds a buffer to this vanilla style, so 3rd party themes have some wiggleroom. - // This does, in most cases, allow you to scale the image beyond the width of the main column, though not infinitely. - // @todo It would be good to revisit this once a content-width variable becomes available. - const maxWidthBuffer = maxWidth * 2.5; - - let showRightHandle = false; - let showLeftHandle = false; - - /* eslint-disable no-lonely-if */ - // See https://github.com/WordPress/gutenberg/issues/7584. - if ( align === 'center' ) { - // When the image is centered, show both handles. - showRightHandle = true; - showLeftHandle = true; - } else if ( isRTL ) { - // In RTL mode the image is on the right by default. - // Show the right handle and hide the left handle only when it is aligned left. - // Otherwise always show the left handle. - if ( align === 'left' ) { - showRightHandle = true; - } else { - showLeftHandle = true; - } - } else { - // Show the left handle and hide the right handle only when the image is aligned right. - // Otherwise always show the right handle. - if ( align === 'right' ) { - showLeftHandle = true; - } else { - showRightHandle = true; - } - } - /* eslint-enable no-lonely-if */ + const img = ( + // Disable reason: Image itself is not meant to be interactive, but + // should direct focus to block. + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + <> + { onImageError() } + /> + { isBlobURL( url ) && } + + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + ); + if ( ! isResizable || ! imageWidthWithinContainer ) { return ( <> { getInspectorControls( imageWidth, imageHeight ) } - { - onResizeStop(); - setAttributes( { - width: parseInt( - currentWidth + delta.width, - 10 - ), - height: parseInt( - currentHeight + - delta.height, - 10 - ), - } ); - } } - > +
{ img } - +
); - } } -
- { ( ! RichText.isEmpty( caption ) || isSelected ) && ( - - setAttributes( { caption: value } ) + } + + const currentWidth = width || imageWidthWithinContainer; + const currentHeight = + height || imageHeightWithinContainer; + + const ratio = imageWidth / imageHeight; + const minWidth = + imageWidth < imageHeight + ? MIN_SIZE + : MIN_SIZE * ratio; + const minHeight = + imageHeight < imageWidth + ? MIN_SIZE + : MIN_SIZE / ratio; + + // With the current implementation of ResizableBox, an image needs an explicit pixel value for the max-width. + // In absence of being able to set the content-width, this max-width is currently dictated by the vanilla editor style. + // The following variable adds a buffer to this vanilla style, so 3rd party themes have some wiggleroom. + // This does, in most cases, allow you to scale the image beyond the width of the main column, though not infinitely. + // @todo It would be good to revisit this once a content-width variable becomes available. + const maxWidthBuffer = maxWidth * 2.5; + + let showRightHandle = false; + let showLeftHandle = false; + + /* eslint-disable no-lonely-if */ + // See https://github.com/WordPress/gutenberg/issues/7584. + if ( align === 'center' ) { + // When the image is centered, show both handles. + showRightHandle = true; + showLeftHandle = true; + } else if ( isRTL ) { + // In RTL mode the image is on the right by default. + // Show the right handle and hide the left handle only when it is aligned left. + // Otherwise always show the left handle. + if ( align === 'left' ) { + showRightHandle = true; + } else { + showLeftHandle = true; } - isSelected={ this.state.captionFocused } - inlineToolbar - __unstableOnSplitAtEnd={ () => - insertBlocksAfter( - createBlock( 'core/paragraph' ) - ) + } else { + // Show the left handle and hide the right handle only when the image is aligned right. + // Otherwise always show the right handle. + if ( align === 'right' ) { + showLeftHandle = true; + } else { + showRightHandle = true; } - /> - ) } + } + /* eslint-enable no-lonely-if */ - { mediaPlaceholder } -
- - ); - /* eslint-enable jsx-a11y/click-events-have-key-events */ - } -} + return ( + <> + { getInspectorControls( + imageWidth, + imageHeight + ) } + { + onResizeStop(); + setAttributes( { + width: parseInt( + currentWidth + delta.width, + 10 + ), + height: parseInt( + currentHeight + delta.height, + 10 + ), + } ); + } } + > + { img } + + + ); + } } +
+ { ( ! RichText.isEmpty( caption ) || isSelected ) && ( + + setAttributes( { caption: value } ) + } + isSelected={ captionFocused } + inlineToolbar + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( createBlock( 'core/paragraph' ) ) + } + /> + ) } -export default compose( [ - withDispatch( ( dispatch ) => { - const { toggleSelection } = dispatch( 'core/block-editor' ); + { mediaPlaceholder } +
+ + ); + /* eslint-enable jsx-a11y/click-events-have-key-events */ +} - return { - onResizeStart: () => toggleSelection( false ), - onResizeStop: () => toggleSelection( true ), - }; - } ), - withSelect( ( select, props ) => { - const { getMedia } = select( 'core' ); - const { getSettings } = select( 'core/block-editor' ); - const { - attributes: { id }, - isSelected, - } = props; - const { mediaUpload, imageSizes, isRTL, maxWidth } = getSettings(); - - return { - image: id && isSelected ? getMedia( id ) : null, - maxWidth, - isRTL, - imageSizes, - mediaUpload, - }; - } ), - withViewportMatch( { isLargeViewport: 'medium' } ), - withNotices, -] )( ImageEdit ); +export default withNotices( ImageEdit );