UNPKG

@wordpress/block-library

Version:
722 lines (657 loc) 20 kB
/** * External dependencies */ import classnames from 'classnames'; import { compact, get, isEmpty, map, last, pick, } from 'lodash'; /** * WordPress dependencies */ import { getBlobByURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import { Button, ButtonGroup, IconButton, PanelBody, ResizableBox, SelectControl, Spinner, TextareaControl, TextControl, ToggleControl, Toolbar, withNotices, } from '@wordpress/components'; import { compose } from '@wordpress/compose'; import { withSelect } from '@wordpress/data'; import { BlockAlignmentToolbar, BlockControls, BlockIcon, InspectorControls, MediaPlaceholder, MediaUpload, MediaUploadCheck, RichText, } from '@wordpress/block-editor'; import { mediaUpload } from '@wordpress/editor'; import { Component, Fragment } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { getPath } from '@wordpress/url'; import { withViewportMatch } from '@wordpress/viewport'; /** * Internal dependencies */ import { createUpgradedEmbedBlock } from '../embed/util'; import icon from './icon'; import ImageSize from './image-size'; /** * Module constants */ const MIN_SIZE = 20; const LINK_DESTINATION_NONE = 'none'; const LINK_DESTINATION_MEDIA = 'media'; const LINK_DESTINATION_ATTACHMENT = 'attachment'; const LINK_DESTINATION_CUSTOM = 'custom'; const NEW_TAB_REL = 'noreferrer noopener'; const ALLOWED_MEDIA_TYPES = [ 'image' ]; export const pickRelevantMediaFiles = ( image ) => { const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] ); imageProps.url = get( image, [ 'sizes', 'large', 'url' ] ) || get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) || image.url; return imageProps; }; /** * Is the URL a temporary blob URL? A blob URL is one that is used temporarily * while the image is being uploaded and will not have an id yet allocated. * * @param {number=} id The id of the image. * @param {string=} url The url of the image. * * @return {boolean} Is the URL a Blob URL */ const isTemporaryImage = ( id, url ) => ! id && isBlobURL( url ); /** * Is the url for the image hosted externally. An externally hosted image has no id * and is not a blob url. * * @param {number=} id The id of the image. * @param {string=} url The url of the image. * * @return {boolean} Is the url an externally hosted url? */ const isExternalImage = ( id, url ) => url && ! id && ! isBlobURL( url ); class ImageEdit extends Component { constructor( { attributes } ) { 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.updateImageURL = this.updateImageURL.bind( this ); this.updateWidth = this.updateWidth.bind( this ); this.updateHeight = this.updateHeight.bind( this ); this.updateDimensions = this.updateDimensions.bind( this ); this.onSetCustomHref = this.onSetCustomHref.bind( this ); this.onSetLinkClass = this.onSetLinkClass.bind( this ); this.onSetLinkRel = this.onSetLinkRel.bind( this ); this.onSetLinkDestination = this.onSetLinkDestination.bind( this ); this.onSetNewTab = this.onSetNewTab.bind( this ); this.getFilename = this.getFilename.bind( this ); this.toggleIsEditing = this.toggleIsEditing.bind( this ); this.onUploadError = this.onUploadError.bind( this ); this.onImageError = this.onImageError.bind( this ); this.state = { captionFocused: false, isEditing: ! attributes.url, }; } componentDidMount() { const { attributes, setAttributes, noticeOperations } = this.props; const { id, url = '' } = attributes; if ( isTemporaryImage( id, url ) ) { const file = getBlobByURL( url ); if ( file ) { mediaUpload( { filesList: [ file ], onFileChange: ( [ image ] ) => { setAttributes( pickRelevantMediaFiles( image ) ); }, allowedTypes: ALLOWED_MEDIA_TYPES, onError: ( message ) => { noticeOperations.createErrorNotice( message ); this.setState( { isEditing: true } ); }, } ); } } } 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, } ); } } onUploadError( message ) { const { noticeOperations } = this.props; noticeOperations.createErrorNotice( message ); this.setState( { isEditing: true, } ); } onSelectImage( media ) { if ( ! media || ! media.url ) { this.props.setAttributes( { url: undefined, alt: undefined, id: undefined, caption: undefined, } ); return; } this.setState( { isEditing: false, } ); this.props.setAttributes( { ...pickRelevantMediaFiles( media ), width: undefined, height: undefined, } ); } onSetLinkDestination( value ) { let href; if ( value === LINK_DESTINATION_NONE ) { href = undefined; } else if ( value === LINK_DESTINATION_MEDIA ) { href = ( this.props.image && this.props.image.source_url ) || this.props.attributes.url; } else if ( value === LINK_DESTINATION_ATTACHMENT ) { href = this.props.image && this.props.image.link; } else { href = this.props.attributes.href; } this.props.setAttributes( { linkDestination: value, href, } ); } onSelectURL( newURL ) { const { url } = this.props.attributes; if ( newURL !== url ) { this.props.setAttributes( { url: newURL, id: undefined, } ); } this.setState( { isEditing: false, } ); } onImageError( url ) { // Check if there's an embed block that handles this URL. const embedBlock = createUpgradedEmbedBlock( { attributes: { url } } ); if ( undefined !== embedBlock ) { this.props.onReplace( embedBlock ); } } onSetCustomHref( value ) { this.props.setAttributes( { href: value } ); } onSetLinkClass( value ) { this.props.setAttributes( { linkClass: value } ); } onSetLinkRel( value ) { this.props.setAttributes( { rel: value } ); } onSetNewTab( value ) { const { rel } = this.props.attributes; const linkTarget = value ? '_blank' : undefined; let updatedRel = rel; if ( linkTarget && ! rel ) { updatedRel = NEW_TAB_REL; } else if ( ! linkTarget && rel === NEW_TAB_REL ) { updatedRel = undefined; } this.props.setAttributes( { linkTarget, rel: updatedRel, } ); } onFocusCaption() { if ( ! this.state.captionFocused ) { this.setState( { captionFocused: true, } ); } } onImageClick() { if ( this.state.captionFocused ) { this.setState( { captionFocused: false, } ); } } updateAlt( newAlt ) { this.props.setAttributes( { alt: newAlt } ); } updateAlignment( nextAlign ) { const extraUpdatedAttributes = [ 'wide', 'full' ].indexOf( nextAlign ) !== -1 ? { width: undefined, height: undefined } : {}; this.props.setAttributes( { ...extraUpdatedAttributes, align: nextAlign } ); } updateImageURL( url ) { this.props.setAttributes( { url, width: undefined, height: undefined } ); } updateWidth( width ) { this.props.setAttributes( { width: parseInt( width, 10 ) } ); } updateHeight( height ) { this.props.setAttributes( { height: parseInt( height, 10 ) } ); } updateDimensions( width = undefined, height = undefined ) { return () => { this.props.setAttributes( { width, height } ); }; } getFilename( url ) { const path = getPath( url ); if ( path ) { return last( path.split( '/' ) ); } } getLinkDestinationOptions() { return [ { value: LINK_DESTINATION_NONE, label: __( 'None' ) }, { value: LINK_DESTINATION_MEDIA, label: __( 'Media File' ) }, { value: LINK_DESTINATION_ATTACHMENT, label: __( 'Attachment Page' ) }, { value: LINK_DESTINATION_CUSTOM, label: __( 'Custom URL' ) }, ]; } toggleIsEditing() { this.setState( { isEditing: ! this.state.isEditing, } ); } getImageSizeOptions() { const { imageSizes, image } = this.props; return compact( map( imageSizes, ( { name, slug } ) => { const sizeUrl = get( image, [ 'media_details', 'sizes', slug, 'source_url' ] ); if ( ! sizeUrl ) { return null; } return { value: sizeUrl, label: name, }; } ) ); } render() { const { isEditing } = this.state; const { attributes, setAttributes, isLargeViewport, isSelected, className, maxWidth, noticeUI, toggleSelection, isRTL, } = this.props; const { url, alt, caption, align, id, href, rel, linkClass, linkDestination, width, height, linkTarget, } = attributes; const isExternal = isExternalImage( id, url ); let toolbarEditButton; if ( url ) { if ( isExternal ) { toolbarEditButton = ( <Toolbar> <IconButton className="components-icon-button components-toolbar__control" label={ __( 'Edit image' ) } onClick={ this.toggleIsEditing } icon="edit" /> </Toolbar> ); } else { toolbarEditButton = ( <MediaUploadCheck> <Toolbar> <MediaUpload onSelect={ this.onSelectImage } allowedTypes={ ALLOWED_MEDIA_TYPES } value={ id } render={ ( { open } ) => ( <IconButton className="components-toolbar__control" label={ __( 'Edit image' ) } icon="edit" onClick={ open } /> ) } /> </Toolbar> </MediaUploadCheck> ); } } const controls = ( <BlockControls> <BlockAlignmentToolbar value={ align } onChange={ this.updateAlignment } /> { toolbarEditButton } </BlockControls> ); if ( isEditing || ! url ) { const src = isExternal ? url : undefined; return ( <Fragment> { controls } <MediaPlaceholder icon={ <BlockIcon icon={ icon } /> } className={ className } onSelect={ this.onSelectImage } onSelectURL={ this.onSelectURL } notices={ noticeUI } onError={ this.onUploadError } accept="image/*" allowedTypes={ ALLOWED_MEDIA_TYPES } value={ { id, src } } /> </Fragment> ); } const classes = classnames( className, { 'is-transient': isBlobURL( url ), 'is-resized': !! width || !! height, 'is-focused': isSelected, } ); const isResizable = [ 'wide', 'full' ].indexOf( align ) === -1 && isLargeViewport; const isLinkURLInputReadOnly = linkDestination !== LINK_DESTINATION_CUSTOM; const imageSizeOptions = this.getImageSizeOptions(); const getInspectorControls = ( imageWidth, imageHeight ) => ( <InspectorControls> <PanelBody title={ __( 'Image Settings' ) }> <TextareaControl label={ __( 'Alt Text (Alternative Text)' ) } value={ alt } onChange={ this.updateAlt } help={ __( 'Alternative text describes your image to people who can’t see it. Add a short description with its key details.' ) } /> { ! isEmpty( imageSizeOptions ) && ( <SelectControl label={ __( 'Image Size' ) } value={ url } options={ imageSizeOptions } onChange={ this.updateImageURL } /> ) } { isResizable && ( <div className="block-library-image__dimensions"> <p className="block-library-image__dimensions__row"> { __( 'Image Dimensions' ) } </p> <div className="block-library-image__dimensions__row"> <TextControl type="number" className="block-library-image__dimensions__width" label={ __( 'Width' ) } value={ width !== undefined ? width : imageWidth } min={ 1 } onChange={ this.updateWidth } /> <TextControl type="number" className="block-library-image__dimensions__height" label={ __( 'Height' ) } value={ height !== undefined ? height : imageHeight } min={ 1 } onChange={ this.updateHeight } /> </div> <div className="block-library-image__dimensions__row"> <ButtonGroup aria-label={ __( 'Image Size' ) }> { [ 25, 50, 75, 100 ].map( ( scale ) => { const scaledWidth = Math.round( imageWidth * ( scale / 100 ) ); const scaledHeight = Math.round( imageHeight * ( scale / 100 ) ); const isCurrent = width === scaledWidth && height === scaledHeight; return ( <Button key={ scale } isSmall isPrimary={ isCurrent } aria-pressed={ isCurrent } onClick={ this.updateDimensions( scaledWidth, scaledHeight ) } > { scale }% </Button> ); } ) } </ButtonGroup> <Button isSmall onClick={ this.updateDimensions() } > { __( 'Reset' ) } </Button> </div> </div> ) } </PanelBody> <PanelBody title={ __( 'Link Settings' ) }> <SelectControl label={ __( 'Link To' ) } value={ linkDestination } options={ this.getLinkDestinationOptions() } onChange={ this.onSetLinkDestination } /> { linkDestination !== LINK_DESTINATION_NONE && ( <Fragment> <TextControl label={ __( 'Link URL' ) } value={ href || '' } onChange={ this.onSetCustomHref } placeholder={ ! isLinkURLInputReadOnly ? 'https://' : undefined } readOnly={ isLinkURLInputReadOnly } /> <ToggleControl label={ __( 'Open in New Tab' ) } onChange={ this.onSetNewTab } checked={ linkTarget === '_blank' } /> <TextControl label={ __( 'Link CSS Class' ) } value={ linkClass || '' } onChange={ this.onSetLinkClass } /> <TextControl label={ __( 'Link Rel' ) } value={ rel || '' } onChange={ this.onSetLinkRel } /> </Fragment> ) } </PanelBody> </InspectorControls> ); // Disable reason: Each block can be selected by clicking on it /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ return ( <Fragment> { controls } <figure className={ classes }> <ImageSize src={ url } dirtynessTrigger={ align }> { ( sizes ) => { const { imageWidthWithinContainer, imageHeightWithinContainer, imageWidth, imageHeight, } = sizes; const filename = this.getFilename( url ); let defaultedAlt; if ( alt ) { defaultedAlt = alt; } else if ( filename ) { defaultedAlt = sprintf( __( '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 */ <Fragment> <img src={ url } alt={ defaultedAlt } onClick={ this.onImageClick } onError={ () => this.onImageError( url ) } /> { isBlobURL( url ) && <Spinner /> } </Fragment> /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ ); if ( ! isResizable || ! imageWidthWithinContainer ) { return ( <Fragment> { getInspectorControls( imageWidth, imageHeight ) } <div style={ { width, height } }> { img } </div> </Fragment> ); } 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 */ return ( <Fragment> { getInspectorControls( imageWidth, imageHeight ) } <ResizableBox size={ width && height ? { width, height, } : undefined } minWidth={ minWidth } maxWidth={ maxWidthBuffer } minHeight={ minHeight } maxHeight={ maxWidthBuffer / ratio } lockAspectRatio enable={ { top: false, right: showRightHandle, bottom: true, left: showLeftHandle, } } onResizeStart={ () => { toggleSelection( false ); } } onResizeStop={ ( event, direction, elt, delta ) => { setAttributes( { width: parseInt( currentWidth + delta.width, 10 ), height: parseInt( currentHeight + delta.height, 10 ), } ); toggleSelection( true ); } } > { img } </ResizableBox> </Fragment> ); } } </ImageSize> { ( ! RichText.isEmpty( caption ) || isSelected ) && ( <RichText tagName="figcaption" placeholder={ __( 'Write caption…' ) } value={ caption } unstableOnFocus={ this.onFocusCaption } onChange={ ( value ) => setAttributes( { caption: value } ) } isSelected={ this.state.captionFocused } inlineToolbar /> ) } </figure> </Fragment> ); /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ } } export default compose( [ withSelect( ( select, props ) => { const { getMedia } = select( 'core' ); const { getSettings } = select( 'core/block-editor' ); const { id } = props.attributes; const { maxWidth, isRTL, imageSizes } = getSettings(); return { image: id ? getMedia( id ) : null, maxWidth, isRTL, imageSizes, }; } ), withViewportMatch( { isLargeViewport: 'medium' } ), withNotices, ] )( ImageEdit );