@wordpress/block-library
Version:
Block library for the WordPress editor.
722 lines (657 loc) • 20 kB
JavaScript
/**
* 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 );