@wordpress/block-library
Version:
Block library for the WordPress editor.
722 lines (665 loc) • 18.5 kB
JavaScript
/**
* External dependencies
*/
import clsx from 'clsx';
/**
* WordPress dependencies
*/
import {
BaseControl,
PanelBody,
SelectControl,
ToggleControl,
RangeControl,
Spinner,
MenuGroup,
MenuItem,
ToolbarDropdownMenu,
} from '@wordpress/components';
import {
store as blockEditorStore,
MediaPlaceholder,
InspectorControls,
useBlockProps,
useInnerBlocksProps,
BlockControls,
MediaReplaceFlow,
useSettings,
} from '@wordpress/block-editor';
import { Platform, useEffect, useMemo } from '@wordpress/element';
import { __, _x, sprintf } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import { View } from '@wordpress/primitives';
import { createBlock } from '@wordpress/blocks';
import { createBlobURL } from '@wordpress/blob';
import { store as noticesStore } from '@wordpress/notices';
import {
link as linkIcon,
customLink,
image as imageIcon,
linkOff,
fullscreen,
} from '@wordpress/icons';
/**
* Internal dependencies
*/
import { sharedIcon } from './shared-icon';
import { defaultColumnsNumber, pickRelevantMediaFiles } from './shared';
import { getHrefAndDestination } from './utils';
import {
getUpdatedLinkTargetSettings,
getImageSizeAttributes,
} from '../image/utils';
import Gallery from './gallery';
import {
LINK_DESTINATION_ATTACHMENT,
LINK_DESTINATION_MEDIA,
LINK_DESTINATION_NONE,
LINK_DESTINATION_LIGHTBOX,
} from './constants';
import useImageSizes from './use-image-sizes';
import useGetNewImages from './use-get-new-images';
import useGetMedia from './use-get-media';
import GapStyles from './gap-styles';
const MAX_COLUMNS = 8;
const LINK_OPTIONS = [
{
icon: customLink,
label: __( 'Link images to attachment pages' ),
value: LINK_DESTINATION_ATTACHMENT,
noticeText: __( 'Attachment Pages' ),
},
{
icon: imageIcon,
label: __( 'Link images to media files' ),
value: LINK_DESTINATION_MEDIA,
noticeText: __( 'Media Files' ),
},
{
icon: fullscreen,
label: __( 'Enlarge on click' ),
value: LINK_DESTINATION_LIGHTBOX,
noticeText: __( 'Lightbox effect' ),
infoText: __( 'Scale images with a lightbox effect' ),
},
{
icon: linkOff,
label: _x( 'None', 'Media item link option' ),
value: LINK_DESTINATION_NONE,
noticeText: __( 'None' ),
},
];
const ALLOWED_MEDIA_TYPES = [ 'image' ];
const PLACEHOLDER_TEXT = Platform.isNative
? __( 'Add media' )
: __( 'Drag and drop images, upload, or choose from your library.' );
const MOBILE_CONTROL_PROPS_RANGE_CONTROL = Platform.isNative
? { type: 'stepper' }
: {};
const DEFAULT_BLOCK = { name: 'core/image' };
const EMPTY_ARRAY = [];
export default function GalleryEdit( props ) {
const {
setAttributes,
attributes,
className,
clientId,
isSelected,
insertBlocksAfter,
isContentLocked,
onFocus,
} = props;
const [ lightboxSetting ] = useSettings( 'blocks.core/image.lightbox' );
const linkOptions = ! lightboxSetting?.allowEditing
? LINK_OPTIONS.filter(
( option ) => option.value !== LINK_DESTINATION_LIGHTBOX
)
: LINK_OPTIONS;
const { columns, imageCrop, randomOrder, linkTarget, linkTo, sizeSlug } =
attributes;
const {
__unstableMarkNextChangeAsNotPersistent,
replaceInnerBlocks,
updateBlockAttributes,
selectBlock,
} = useDispatch( blockEditorStore );
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );
const {
getBlock,
getSettings,
innerBlockImages,
blockWasJustInserted,
multiGallerySelection,
} = useSelect(
( select ) => {
const {
getBlockName,
getMultiSelectedBlockClientIds,
getSettings: _getSettings,
getBlock: _getBlock,
wasBlockJustInserted,
} = select( blockEditorStore );
const multiSelectedClientIds = getMultiSelectedBlockClientIds();
return {
getBlock: _getBlock,
getSettings: _getSettings,
innerBlockImages:
_getBlock( clientId )?.innerBlocks ?? EMPTY_ARRAY,
blockWasJustInserted: wasBlockJustInserted(
clientId,
'inserter_menu'
),
multiGallerySelection:
multiSelectedClientIds.length &&
multiSelectedClientIds.every(
( _clientId ) =>
getBlockName( _clientId ) === 'core/gallery'
),
};
},
[ clientId ]
);
const images = useMemo(
() =>
innerBlockImages?.map( ( block ) => ( {
clientId: block.clientId,
id: block.attributes.id,
url: block.attributes.url,
attributes: block.attributes,
fromSavedContent: Boolean( block.originalContent ),
} ) ),
[ innerBlockImages ]
);
const imageData = useGetMedia( innerBlockImages );
const newImages = useGetNewImages( images, imageData );
useEffect( () => {
newImages?.forEach( ( newImage ) => {
// Update the images data without creating new undo levels.
__unstableMarkNextChangeAsNotPersistent();
updateBlockAttributes( newImage.clientId, {
...buildImageAttributes( newImage.attributes ),
id: newImage.id,
align: undefined,
} );
} );
}, [ newImages ] );
const imageSizeOptions = useImageSizes(
imageData,
isSelected,
getSettings
);
/**
* Determines the image attributes that should be applied to an image block
* after the gallery updates.
*
* The gallery will receive the full collection of images when a new image
* is added. As a result we need to reapply the image's original settings if
* it already existed in the gallery. If the image is in fact new, we need
* to apply the gallery's current settings to the image.
*
* @param {Object} imageAttributes Media object for the actual image.
* @return {Object} Attributes to set on the new image block.
*/
function buildImageAttributes( imageAttributes ) {
const image = imageAttributes.id
? imageData.find( ( { id } ) => id === imageAttributes.id )
: null;
let newClassName;
if ( imageAttributes.className && imageAttributes.className !== '' ) {
newClassName = imageAttributes.className;
}
let newLinkTarget;
if ( imageAttributes.linkTarget || imageAttributes.rel ) {
// When transformed from image blocks, the link destination and rel attributes are inherited.
newLinkTarget = {
linkTarget: imageAttributes.linkTarget,
rel: imageAttributes.rel,
};
} else {
// When an image is added, update the link destination and rel attributes according to the gallery settings
newLinkTarget = getUpdatedLinkTargetSettings(
linkTarget,
attributes
);
}
return {
...pickRelevantMediaFiles( image, sizeSlug ),
...getHrefAndDestination(
image,
linkTo,
imageAttributes?.linkDestination
),
...newLinkTarget,
className: newClassName,
sizeSlug,
caption: imageAttributes.caption || image.caption?.raw,
alt: imageAttributes.alt || image.alt_text,
};
}
function isValidFileType( file ) {
// It's necessary to retrieve the media type from the raw image data for already-uploaded images on native.
const nativeFileData =
Platform.isNative && file.id
? imageData.find( ( { id } ) => id === file.id )
: null;
const mediaTypeSelector = nativeFileData
? nativeFileData?.media_type
: file.type;
return (
ALLOWED_MEDIA_TYPES.some(
( mediaType ) => mediaTypeSelector?.indexOf( mediaType ) === 0
) || file.blob
);
}
function updateImages( selectedImages ) {
const newFileUploads =
Object.prototype.toString.call( selectedImages ) ===
'[object FileList]';
const imageArray = newFileUploads
? Array.from( selectedImages ).map( ( file ) => {
if ( ! file.url ) {
return {
blob: createBlobURL( file ),
};
}
return file;
} )
: selectedImages;
if ( ! imageArray.every( isValidFileType ) ) {
createErrorNotice(
__(
'If uploading to a gallery all files need to be image formats'
),
{ id: 'gallery-upload-invalid-file', type: 'snackbar' }
);
}
const processedImages = imageArray
.filter( ( file ) => file.url || isValidFileType( file ) )
.map( ( file ) => {
if ( ! file.url ) {
return {
blob: file.blob || createBlobURL( file ),
};
}
return file;
} );
// Because we are reusing existing innerImage blocks any reordering
// done in the media library will be lost so we need to reapply that ordering
// once the new image blocks are merged in with existing.
const newOrderMap = processedImages.reduce(
( result, image, index ) => (
( result[ image.id ] = index ), result
),
{}
);
const existingImageBlocks = ! newFileUploads
? innerBlockImages.filter( ( block ) =>
processedImages.find(
( img ) => img.id === block.attributes.id
)
)
: innerBlockImages;
const newImageList = processedImages.filter(
( img ) =>
! existingImageBlocks.find(
( existingImg ) => img.id === existingImg.attributes.id
)
);
const newBlocks = newImageList.map( ( image ) => {
return createBlock( 'core/image', {
id: image.id,
blob: image.blob,
url: image.url,
caption: image.caption,
alt: image.alt,
} );
} );
replaceInnerBlocks(
clientId,
existingImageBlocks
.concat( newBlocks )
.sort(
( a, b ) =>
newOrderMap[ a.attributes.id ] -
newOrderMap[ b.attributes.id ]
)
);
// Select the first block to scroll into view when new blocks are added.
if ( newBlocks?.length > 0 ) {
selectBlock( newBlocks[ 0 ].clientId );
}
}
function onUploadError( message ) {
createErrorNotice( message, { type: 'snackbar' } );
}
function setLinkTo( value ) {
setAttributes( { linkTo: value } );
const changedAttributes = {};
const blocks = [];
getBlock( clientId ).innerBlocks.forEach( ( block ) => {
blocks.push( block.clientId );
const image = block.attributes.id
? imageData.find( ( { id } ) => id === block.attributes.id )
: null;
changedAttributes[ block.clientId ] = getHrefAndDestination(
image,
value,
false,
block.attributes,
lightboxSetting
);
} );
updateBlockAttributes( blocks, changedAttributes, true );
const linkToText = [ ...linkOptions ].find(
( linkType ) => linkType.value === value
);
createSuccessNotice(
sprintf(
/* translators: %s: image size settings */
__( 'All gallery image links updated to: %s' ),
linkToText.noticeText
),
{
id: 'gallery-attributes-linkTo',
type: 'snackbar',
}
);
}
function setColumnsNumber( value ) {
setAttributes( { columns: value } );
}
function toggleImageCrop() {
setAttributes( { imageCrop: ! imageCrop } );
}
function toggleRandomOrder() {
setAttributes( { randomOrder: ! randomOrder } );
}
function toggleOpenInNewTab( openInNewTab ) {
const newLinkTarget = openInNewTab ? '_blank' : undefined;
setAttributes( { linkTarget: newLinkTarget } );
const changedAttributes = {};
const blocks = [];
getBlock( clientId ).innerBlocks.forEach( ( block ) => {
blocks.push( block.clientId );
changedAttributes[ block.clientId ] = getUpdatedLinkTargetSettings(
newLinkTarget,
block.attributes
);
} );
updateBlockAttributes( blocks, changedAttributes, true );
const noticeText = openInNewTab
? __( 'All gallery images updated to open in new tab' )
: __( 'All gallery images updated to not open in new tab' );
createSuccessNotice( noticeText, {
id: 'gallery-attributes-openInNewTab',
type: 'snackbar',
} );
}
function updateImagesSize( newSizeSlug ) {
setAttributes( { sizeSlug: newSizeSlug } );
const changedAttributes = {};
const blocks = [];
getBlock( clientId ).innerBlocks.forEach( ( block ) => {
blocks.push( block.clientId );
const image = block.attributes.id
? imageData.find( ( { id } ) => id === block.attributes.id )
: null;
changedAttributes[ block.clientId ] = getImageSizeAttributes(
image,
newSizeSlug
);
} );
updateBlockAttributes( blocks, changedAttributes, true );
const imageSize = imageSizeOptions.find(
( size ) => size.value === newSizeSlug
);
createSuccessNotice(
sprintf(
/* translators: %s: image size settings */
__( 'All gallery image sizes updated to: %s' ),
imageSize.label
),
{
id: 'gallery-attributes-sizeSlug',
type: 'snackbar',
}
);
}
useEffect( () => {
// linkTo attribute must be saved so blocks don't break when changing image_default_link_type in options.php.
if ( ! linkTo ) {
__unstableMarkNextChangeAsNotPersistent();
setAttributes( {
linkTo:
window?.wp?.media?.view?.settings?.defaultProps?.link ||
LINK_DESTINATION_NONE,
} );
}
}, [ linkTo ] );
const hasImages = !! images.length;
const hasImageIds = hasImages && images.some( ( image ) => !! image.id );
const imagesUploading = images.some( ( img ) =>
! Platform.isNative
? ! img.id && img.url?.indexOf( 'blob:' ) === 0
: img.url?.indexOf( 'file:' ) === 0
);
// MediaPlaceholder props are different between web and native hence, we provide a platform-specific set.
const mediaPlaceholderProps = Platform.select( {
web: {
addToGallery: false,
disableMediaButtons: imagesUploading,
value: {},
},
native: {
addToGallery: hasImageIds,
isAppender: hasImages,
disableMediaButtons:
( hasImages && ! isSelected ) || imagesUploading,
value: hasImageIds ? images : {},
autoOpenMediaUpload:
! hasImages && isSelected && blockWasJustInserted,
onFocus,
},
} );
const mediaPlaceholder = (
<MediaPlaceholder
handleUpload={ false }
icon={ sharedIcon }
labels={ {
title: __( 'Gallery' ),
instructions: PLACEHOLDER_TEXT,
} }
onSelect={ updateImages }
accept="image/*"
allowedTypes={ ALLOWED_MEDIA_TYPES }
multiple
onError={ onUploadError }
{ ...mediaPlaceholderProps }
/>
);
const blockProps = useBlockProps( {
className: clsx( className, 'has-nested-images' ),
} );
const nativeInnerBlockProps = Platform.isNative && {
marginHorizontal: 0,
marginVertical: 0,
};
const innerBlocksProps = useInnerBlocksProps( blockProps, {
defaultBlock: DEFAULT_BLOCK,
directInsert: true,
orientation: 'horizontal',
renderAppender: false,
...nativeInnerBlockProps,
} );
if ( ! hasImages ) {
return (
<View { ...innerBlocksProps }>
{ innerBlocksProps.children }
{ mediaPlaceholder }
</View>
);
}
const hasLinkTo = linkTo && linkTo !== 'none';
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings' ) }>
{ images.length > 1 && (
<RangeControl
__nextHasNoMarginBottom
label={ __( 'Columns' ) }
value={
columns
? columns
: defaultColumnsNumber( images.length )
}
onChange={ setColumnsNumber }
min={ 1 }
max={ Math.min( MAX_COLUMNS, images.length ) }
{ ...MOBILE_CONTROL_PROPS_RANGE_CONTROL }
required
__next40pxDefaultSize
/>
) }
{ imageSizeOptions?.length > 0 && (
<SelectControl
__nextHasNoMarginBottom
label={ __( 'Resolution' ) }
help={ __(
'Select the size of the source images.'
) }
value={ sizeSlug }
options={ imageSizeOptions }
onChange={ updateImagesSize }
hideCancelButton
size="__unstable-large"
/>
) }
{ Platform.isNative ? (
<SelectControl
__nextHasNoMarginBottom
label={ __( 'Link' ) }
value={ linkTo }
onChange={ setLinkTo }
options={ linkOptions }
hideCancelButton
size="__unstable-large"
/>
) : null }
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Crop images to fit' ) }
checked={ !! imageCrop }
onChange={ toggleImageCrop }
/>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Randomize order' ) }
checked={ !! randomOrder }
onChange={ toggleRandomOrder }
/>
{ hasLinkTo && (
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Open images in new tab' ) }
checked={ linkTarget === '_blank' }
onChange={ toggleOpenInNewTab }
/>
) }
{ Platform.isWeb && ! imageSizeOptions && hasImageIds && (
<BaseControl
className="gallery-image-sizes"
__nextHasNoMarginBottom
>
<BaseControl.VisualLabel>
{ __( 'Resolution' ) }
</BaseControl.VisualLabel>
<View className="gallery-image-sizes__loading">
<Spinner />
{ __( 'Loading options…' ) }
</View>
</BaseControl>
) }
</PanelBody>
</InspectorControls>
{ Platform.isWeb ? (
<BlockControls group="block">
<ToolbarDropdownMenu
icon={ linkIcon }
label={ __( 'Link' ) }
>
{ ( { onClose } ) => (
<MenuGroup>
{ linkOptions.map( ( linkItem ) => {
const isOptionSelected =
linkTo === linkItem.value;
return (
<MenuItem
key={ linkItem.value }
isSelected={ isOptionSelected }
className={ clsx(
'components-dropdown-menu__menu-item',
{
'is-active':
isOptionSelected,
}
) }
iconPosition="left"
icon={ linkItem.icon }
onClick={ () => {
setLinkTo( linkItem.value );
onClose();
} }
role="menuitemradio"
info={ linkItem.infoText }
>
{ linkItem.label }
</MenuItem>
);
} ) }
</MenuGroup>
) }
</ToolbarDropdownMenu>
</BlockControls>
) : null }
{ Platform.isWeb && (
<>
{ ! multiGallerySelection && (
<BlockControls group="other">
<MediaReplaceFlow
allowedTypes={ ALLOWED_MEDIA_TYPES }
accept="image/*"
handleUpload={ false }
onSelect={ updateImages }
name={ __( 'Add' ) }
multiple
mediaIds={ images
.filter( ( image ) => image.id )
.map( ( image ) => image.id ) }
addToGallery={ hasImageIds }
/>
</BlockControls>
) }
<GapStyles
blockGap={ attributes.style?.spacing?.blockGap }
clientId={ clientId }
/>
</>
) }
<Gallery
{ ...props }
isContentLocked={ isContentLocked }
images={ images }
mediaPlaceholder={
! hasImages || Platform.isNative
? mediaPlaceholder
: undefined
}
blockProps={ innerBlocksProps }
insertBlocksAfter={ insertBlocksAfter }
multiGallerySelection={ multiGallerySelection }
/>
</>
);
}