diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 05be96ef6ec7d6..269878ac6c8a3b 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -62,6 +62,17 @@ function gutenberg_initialize_experiments_settings() { 'id' => 'gutenberg-widgets-in-customizer', ) ); + add_settings_field( + 'gutenberg-gallery-refactor', + __( 'Gallery Refactor', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Enable the refactored gallery block', 'gutenberg' ), + 'id' => 'gutenberg-gallery-refactor', + ) + ); register_setting( 'gutenberg-experiments', 'gutenberg-experiments' @@ -99,3 +110,19 @@ function gutenberg_display_experiment_section() { $experiments_exist ? array_key_exists( 'gutenberg-gallery-refactor', get_option( 'gutenberg-experiments' ) ) : false, + ); + return array_merge( $settings, $experiments_settings ); +} +add_filter( 'block_editor_settings', 'gutenberg_experiments_editor_settings' ); diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 55653a083dd7c6..b6aedc7f35c398 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -506,6 +506,7 @@ _Properties_ - _\_\_experimentalBlockDirectory_ `boolean`: Whether the user has enabled the Block Directory - _\_\_experimentalBlockPatterns_ `Array`: Array of objects representing the block patterns - _\_\_experimentalBlockPatternCategories_ `Array`: Array of objects representing the block pattern categories +- _\_\_experimentalGalleryRefactor_ `boolean`: Whether the user has enabled the refactored gallery block which uses InnerBlocks # **SkipToSelectedBlock** diff --git a/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js index 99edaadbd16ec8..0a03a0689f10c1 100644 --- a/packages/block-editor/src/components/media-placeholder/index.js +++ b/packages/block-editor/src/components/media-placeholder/index.js @@ -128,7 +128,7 @@ export function MediaPlaceholder( { const onFilesUpload = ( files ) => { if ( isGallery ) { - // Because the Gallery hands the files over to Image component InnerBlocks just + // Because the refactored Gallery hands the files over to Image component InnerBlocks just // hand the handling of the files over to the Gallery onSelect( files ); return; @@ -136,7 +136,46 @@ export function MediaPlaceholder( { onFilesPreUpload( files ); let setMedia; if ( multiple ) { - setMedia = onSelect; + // This is still needed to handle v1 versions of the Gallery block. It can be removed + // once all Gallery instances are forced to migrate. + if ( addToGallery ) { + // Since the setMedia function runs multiple times per upload group + // and is passed newMedia containing every item in its group each time, we must + // filter out whatever this upload group had previously returned to the + // gallery before adding and returning the image array with replacement newMedia + // values. + + // Define an array to store urls from newMedia between subsequent function calls. + let lastMediaPassed = []; + setMedia = ( newMedia ) => { + // Remove any images this upload group is responsible for (lastMediaPassed). + // Their replacements are contained in newMedia. + const filteredMedia = ( value ?? [] ).filter( ( item ) => { + // If Item has id, only remove it if lastMediaPassed has an item with that id. + if ( item.id ) { + return ! lastMediaPassed.some( + // Be sure to convert to number for comparison. + ( { id } ) => Number( id ) === Number( item.id ) + ); + } + // Compare transient images via .includes since gallery may append extra info onto the url. + return ! lastMediaPassed.some( ( { urlSlug } ) => + item.url.includes( urlSlug ) + ); + } ); + // Return the filtered media array along with newMedia. + onSelect( filteredMedia.concat( newMedia ) ); + // Reset lastMediaPassed and set it with ids and urls from newMedia. + lastMediaPassed = newMedia.map( ( media ) => { + // Add everything up to '.fileType' to compare via .includes. + const cutOffIndex = media.url.lastIndexOf( '.' ); + const urlSlug = media.url.slice( 0, cutOffIndex ); + return { id: media.id, urlSlug }; + } ); + }; + } else { + setMedia = onSelect; + } } else { setMedia = ( [ media ] ) => onSelect( media ); } diff --git a/packages/block-editor/src/store/defaults.js b/packages/block-editor/src/store/defaults.js index ba608d34a35f87..a6c3d565c7d398 100644 --- a/packages/block-editor/src/store/defaults.js +++ b/packages/block-editor/src/store/defaults.js @@ -28,6 +28,7 @@ export const PREFERENCES_DEFAULTS = { * @property {boolean} __experimentalBlockDirectory Whether the user has enabled the Block Directory * @property {Array} __experimentalBlockPatterns Array of objects representing the block patterns * @property {Array} __experimentalBlockPatternCategories Array of objects representing the block pattern categories + * @property {boolean} __experimentalGalleryRefactor Whether the user has enabled the refactored gallery block which uses InnerBlocks */ export const SETTINGS_DEFAULTS = { alignWide: false, @@ -151,6 +152,7 @@ export const SETTINGS_DEFAULTS = { __experimentalBlockPatterns: [], __experimentalBlockPatternCategories: [], __experimentalSpotlightEntityBlocks: [], + __experimentalGalleryRefactor: false, // gradients setting is not used anymore now defaults are passed from theme.json on the server and core has its own defaults. // The setting is only kept for backward compatibility purposes. diff --git a/packages/block-library/src/gallery/block.json b/packages/block-library/src/gallery/block.json index 79ae042f660069..68a16fde03b550 100644 --- a/packages/block-library/src/gallery/block.json +++ b/packages/block-library/src/gallery/block.json @@ -3,6 +3,57 @@ "name": "core/gallery", "category": "media", "attributes": { + "images": { + "type": "array", + "default": [], + "source": "query", + "selector": ".blocks-gallery-item", + "query": { + "url": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "src" + }, + "fullUrl": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "data-full-url" + }, + "link": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "data-link" + }, + "alt": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "alt", + "default": "" + }, + "id": { + "type": "string", + "source": "attribute", + "selector": "img", + "attribute": "data-id" + }, + "caption": { + "type": "string", + "source": "html", + "selector": ".blocks-gallery-item__caption" + } + } + }, + "ids": { + "type": "array", + "items": { + "type": "number" + }, + "default": [] + }, "imageUploads": { "type": "array", "default": [], diff --git a/packages/block-library/src/gallery/deprecated.js b/packages/block-library/src/gallery/deprecated.js index 41753817dc7837..f97adbdd300245 100644 --- a/packages/block-library/src/gallery/deprecated.js +++ b/packages/block-library/src/gallery/deprecated.js @@ -7,20 +7,7 @@ import { map, some } from 'lodash'; /** * WordPress dependencies */ -import { RichText, useBlockProps } from '@wordpress/block-editor'; -import { createBlock } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { - LINK_DESTINATION_ATTACHMENT, - LINK_DESTINATION_MEDIA, - LINK_DESTINATION_NONE, -} from './constants'; - -const DEPRECATED_LINK_DESTINATION_MEDIA = 'file'; -const DEPRECATED_LINK_DESTINATION_ATTACHMENT = 'post'; +import { RichText } from '@wordpress/block-editor'; /** * Original function to determine default number of columns from a block's @@ -35,562 +22,411 @@ export function defaultColumnsNumberV1( attributes ) { return Math.min( 3, attributes.images.length ); } -/** - * Original function to determines new href and linkDestination values for an image block from the - * supplied Gallery link destination. - * - * Used in deprecations: v1-6. - * - * @param {Object} image Gallery image. - * @param {string} destination Gallery's selected link destination. - * @return {Object} New attributes to assign to image block. - */ -export function getHrefAndDestination( image, destination ) { - // Need to determine the URL that the selected destination maps to. - // Gutenberg and WordPress use different constants so the new link - // destination also needs to be tweaked. - switch ( destination ) { - case DEPRECATED_LINK_DESTINATION_MEDIA: - return { - href: image?.source_url || image?.url, // eslint-disable-line camelcase - linkDestination: LINK_DESTINATION_MEDIA, - }; - case DEPRECATED_LINK_DESTINATION_ATTACHMENT: - return { - href: image?.link, - linkDestination: LINK_DESTINATION_ATTACHMENT, - }; - case LINK_DESTINATION_MEDIA: - return { - href: image?.source_url || image?.url, // eslint-disable-line camelcase - linkDestination: LINK_DESTINATION_MEDIA, - }; - case LINK_DESTINATION_ATTACHMENT: - return { - href: image?.link, - linkDestination: LINK_DESTINATION_ATTACHMENT, - }; - case LINK_DESTINATION_NONE: - return { - href: undefined, - linkDestination: LINK_DESTINATION_NONE, - }; - } - - return {}; -} - -/** - * Gets an Image block from gallery image data - * - * Used to migrate Galleries to nested Image InnerBlocks. - * - * @param {Object} image Image properties. - * @param {string} sizeSlug Gallery sizeSlug attribute. - * @param {string} linkTo Gallery linkTo attribute. - * @return {Object} Image block. - */ -export function getImageBlock( image, sizeSlug, linkTo ) { - return createBlock( 'core/image', { - ...( image.id && { id: parseInt( image.id ) } ), - url: image.url, - alt: image.alt, - caption: image.caption, - sizeSlug, - ...getHrefAndDestination( image, linkTo ), - } ); -} -const v1 = { - attributes: { - images: { - type: 'array', - default: [], - source: 'query', - selector: 'div.wp-block-gallery figure.blocks-gallery-image img', - query: { - url: { - source: 'attribute', - attribute: 'src', +const deprecated = [ + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: '.blocks-gallery-item', + query: { + url: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + fullUrl: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'data-full-url', + }, + link: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'data-link', + }, + alt: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + id: { + type: 'string', + source: 'attribute', + selector: 'img', + attribute: 'data-id', + }, + caption: { + type: 'string', + source: 'html', + selector: '.blocks-gallery-item__caption', + }, }, - alt: { - source: 'attribute', - attribute: 'alt', - default: '', - }, - id: { - source: 'attribute', - attribute: 'data-id', + }, + ids: { + type: 'array', + items: { + type: 'number', }, + default: [], }, - }, - columns: { - type: 'number', - }, - imageCrop: { - type: 'boolean', - default: true, - }, - linkTo: { - type: 'string', - default: 'none', - }, - align: { - type: 'string', - default: 'none', - }, - }, - supports: { - align: true, - }, - save( { attributes } ) { - const { - images, - columns = defaultColumnsNumberV1( attributes ), - align, - imageCrop, - linkTo, - } = attributes; - const className = classnames( `columns-${ columns }`, { - alignnone: align === 'none', - 'is-cropped': imageCrop, - } ); - return ( -
- { images.map( ( image ) => { - let href; - - switch ( linkTo ) { - case 'media': - href = image.url; - break; - case 'attachment': - href = image.link; - break; - } - - const img = ( - { - ); - - return ( -
- { href ? { img } : img } -
- ); - } ) } -
- ); - }, - isEligible( { imageCount } ) { - return ! imageCount; - }, - migrate( { images, imageCrop, linkTo, sizeSlug, columns, caption } ) { - const imageBlocks = images.map( ( image ) => { - return getImageBlock( image, sizeSlug, linkTo ); - } ); - return [ - { - caption, - columns, - imageCrop, - linkTo, - sizeSlug, - imageCount: imageBlocks.length, - allowResize: false, - isGrouped: true, + columns: { + type: 'number', + minimum: 1, + maximum: 8, }, - imageBlocks, - ]; - }, -}; - -const v2 = { - attributes: { - images: { - type: 'array', - default: [], - source: 'query', - selector: 'ul.wp-block-gallery .blocks-gallery-item', - query: { - url: { - source: 'attribute', - selector: 'img', - attribute: 'src', - }, - alt: { - source: 'attribute', - selector: 'img', - attribute: 'alt', - default: '', - }, - id: { - source: 'attribute', - selector: 'img', - attribute: 'data-id', - }, - link: { - source: 'attribute', - selector: 'img', - attribute: 'data-link', - }, - caption: { - type: 'array', - source: 'children', - selector: 'figcaption', - }, + caption: { + type: 'string', + source: 'html', + selector: '.blocks-gallery-caption', + }, + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, + sizeSlug: { + type: 'string', + default: 'large', }, }, - columns: { - type: 'number', - }, - imageCrop: { - type: 'boolean', - default: true, - }, - linkTo: { - type: 'string', - default: 'none', + supports: { + align: true, + }, + isEligible( { linkTo } ) { + return ! linkTo || linkTo === 'attachment' || linkTo === 'media'; + }, + migrate( attributes ) { + let linkTo = attributes.linkTo; + if ( ! attributes.linkTo ) { + linkTo = 'none'; + } else if ( attributes.linkTo === 'attachment' ) { + linkTo = 'post'; + } else if ( attributes.linkTo === 'media' ) { + linkTo = 'file'; + } + return { + ...attributes, + linkTo, + }; }, - }, - isEligible( { images, ids } ) { - return ( - images && - images.length > 0 && - ( ( ! ids && images ) || - ( ids && images && ids.length !== images.length ) || - some( images, ( id, index ) => { - if ( ! id && ids[ index ] !== null ) { - return true; - } - return parseInt( id, 10 ) !== ids[ index ]; - } ) ) - ); - }, - migrate( attributes ) { - return { - ...attributes, - ids: map( attributes.images, ( { id } ) => { - if ( ! id ) { - return null; - } - return parseInt( id, 10 ); - } ), - }; - }, - supports: { - align: true, - }, - save( { attributes } ) { - const { - images, - columns = defaultColumnsNumberV1( attributes ), - imageCrop, - linkTo, - } = attributes; - return ( - + { ! RichText.isEmpty( caption ) && ( + + ) } + + ); + }, }, -}; - -const v3 = { - attributes: { - images: { - type: 'array', - default: [], - source: 'query', - selector: 'ul.wp-block-gallery .blocks-gallery-item', - query: { - url: { - source: 'attribute', - selector: 'img', - attribute: 'src', - }, - fullUrl: { - source: 'attribute', - selector: 'img', - attribute: 'data-full-url', - }, - alt: { - source: 'attribute', - selector: 'img', - attribute: 'alt', - default: '', - }, - id: { - source: 'attribute', - selector: 'img', - attribute: 'data-id', - }, - link: { - source: 'attribute', - selector: 'img', - attribute: 'data-link', - }, - caption: { - type: 'array', - source: 'children', - selector: 'figcaption', + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: '.blocks-gallery-item', + query: { + url: { + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + fullUrl: { + source: 'attribute', + selector: 'img', + attribute: 'data-full-url', + }, + link: { + source: 'attribute', + selector: 'img', + attribute: 'data-link', + }, + alt: { + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + id: { + source: 'attribute', + selector: 'img', + attribute: 'data-id', + }, + caption: { + type: 'string', + source: 'html', + selector: '.blocks-gallery-item__caption', + }, }, }, + ids: { + type: 'array', + default: [], + }, + columns: { + type: 'number', + }, + caption: { + type: 'string', + source: 'html', + selector: '.blocks-gallery-caption', + }, + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, }, - ids: { - type: 'array', - default: [], - }, - columns: { - type: 'number', + supports: { + align: true, }, - imageCrop: { - type: 'boolean', - default: true, + isEligible( { ids } ) { + return ids && ids.some( ( id ) => typeof id === 'string' ); }, - linkTo: { - type: 'string', - default: 'none', + migrate( attributes ) { + return { + ...attributes, + ids: map( attributes.ids, ( id ) => { + const parsedId = parseInt( id, 10 ); + return Number.isInteger( parsedId ) ? parsedId : null; + } ), + }; }, - }, - supports: { - align: true, - }, - save( { attributes } ) { - const { - images, - columns = defaultColumnsNumberV1( attributes ), - imageCrop, - linkTo, - } = attributes; - return ( - - ); - }, - isEligible( { imageCount } ) { - return ! imageCount; - }, - migrate( { images, imageCrop, linkTo, sizeSlug, columns, caption } ) { - const imageBlocks = images.map( ( image ) => { - return getImageBlock( image, sizeSlug, linkTo ); - } ); - - return [ - { - caption, - columns, + save( { attributes } ) { + const { + images, + columns = defaultColumnsNumberV1( attributes ), imageCrop, + caption, linkTo, - sizeSlug, - imageCount: imageBlocks.length, - allowResize: false, - isGrouped: true, - }, - imageBlocks, - ]; - }, -}; + } = attributes; + + return ( +
+ + { ! RichText.isEmpty( caption ) && ( + + ) } +
+ ); + }, + }, + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: 'ul.wp-block-gallery .blocks-gallery-item', + query: { + url: { + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + fullUrl: { + source: 'attribute', + selector: 'img', + attribute: 'data-full-url', + }, + alt: { + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + id: { + source: 'attribute', + selector: 'img', + attribute: 'data-id', + }, + link: { + source: 'attribute', + selector: 'img', + attribute: 'data-link', + }, + caption: { + type: 'array', + source: 'children', + selector: 'figcaption', + }, }, }, + ids: { + type: 'array', + default: [], + }, + columns: { + type: 'number', + }, + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, }, - ids: { - type: 'array', - default: [], - }, - columns: { - type: 'number', - }, - caption: { - type: 'string', - source: 'html', - selector: '.blocks-gallery-caption', - }, - imageCrop: { - type: 'boolean', - default: true, - }, - linkTo: { - type: 'string', - default: 'none', + supports: { + align: true, }, - }, - supports: { - align: true, - }, - isEligible( { ids } ) { - return ids && ids.some( ( id ) => typeof id === 'string' ); - }, - migrate( { images, imageCrop, linkTo, sizeSlug, columns, caption } ) { - const imageBlocks = images.map( ( image ) => { - return getImageBlock( image, sizeSlug, linkTo ); - } ); - - return [ - { - caption, - columns, + save( { attributes } ) { + const { + images, + columns = defaultColumnsNumberV1( attributes ), imageCrop, linkTo, - sizeSlug, - imageCount: imageBlocks.length, - allowResize: false, - isGrouped: true, - }, - imageBlocks, - ]; - }, - save( { attributes } ) { - const { - images, - columns = defaultColumnsNumberV1( attributes ), - imageCrop, - caption, - linkTo, - } = attributes; - - return ( -
-
); } ) } - { ! RichText.isEmpty( caption ) && ( - - ) } - - ); + ); + }, }, -}; - -const v5 = { - attributes: { - images: { - type: 'array', - default: [], - source: 'query', - selector: '.blocks-gallery-item', - query: { - url: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'src', - }, - fullUrl: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'data-full-url', - }, - link: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'data-link', - }, - alt: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'alt', - default: '', - }, - id: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'data-id', - }, - caption: { - type: 'string', - source: 'html', - selector: '.blocks-gallery-item__caption', + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: 'ul.wp-block-gallery .blocks-gallery-item', + query: { + url: { + source: 'attribute', + selector: 'img', + attribute: 'src', + }, + alt: { + source: 'attribute', + selector: 'img', + attribute: 'alt', + default: '', + }, + id: { + source: 'attribute', + selector: 'img', + attribute: 'data-id', + }, + link: { + source: 'attribute', + selector: 'img', + attribute: 'data-link', + }, + caption: { + type: 'array', + source: 'children', + selector: 'figcaption', + }, }, }, - }, - ids: { - type: 'array', - items: { + columns: { type: 'number', }, - default: [], - }, - columns: { - type: 'number', - minimum: 1, - maximum: 8, - }, - caption: { - type: 'string', - source: 'html', - selector: '.blocks-gallery-caption', + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, }, - imageCrop: { - type: 'boolean', - default: true, + isEligible( { images, ids } ) { + return ( + images && + images.length > 0 && + ( ( ! ids && images ) || + ( ids && images && ids.length !== images.length ) || + some( images, ( id, index ) => { + if ( ! id && ids[ index ] !== null ) { + return true; + } + return parseInt( id, 10 ) !== ids[ index ]; + } ) ) + ); }, - linkTo: { - type: 'string', - default: 'none', + migrate( attributes ) { + return { + ...attributes, + ids: map( attributes.images, ( { id } ) => { + if ( ! id ) { + return null; + } + return parseInt( id, 10 ); + } ), + }; }, - sizeSlug: { - type: 'string', - default: 'large', + supports: { + align: true, }, - }, - supports: { - align: true, - }, - isEligible( { linkTo, imageCount } ) { - return ( - ! imageCount && - ( ! linkTo || linkTo === 'attachment' || linkTo === 'media' ) - ); - }, - migrate( attributes ) { - let linkTo = attributes.linkTo; - if ( ! attributes.linkTo ) { - linkTo = 'none'; - } - const imageBlocks = attributes.images.map( ( image ) => { - return getImageBlock( image, attributes.sizeSlug, linkTo ); - } ); - return [ - { - caption: attributes.caption, - columns: attributes.columns, - imageCrop: attributes.imageCrop, + save( { attributes } ) { + const { + images, + columns = defaultColumnsNumberV1( attributes ), + imageCrop, linkTo, - sizeSlug: attributes.sizeSlug, - imageCount: imageBlocks.length, - allowResize: false, - isGrouped: true, - }, - imageBlocks, - ]; - }, - save( { attributes } ) { - const { - images, - columns = defaultColumnsNumberV1( attributes ), - imageCrop, - caption, - linkTo, - } = attributes; - - return ( -
-
    + } = attributes; + return ( +
      { images.map( ( image ) => { let href; switch ( linkTo ) { case 'media': - href = image.fullUrl || image.url; + href = image.url; break; case 'attachment': href = image.link; @@ -791,7 +584,6 @@ const v5 = { src={ image.url } alt={ image.alt } data-id={ image.id } - data-full-url={ image.fullUrl } data-link={ image.link } className={ image.id ? `wp-image-${ image.id }` : null @@ -810,132 +602,86 @@ const v5 = { ) : ( img ) } - { ! RichText.isEmpty( image.caption ) && ( - - ) } + { image.caption && + image.caption.length > 0 && ( + + ) }
); } ) } - { ! RichText.isEmpty( caption ) && ( - - ) } - - ); + ); + }, }, -}; - -const v6 = { - attributes: { - images: { - type: 'array', - default: [], - source: 'query', - selector: '.blocks-gallery-item', - query: { - url: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'src', - }, - fullUrl: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'data-full-url', - }, - link: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'data-link', - }, - alt: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'alt', - default: '', - }, - id: { - type: 'string', - source: 'attribute', - selector: 'img', - attribute: 'data-id', - }, - caption: { - type: 'string', - source: 'html', - selector: '.blocks-gallery-item__caption', + { + attributes: { + images: { + type: 'array', + default: [], + source: 'query', + selector: + 'div.wp-block-gallery figure.blocks-gallery-image img', + query: { + url: { + source: 'attribute', + attribute: 'src', + }, + alt: { + source: 'attribute', + attribute: 'alt', + default: '', + }, + id: { + source: 'attribute', + attribute: 'data-id', + }, }, }, - }, - ids: { - type: 'array', - items: { + columns: { type: 'number', }, - default: [], - }, - columns: { - type: 'number', - minimum: 1, - maximum: 8, - }, - caption: { - type: 'string', - source: 'html', - selector: '.blocks-gallery-caption', - }, - imageCrop: { - type: 'boolean', - default: true, - }, - linkTo: { - type: 'string', + imageCrop: { + type: 'boolean', + default: true, + }, + linkTo: { + type: 'string', + default: 'none', + }, + align: { + type: 'string', + default: 'none', + }, }, - sizeSlug: { - type: 'string', - default: 'large', + supports: { + align: true, }, - }, - supports: { - anchor: true, - align: true, - }, - save( { attributes } ) { - const { - images, - columns = defaultColumnsNumberV1( attributes ), - imageCrop, - caption, - linkTo, - } = attributes; - const className = `columns-${ columns } ${ - imageCrop ? 'is-cropped' : '' - }`; - - return ( -
-
    + save( { attributes } ) { + const { + images, + columns = defaultColumnsNumberV1( attributes ), + align, + imageCrop, + linkTo, + } = attributes; + const className = classnames( `columns-${ columns }`, { + alignnone: align === 'none', + 'is-cropped': imageCrop, + } ); + return ( +
    { images.map( ( image ) => { let href; switch ( linkTo ) { - case DEPRECATED_LINK_DESTINATION_MEDIA: - href = image.fullUrl || image.url; + case 'media': + href = image.url; break; - case DEPRECATED_LINK_DESTINATION_ATTACHMENT: + case 'attachment': href = image.link; break; } @@ -945,73 +691,22 @@ const v6 = { src={ image.url } alt={ image.alt } data-id={ image.id } - data-full-url={ image.fullUrl } - data-link={ image.link } - className={ - image.id ? `wp-image-${ image.id }` : null - } /> ); return ( -
  • -
    - { href ? ( - { img } - ) : ( - img - ) } - { ! RichText.isEmpty( image.caption ) && ( - - ) } -
    -
  • + { href ? { img } : img } +
); } ) } - - { ! RichText.isEmpty( caption ) && ( - - ) } - - ); - }, - isEligible( { imageCount } ) { - return ! imageCount; - }, - migrate( { images, imageCrop, linkTo, sizeSlug, columns, caption } ) { - if ( linkTo === 'post' ) { - linkTo = 'attachment'; - } else if ( linkTo === 'file' ) { - linkTo = 'media'; - } - const imageBlocks = images.map( ( image ) => { - return getImageBlock( image, sizeSlug, linkTo ); - } ); - return [ - { - caption, - columns, - imageCrop, - linkTo, - sizeSlug, - imageCount: imageBlocks.length, - allowResize: false, - isGrouped: true, - }, - imageBlocks, - ]; + + ); + }, }, -}; +]; -export default [ v6, v5, v4, v3, v2, v1 ]; +export default deprecated; diff --git a/packages/block-library/src/gallery/edit-wrapper.js b/packages/block-library/src/gallery/edit-wrapper.js new file mode 100644 index 00000000000000..b29b50eeb1e97d --- /dev/null +++ b/packages/block-library/src/gallery/edit-wrapper.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import GalleryEdit from './edit'; +import GalleryEditV1 from './v1/edit'; + +/* + * Using a wrapper around the logic to load the edit for v1 of Gallery block + * or the refactored version with InnerBlocks. This is to prevent conditional + * use of hooks lint errors if adding this logic to the top of the edit component. + */ +export default function GalleryEditWrapper( props ) { + const { attributes } = props; + + const __experimentalGalleryRefactor = useSelect( ( select ) => { + const settings = select( blockEditorStore ).getSettings(); + return settings.__experimentalGalleryRefactor; + }, [] ); + + if ( + ! __experimentalGalleryRefactor || + attributes?.ids?.length > 0 || + attributes?.images?.length > 0 + ) { + return ; + } + + return ; +} diff --git a/packages/block-library/src/gallery/edit.js b/packages/block-library/src/gallery/edit.js index 5c3ba33400229c..1d67431b659784 100644 --- a/packages/block-library/src/gallery/edit.js +++ b/packages/block-library/src/gallery/edit.js @@ -18,11 +18,13 @@ import { Spinner, } from '@wordpress/components'; import { + store as blockEditorStore, MediaPlaceholder, InspectorControls, useBlockProps, store as blockEditorStore, } from '@wordpress/block-editor'; +import { store as coreStore } from '@wordpress/core-data'; import { Platform, useEffect, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -98,23 +100,22 @@ function GalleryEdit( props ) { const { __unstableMarkNextChangeAsNotPersistent, replaceInnerBlocks, - } = useDispatch( 'core/block-editor' ); + } = useDispatch( blockEditorStore ); const { getSettings, preferredStyle } = useSelect( ( select ) => { - const settings = select( 'core/block-editor' ).getSettings(); + const settings = select( blockEditorStore ).getSettings(); const preferredStyleVariations = settings.__experimentalPreferredStyleVariations; return { - getBlock: select( 'core/block-editor' ).getBlock, - getSettings: select( 'core/block-editor' ).getSettings, + getBlock: select( blockEditorStore ).getBlock, + getSettings: select( blockEditorStore ).getSettings, preferredStyle: preferredStyleVariations?.value?.[ 'core/image' ], }; }, [] ); const innerBlockImages = useSelect( ( select ) => { - return select( 'core/block-editor' ).getBlock( clientId ) - ?.innerBlocks; + return select( blockEditorStore ).getBlock( clientId )?.innerBlocks; }, [ clientId ] ); @@ -140,7 +141,7 @@ function GalleryEdit( props ) { ) { return imageData; } - const getMedia = select( 'core' ).getMedia; + const getMedia = select( coreStore ).getMedia; const newImageData = innerBlockImages.map( ( imageBlock ) => { return { id: imageBlock.attributes.id, diff --git a/packages/block-library/src/gallery/editor.scss b/packages/block-library/src/gallery/editor.scss index 3f23d81f2656df..65b9e86692de8d 100644 --- a/packages/block-library/src/gallery/editor.scss +++ b/packages/block-library/src/gallery/editor.scss @@ -5,11 +5,13 @@ figure.wp-block-gallery { display: block; margin: 0; - - .components-drop-zone { - display: none; - pointer-events: none; + &.has-nested-images { + .components-drop-zone { + display: none; + pointer-events: none; + } } + > .blocks-gallery-caption { flex: 0 0 100%; } @@ -68,3 +70,104 @@ figure.wp-block-gallery { margin: 0 8px 0 4px; } } + +/** + * Deprecated css past this point. This can be removed once all galleries are migrated + * to V2. + */ +.blocks-gallery-item { + // Hide the focus outline that otherwise briefly appears when selecting a block. + figure:not(.is-selected):focus, + img:focus { + outline: none; + } + + figure.is-selected { + + &::before { + box-shadow: 0 0 0 $border-width $white inset, 0 0 0 3px var(--wp-admin-theme-color) inset; + content: ""; + // Shown in Windows 10 high contrast mode. + outline: 2px solid transparent; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + } + } + + figure.is-transient img { + opacity: 0.3; + } + + .is-selected .block-library-gallery-item__inline-menu { + display: inline-flex; + } + + .block-editor-media-placeholder { + margin: 0; + height: 100%; + + .components-placeholder__label { + display: flex; + } + } +} + +.block-library-gallery-item__inline-menu { + display: none; + position: absolute; + top: -2px; + margin: $grid-unit-10; + z-index: z-index(".block-library-gallery-item__inline-menu"); + transition: box-shadow 0.2s ease-out; + @include reduce-motion("transition"); + border-radius: $radius-block-ui; + background: $white; + border: $border-width solid $gray-900; + + &:hover { + box-shadow: $shadow-popover; + } + + @include break-small() { + // Use smaller buttons to fit when there are many columns. + .columns-7 &, + .columns-8 & { + padding: $grid-unit-05 / 2; + } + } + + .components-button.has-icon { + &:not(:focus) { + border: none; + box-shadow: none; + } + + @include break-small() { + // Use smaller buttons to fit when there are many columns. + .columns-7 &, + .columns-8 & { + padding: 0; + width: inherit; + height: inherit; + } + } + } + + &.is-left { + left: -2px; + } + + &.is-right { + right: -2px; + } +} + +.wp-block-gallery ul.blocks-gallery-grid { + padding: 0; + // Some themes give all
    default margin instead of padding. + margin: 0; +} \ No newline at end of file diff --git a/packages/block-library/src/gallery/index.js b/packages/block-library/src/gallery/index.js index 4e6431c19dbc03..224688ddfdd681 100644 --- a/packages/block-library/src/gallery/index.js +++ b/packages/block-library/src/gallery/index.js @@ -8,7 +8,7 @@ import { gallery as icon } from '@wordpress/icons'; * Internal dependencies */ import deprecated from './deprecated'; -import edit from './edit'; +import edit from './edit-wrapper'; import metadata from './block.json'; import save from './save'; import transforms from './transforms'; diff --git a/packages/block-library/src/gallery/save.js b/packages/block-library/src/gallery/save.js index dfb21461e31dc8..242dcabf986ac2 100644 --- a/packages/block-library/src/gallery/save.js +++ b/packages/block-library/src/gallery/save.js @@ -7,8 +7,12 @@ import { RichText, useBlockProps, InnerBlocks } from '@wordpress/block-editor'; * Internal dependencies */ import { defaultColumnsNumber } from './shared'; +import saveV1 from './v1/save'; export default function save( { attributes } ) { + if ( attributes?.ids?.length > 0 || attributes?.images?.length > 0 ) { + return saveV1( { attributes } ); + } const { imageCount, caption, diff --git a/packages/block-library/src/gallery/v1/edit.js b/packages/block-library/src/gallery/v1/edit.js new file mode 100644 index 00000000000000..15563f51795d79 --- /dev/null +++ b/packages/block-library/src/gallery/v1/edit.js @@ -0,0 +1,470 @@ +/** + * External dependencies + */ +import { + every, + filter, + find, + forEach, + get, + isEmpty, + map, + reduce, + some, + toString, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { + PanelBody, + SelectControl, + ToggleControl, + withNotices, + RangeControl, +} from '@wordpress/components'; +import { + store as blockEditorStore, + MediaPlaceholder, + InspectorControls, + useBlockProps, +} from '@wordpress/block-editor'; +import { Platform, useEffect, useState, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { getBlobByURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; +import { useDispatch, withSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { withViewportMatch } from '@wordpress/viewport'; +import { View } from '@wordpress/primitives'; + +/** + * Internal dependencies + */ +import { sharedIcon } from '../shared-icon'; +import { pickRelevantMediaFiles } from '../shared'; +import { defaultColumnsNumberV1 } from '../deprecated'; +import Gallery from './gallery'; +import { + LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA, + LINK_DESTINATION_NONE, +} from '../constants'; + +const MAX_COLUMNS = 8; +const linkOptions = [ + { value: LINK_DESTINATION_ATTACHMENT, label: __( 'Attachment Page' ) }, + { value: LINK_DESTINATION_MEDIA, label: __( 'Media File' ) }, + { value: LINK_DESTINATION_NONE, label: __( 'None' ) }, +]; +const ALLOWED_MEDIA_TYPES = [ 'image' ]; + +const PLACEHOLDER_TEXT = Platform.select( { + web: __( + 'Drag images, upload new ones or select files from your library.' + ), + native: __( 'ADD MEDIA' ), +} ); + +const MOBILE_CONTROL_PROPS_RANGE_CONTROL = Platform.select( { + web: {}, + native: { type: 'stepper' }, +} ); + +export function GalleryEditV1( props ) { + const { + attributes, + isSelected, + noticeUI, + noticeOperations, + mediaUpload, + imageSizes, + resizedImages, + onFocus, + } = props; + const { + columns = defaultColumnsNumberV1( attributes ), + imageCrop, + images, + linkTo, + sizeSlug, + } = attributes; + const [ selectedImage, setSelectedImage ] = useState(); + const [ attachmentCaptions, setAttachmentCaptions ] = useState(); + const { __unstableMarkNextChangeAsNotPersistent } = useDispatch( + blockEditorStore + ); + + function setAttributes( newAttrs ) { + if ( newAttrs.ids ) { + throw new Error( + 'The "ids" attribute should not be changed directly. It is managed automatically when "images" attribute changes' + ); + } + + if ( newAttrs.images ) { + newAttrs = { + ...newAttrs, + // Unlike images[ n ].id which is a string, always ensure the + // ids array contains numbers as per its attribute type. + ids: map( newAttrs.images, ( { id } ) => parseInt( id, 10 ) ), + }; + } + + props.setAttributes( newAttrs ); + } + + function onSelectImage( index ) { + return () => { + setSelectedImage( index ); + }; + } + + function onDeselectImage() { + return () => { + setSelectedImage(); + }; + } + + function onMove( oldIndex, newIndex ) { + const newImages = [ ...images ]; + newImages.splice( newIndex, 1, images[ oldIndex ] ); + newImages.splice( oldIndex, 1, images[ newIndex ] ); + setSelectedImage( newIndex ); + setAttributes( { images: newImages } ); + } + + function onMoveForward( oldIndex ) { + return () => { + if ( oldIndex === images.length - 1 ) { + return; + } + onMove( oldIndex, oldIndex + 1 ); + }; + } + + function onMoveBackward( oldIndex ) { + return () => { + if ( oldIndex === 0 ) { + return; + } + onMove( oldIndex, oldIndex - 1 ); + }; + } + + function onRemoveImage( index ) { + return () => { + const newImages = filter( images, ( img, i ) => index !== i ); + setSelectedImage(); + setAttributes( { + images: newImages, + columns: attributes.columns + ? Math.min( newImages.length, attributes.columns ) + : attributes.columns, + } ); + }; + } + + function selectCaption( newImage ) { + // The image id in both the images and attachmentCaptions arrays is a + // string, so ensure comparison works correctly by converting the + // newImage.id to a string. + const newImageId = toString( newImage.id ); + const currentImage = find( images, { id: newImageId } ); + const currentImageCaption = currentImage + ? currentImage.caption + : newImage.caption; + + if ( ! attachmentCaptions ) { + return currentImageCaption; + } + + const attachment = find( attachmentCaptions, { + id: newImageId, + } ); + + // if the attachment caption is updated + if ( attachment && attachment.caption !== newImage.caption ) { + return newImage.caption; + } + + return currentImageCaption; + } + + function onSelectImages( newImages ) { + setAttachmentCaptions( + newImages.map( ( newImage ) => ( { + // Store the attachmentCaption id as a string for consistency + // with the type of the id in the images attribute. + id: toString( newImage.id ), + caption: newImage.caption, + } ) ) + ); + setAttributes( { + images: newImages.map( ( newImage ) => ( { + ...pickRelevantMediaFiles( newImage, sizeSlug ), + caption: selectCaption( newImage, images, attachmentCaptions ), + // The id value is stored in a data attribute, so when the + // block is parsed it's converted to a string. Converting + // to a string here ensures it's type is consistent. + id: toString( newImage.id ), + } ) ), + columns: attributes.columns + ? Math.min( newImages.length, attributes.columns ) + : attributes.columns, + } ); + } + + function onUploadError( message ) { + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( message ); + } + + function setLinkTo( value ) { + setAttributes( { linkTo: value } ); + } + + function setColumnsNumber( value ) { + setAttributes( { columns: value } ); + } + + function toggleImageCrop() { + setAttributes( { imageCrop: ! imageCrop } ); + } + + function getImageCropHelp( checked ) { + return checked + ? __( 'Thumbnails are cropped to align.' ) + : __( 'Thumbnails are not cropped.' ); + } + + function onFocusGalleryCaption() { + setSelectedImage(); + } + + function setImageAttributes( index, newAttributes ) { + if ( ! images[ index ] ) { + return; + } + + setAttributes( { + images: [ + ...images.slice( 0, index ), + { + ...images[ index ], + ...newAttributes, + }, + ...images.slice( index + 1 ), + ], + } ); + } + + function getImagesSizeOptions() { + return map( + filter( imageSizes, ( { slug } ) => + some( resizedImages, ( sizes ) => sizes[ slug ] ) + ), + ( { name, slug } ) => ( { value: slug, label: name } ) + ); + } + + function updateImagesSize( newSizeSlug ) { + const updatedImages = map( images, ( image ) => { + if ( ! image.id ) { + return image; + } + const url = get( resizedImages, [ + parseInt( image.id, 10 ), + newSizeSlug, + ] ); + return { + ...image, + ...( url && { url } ), + }; + } ); + + setAttributes( { images: updatedImages, sizeSlug: newSizeSlug } ); + } + + useEffect( () => { + if ( + Platform.OS === 'web' && + images && + images.length > 0 && + every( images, ( { url } ) => isBlobURL( url ) ) + ) { + const filesList = map( images, ( { url } ) => getBlobByURL( url ) ); + forEach( images, ( { url } ) => revokeBlobURL( url ) ); + mediaUpload( { + filesList, + onFileChange: onSelectImages, + allowedTypes: [ 'image' ], + } ); + } + }, [] ); + + useEffect( () => { + // Deselect images when deselecting the block + if ( ! isSelected ) { + setSelectedImage(); + } + }, [ isSelected ] ); + + 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 mediaPlaceholder = ( + + ); + + const blockProps = useBlockProps(); + + if ( ! hasImages ) { + return { mediaPlaceholder }; + } + + const imageSizeOptions = getImagesSizeOptions(); + const shouldShowSizeOptions = hasImages && ! isEmpty( imageSizeOptions ); + + return ( + <> + + + { images.length > 1 && ( + + ) } + + + { shouldShowSizeOptions && ( + + ) } + + + { noticeUI } + + + ); +} + +export default compose( [ + withSelect( ( select, { attributes: { ids }, isSelected } ) => { + const { getMedia } = select( coreStore ); + const { getSettings } = select( blockEditorStore ); + const { imageSizes, mediaUpload } = getSettings(); + + const resizedImages = useMemo( () => { + if ( isSelected ) { + return reduce( + ids, + ( currentResizedImages, id ) => { + if ( ! id ) { + return currentResizedImages; + } + const image = getMedia( id ); + const sizes = reduce( + imageSizes, + ( currentSizes, size ) => { + const defaultUrl = get( image, [ + 'sizes', + size.slug, + 'url', + ] ); + const mediaDetailsUrl = get( image, [ + 'media_details', + 'sizes', + size.slug, + 'source_url', + ] ); + return { + ...currentSizes, + [ size.slug ]: + defaultUrl || mediaDetailsUrl, + }; + }, + {} + ); + return { + ...currentResizedImages, + [ parseInt( id, 10 ) ]: sizes, + }; + }, + {} + ); + } + return {}; + }, [ isSelected, ids, imageSizes ] ); + + return { + imageSizes, + mediaUpload, + resizedImages, + }; + } ), + withNotices, + withViewportMatch( { isNarrow: '< small' } ), +] )( GalleryEditV1 ); diff --git a/packages/block-library/src/gallery/v1/gallery-image.js b/packages/block-library/src/gallery/v1/gallery-image.js new file mode 100644 index 00000000000000..b358d845b3599a --- /dev/null +++ b/packages/block-library/src/gallery/v1/gallery-image.js @@ -0,0 +1,313 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { get, omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { Button, Spinner, ButtonGroup } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { BACKSPACE, DELETE } from '@wordpress/keycodes'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { RichText, MediaPlaceholder } from '@wordpress/block-editor'; +import { isBlobURL } from '@wordpress/blob'; +import { compose } from '@wordpress/compose'; +import { + closeSmall, + chevronLeft, + chevronRight, + edit, + image as imageIcon, +} from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { pickRelevantMediaFiles } from '../shared'; +import { + LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA, +} from '../constants'; + +const isTemporaryImage = ( id, url ) => ! id && isBlobURL( url ); + +class GalleryImage extends Component { + constructor() { + super( ...arguments ); + + this.onSelectImage = this.onSelectImage.bind( this ); + this.onSelectCaption = this.onSelectCaption.bind( this ); + this.onRemoveImage = this.onRemoveImage.bind( this ); + this.bindContainer = this.bindContainer.bind( this ); + this.onEdit = this.onEdit.bind( this ); + this.onSelectImageFromLibrary = this.onSelectImageFromLibrary.bind( + this + ); + this.onSelectCustomURL = this.onSelectCustomURL.bind( this ); + this.state = { + captionSelected: false, + isEditing: false, + }; + } + + bindContainer( ref ) { + this.container = ref; + } + + onSelectCaption() { + if ( ! this.state.captionSelected ) { + this.setState( { + captionSelected: true, + } ); + } + + if ( ! this.props.isSelected ) { + this.props.onSelect(); + } + } + + onSelectImage() { + if ( ! this.props.isSelected ) { + this.props.onSelect(); + } + + if ( this.state.captionSelected ) { + this.setState( { + captionSelected: false, + } ); + } + } + + onRemoveImage( event ) { + if ( + this.container === this.container.ownerDocument.activeElement && + this.props.isSelected && + [ BACKSPACE, DELETE ].indexOf( event.keyCode ) !== -1 + ) { + event.stopPropagation(); + event.preventDefault(); + this.props.onRemove(); + } + } + + onEdit() { + this.setState( { + isEditing: true, + } ); + } + + componentDidUpdate( prevProps ) { + const { + isSelected, + image, + url, + __unstableMarkNextChangeAsNotPersistent, + } = this.props; + if ( image && ! url ) { + __unstableMarkNextChangeAsNotPersistent(); + this.props.setAttributes( { + url: image.source_url, + alt: image.alt_text, + } ); + } + + // unselect the caption so when the user selects other image and comeback + // the caption is not immediately selected + if ( + this.state.captionSelected && + ! isSelected && + prevProps.isSelected + ) { + this.setState( { + captionSelected: false, + } ); + } + } + + deselectOnBlur() { + this.props.onDeselect(); + } + + onSelectImageFromLibrary( media ) { + const { setAttributes, id, url, alt, caption, sizeSlug } = this.props; + if ( ! media || ! media.url ) { + return; + } + + let mediaAttributes = pickRelevantMediaFiles( media, sizeSlug ); + + // If the current image is temporary but an alt text was meanwhile + // written by the user, make sure the text is not overwritten. + if ( isTemporaryImage( id, url ) ) { + if ( alt ) { + mediaAttributes = omit( mediaAttributes, [ 'alt' ] ); + } + } + + // If a caption text was meanwhile written by the user, + // make sure the text is not overwritten by empty captions. + if ( caption && ! get( mediaAttributes, [ 'caption' ] ) ) { + mediaAttributes = omit( mediaAttributes, [ 'caption' ] ); + } + + setAttributes( mediaAttributes ); + this.setState( { + isEditing: false, + } ); + } + + onSelectCustomURL( newURL ) { + const { setAttributes, url } = this.props; + if ( newURL !== url ) { + setAttributes( { + url: newURL, + id: undefined, + } ); + this.setState( { + isEditing: false, + } ); + } + } + + render() { + const { + url, + alt, + id, + linkTo, + link, + isFirstItem, + isLastItem, + isSelected, + caption, + onRemove, + onMoveForward, + onMoveBackward, + setAttributes, + 'aria-label': ariaLabel, + } = this.props; + const { isEditing } = this.state; + + let href; + + switch ( linkTo ) { + case LINK_DESTINATION_MEDIA: + href = url; + break; + case LINK_DESTINATION_ATTACHMENT: + href = link; + break; + } + + const img = ( + // Disable reason: Image itself is not meant to be interactive, but should + // direct image selection and unfocus caption fields. + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + <> + { + { isBlobURL( url ) && } + + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + ); + + const className = classnames( { + 'is-selected': isSelected, + 'is-transient': isBlobURL( url ), + } ); + + return ( +
    + { ! isEditing && ( href ? { img } : img ) } + { isEditing && ( + + ) } + +
    + ); + } +} + +export default compose( [ + withSelect( ( select, ownProps ) => { + const { getMedia } = select( 'core' ); + const { id } = ownProps; + + return { + image: id ? getMedia( parseInt( id, 10 ) ) : null, + }; + } ), + withDispatch( ( dispatch ) => { + const { __unstableMarkNextChangeAsNotPersistent } = dispatch( + 'core/block-editor' + ); + return { + __unstableMarkNextChangeAsNotPersistent, + }; + } ), +] )( GalleryImage ); diff --git a/packages/block-library/src/gallery/v1/gallery.js b/packages/block-library/src/gallery/v1/gallery.js new file mode 100644 index 00000000000000..ee385651d89364 --- /dev/null +++ b/packages/block-library/src/gallery/v1/gallery.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; +import { VisuallyHidden } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import GalleryImage from './gallery-image'; +import { defaultColumnsNumberV1 } from '../deprecated'; + +export const Gallery = ( props ) => { + const { + attributes, + isSelected, + setAttributes, + selectedImage, + mediaPlaceholder, + onMoveBackward, + onMoveForward, + onRemoveImage, + onSelectImage, + onDeselectImage, + onSetImageAttributes, + onFocusGalleryCaption, + insertBlocksAfter, + blockProps, + } = props; + + const { + align, + columns = defaultColumnsNumberV1( attributes ), + caption, + imageCrop, + images, + } = attributes; + + return ( +
    +
      + { images.map( ( img, index ) => { + const ariaLabel = sprintf( + /* translators: 1: the order number of the image. 2: the total number of images. */ + __( 'image %1$d of %2$d in gallery' ), + index + 1, + images.length + ); + + return ( +
    • + + onSetImageAttributes( index, attrs ) + } + caption={ img.caption } + aria-label={ ariaLabel } + sizeSlug={ attributes.sizeSlug } + /> +
    • + ); + } ) } +
    + { mediaPlaceholder } + setAttributes( { caption: value } ) } + inlineToolbar + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( createBlock( 'core/paragraph' ) ) + } + /> +
    + ); +}; + +function RichTextVisibilityHelper( { isHidden, ...richTextProps } ) { + return isHidden ? ( + + ) : ( + + ); +} + +export default Gallery; diff --git a/packages/block-library/src/gallery/v1/save.js b/packages/block-library/src/gallery/v1/save.js new file mode 100644 index 00000000000000..a053f5219be0d6 --- /dev/null +++ b/packages/block-library/src/gallery/v1/save.js @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { RichText, useBlockProps } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { defaultColumnsNumberV1 } from '../deprecated'; +import { + LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA, +} from '../constants'; + +export default function saveV1( { attributes } ) { + const { + images, + columns = defaultColumnsNumberV1( attributes ), + imageCrop, + caption, + linkTo, + } = attributes; + const className = `columns-${ columns } ${ imageCrop ? 'is-cropped' : '' }`; + + return ( +
    +
      + { images.map( ( image ) => { + let href; + + switch ( linkTo ) { + case LINK_DESTINATION_MEDIA: + href = image.fullUrl || image.url; + break; + case LINK_DESTINATION_ATTACHMENT: + href = image.link; + break; + } + + const img = ( + { + ); + + return ( +
    • +
      + { href ? { img } : img } + { ! RichText.isEmpty( image.caption ) && ( + + ) } +
      +
    • + ); + } ) } +
    + { ! RichText.isEmpty( caption ) && ( + + ) } +
    + ); +} diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 67546ff6710cd3..2be4a5a817c77a 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -158,6 +158,7 @@ function useBlockEditorSettings( settings, hasTemplate ) { '__experimentalGlobalStylesUserEntityId', '__experimentalPreferredStyleVariations', '__experimentalSetIsInserterOpened', + '__experimentalGalleryRefactor', 'alignWide', 'allowedBlockTypes', 'availableLegacyWidgets',