diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 1d06f48e9ed808..224f058edaddfe 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -51,6 +51,17 @@ function gutenberg_initialize_experiments_settings() { 'id' => 'gutenberg-navigation', ) ); + add_settings_field( + 'gutenberg-gallery-refactor', + __( 'Gallery block experiment', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Test a new gallery block that uses nested image blocks (Warning: The new gallery is not compatible with WordPress mobile apps prior to version 18.1. If you use the mobile app, please update to the latest version to avoid content loss.)', 'gutenberg' ), + 'id' => 'gutenberg-gallery-refactor', + ) + ); register_setting( 'gutenberg-experiments', 'gutenberg-experiments' @@ -88,3 +99,24 @@ function gutenberg_display_experiment_section() { isset( $experiments['gutenberg-gallery-refactor'] ), + ); + return array_merge( $settings, $experiments_settings ); +} +// This can be removed when plugin support requires WordPress 5.8.0+. +if ( function_exists( 'get_block_editor_settings' ) ) { + add_filter( 'block_editor_settings_all', 'gutenberg_experiments_editor_settings' ); +} else { + add_filter( 'block_editor_settings', 'gutenberg_experiments_editor_settings' ); +} diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 4dc58c54807546..03ef4ee43b0381 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -554,6 +554,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 +- _\_\_unstableGalleryWithImageBlocks_ `boolean`: Whether the user has enabled the refactored gallery block which uses InnerBlocks ### SkipToSelectedBlock diff --git a/packages/block-editor/src/components/block-list/block-list-item.native.js b/packages/block-editor/src/components/block-list/block-list-item.native.js index d1d50a3c3a1f7d..fae95956fa735a 100644 --- a/packages/block-editor/src/components/block-list/block-list-item.native.js +++ b/packages/block-editor/src/components/block-list/block-list-item.native.js @@ -16,6 +16,8 @@ import { ReadableContentView, alignmentHelpers } from '@wordpress/components'; */ import BlockListBlock from './block'; import BlockInsertionPoint from './insertion-point'; +import Grid from './grid-item'; + import styles from './block-list-item.native.scss'; import { store as blockEditorStore } from '../../store'; @@ -104,7 +106,7 @@ export class BlockListItem extends Component { ]; } - render() { + renderContent() { const { blockAlignment, clientId, @@ -123,10 +125,6 @@ export class BlockListItem extends Component { contentResizeMode === 'stretch' && stretchStyle; const { isContainerRelated } = alignmentHelpers; - if ( ! blockWidth ) { - return null; - } - return ( ); } + + render() { + const { + gridProperties, + clientId, + parentWidth, + items, + blockWidth, + } = this.props; + + if ( ! blockWidth ) { + return null; + } + + if ( gridProperties ) { + return ( + + { this.renderContent() } + + ); + } + return this.renderContent(); + } } export default compose( [ diff --git a/packages/block-editor/src/components/block-list/block-list-item.native.scss b/packages/block-editor/src/components/block-list/block-list-item.native.scss index 8e2eca61430341..82baa74a07111a 100644 --- a/packages/block-editor/src/components/block-list/block-list-item.native.scss +++ b/packages/block-editor/src/components/block-list/block-list-item.native.scss @@ -10,3 +10,7 @@ .fullAlignmentPadding { padding: $block-edge-to-content; } + +.gridItem { + overflow: visible; +} diff --git a/packages/block-editor/src/components/block-list/grid-item.native.js b/packages/block-editor/src/components/block-list/grid-item.native.js new file mode 100644 index 00000000000000..053b3ecfe90d5d --- /dev/null +++ b/packages/block-editor/src/components/block-list/grid-item.native.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; + +/** + * Internal dependencies + */ +import styles from './block-list-item.scss'; + +function Grid( props ) { + /** + * Since we don't have `calc()`, we must calculate our spacings here in + * order to preserve even spacing between tiles and equal width for tiles + * in a given row. + * + * In order to ensure equal sizing of tile contents, we distribute the + * spacing such that each tile has an equal "share" of the fixed spacing. To + * keep the tiles properly aligned within their rows, we calculate the left + * and right paddings based on the tile's relative position within the row. + * + * Note: we use padding instead of margins so that the fixed spacing is + * included within the relative spacing (i.e. width percentage), and + * wrapping behavior is preserved. + * + * - The left most tile in a row must have left padding of zero. + * - The right most tile in a row must have a right padding of zero. + * + * The values of these left and right paddings are interpolated for tiles in + * between. The right padding is complementary with the left padding of the + * next tile (i.e. the right padding of [tile n] + the left padding of + * [tile n + 1] will be equal for all tiles except the last one in a given + * row). + * + */ + const { numOfColumns, children, tileCount, index, maxWidth } = props; + const lastTile = tileCount - 1; + const lastRow = Math.floor( lastTile / numOfColumns ); + + const row = Math.floor( index / numOfColumns ); + const rowLength = + row === lastRow ? ( lastTile % numOfColumns ) + 1 : numOfColumns; + + return ( + + { children } + + ); +} + +export default Grid; diff --git a/packages/block-editor/src/components/block-list/index.native.js b/packages/block-editor/src/components/block-list/index.native.js index fec28608b04641..4ce520ed818751 100644 --- a/packages/block-editor/src/components/block-list/index.native.js +++ b/packages/block-editor/src/components/block-list/index.native.js @@ -47,6 +47,7 @@ const getStyles = ( const computedStyles = [ isStackedHorizontally && styles.horizontal, horizontalAlignment && styles[ `is-aligned-${ horizontalAlignment }` ], + styles.overflowVisible, ]; stylesMemo[ styleName ] = computedStyles; return computedStyles; @@ -128,6 +129,7 @@ export class BlockList extends Component { onDeleteBlock, contentStyle, renderAppender, + gridProperties, } = this.props; const { blockWidth } = this.state; if ( @@ -136,7 +138,8 @@ export class BlockList extends Component { this.extraData.onDeleteBlock !== onDeleteBlock || this.extraData.contentStyle !== contentStyle || this.extraData.renderAppender !== renderAppender || - this.extraData.blockWidth !== blockWidth + this.extraData.blockWidth !== blockWidth || + this.extraData.gridProperties !== gridProperties ) { this.extraData = { parentWidth, @@ -145,6 +148,7 @@ export class BlockList extends Component { contentStyle, renderAppender, blockWidth, + gridProperties, }; } return this.extraData; @@ -312,9 +316,11 @@ export class BlockList extends Component { onDeleteBlock, rootClientId, isStackedHorizontally, + blockClientIds, parentWidth, marginVertical = styles.defaultBlock.marginTop, marginHorizontal = styles.defaultBlock.marginLeft, + gridProperties, } = this.props; const { blockWidth } = this.state; return ( @@ -333,6 +339,8 @@ export class BlockList extends Component { this.shouldShowInnerBlockAppender } blockWidth={ blockWidth } + gridProperties={ gridProperties } + items={ blockClientIds } /> ); } diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index 1f3749693389f2..ff6c46c7948f40 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -99,7 +99,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { index: getBlockIndex( clientId, rootClientId ), mode: getBlockMode( clientId ), name: blockName, - blockTitle: blockType.title, + blockTitle: blockType?.title, isPartOfSelection: isSelected || isPartOfMultiSelection, adjustScrolling: isSelected || isFirstMultiSelectedBlock( clientId ), @@ -107,7 +107,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { ! isTyping() && getGlobalBlockCount() <= BLOCK_ANIMATION_THRESHOLD, lightBlockWrapper: - blockType.apiVersion > 1 || + blockType?.apiVersion > 1 || hasBlockSupport( blockType, 'lightBlockWrapper', false ), }; }, diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js index 4752676c38b21e..205d5de9c5594f 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js @@ -25,9 +25,9 @@ export function useBlockCustomClassName( clientId ) { const { getBlockName, getBlockAttributes } = select( blockEditorStore ); - const { className } = getBlockAttributes( clientId ); + const attributes = getBlockAttributes( clientId ); - if ( ! className ) { + if ( ! attributes?.className ) { return; } @@ -40,7 +40,7 @@ export function useBlockCustomClassName( clientId ) { return; } - return className; + return attributes.className; }, [ clientId ] ); diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js index 1c577d8e071d96..fa84fd8d0be1d7 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js @@ -27,7 +27,7 @@ export function useBlockDefaultClassName( clientId ) { const name = select( blockEditorStore ).getBlockName( clientId ); const blockType = getBlockType( name ); const hasLightBlockWrapper = - blockType.apiVersion > 1 || + blockType?.apiVersion > 1 || hasBlockSupport( blockType, 'lightBlockWrapper', false ); if ( ! hasLightBlockWrapper ) { diff --git a/packages/block-editor/src/components/block-styles/preview.native.js b/packages/block-editor/src/components/block-styles/preview.native.js index c5461bf330acb9..c017331ad38a32 100644 --- a/packages/block-editor/src/components/block-styles/preview.native.js +++ b/packages/block-editor/src/components/block-styles/preview.native.js @@ -71,7 +71,7 @@ function StylePreview( { onPress, isActive, style, url } ) { return ( ); } ); diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index b3617e95ef63eb..89fa80d8b44c83 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -18,7 +18,10 @@ export * from './colors'; export * from './gradients'; export * from './font-sizes'; export { AlignmentControl, AlignmentToolbar } from './alignment-control'; -export { default as InnerBlocks } from './inner-blocks'; +export { + default as InnerBlocks, + useInnerBlocksProps as __experimentalUseInnerBlocksProps, +} from './inner-blocks'; export { default as InspectorAdvancedControls } from './inspector-advanced-controls'; export { default as InspectorControls } from './inspector-controls'; export { diff --git a/packages/block-editor/src/components/inner-blocks/index.native.js b/packages/block-editor/src/components/inner-blocks/index.native.js index 0b9401425e8640..e1b87087fcbebb 100644 --- a/packages/block-editor/src/components/inner-blocks/index.native.js +++ b/packages/block-editor/src/components/inner-blocks/index.native.js @@ -3,6 +3,7 @@ */ import { useSelect } from '@wordpress/data'; import { getBlockType, withBlockContentContext } from '@wordpress/blocks'; +import { useRef } from '@wordpress/element'; /** * Internal dependencies @@ -23,6 +24,44 @@ import { BlockContextProvider } from '../block-context'; import { defaultLayout, LayoutProvider } from '../block-list/layout'; import { store as blockEditorStore } from '../../store'; +/** + * This hook is used to lightly mark an element as an inner blocks wrapper + * element. Call this hook and pass the returned props to the element to mark as + * an inner blocks wrapper, automatically rendering inner blocks as children. If + * you define a ref for the element, it is important to pass the ref to this + * hook, which the hook in turn will pass to the component through the props it + * returns. Optionally, you can also pass any other props through this hook, and + * they will be merged and returned. + * + * @param {Object} props Optional. Props to pass to the element. Must contain + * the ref if one is defined. + * @param {Object} options Optional. Inner blocks options. + * + * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/inner-blocks/README.md + */ +export function useInnerBlocksProps( props = {}, options = {} ) { + const fallbackRef = useRef(); + const { clientId } = useBlockEditContext(); + + const ref = props.ref || fallbackRef; + const InnerBlocks = + options.value && options.onChange + ? ControlledInnerBlocks + : UncontrolledInnerBlocks; + + return { + ...props, + ref, + children: ( + + ), + }; +} + /** * InnerBlocks is a component which allows a single block to have multiple blocks * as children. The UncontrolledInnerBlocks component is used whenever the inner @@ -53,6 +92,7 @@ function UncontrolledInnerBlocks( props ) { filterInnerBlocks, blockWidth, __experimentalLayout: layout = defaultLayout, + gridProperties, } = props; const block = useSelect( @@ -86,6 +126,7 @@ function UncontrolledInnerBlocks( props ) { onAddBlock={ onAddBlock } onDeleteBlock={ onDeleteBlock } filterInnerBlocks={ filterInnerBlocks } + gridProperties={ gridProperties } blockWidth={ blockWidth } /> ); diff --git a/packages/block-editor/src/components/media-placeholder/README.md b/packages/block-editor/src/components/media-placeholder/README.md index 1a2c9693f61735..91e18f4e8ddc1a 100644 --- a/packages/block-editor/src/components/media-placeholder/README.md +++ b/packages/block-editor/src/components/media-placeholder/README.md @@ -188,6 +188,15 @@ Callback called when urls can be configured. No media insertion from url will be - Required: No - Platform: Web +### handleUpload + +When set to false the handling of the upload is left to the calling component. + +- Type: `Boolean` +- Required: No +- Default: `true` +- Platform: Web + ## Extend It includes a `wp.hooks` filter `editor.MediaPlaceholder` that enables developers to replace or extend it. diff --git a/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js index cac2091c677032..f00eae7650e487 100644 --- a/packages/block-editor/src/components/media-placeholder/index.js +++ b/packages/block-editor/src/components/media-placeholder/index.js @@ -64,6 +64,7 @@ export function MediaPlaceholder( { accept, addToGallery, multiple = false, + handleUpload = true, dropZoneUIOnly, disableDropZone, disableMediaButtons, @@ -118,6 +119,9 @@ export function MediaPlaceholder( { }; const onFilesUpload = ( files ) => { + if ( ! handleUpload ) { + return onSelect( files ); + } onFilesPreUpload( files ); let setMedia; if ( multiple ) { diff --git a/packages/block-editor/src/components/media-placeholder/index.native.js b/packages/block-editor/src/components/media-placeholder/index.native.js index 24b826c8c8f9e1..ed9b43e5ada567 100644 --- a/packages/block-editor/src/components/media-placeholder/index.native.js +++ b/packages/block-editor/src/components/media-placeholder/index.native.js @@ -38,6 +38,7 @@ function MediaPlaceholder( props ) { labels = {}, icon, onSelect, + onFocus, __experimentalOnlyMediaLibrary, isAppender, disableMediaButtons, @@ -175,7 +176,7 @@ function MediaPlaceholder( props ) { accessibilityRole={ 'button' } accessibilityHint={ accessibilityHint } onPress={ ( event ) => { - props.onFocus( event ); + onFocus?.( event ); open(); } } > diff --git a/packages/block-editor/src/store/defaults.js b/packages/block-editor/src/store/defaults.js index 49035943cb4a50..33165125325e13 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} __unstableGalleryWithImageBlocks Whether the user has enabled the refactored gallery block which uses InnerBlocks */ export const SETTINGS_DEFAULTS = { alignWide: false, @@ -155,6 +156,7 @@ export const SETTINGS_DEFAULTS = { __experimentalBlockPatterns: [], __experimentalBlockPatternCategories: [], __experimentalSpotlightEntityBlocks: [], + __unstableGalleryWithImageBlocks: 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-editor/src/store/defaults.native.js b/packages/block-editor/src/store/defaults.native.js index ba05941e117dd6..e660d821784059 100644 --- a/packages/block-editor/src/store/defaults.native.js +++ b/packages/block-editor/src/store/defaults.native.js @@ -8,6 +8,9 @@ import { const SETTINGS_DEFAULTS = { ...SETTINGS, + // FOR TESTING ONLY - Later, this will come from a REST API + // eslint-disable-next-line no-undef + __unstableGalleryWithImageBlocks: __DEV__, alignWide: true, supportsLayout: false, }; diff --git a/packages/block-library/src/gallery/block.json b/packages/block-library/src/gallery/block.json index a87edb1eda9729..2f70b440f3a4d5 100644 --- a/packages/block-library/src/gallery/block.json +++ b/packages/block-library/src/gallery/block.json @@ -58,6 +58,13 @@ }, "default": [] }, + "shortCodeTransforms": { + "type": "array", + "default": [], + "items": { + "type": "object" + } + }, "columns": { "type": "number", "minimum": 1, @@ -72,14 +79,25 @@ "type": "boolean", "default": true }, + "linkTarget": { + "type": "string" + }, "linkTo": { "type": "string" }, "sizeSlug": { "type": "string", "default": "large" + }, + "allowResize": { + "type": "boolean", + "default": false } }, + "providesContext": { + "allowResize": "allowResize", + "imageCrop": "imageCrop" + }, "supports": { "anchor": true, "align": true diff --git a/packages/block-library/src/gallery/constants.js b/packages/block-library/src/gallery/constants.js index f4b6e7af56d473..3ac422b3e95907 100644 --- a/packages/block-library/src/gallery/constants.js +++ b/packages/block-library/src/gallery/constants.js @@ -1,3 +1,3 @@ export const LINK_DESTINATION_NONE = 'none'; -export const LINK_DESTINATION_MEDIA = 'file'; -export const LINK_DESTINATION_ATTACHMENT = 'post'; +export const LINK_DESTINATION_MEDIA = 'media'; +export const LINK_DESTINATION_ATTACHMENT = 'attachment'; diff --git a/packages/block-library/src/gallery/deprecated.js b/packages/block-library/src/gallery/deprecated.js index 1b82131b890850..bc89ebbcc9ab16 100644 --- a/packages/block-library/src/gallery/deprecated.js +++ b/packages/block-library/src/gallery/deprecated.js @@ -10,9 +10,17 @@ import { map, some } from 'lodash'; import { RichText } from '@wordpress/block-editor'; /** - * Internal dependencies + * Original function to determine default number of columns from a block's + * attributes. + * + * Used in deprecations: v1-6, for versions of the gallery block that didn't use inner blocks. + * + * @param {Object} attributes Block attributes. + * @return {number} Default number of columns for the gallery. */ -import { defaultColumnsNumber } from './shared'; +export function defaultColumnsNumberV1( attributes ) { + return Math.min( 3, attributes.images.length ); +} const deprecated = [ { @@ -114,7 +122,7 @@ const deprecated = [ save( { attributes } ) { const { images, - columns = defaultColumnsNumber( attributes ), + columns = defaultColumnsNumberV1( attributes ), imageCrop, caption, linkTo, @@ -270,7 +278,7 @@ const deprecated = [ save( { attributes } ) { const { images, - columns = defaultColumnsNumber( attributes ), + columns = defaultColumnsNumberV1( attributes ), imageCrop, caption, linkTo, @@ -409,7 +417,7 @@ const deprecated = [ save( { attributes } ) { const { images, - columns = defaultColumnsNumber( attributes ), + columns = defaultColumnsNumberV1( attributes ), imageCrop, linkTo, } = attributes; @@ -549,7 +557,7 @@ const deprecated = [ save( { attributes } ) { const { images, - columns = defaultColumnsNumber( attributes ), + columns = defaultColumnsNumberV1( attributes ), imageCrop, linkTo, } = attributes; @@ -655,7 +663,7 @@ const deprecated = [ save( { attributes } ) { const { images, - columns = defaultColumnsNumber( attributes ), + columns = defaultColumnsNumberV1( attributes ), align, imageCrop, linkTo, diff --git a/packages/block-library/src/gallery/deprecated.scss b/packages/block-library/src/gallery/deprecated.scss new file mode 100644 index 00000000000000..c72fecd994fdea --- /dev/null +++ b/packages/block-library/src/gallery/deprecated.scss @@ -0,0 +1,149 @@ +// Deprecated gallery styles pre refactoring to use nested image blocks. +// https://github.com/WordPress/gutenberg/pull/25940. +.wp-block-gallery, +.blocks-gallery-grid { + display: flex; + flex-wrap: wrap; + list-style-type: none; + padding: 0; + // Some themes give all
    default margin instead of padding. + margin: 0; + + .blocks-gallery-image, + .blocks-gallery-item { + // Add space between thumbnails, and unset right most thumbnails later. + margin: 0 1em 1em 0; + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: center; + position: relative; + + // On mobile and responsive viewports, we allow only 1 or 2 columns at the most. + width: calc(50% - 1em); + + &:nth-of-type(even) { + margin-right: 0; + } + + figure { + margin: 0; + height: 100%; + + // IE doesn't support flex so omit that. + @supports (position: sticky) { + display: flex; + align-items: flex-end; + justify-content: flex-start; + } + } + + img { + display: block; + max-width: 100%; + height: auto; + + // IE doesn't handle cropping, so we need an explicit width here. + width: 100%; + + // IE11 doesn't read rules inside this query. They are applied only to modern browsers. + @supports (position: sticky) { + width: auto; + } + } + + figcaption { + position: absolute; + bottom: 0; + width: 100%; + max-height: 100%; + overflow: auto; + padding: 3em 0.77em 0.7em; + color: $white; + text-align: center; + font-size: 0.8em; + background: linear-gradient(0deg, rgba($color: $black, $alpha: 0.7) 0, rgba($color: $black, $alpha: 0.3) 70%, transparent); + box-sizing: border-box; + margin: 0; + z-index: 2; + img { + display: inline; + } + } + } + + figcaption { + flex-grow: 1; + } + + // Cropped + &.is-cropped .blocks-gallery-image, + &.is-cropped .blocks-gallery-item { + a, + img { + // IE11 doesn't support object-fit, so just make sure images aren't skewed. + // The following rules are for all browsers. + width: 100%; + + // IE11 doesn't read rules inside this query. They are applied only to modern browsers. + @supports (position: sticky) { + height: 100%; + flex: 1; + object-fit: cover; + } + } + } + + &.columns-1 .blocks-gallery-image, + &.columns-1 .blocks-gallery-item { + width: 100%; + margin-right: 0; + } + + // Beyond mobile viewports, we allow up to 8 columns. + @include break-small { + @for $i from 3 through 8 { + &.columns-#{ $i } .blocks-gallery-image, + &.columns-#{ $i } .blocks-gallery-item { + width: calc(#{ math.div(100%, $i) } - #{ math.div(1em * ( $i - 1 ), $i) }); + margin-right: 1em; + } + } + + // Unset the right margin on every rightmost gallery item to ensure center balance. + @for $column-count from 1 through 8 { + &.columns-#{ $column-count } .blocks-gallery-image:nth-of-type(#{ $column-count }n), + &.columns-#{ $column-count } .blocks-gallery-item:nth-of-type(#{ $column-count }n) { + margin-right: 0; + } + } + } + + // Last item always needs margins reset. + .blocks-gallery-image:last-child, + .blocks-gallery-item:last-child { + margin-right: 0; + } + + // Apply max-width to floated items that have no intrinsic width. + &.alignleft, + &.alignright { + max-width: $content-width * 0.5; + width: 100%; + } + + // If the gallery is centered, center the content inside as well. + &.aligncenter { + .blocks-gallery-item figure { + justify-content: center; + } + } +} +.wp-block-gallery { + // Non cropped images. + &:not(.is-cropped) { + .blocks-gallery-item { + align-self: flex-start; + } + } +} 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..0e101dfa85e216 --- /dev/null +++ b/packages/block-library/src/gallery/edit-wrapper.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import EditWithInnerBlocks from './edit'; +import EditWithoutInnerBlocks 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, clientId } = props; + + const innerBlockImages = useSelect( + ( select ) => { + return select( blockEditorStore ).getBlock( clientId )?.innerBlocks; + }, + [ clientId ] + ); + + const __unstableGalleryWithImageBlocks = useSelect( ( select ) => { + const settings = select( blockEditorStore ).getSettings(); + return settings.__unstableGalleryWithImageBlocks; + }, [] ); + + // This logic is used to infer version information from content with higher + // precedence than the flag. New galleries (and existing empty galleries) will + // honor the flag. + const hasNewVersionContent = !! innerBlockImages?.length; + const hasOldVersionContent = + 0 < attributes?.ids?.length || 0 < attributes?.images?.length; + if ( + hasOldVersionContent || + ( ! hasNewVersionContent && ! __unstableGalleryWithImageBlocks ) + ) { + return ; + } + + return ; +} diff --git a/packages/block-library/src/gallery/edit.js b/packages/block-library/src/gallery/edit.js index f134555a72ed0c..3a1fa9c990b952 100644 --- a/packages/block-library/src/gallery/edit.js +++ b/packages/block-library/src/gallery/edit.js @@ -1,55 +1,57 @@ /** * External dependencies */ -import { - every, - filter, - find, - forEach, - get, - isEmpty, - map, - reduce, - some, - toString, -} from 'lodash'; +import classnames from 'classnames'; +import { concat, find } from 'lodash'; /** * WordPress dependencies */ import { compose } from '@wordpress/compose'; import { + BaseControl, PanelBody, SelectControl, ToggleControl, withNotices, RangeControl, + Spinner, } from '@wordpress/components'; import { + store as blockEditorStore, MediaPlaceholder, InspectorControls, useBlockProps, - store as blockEditorStore, } 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, useSelect } from '@wordpress/data'; +import { Platform, useEffect, useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; import { withViewportMatch } from '@wordpress/viewport'; import { View } from '@wordpress/primitives'; -import { store as coreStore } from '@wordpress/core-data'; +import { createBlock } from '@wordpress/blocks'; +import { createBlobURL } from '@wordpress/blob'; +import { store as noticesStore } from '@wordpress/notices'; /** * 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, } from './constants'; +import useImageSizes from './use-image-sizes'; +import useShortCodeTransform from './use-short-code-transform'; +import useGetNewImages from './use-get-new-images'; +import useGetMedia from './use-get-media'; const MAX_COLUMNS = 8; const linkOptions = [ @@ -59,221 +61,226 @@ const linkOptions = [ ]; 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 PLACEHOLDER_TEXT = Platform.isNative + ? __( 'ADD MEDIA' ) + : __( 'Drag images, upload new ones or select files from your library.' ); -const MOBILE_CONTROL_PROPS_RANGE_CONTROL = Platform.select( { - web: {}, - native: { type: 'stepper' }, -} ); +const MOBILE_CONTROL_PROPS_RANGE_CONTROL = Platform.isNative + ? { type: 'stepper' } + : {}; function GalleryEdit( props ) { const { + setAttributes, attributes, + className, clientId, + noticeOperations, isSelected, noticeUI, - noticeOperations, - onFocus, + insertBlocksAfter, } = props; + const { - columns = defaultColumnsNumber( attributes ), + columns, imageCrop, - images, + linkTarget, linkTo, + shortCodeTransforms, sizeSlug, } = attributes; - const [ selectedImage, setSelectedImage ] = useState(); - const [ attachmentCaptions, setAttachmentCaptions ] = useState(); - const { __unstableMarkNextChangeAsNotPersistent } = useDispatch( - blockEditorStore - ); const { - imageSizes, - mediaUpload, - getMedia, - wasBlockJustInserted, - } = useSelect( ( select ) => { - const settings = select( blockEditorStore ).getSettings(); + __unstableMarkNextChangeAsNotPersistent, + replaceInnerBlocks, + updateBlockAttributes, + } = useDispatch( blockEditorStore ); + const { createSuccessNotice } = useDispatch( noticesStore ); + const { getBlock, getSettings, preferredStyle } = useSelect( ( select ) => { + const settings = select( blockEditorStore ).getSettings(); + const preferredStyleVariations = + settings.__experimentalPreferredStyleVariations; return { - imageSizes: settings.imageSizes, - mediaUpload: settings.mediaUpload, - getMedia: select( coreStore ).getMedia, - wasBlockJustInserted: select( - blockEditorStore - ).wasBlockJustInserted( clientId, 'inserter_menu' ), + getBlock: select( blockEditorStore ).getBlock, + getSettings: select( blockEditorStore ).getSettings, + preferredStyle: preferredStyleVariations?.value?.[ 'core/image' ], }; - } ); + }, [] ); - const resizedImages = useMemo( () => { - if ( isSelected ) { - return reduce( - attributes.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, attributes.ids, imageSizes ] ); + const innerBlockImages = useSelect( + ( select ) => { + return select( blockEditorStore ).getBlock( clientId )?.innerBlocks; + }, + [ clientId ] + ); - function onFocusGalleryCaption() { - setSelectedImage(); - } + 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 ] + ); - 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' - ); - } + const imageData = useGetMedia( innerBlockImages ); - 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 ) ), - }; - } + const newImages = useGetNewImages( images, imageData ); - props.setAttributes( newAttrs ); - } + useEffect( () => { + newImages?.forEach( ( newImage ) => { + updateBlockAttributes( newImage.clientId, { + ...buildImageAttributes( false, newImage.attributes ), + id: newImage.id, + } ); + } ); + }, [ newImages ] ); - function onSelectImage( index ) { - return () => { - setSelectedImage( index ); - }; - } + const shortCodeImages = useShortCodeTransform( shortCodeTransforms ); - function onDeselectImage() { - return () => { - setSelectedImage(); - }; - } + useEffect( () => { + if ( ! shortCodeTransforms || ! shortCodeImages ) { + return; + } + updateImages( shortCodeImages ); + setAttributes( { shortCodeTransforms: undefined } ); + }, [ shortCodeTransforms, shortCodeImages ] ); - function onMove( oldIndex, newIndex ) { - const newImages = [ ...images ]; - newImages.splice( newIndex, 1, images[ oldIndex ] ); - newImages.splice( oldIndex, 1, images[ newIndex ] ); - setSelectedImage( newIndex ); - setAttributes( { images: newImages } ); - } + const imageSizeOptions = useImageSizes( + imageData, + isSelected, + getSettings + ); - function onMoveForward( oldIndex ) { - return () => { - if ( oldIndex === images.length - 1 ) { - return; - } - onMove( oldIndex, oldIndex + 1 ); - }; - } + /** + * 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} existingBlock Existing Image block that still exists after gallery update. + * @param {Object} image Media object for the actual image. + * @return {Object} Attributes to set on the new image block. + */ + function buildImageAttributes( existingBlock, image ) { + if ( existingBlock ) { + return existingBlock.attributes; + } - function onMoveBackward( oldIndex ) { - return () => { - if ( oldIndex === 0 ) { - return; - } - onMove( oldIndex, oldIndex - 1 ); + let newClassName; + if ( image.className && image.className !== '' ) { + newClassName = image.className; + } else { + newClassName = preferredStyle + ? `is-style-${ preferredStyle }` + : undefined; + } + + return { + ...pickRelevantMediaFiles( image, sizeSlug ), + ...getHrefAndDestination( image, linkTo ), + ...getUpdatedLinkTargetSettings( linkTarget, attributes ), + className: newClassName, + sizeSlug, }; } - 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 isValidFileType( file ) { + return ( + ALLOWED_MEDIA_TYPES.some( + ( mediaType ) => file.type?.indexOf( mediaType ) === 0 + ) || file.url?.indexOf( 'blob:' ) === 0 + ); } - 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; + function updateImages( selectedImages ) { + const newFileUploads = + Object.prototype.toString.call( selectedImages ) === + '[object FileList]'; + + const imageArray = newFileUploads + ? Array.from( selectedImages ).map( ( file ) => { + if ( ! file.url ) { + return pickRelevantMediaFiles( { + url: createBlobURL( file ), + } ); + } + + return file; + } ) + : selectedImages; + + if ( ! imageArray.every( isValidFileType ) ) { + noticeOperations.removeAllNotices(); + noticeOperations.createErrorNotice( + __( + 'If uploading to a gallery all files need to be image formats' + ), + { id: 'gallery-upload-invalid-file' } + ); } - const attachment = find( attachmentCaptions, { - id: newImageId, - } ); + const processedImages = imageArray + .filter( ( file ) => file.url || isValidFileType( file ) ) + .map( ( file ) => { + if ( ! file.url ) { + return pickRelevantMediaFiles( { + url: createBlobURL( file ), + } ); + } - // if the attachment caption is updated - if ( attachment && attachment.caption !== newImage.caption ) { - return newImage.caption; - } + return file; + } ); - return currentImageCaption; - } + // 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 + ), + {} + ); - 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, - } ) ) + 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 + ) ); - 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, + + const newBlocks = newImageList.map( ( image ) => { + return createBlock( 'core/image', { + id: image.id, + url: image.url, + caption: image.caption, + alt: image.alt, + } ); } ); + + replaceInnerBlocks( + clientId, + concat( existingImageBlocks, newBlocks ).sort( + ( a, b ) => + newOrderMap[ a.attributes.id ] - + newOrderMap[ b.attributes.id ] + ) + ); } function onUploadError( message ) { @@ -283,6 +290,34 @@ function GalleryEdit( props ) { function setLinkTo( value ) { setAttributes( { linkTo: value } ); + const changedAttributes = {}; + const blocks = []; + getBlock( clientId ).innerBlocks.forEach( ( block ) => { + blocks.push( block.clientId ); + const image = block.attributes.id + ? find( imageData, { id: block.attributes.id } ) + : null; + changedAttributes[ block.clientId ] = getHrefAndDestination( + image, + value + ); + } ); + 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.label + ), + { + id: 'gallery-attributes-linkTo', + type: 'snackbar', + } + ); } function setColumnsNumber( value ) { @@ -299,77 +334,62 @@ function GalleryEdit( props ) { : __( 'Thumbnails are not cropped.' ); } - function setImageAttributes( index, newAttributes ) { - if ( ! images[ index ] ) { - return; - } - - setAttributes( { - images: [ - ...images.slice( 0, index ), - { - ...images[ index ], - ...newAttributes, - }, - ...images.slice( index + 1 ), - ], + 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 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( { sizeSlug: newSizeSlug } ); + const changedAttributes = {}; + const blocks = []; + getBlock( clientId ).innerBlocks.forEach( ( block ) => { + blocks.push( block.clientId ); + const image = block.attributes.id + ? find( imageData, { id: block.attributes.id } ) + : null; + changedAttributes[ block.clientId ] = getImageSizeAttributes( + image, + newSizeSlug + ); } ); + updateBlockAttributes( blocks, changedAttributes, true ); + const imageSize = imageSizeOptions.find( + ( size ) => size.value === newSizeSlug + ); - setAttributes( { images: updatedImages, sizeSlug: newSizeSlug } ); + createSuccessNotice( + sprintf( + /* translators: %s: image size settings */ + __( 'All gallery image sizes updated to: %s' ), + imageSize.label + ), + { + id: 'gallery-attributes-sizeSlug', + type: 'snackbar', + } + ); } 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 + // linkTo attribute must be saved so blocks don't break when changing image_default_link_type in options.php if ( ! linkTo ) { __unstableMarkNextChangeAsNotPersistent(); setAttributes( { @@ -382,39 +402,42 @@ function GalleryEdit( props ) { const hasImages = !! images.length; const hasImageIds = hasImages && images.some( ( image ) => !! image.id ); + const imagesUploading = images.some( + ( img ) => ! img.id && img.url?.indexOf( 'blob:' ) === 0 + ); const mediaPlaceholder = ( ); - const blockProps = useBlockProps(); + const blockProps = useBlockProps( { + className: classnames( className, 'has-nested-images' ), + } ); if ( ! hasImages ) { return { mediaPlaceholder }; } - const imageSizeOptions = getImagesSizeOptions(); - const shouldShowSizeOptions = hasImages && ! isEmpty( imageSizeOptions ); + const hasLinkTo = linkTo && linkTo !== 'none'; return ( <> @@ -423,7 +446,11 @@ function GalleryEdit( props ) { { images.length > 1 && ( - { shouldShowSizeOptions && ( + { hasLinkTo && ( + + ) } + { imageSizeOptions?.length > 0 && ( ) } + { Platform.isWeb && ! imageSizeOptions && ( + + + { __( 'Image size' ) } + + + + { __( 'Loading options…' ) } + + + ) } { noticeUI } ); } - export default compose( [ withNotices, withViewportMatch( { isNarrow: '< small' } ), diff --git a/packages/block-library/src/gallery/editor.scss b/packages/block-library/src/gallery/editor.scss index 739a7099b764b3..32b2b39405cf86 100644 --- a/packages/block-library/src/gallery/editor.scss +++ b/packages/block-library/src/gallery/editor.scss @@ -1,11 +1,24 @@ -.wp-block-gallery { +figure.wp-block-gallery { // Override the default list style type _only in the editor_ // to avoid :not() selector specificity issues. // See https://github.com/WordPress/gutenberg/pull/10358 - li { - list-style-type: none; + + display: block; + margin: 0; + &.has-nested-images { + .components-drop-zone { + display: none; + pointer-events: none; + } } + > .blocks-gallery-caption { + flex: 0 0 100%; + } + + .components-form-file-upload { + flex-basis: 100%; + } // @todo: this deserves a refactor, by being moved to the toolbar. .block-editor-media-placeholder.is-appender { .components-placeholder__label { @@ -15,22 +28,74 @@ margin-bottom: 0; } } + .block-editor-media-placeholder { + margin: 0; + height: 100%; + + &::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; + pointer-events: none; + } + .components-placeholder__label { + display: flex; + } + figcaption { + z-index: 2; + } + } + + // Shown while image is being uploaded + .components-spinner { + position: absolute; + top: 50%; + left: 50%; + margin-top: -9px; + margin-left: -9px; + } } -figure.wp-block-gallery { - display: block; - margin: 0; + +/** + * Gallery inspector controls settings. + */ +.gallery-settings-buttons { + .components-button:first-child { + margin-right: 8px; + } } -// Necessary to to override default editor ul styles. -.blocks-gallery-grid.blocks-gallery-grid { - padding-left: 0; - margin-left: 0; - margin-bottom: 0; +.gallery-image-sizes { + .components-base-control__label { + display: block; + margin-bottom: 4px; + } + + .gallery-image-sizes__loading { + display: flex; + align-items: center; + color: $gray-700; + font-size: $helptext-font-size; + } + + .components-spinner { + 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 { @@ -50,10 +115,6 @@ figure.wp-block-gallery { bottom: 0; left: 0; z-index: 1; - pointer-events: none; - } - figcaption { - z-index: 2; } } @@ -125,11 +186,8 @@ figure.wp-block-gallery { } } - -.blocks-gallery-item .components-spinner { - position: absolute; - top: 50%; - left: 50%; - margin-top: -9px; - margin-left: -9px; +.wp-block-gallery ul.blocks-gallery-grid { + padding: 0; + // Some themes give all
      default margin instead of padding. + margin: 0; } diff --git a/packages/block-library/src/gallery/gallery-styles.native.scss b/packages/block-library/src/gallery/gallery-styles.native.scss index 9b3169da048b22..a3073592291b93 100644 --- a/packages/block-library/src/gallery/gallery-styles.native.scss +++ b/packages/block-library/src/gallery/gallery-styles.native.scss @@ -1,5 +1,7 @@ -.galleryTilesContainerSelected { - margin-bottom: 16px; +@import "./v1/gallery-styles.native.scss"; + +.galleryAppender { + padding-top: $grid-unit-20; } .fullWidth { diff --git a/packages/block-library/src/gallery/gallery.js b/packages/block-library/src/gallery/gallery.js index fc9d0fa39448e4..d0ad30f3e47ee6 100644 --- a/packages/block-library/src/gallery/gallery.js +++ b/packages/block-library/src/gallery/gallery.js @@ -6,93 +6,86 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { RichText } from '@wordpress/block-editor'; +import { + RichText, + __experimentalUseInnerBlocksProps as useInnerBlocksProps, +} from '@wordpress/block-editor'; import { VisuallyHidden } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; +import { + useState, + useEffect, + useRef, + useLayoutEffect, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import { createBlock } from '@wordpress/blocks'; -/** - * Internal dependencies - */ -import GalleryImage from './gallery-image'; -import { defaultColumnsNumber } from './shared'; +const allowedBlocks = [ 'core/image' ]; export const Gallery = ( props ) => { const { attributes, isSelected, setAttributes, - selectedImage, mediaPlaceholder, - onMoveBackward, - onMoveForward, - onRemoveImage, - onSelectImage, - onDeselectImage, - onSetImageAttributes, insertBlocksAfter, blockProps, } = props; - const { - align, - columns = defaultColumnsNumber( attributes ), - caption, - imageCrop, - images, - } = attributes; + const { align, columns, caption, imageCrop } = attributes; + + const { children, ...innerBlocksProps } = useInnerBlocksProps( blockProps, { + allowedBlocks, + orientation: 'horizontal', + renderAppender: false, + __experimentalLayout: { type: 'default', alignments: [] }, + } ); + + const [ captionFocused, setCaptionFocused ] = useState( false ); + + const captionRef = useRef(); + + // Need to use a layout effect here as we can't focus the RichText element + // until the DOM render cycle is finished. + useLayoutEffect( () => { + if ( captionFocused && captionRef.current ) { + captionRef.current.focus(); + } + }, [ captionFocused ] ); + + function onFocusCaption() { + if ( ! captionFocused ) { + setCaptionFocused( true ); + } + } + + useEffect( () => { + if ( ! isSelected ) { + setCaptionFocused( false ); + } + }, [ isSelected ] ); 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 } - /> -
      • - ); - } ) } -
      + { children } { mediaPlaceholder } { ); }; -function RichTextVisibilityHelper( { isHidden, ...richTextProps } ) { - return isHidden ? ( - - ) : ( - +function RichTextVisibilityHelper( { + isHidden, + captionFocused, + onFocusCaption, + className, + value, + placeholder, + tagName, + captionRef, + ...richTextProps +} ) { + if ( isHidden ) { + return ; + } + + if ( captionFocused ) { + return ( + + ); + } + + return ( +
      + { RichText.isEmpty( value ) ? placeholder : value } +
      ); } diff --git a/packages/block-library/src/gallery/gallery.native.js b/packages/block-library/src/gallery/gallery.native.js index 8a58e1f9ca3385..6dc9399747aae4 100644 --- a/packages/block-library/src/gallery/gallery.native.js +++ b/packages/block-library/src/gallery/gallery.native.js @@ -7,10 +7,8 @@ import { isEmpty } from 'lodash'; /** * Internal dependencies */ -import GalleryImage from './gallery-image'; import { defaultColumnsNumber } from './shared'; import styles from './gallery-styles.scss'; -import Tiles from './tiles'; /** * WordPress dependencies @@ -18,123 +16,88 @@ import Tiles from './tiles'; import { __, sprintf } from '@wordpress/i18n'; import { BlockCaption, - store as blockEditorStore, + __experimentalUseInnerBlocksProps as useInnerBlocksProps, } from '@wordpress/block-editor'; import { useState, useEffect } from '@wordpress/element'; import { mediaUploadSync } from '@wordpress/react-native-bridge'; -import { useSelect } from '@wordpress/data'; -import { alignmentHelpers } from '@wordpress/components'; +import { WIDE_ALIGNMENTS } from '@wordpress/components'; +import { useResizeObserver } from '@wordpress/compose'; -const TILE_SPACING = 15; +const TILE_SPACING = 8; // we must limit displayed columns since readable content max-width is 580px const MAX_DISPLAYED_COLUMNS = 4; const MAX_DISPLAYED_COLUMNS_NARROW = 2; -const { isFullWidth } = alignmentHelpers; - export const Gallery = ( props ) => { const [ isCaptionSelected, setIsCaptionSelected ] = useState( false ); + const [ resizeObserver, sizes ] = useResizeObserver(); + const [ maxWidth, setMaxWidth ] = useState( 0 ); useEffect( mediaUploadSync, [] ); - const isRTL = useSelect( ( select ) => { - return !! select( blockEditorStore ).getSettings().isRTL; - }, [] ); - const { - clientId, - selectedImage, mediaPlaceholder, - onBlur, - onMoveBackward, - onMoveForward, - onRemoveImage, - onSelectImage, - onSetImageAttributes, - onFocusGalleryCaption, attributes, - isSelected, isNarrow, - onFocus, + onBlur, insertBlocksAfter, + clientId, } = props; + useEffect( () => { + const { width } = sizes || {}; + if ( width ) { + setMaxWidth( width ); + } + }, [ sizes ] ); + const { - align, - columns = defaultColumnsNumber( attributes ), - imageCrop, images, + align, + columns = defaultColumnsNumber( images.length ), } = attributes; - // limit displayed columns when isNarrow is true (i.e. when viewport width is - // less than "small", where small = 600) - const displayedColumns = isNarrow - ? Math.min( columns, MAX_DISPLAYED_COLUMNS_NARROW ) - : Math.min( columns, MAX_DISPLAYED_COLUMNS ); + const displayedColumns = Math.min( + columns, + isNarrow ? MAX_DISPLAYED_COLUMNS_NARROW : MAX_DISPLAYED_COLUMNS + ); - const selectImage = ( index ) => { - return () => { - if ( isCaptionSelected ) { - setIsCaptionSelected( false ); - } - // we need to fully invoke the curried function here - onSelectImage( index )(); - }; - }; + const innerBlocksProps = useInnerBlocksProps( + {}, + { + contentResizeMode: 'stretch', + allowedBlocks: [ 'core/image' ], + orientation: 'horizontal', + renderAppender: false, + numColumns: displayedColumns, + marginHorizontal: TILE_SPACING, + marginVertical: TILE_SPACING, + __experimentalLayout: { type: 'default', alignments: [] }, + gridProperties: { + numColumns: displayedColumns, + }, + parentWidth: maxWidth + 2 * TILE_SPACING, + } + ); const focusGalleryCaption = () => { if ( ! isCaptionSelected ) { setIsCaptionSelected( true ); } - onFocusGalleryCaption(); }; + const isFullWidth = align === WIDE_ALIGNMENTS.alignments.full; + return ( - - + { resizeObserver } + + - { 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 } - isRTL={ isRTL } - /> - ); - } ) } - - { mediaPlaceholder } { accessibilityLabelCreator={ ( caption ) => isEmpty( caption ) ? /* translators: accessibility text. Empty gallery caption. */ + 'Gallery caption. Empty' : sprintf( /* translators: accessibility text. %s: gallery caption. */ diff --git a/packages/block-library/src/gallery/index.js b/packages/block-library/src/gallery/index.js index 01e8a7a61502f6..b60a324dffc039 100644 --- a/packages/block-library/src/gallery/index.js +++ b/packages/block-library/src/gallery/index.js @@ -7,7 +7,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'; @@ -21,17 +21,23 @@ export const settings = { example: { attributes: { columns: 2, - images: [ - { + }, + innerBlocks: [ + { + name: 'core/image', + attributes: { url: 'https://s.w.org/images/core/5.3/Glacial_lakes%2C_Bhutan.jpg', }, - { + }, + { + name: 'core/image', + attributes: { url: 'https://s.w.org/images/core/5.3/Sediment_off_the_Yucatan_Peninsula.jpg', }, - ], - }, + }, + ], }, transforms, edit, diff --git a/packages/block-library/src/gallery/save.js b/packages/block-library/src/gallery/save.js index 0c51884a5db957..bb1fa63a1ba594 100644 --- a/packages/block-library/src/gallery/save.js +++ b/packages/block-library/src/gallery/save.js @@ -1,74 +1,34 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ -import { RichText, useBlockProps } from '@wordpress/block-editor'; +import { RichText, useBlockProps, InnerBlocks } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { defaultColumnsNumber } from './shared'; -import { - LINK_DESTINATION_ATTACHMENT, - LINK_DESTINATION_MEDIA, -} from './constants'; - -export default function save( { attributes } ) { - const { - images, - columns = defaultColumnsNumber( attributes ), - imageCrop, - caption, - linkTo, - } = attributes; - const className = `columns-${ columns } ${ imageCrop ? 'is-cropped' : '' }`; +import saveWithoutInnerBlocks from './v1/save'; - return ( -
      -
        - { images.map( ( image ) => { - let href; +export default function saveWithInnerBlocks( { attributes } ) { + if ( attributes?.ids?.length > 0 || attributes?.images?.length > 0 ) { + return saveWithoutInnerBlocks( { attributes } ); + } - switch ( linkTo ) { - case LINK_DESTINATION_MEDIA: - href = image.fullUrl || image.url; - break; - case LINK_DESTINATION_ATTACHMENT: - href = image.link; - break; - } + const { caption, columns, imageCrop } = attributes; - const img = ( - { - ); + const className = classnames( 'blocks-gallery-grid', 'has-nested-images', { + [ `columns-${ columns }` ]: columns !== undefined, + [ `columns-default` ]: columns === undefined, + 'is-cropped': imageCrop, + } ); - return ( -
      • -
        - { href ? { img } : img } - { ! RichText.isEmpty( image.caption ) && ( - - ) } -
        -
      • - ); - } ) } -
      + return ( +
      + { ! RichText.isEmpty( caption ) && ( { @@ -19,5 +24,7 @@ export const pickRelevantMediaFiles = ( image, sizeSlug = 'large' ) => { if ( fullUrl ) { imageProps.fullUrl = fullUrl; } + imageProps.alt = + imageProps.alt !== '' ? imageProps.alt : __( 'Image gallery image' ); return imageProps; }; diff --git a/packages/block-library/src/gallery/style.scss b/packages/block-library/src/gallery/style.scss index 788259032f1530..e1e8300936b60e 100644 --- a/packages/block-library/src/gallery/style.scss +++ b/packages/block-library/src/gallery/style.scss @@ -1,129 +1,197 @@ -.wp-block-gallery, -.blocks-gallery-grid { +// Import styles for rendering the static content of deprecated gallery versions. +@import "./deprecated.scss"; + +// Styles for current version of gallery block. +.wp-block-gallery.blocks-gallery-grid.has-nested-images { display: flex; flex-wrap: wrap; - list-style-type: none; - padding: 0; - // Some themes give all
        default margin instead of padding. - margin: 0; - - .blocks-gallery-image, - .blocks-gallery-item { + // Need bogus :not(#individual-image) to override long :not() + // specificity chain on default image block on front end. + figure.wp-block-image:not(#individual-image) { // Add space between thumbnails, and unset right most thumbnails later. - margin: 0 1em 1em 0; - display: flex; - flex-grow: 1; - flex-direction: column; - justify-content: center; - position: relative; - align-self: flex-start; + margin: 0 var(--gallery-block--gutter-size, #{$grid-unit-20}) var(--gallery-block--gutter-size, #{$grid-unit-20}) 0; + + &:last-of-type:not(#individual-image) { + margin-right: 0; + } - // On mobile and responsive viewports, we allow only 1 or 2 columns at the most. - width: calc(50% - 1em); + width: calc(50% - (var(--gallery-block--gutter-size, #{$grid-unit-20}) / 2)); &:nth-of-type(even) { margin-right: 0; } + } + + figure.wp-block-image { + display: flex; + flex-grow: 1; + justify-content: center; + position: relative; + margin-top: auto; + margin-bottom: auto; + // IE11 doesn't like the "flex-direction: column;" here. + @supports ( position: sticky ) { + flex-direction: column; + } - figure { + > div, + > a { margin: 0; - height: 100%; - // IE doesn't support flex so omit that. - @supports (position: sticky) { - display: flex; - align-items: flex-end; - justify-content: flex-start; + // Avoid applying flex styles to IE11. + @supports ( position: sticky ) { + flex-direction: column; + flex-grow: 1; } } img { display: block; - max-width: 100%; height: auto; - - // IE doesn't handle cropping, so we need an explicit width here. + max-width: 100%; width: 100%; + // IE doesn't handle cropping, so we need an explicit width here. // IE11 doesn't read rules inside this query. They are applied only to modern browsers. - @supports (position: sticky) { + @supports ( position: sticky ) { width: auto; } } figcaption { - position: absolute; + background: linear-gradient(0deg, rgba($color: $black, $alpha: 0.7) 0, rgba($color: $black, $alpha: 0.3) 70%, transparent); bottom: 0; - width: 100%; + color: $white; + font-size: $default-font-size; + left: 0; + margin-bottom: 0; max-height: 100%; overflow: auto; - padding: 3em 0.77em 0.7em; - color: $white; + padding: 40px 10px 9px; + position: absolute; text-align: center; - font-size: 0.8em; - background: linear-gradient(0deg, rgba($color: $black, $alpha: 0.7) 0, rgba($color: $black, $alpha: 0.3) 70%, transparent); - box-sizing: border-box; - margin: 0; + width: 100%; img { display: inline; } } + + &.is-style-rounded { + > div, + > a { + // Not supported in IE11. + @supports ( position: sticky ) { + flex: 1 1 auto; + } + } + figcaption { + background: none; + // Not supported in IE11. + @supports ( position: sticky ) { + flex: initial; + background: none; + color: inherit; + margin: 0; + padding: 10px 10px 9px; + position: relative; + } + } + } } figcaption { flex-grow: 1; + flex-basis: 100%; + text-align: center; + } + + // Non cropped images. + &:not(.is-cropped) { + + figure.wp-block-image:not(#individual-image) { + margin-top: 0; + margin-bottom: auto; + img { + margin-bottom: var(--gallery-block--gutter-size, #{$grid-unit-20}); + } + + figcaption { + bottom: var(--gallery-block--gutter-size, #{$grid-unit-20}); + } + } } - // Cropped - &.is-cropped .blocks-gallery-image, - &.is-cropped .blocks-gallery-item { + // Cropped Images. + &.is-cropped figure.wp-block-image:not(#individual-image) { align-self: inherit; + > div:not(.components-drop-zone), + > a { + display: block; // Thanks to IE11 not supporting object-fit fall back to display: block. + + // Without IE11 object-fit support "display: flex;" here causes distortion of aspect ratio. + @supports ( position: sticky ) { + display: flex; + } + } a, img { - // IE11 doesn't support object-fit, so just make sure images aren't skewed. - // The following rules are for all browsers. width: 100%; // IE11 doesn't read rules inside this query. They are applied only to modern browsers. - @supports (position: sticky) { + @supports ( position: sticky ) { + flex: 1 0 0%; height: 100%; - flex: 1; object-fit: cover; } } } - &.columns-1 .blocks-gallery-image, - &.columns-1 .blocks-gallery-item { - width: 100%; + &.columns-1 figure.wp-block-image:not(#individual-image) { margin-right: 0; + width: 100%; } // Beyond mobile viewports, we allow up to 8 columns. @include break-small { @for $i from 3 through 8 { - &.columns-#{ $i } .blocks-gallery-image, - &.columns-#{ $i } .blocks-gallery-item { - width: calc(#{ math.div(100%, $i) } - #{ math.div(1em * ( $i - 1 ), $i) }); - margin-right: 1em; + &.columns-#{ $i } figure.wp-block-image:not(#individual-image) { + margin-right: var(--gallery-block--gutter-size, #{$grid-unit-20}); + width: calc(#{math.div(100%, $i)} - (var(--gallery-block--gutter-size, #{$grid-unit-20}) * #{math.div($i - 1, $i)})); + } - } + // Prevent collapsing margin while sibling is being dragged. + &.columns-#{$i} figure.wp-block-image:not(#individual-image).is-dragging ~ figure.wp-block-image:not(#individual-image) { + margin-right: var(--gallery-block--gutter-size, #{$grid-unit-20}); + } + } // Unset the right margin on every rightmost gallery item to ensure center balance. @for $column-count from 1 through 8 { - &.columns-#{ $column-count } .blocks-gallery-image:nth-of-type(#{ $column-count }n), - &.columns-#{ $column-count } .blocks-gallery-item:nth-of-type(#{ $column-count }n) { + &.columns-#{$column-count} figure.wp-block-image:not(#individual-image):nth-of-type(#{ $column-count }n) { margin-right: 0; } } - } - - // Last item always needs margins reset. - .blocks-gallery-image:last-child, - .blocks-gallery-item:last-child { - margin-right: 0; + // If number of columns not explicitly set default to 3 columns if 3 or more images. + &.columns-default { + figure.wp-block-image:not(#individual-image) { + margin-right: var(--gallery-block--gutter-size, #{$grid-unit-20}); + width: calc(33.33% - (var(--gallery-block--gutter-size, 16px) * #{math.div(2, 3)})); + } + figure.wp-block-image:not(#individual-image):nth-of-type(3n+3) { + margin-right: 0; + } + // If only 2 child images use 2 columns. + figure.wp-block-image:not(#individual-image):first-child:nth-last-child(2), + figure.wp-block-image:not(#individual-image):first-child:nth-last-child(2) ~ figure.wp-block-image:not(#individual-image) { + width: calc(50% - (var(--gallery-block--gutter-size, 16px) * 0.5)); + } + // For a single image set to 100%. + figure.wp-block-image:not(#individual-image):first-child:nth-last-child(1) { + width: 100%; + } + } } // Apply max-width to floated items that have no intrinsic width. @@ -135,8 +203,6 @@ // If the gallery is centered, center the content inside as well. &.aligncenter { - .blocks-gallery-item figure { - justify-content: center; - } + justify-content: center; } } diff --git a/packages/block-library/src/gallery/transforms.js b/packages/block-library/src/gallery/transforms.js index fa7fff8d46f9f5..f5fe6b341cd9e6 100644 --- a/packages/block-library/src/gallery/transforms.js +++ b/packages/block-library/src/gallery/transforms.js @@ -8,12 +8,23 @@ import { filter, every, toString } from 'lodash'; */ import { createBlock } from '@wordpress/blocks'; import { createBlobURL } from '@wordpress/blob'; +import { select } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies */ +import { + LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_NONE, + LINK_DESTINATION_MEDIA, +} from './constants'; +import { + LINK_DESTINATION_ATTACHMENT as DEPRECATED_LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA as DEPRECATED_LINK_DESTINATION_MEDIA, +} from './v1/constants'; import { pickRelevantMediaFiles } from './shared'; -import { LINK_DESTINATION_ATTACHMENT } from './constants'; const parseShortcodeIds = ( ids ) => { if ( ! ids ) { @@ -23,6 +34,98 @@ const parseShortcodeIds = ( ids ) => { return ids.split( ',' ).map( ( id ) => parseInt( id, 10 ) ); }; +/** + * Third party block plugins don't have an easy way to detect if the + * innerBlocks version of the Gallery is running when they run a + * 3rdPartyBlock -> GalleryBlock transform so this tranform filter + * will handle this. Once the innerBlocks version is the default + * in a core release, this could be deprecated and removed after + * plugin authors have been given time to update transforms. + * + * @typedef {Object} Attributes + * @typedef {Object} Block + * @property {Attributes} attributes The attributes of the block. + * @param {Block} block The transformed block. + * @return {Block} The transformed block. + */ +function updateThirdPartyTransformToGallery( block ) { + const settings = select( blockEditorStore ).getSettings(); + if ( + settings.__unstableGalleryWithImageBlocks && + block.name === 'core/gallery' && + block.attributes?.images.length > 0 + ) { + const innerBlocks = block.attributes.images.map( + ( { url, id, alt } ) => { + return createBlock( 'core/image', { + url, + id: id ? parseInt( id, 10 ) : null, + alt, + sizeSlug: block.attributes.sizeSlug, + linkDestination: block.attributes.linkDestination, + } ); + } + ); + + delete block.attributes.ids; + delete block.attributes.images; + block.innerBlocks = innerBlocks; + } + + return block; +} +addFilter( + 'blocks.switchToBlockType.transformedBlock', + 'core/gallery/update-third-party-transform-to', + updateThirdPartyTransformToGallery +); + +/** + * Third party block plugins don't have an easy way to detect if the + * innerBlocks version of the Gallery is running when they run a + * GalleryBlock -> 3rdPartyBlock transform so this transform filter + * will handle this. Once the innerBlocks version is the default + * in a core release, this could be deprecated and removed after + * plugin authors have been given time to update transforms. + * + * @typedef {Object} Attributes + * @typedef {Object} Block + * @property {Attributes} attributes The attributes of the block. + * @param {Block} toBlock The block to transform to. + * @param {Block[]} fromBlocks The blocks to transform from. + * @return {Block} The transformed block. + */ +function updateThirdPartyTransformFromGallery( toBlock, fromBlocks ) { + const from = Array.isArray( fromBlocks ) ? fromBlocks : [ fromBlocks ]; + const galleryBlock = from.find( + ( transformedBlock ) => + transformedBlock.name === 'core/gallery' && + transformedBlock.innerBlocks.length > 0 && + ! transformedBlock.attributes.images?.length > 0 && + ! toBlock.name.includes( 'core/' ) + ); + + if ( galleryBlock ) { + const images = galleryBlock.innerBlocks.map( + ( { attributes: { url, id, alt } } ) => ( { + url, + id: id ? parseInt( id, 10 ) : null, + alt, + } ) + ); + const ids = images.map( ( { id } ) => id ); + galleryBlock.attributes.images = images; + galleryBlock.attributes.ids = ids; + } + + return toBlock; +} +addFilter( + 'blocks.switchToBlockType.transformedBlock', + 'core/gallery/update-third-party-transform-from', + updateThirdPartyTransformFromGallery +); + const transforms = { from: [ { @@ -42,6 +145,22 @@ const transforms = { const validImages = filter( attributes, ( { url } ) => url ); + const settings = select( blockEditorStore ).getSettings(); + if ( settings.__unstableGalleryWithImageBlocks ) { + const innerBlocks = validImages.map( ( image ) => { + return createBlock( 'core/image', image ); + } ); + + return createBlock( + 'core/gallery', + { + align, + sizeSlug, + }, + innerBlocks + ); + } + return createBlock( 'core/gallery', { images: validImages.map( ( { id, url, alt, caption } ) => ( { @@ -60,19 +179,43 @@ const transforms = { { type: 'shortcode', tag: 'gallery', + attributes: { images: { type: 'array', shortcode: ( { named: { ids } } ) => { - return parseShortcodeIds( ids ).map( ( id ) => ( { - id: toString( id ), - } ) ); + const settings = select( + blockEditorStore + ).getSettings(); + if ( ! settings.__unstableGalleryWithImageBlocks ) { + return parseShortcodeIds( ids ).map( ( id ) => ( { + id: toString( id ), + } ) ); + } }, }, ids: { type: 'array', shortcode: ( { named: { ids } } ) => { - return parseShortcodeIds( ids ); + const settings = select( + blockEditorStore + ).getSettings(); + if ( ! settings.__unstableGalleryWithImageBlocks ) { + return parseShortcodeIds( ids ); + } + }, + }, + shortCodeTransforms: { + type: 'array', + shortcode: ( { named: { ids } } ) => { + const settings = select( + blockEditorStore + ).getSettings(); + if ( settings.__unstableGalleryWithImageBlocks ) { + return parseShortcodeIds( ids ).map( ( id ) => ( { + id: parseInt( id ), + } ) ); + } }, }, columns: { @@ -83,10 +226,28 @@ const transforms = { }, linkTo: { type: 'string', - shortcode: ( { - named: { link = LINK_DESTINATION_ATTACHMENT }, - } ) => { - return link; + shortcode: ( { named: { link } } ) => { + const settings = select( + blockEditorStore + ).getSettings(); + if ( ! settings.__unstableGalleryWithImageBlocks ) { + switch ( link ) { + case 'post': + return DEPRECATED_LINK_DESTINATION_ATTACHMENT; + case 'file': + return DEPRECATED_LINK_DESTINATION_MEDIA; + default: + return DEPRECATED_LINK_DESTINATION_ATTACHMENT; + } + } + switch ( link ) { + case 'post': + return LINK_DESTINATION_ATTACHMENT; + case 'file': + return LINK_DESTINATION_MEDIA; + default: + return LINK_DESTINATION_NONE; + } }, }, }, @@ -95,8 +256,13 @@ const transforms = { }, }, { - // When created by drag and dropping multiple files on an insertion point + // When created by drag and dropping multiple files on an insertion point. Because multiple + // files must not be transformed to a gallery when dropped within a gallery there is another transform + // within the image block to handle that case. Therefore this transform has to have priority 1 + // set so that it overrrides the image block transformation when mulitple images are dropped outside + // of a gallery block. type: 'files', + priority: 1, isMatch( files ) { return ( files.length !== 1 && @@ -107,6 +273,16 @@ const transforms = { ); }, transform( files ) { + const settings = select( blockEditorStore ).getSettings(); + if ( settings.__unstableGalleryWithImageBlocks ) { + const innerBlocks = files.map( ( file ) => + createBlock( 'core/image', { + url: createBlobURL( file ), + } ) + ); + + return createBlock( 'core/gallery', {}, innerBlocks ); + } const block = createBlock( 'core/gallery', { images: files.map( ( file ) => pickRelevantMediaFiles( { @@ -122,7 +298,32 @@ const transforms = { { type: 'block', blocks: [ 'core/image' ], - transform: ( { images, align, sizeSlug, ids } ) => { + transform: ( { align, images, ids, sizeSlug }, innerBlocks ) => { + const settings = select( blockEditorStore ).getSettings(); + if ( settings.__unstableGalleryWithImageBlocks ) { + if ( innerBlocks.length > 0 ) { + return innerBlocks.map( + ( { + attributes: { + id, + url, + alt, + caption, + imageSizeSlug, + }, + } ) => + createBlock( 'core/image', { + id, + url, + alt, + caption, + sizeSlug: imageSizeSlug, + align, + } ) + ); + } + return createBlock( 'core/image', { align } ); + } if ( images.length > 0 ) { return images.map( ( { url, alt, caption }, index ) => createBlock( 'core/image', { diff --git a/packages/block-library/src/gallery/use-get-media.js b/packages/block-library/src/gallery/use-get-media.js new file mode 100644 index 00000000000000..597b112a8af3d2 --- /dev/null +++ b/packages/block-library/src/gallery/use-get-media.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +export default function useGetMedia( innerBlockImages ) { + const [ currentImageMedia, setCurrentImageMedia ] = useState( [] ); + + const imageMedia = useSelect( + ( select ) => { + if ( + ! innerBlockImages?.length || + innerBlockImages.some( + ( imageBlock ) => ! imageBlock.attributes.id + ) + ) { + return currentImageMedia; + } + + const imageIds = innerBlockImages.map( + ( imageBlock ) => imageBlock.attributes.id + ); + + if ( imageIds.length === 0 ) { + return currentImageMedia; + } + const getMedia = select( coreStore ).getMedia; + const newImageMedia = imageIds.map( ( img ) => { + return getMedia( img ); + } ); + + if ( newImageMedia.some( ( img ) => ! img ) ) { + return currentImageMedia; + } + + return newImageMedia; + }, + [ innerBlockImages ] + ); + + if ( + imageMedia?.length !== currentImageMedia.length || + imageMedia.some( + ( newImage ) => + ! currentImageMedia.find( + ( currentImage ) => currentImage.id === newImage.id + ) + ) + ) { + setCurrentImageMedia( imageMedia ); + return imageMedia; + } + return currentImageMedia; +} diff --git a/packages/block-library/src/gallery/use-get-new-images.js b/packages/block-library/src/gallery/use-get-new-images.js new file mode 100644 index 00000000000000..67578a5cb9941b --- /dev/null +++ b/packages/block-library/src/gallery/use-get-new-images.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies + */ +import { useMemo, useState } from '@wordpress/element'; + +export default function useGetNewImages( images, imageData ) { + const [ currentImages, setCurrentImages ] = useState( [] ); + + return useMemo( () => getNewImages(), [ images, imageData ] ); + + function getNewImages() { + let imagesUpdated = false; + + // First lets check if any images have been deleted. + const newCurrentImages = currentImages.filter( ( currentImg ) => + images.find( ( img ) => { + return currentImg.clientId === img.clientId; + } ) + ); + + if ( newCurrentImages.length < currentImages.length ) { + imagesUpdated = true; + } + + // Now lets see if we have any images hydrated from saved content and if so + // add them to currentImages state. + images.forEach( ( image ) => { + if ( + image.fromSavedContent && + ! newCurrentImages.find( + ( currentImage ) => currentImage.id === image.id + ) + ) { + imagesUpdated = true; + newCurrentImages.push( image ); + } + } ); + + // Now check for any new images that have been added to InnerBlocks and for which + // we have the imageData we need for setting default block attributes. + const newImages = images.filter( + ( image ) => + ! newCurrentImages.find( + ( currentImage ) => + image.clientId && + currentImage.clientId === image.clientId + ) && + imageData?.find( ( img ) => img.id === image.id ) && + ! image.fromSavedConent + ); + + if ( imagesUpdated || newImages?.length > 0 ) { + setCurrentImages( [ ...newCurrentImages, ...newImages ] ); + } + + return newImages.length > 0 ? newImages : null; + } +} diff --git a/packages/block-library/src/gallery/use-image-sizes.js b/packages/block-library/src/gallery/use-image-sizes.js new file mode 100644 index 00000000000000..5b406adab4cc9a --- /dev/null +++ b/packages/block-library/src/gallery/use-image-sizes.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { get, some } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +export default function useImageSizes( images, isSelected, getSettings ) { + return useMemo( () => getImageSizing(), [ images, isSelected ] ); + + function getImageSizing() { + if ( ! images || images.length === 0 ) { + return; + } + const { imageSizes } = getSettings(); + let resizedImages = {}; + + if ( isSelected ) { + resizedImages = images.reduce( ( currentResizedImages, img ) => { + if ( ! img.id ) { + return currentResizedImages; + } + + const sizes = imageSizes.reduce( ( currentSizes, size ) => { + const defaultUrl = get( img, [ + 'sizes', + size.slug, + 'url', + ] ); + const mediaDetailsUrl = get( img, [ + 'media_details', + 'sizes', + size.slug, + 'source_url', + ] ); + return { + ...currentSizes, + [ size.slug ]: defaultUrl || mediaDetailsUrl, + }; + }, {} ); + return { + ...currentResizedImages, + [ parseInt( img.id, 10 ) ]: sizes, + }; + }, {} ); + } + return imageSizes + .filter( ( { slug } ) => + some( resizedImages, ( sizes ) => sizes[ slug ] ) + ) + .map( ( { name, slug } ) => ( { value: slug, label: name } ) ); + } +} diff --git a/packages/block-library/src/gallery/use-short-code-transform.js b/packages/block-library/src/gallery/use-short-code-transform.js new file mode 100644 index 00000000000000..3843df9d5a6427 --- /dev/null +++ b/packages/block-library/src/gallery/use-short-code-transform.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { every } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +export default function useShortCodeTransform( shortCodeTransforms ) { + const newImageData = useSelect( + ( select ) => { + if ( ! shortCodeTransforms || shortCodeTransforms.length === 0 ) { + return; + } + const getMedia = select( coreStore ).getMedia; + return shortCodeTransforms.map( ( image ) => { + const imageData = getMedia( image.id ); + if ( imageData ) { + return { + id: imageData.id, + type: 'image', + url: imageData.source_url, + mime: imageData.mime_type, + alt: imageData.alt_text, + link: imageData.link, + }; + } + return undefined; + } ); + }, + [ shortCodeTransforms ] + ); + + if ( ! newImageData ) { + return; + } + + if ( every( newImageData, ( img ) => img && img.url ) ) { + return newImageData; + } +} diff --git a/packages/block-library/src/gallery/utils.js b/packages/block-library/src/gallery/utils.js new file mode 100644 index 00000000000000..e166bac3d5e854 --- /dev/null +++ b/packages/block-library/src/gallery/utils.js @@ -0,0 +1,46 @@ +/** + * Internal dependencies + */ +import { + LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA, + LINK_DESTINATION_NONE, +} from './constants'; +import { + LINK_DESTINATION_ATTACHMENT as IMAGE_LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA as IMAGE_LINK_DESTINATION_MEDIA, + LINK_DESTINATION_NONE as IMAGE_LINK_DESTINATION_NONE, +} from '../image/constants'; + +/** + * Determines new href and linkDestination values for an image block from the + * supplied Gallery link destination. + * + * @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 LINK_DESTINATION_MEDIA: + return { + href: image?.source_url || image?.url, // eslint-disable-line camelcase + linkDestination: IMAGE_LINK_DESTINATION_MEDIA, + }; + case LINK_DESTINATION_ATTACHMENT: + return { + href: image?.link, + linkDestination: IMAGE_LINK_DESTINATION_ATTACHMENT, + }; + case LINK_DESTINATION_NONE: + return { + href: undefined, + linkDestination: IMAGE_LINK_DESTINATION_NONE, + }; + } + + return {}; +} diff --git a/packages/block-library/src/gallery/v1/constants.js b/packages/block-library/src/gallery/v1/constants.js new file mode 100644 index 00000000000000..f4b6e7af56d473 --- /dev/null +++ b/packages/block-library/src/gallery/v1/constants.js @@ -0,0 +1,3 @@ +export const LINK_DESTINATION_NONE = 'none'; +export const LINK_DESTINATION_MEDIA = 'file'; +export const LINK_DESTINATION_ATTACHMENT = 'post'; 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..3f0d21277f8ccd --- /dev/null +++ b/packages/block-library/src/gallery/v1/edit.js @@ -0,0 +1,481 @@ +/** + * 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 { + MediaPlaceholder, + InspectorControls, + useBlockProps, + store as blockEditorStore, +} 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, useSelect } from '@wordpress/data'; +import { withViewportMatch } from '@wordpress/viewport'; +import { View } from '@wordpress/primitives'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * 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' }, +} ); + +function GalleryEdit( props ) { + const { + attributes, + clientId, + isSelected, + noticeUI, + noticeOperations, + onFocus, + } = props; + const { + columns = defaultColumnsNumberV1( attributes ), + imageCrop, + images, + linkTo, + sizeSlug, + } = attributes; + const [ selectedImage, setSelectedImage ] = useState(); + const [ attachmentCaptions, setAttachmentCaptions ] = useState(); + const { __unstableMarkNextChangeAsNotPersistent } = useDispatch( + blockEditorStore + ); + + const { + imageSizes, + mediaUpload, + getMedia, + wasBlockJustInserted, + } = useSelect( ( select ) => { + const settings = select( blockEditorStore ).getSettings(); + + return { + imageSizes: settings.imageSizes, + mediaUpload: settings.mediaUpload, + getMedia: select( coreStore ).getMedia, + wasBlockJustInserted: select( + blockEditorStore + ).wasBlockJustInserted( clientId, 'inserter_menu' ), + }; + } ); + + const resizedImages = useMemo( () => { + if ( isSelected ) { + return reduce( + attributes.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, attributes.ids, imageSizes ] ); + + function onFocusGalleryCaption() { + setSelectedImage(); + } + + 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 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 hasImageIds = hasImages && images.some( ( image ) => !! image.id ); + + 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( [ + withNotices, + withViewportMatch( { isNarrow: '< small' } ), +] )( GalleryEdit ); diff --git a/packages/block-library/src/gallery/gallery-button.native.js b/packages/block-library/src/gallery/v1/gallery-button.native.js similarity index 100% rename from packages/block-library/src/gallery/gallery-button.native.js rename to packages/block-library/src/gallery/v1/gallery-button.native.js diff --git a/packages/block-library/src/gallery/gallery-image-style.native.scss b/packages/block-library/src/gallery/v1/gallery-image-style.native.scss similarity index 100% rename from packages/block-library/src/gallery/gallery-image-style.native.scss rename to packages/block-library/src/gallery/v1/gallery-image-style.native.scss diff --git a/packages/block-library/src/gallery/gallery-image.js b/packages/block-library/src/gallery/v1/gallery-image.js similarity index 100% rename from packages/block-library/src/gallery/gallery-image.js rename to packages/block-library/src/gallery/v1/gallery-image.js diff --git a/packages/block-library/src/gallery/gallery-image.native.js b/packages/block-library/src/gallery/v1/gallery-image.native.js similarity index 100% rename from packages/block-library/src/gallery/gallery-image.native.js rename to packages/block-library/src/gallery/v1/gallery-image.native.js diff --git a/packages/block-library/src/gallery/v1/gallery-styles.native.scss b/packages/block-library/src/gallery/v1/gallery-styles.native.scss new file mode 100644 index 00000000000000..9b3169da048b22 --- /dev/null +++ b/packages/block-library/src/gallery/v1/gallery-styles.native.scss @@ -0,0 +1,8 @@ +.galleryTilesContainerSelected { + margin-bottom: 16px; +} + +.fullWidth { + margin-left: $block-edge-to-content; + margin-right: $block-edge-to-content; +} 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..a9cee26e7d3021 --- /dev/null +++ b/packages/block-library/src/gallery/v1/gallery.js @@ -0,0 +1,119 @@ +/** + * 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, + 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/gallery.native.js b/packages/block-library/src/gallery/v1/gallery.native.js new file mode 100644 index 00000000000000..4adb432b93f1e6 --- /dev/null +++ b/packages/block-library/src/gallery/v1/gallery.native.js @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import { View } from 'react-native'; +import { isEmpty } from 'lodash'; + +/** + * Internal dependencies + */ +import GalleryImage from './gallery-image'; +import { defaultColumnsNumberV1 } from '../deprecated'; +import styles from './gallery-styles.scss'; +import Tiles from './tiles'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + BlockCaption, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { useState, useEffect } from '@wordpress/element'; +import { mediaUploadSync } from '@wordpress/react-native-bridge'; +import { useSelect } from '@wordpress/data'; +import { alignmentHelpers } from '@wordpress/components'; + +const TILE_SPACING = 15; + +// we must limit displayed columns since readable content max-width is 580px +const MAX_DISPLAYED_COLUMNS = 4; +const MAX_DISPLAYED_COLUMNS_NARROW = 2; + +const { isFullWidth } = alignmentHelpers; + +export const Gallery = ( props ) => { + const [ isCaptionSelected, setIsCaptionSelected ] = useState( false ); + useEffect( mediaUploadSync, [] ); + + const isRTL = useSelect( ( select ) => { + return !! select( blockEditorStore ).getSettings().isRTL; + }, [] ); + + const { + clientId, + selectedImage, + mediaPlaceholder, + onBlur, + onMoveBackward, + onMoveForward, + onRemoveImage, + onSelectImage, + onSetImageAttributes, + onFocusGalleryCaption, + attributes, + isSelected, + isNarrow, + onFocus, + insertBlocksAfter, + } = props; + + const { + align, + columns = defaultColumnsNumberV1( attributes ), + imageCrop, + images, + } = attributes; + + // limit displayed columns when isNarrow is true (i.e. when viewport width is + // less than "small", where small = 600) + const displayedColumns = isNarrow + ? Math.min( columns, MAX_DISPLAYED_COLUMNS_NARROW ) + : Math.min( columns, MAX_DISPLAYED_COLUMNS ); + + const selectImage = ( index ) => { + return () => { + if ( isCaptionSelected ) { + setIsCaptionSelected( false ); + } + // we need to fully invoke the curried function here + onSelectImage( index )(); + }; + }; + + const focusGalleryCaption = () => { + if ( ! isCaptionSelected ) { + setIsCaptionSelected( true ); + } + onFocusGalleryCaption(); + }; + + 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 } + isRTL={ isRTL } + /> + ); + } ) } + + + { mediaPlaceholder } + + + isEmpty( caption ) + ? /* translators: accessibility text. Empty gallery caption. */ + 'Gallery caption. Empty' + : sprintf( + /* translators: accessibility text. %s: gallery caption. */ + __( 'Gallery caption. %s' ), + caption + ) + } + onFocus={ focusGalleryCaption } + onBlur={ onBlur } // always assign onBlur as props + insertBlocksAfter={ insertBlocksAfter } + /> + + ); +}; + +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..d7a0d44d62b1bd --- /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/block-library/src/gallery/v1/shared.js b/packages/block-library/src/gallery/v1/shared.js new file mode 100644 index 00000000000000..484020cb9d58cf --- /dev/null +++ b/packages/block-library/src/gallery/v1/shared.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { get, pick } from 'lodash'; + +export const pickRelevantMediaFiles = ( image, sizeSlug = 'large' ) => { + const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] ); + imageProps.url = + get( image, [ 'sizes', sizeSlug, 'url' ] ) || + get( image, [ 'media_details', 'sizes', sizeSlug, 'source_url' ] ) || + image.url; + const fullUrl = + get( image, [ 'sizes', 'full', 'url' ] ) || + get( image, [ 'media_details', 'sizes', 'full', 'source_url' ] ); + if ( fullUrl ) { + imageProps.fullUrl = fullUrl; + } + return imageProps; +}; diff --git a/packages/block-library/src/gallery/tiles-styles.native.scss b/packages/block-library/src/gallery/v1/tiles-styles.native.scss similarity index 100% rename from packages/block-library/src/gallery/tiles-styles.native.scss rename to packages/block-library/src/gallery/v1/tiles-styles.native.scss diff --git a/packages/block-library/src/gallery/tiles.native.js b/packages/block-library/src/gallery/v1/tiles.native.js similarity index 100% rename from packages/block-library/src/gallery/tiles.native.js rename to packages/block-library/src/gallery/v1/tiles.native.js diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 9cb1901028c9da..7aced7d40505e1 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -3,6 +3,7 @@ "name": "core/image", "title": "Image", "category": "media", + "usesContext": [ "allowResize", "imageCrop" ], "description": "Insert an image to make a visual statement.", "keywords": [ "img", "photo", "picture" ], "textdomain": "default", diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index bb023959eebecb..3a02291ea0c954 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -96,6 +96,7 @@ export function ImageEdit( { insertBlocksAfter, noticeOperations, onReplace, + context, clientId, } ) { const { @@ -140,6 +141,7 @@ export function ImageEdit( { title: undefined, caption: undefined, } ); + return; } @@ -276,14 +278,12 @@ export function ImageEdit( { // If an image is temporary, revoke the Blob url when it is uploaded (and is // no longer temporary). useEffect( () => { - if ( ! temporaryURL ) { + if ( isTemp ) { + setTemporaryURL( url ); return; } - - return () => { - revokeBlobURL( temporaryURL ); - }; - }, [ temporaryURL ] ); + revokeBlobURL( temporaryURL ); + }, [ isTemp, url ] ); const isExternal = isExternalImage( id, url ); const src = isExternal ? url : undefined; @@ -321,6 +321,7 @@ export function ImageEdit( { onSelectURL={ onSelectURL } onUploadError={ onUploadError } containerRef={ ref } + context={ context } clientId={ clientId } /> ) } diff --git a/packages/block-library/src/image/edit.native.js b/packages/block-library/src/image/edit.native.js index 83d561d8aad9a7..54174c3607a74b 100644 --- a/packages/block-library/src/image/edit.native.js +++ b/packages/block-library/src/image/edit.native.js @@ -64,6 +64,8 @@ import { getUpdatedLinkTargetSettings } from './utils'; import { LINK_DESTINATION_CUSTOM, + LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA, MEDIA_ID_NO_FEATURED_IMAGE_SET, } from './constants'; @@ -172,12 +174,12 @@ export class ImageEdit extends Component { } componentDidUpdate( previousProps ) { - if ( ! previousProps.image && this.props.image ) { - const { image, attributes } = this.props; + const { image, attributes, setAttributes } = this.props; + if ( ! previousProps.image && image ) { const url = getUrlForSlug( image, attributes?.sizeSlug ) || image.source_url; - this.props.setAttributes( { url } ); + setAttributes( { url } ); } } @@ -293,14 +295,13 @@ export class ImageEdit extends Component { } onSetSizeSlug( sizeSlug ) { - const { image } = this.props; + const { image, setAttributes } = this.props; const url = getUrlForSlug( image, sizeSlug ); if ( ! url ) { return null; } - - this.props.setAttributes( { + setAttributes( { url, width: undefined, height: undefined, @@ -309,11 +310,8 @@ export class ImageEdit extends Component { } onSelectMediaUploadOption( media ) { - const { - attributes: { id, url }, - imageDefaultSize, - } = this.props; - + const { imageDefaultSize } = this.props; + const { id, url, destination } = this.props.attributes; const mediaAttributes = { id: media.id, url: media.url, @@ -333,6 +331,17 @@ export class ImageEdit extends Component { additionalAttributes = { url }; } + let href; + switch ( destination ) { + case LINK_DESTINATION_MEDIA: + href = media.url; + break; + case LINK_DESTINATION_ATTACHMENT: + href = media.link; + break; + } + mediaAttributes.href = href; + this.props.setAttributes( { ...mediaAttributes, ...additionalAttributes, @@ -373,9 +382,17 @@ export class ImageEdit extends Component { setMappedAttributes( { url: href, ...restAttributes } ) { const { setAttributes } = this.props; + return href === undefined - ? setAttributes( restAttributes ) - : setAttributes( { ...restAttributes, href } ); + ? setAttributes( { + ...restAttributes, + linkDestination: LINK_DESTINATION_CUSTOM, + } ) + : setAttributes( { + ...restAttributes, + href, + linkDestination: LINK_DESTINATION_CUSTOM, + } ); } getLinkSettings() { @@ -383,7 +400,6 @@ export class ImageEdit extends Component { const { attributes: { href: url, ...unMappedAttributes }, } = this.props; - const mappedAttributes = { ...unMappedAttributes, url }; return ( @@ -490,6 +506,7 @@ export class ImageEdit extends Component { image, clientId, imageDefaultSize, + context: { imageCrop = false } = {}, featuredImageId, wasBlockJustInserted, } = this.props; @@ -605,6 +622,11 @@ export class ImageEdit extends Component { wide: 'center', }; + const additionalImageProps = { + height: '100%', + resizeMode: imageCrop ? 'cover' : 'contain', + }; + const getImageComponent = ( openMediaOptions, getMediaOptions ) => ( { return ( - { + + { + ); } } /> diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index 9abe05f107a1c3..54b552186e7ffe 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -83,11 +83,14 @@ export default function Image( { onSelectURL, onUploadError, containerRef, + context, clientId, } ) { const captionRef = useRef(); const prevUrl = usePrevious( url ); + const { allowResize = true } = context; const { getBlock } = useSelect( blockEditorStore ); + const { image, multiImageSelection } = useSelect( ( select ) => { const { getMedia } = select( coreStore ); @@ -149,7 +152,7 @@ export default function Image( { const [ isEditingImage, setIsEditingImage ] = useState( false ); const [ externalBlob, setExternalBlob ] = useState(); const clientWidth = useClientWidth( containerRef, [ align ] ); - const isResizable = ! isWideAligned && isLargeViewport; + const isResizable = allowResize && ! ( isWideAligned && isLargeViewport ); const imageSizeOptions = map( filter( imageSizes, ( { slug } ) => get( image, [ 'media_details', 'sizes', slug, 'source_url' ] ) diff --git a/packages/block-library/src/image/styles.native.scss b/packages/block-library/src/image/styles.native.scss index 4c2ad06c4da7f7..e7a9516b9d8d8d 100644 --- a/packages/block-library/src/image/styles.native.scss +++ b/packages/block-library/src/image/styles.native.scss @@ -31,6 +31,11 @@ padding-bottom: $grid-unit; } +.isGallery { + height: 150; + overflow: visible; +} + .featuredImagePanelTitle { padding-bottom: 0; } diff --git a/packages/block-library/src/image/transforms.js b/packages/block-library/src/image/transforms.js index 80750e8601bf5f..91c86305f17c0a 100644 --- a/packages/block-library/src/image/transforms.js +++ b/packages/block-library/src/image/transforms.js @@ -1,8 +1,16 @@ +/** + * External dependencies + */ +import { every } from 'lodash'; + /** * WordPress dependencies */ import { createBlobURL } from '@wordpress/blob'; import { createBlock, getBlockAttributes } from '@wordpress/blocks'; +import { dispatch } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import { __ } from '@wordpress/i18n'; export function stripFirstImage( attributes, { shortcode } ) { const { body } = document.implementation.createHTMLDocument( '' ); @@ -123,21 +131,36 @@ const transforms = { }, }, { + // Note: when dragging and dropping multiple files onto a gallery this overrides the + // gallery transform in order to add new images to the gallery instead of + // creating a new gallery. type: 'files', isMatch( files ) { - return ( - files.length === 1 && - files[ 0 ].type.indexOf( 'image/' ) === 0 + if ( + files.some( + ( file ) => file.type.indexOf( 'image/' ) !== 0 + ) + ) { + const { createErrorNotice } = dispatch( noticesStore ); + createErrorNotice( + __( + 'If uploading to a gallery all files need to be image formats' + ), + { id: 'gallery-transform-invalid-file' } + ); + } + return every( + files, + ( file ) => file.type.indexOf( 'image/' ) === 0 ); }, transform( files ) { - const file = files[ 0 ]; - // We don't need to upload the media directly here - // It's already done as part of the `componentDidMount` - // int the image block - return createBlock( 'core/image', { - url: createBlobURL( file ), + const blocks = files.map( ( file ) => { + return createBlock( 'core/image', { + url: createBlobURL( file ), + } ); } ); + return blocks; }, }, { diff --git a/packages/block-library/src/image/utils.js b/packages/block-library/src/image/utils.js index 0f6a1ec38ad4a1..9ac6e7fc38cebd 100644 --- a/packages/block-library/src/image/utils.js +++ b/packages/block-library/src/image/utils.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { isEmpty, each } from 'lodash'; +import { isEmpty, each, get } from 'lodash'; /** * Internal dependencies @@ -56,3 +56,19 @@ export function getUpdatedLinkTargetSettings( value, { rel } ) { rel: updatedRel, }; } + +/** + * Determines new Image block attributes size selection. + * + * @param {Object} image Media file object for gallery image. + * @param {string} size Selected size slug to apply. + */ +export function getImageSizeAttributes( image, size ) { + const url = get( image, [ 'media_details', 'sizes', size, 'source_url' ] ); + + if ( url ) { + return { url, width: undefined, height: undefined, sizeSlug: size }; + } + + return {}; +} diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index c3148c908b60c9..bd400e148a6ebe 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -603,7 +603,7 @@ export function hasBlockSupport( nameOrType, feature, defaultSupports ) { * @return {boolean} Whether the given block is a reusable block. */ export function isReusableBlock( blockOrType ) { - return blockOrType.name === 'core/block'; + return blockOrType?.name === 'core/block'; } /** 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 21592e506b7bf4..41d00d360fc15e 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -77,6 +77,7 @@ function useBlockEditorSettings( settings, hasTemplate ) { '__experimentalGlobalStylesUserEntityId', '__experimentalPreferredStyleVariations', '__experimentalSetIsInserterOpened', + '__unstableGalleryWithImageBlocks', 'alignWide', 'allowedBlockTypes', 'bodyPlaceholder', diff --git a/packages/element/src/platform.android.js b/packages/element/src/platform.android.js index 9f9bd4c0d9e4c9..e6be5eb051e0b2 100644 --- a/packages/element/src/platform.android.js +++ b/packages/element/src/platform.android.js @@ -14,6 +14,8 @@ const Platform = { } return spec.default; }, + isNative: true, + isAndroid: true, }; export default Platform; diff --git a/packages/element/src/platform.ios.js b/packages/element/src/platform.ios.js index f5833141e33028..c29a4006143d47 100644 --- a/packages/element/src/platform.ios.js +++ b/packages/element/src/platform.ios.js @@ -14,6 +14,8 @@ const Platform = { } return spec.default; }, + isNative: true, + isIOS: true, }; export default Platform; diff --git a/packages/element/src/platform.js b/packages/element/src/platform.js index 328f5523b6f95f..c646b6c86d51a2 100644 --- a/packages/element/src/platform.js +++ b/packages/element/src/platform.js @@ -9,6 +9,7 @@ const Platform = { OS: 'web', select: ( spec ) => ( 'web' in spec ? spec.web : spec.default ), + isWeb: true, }; /** * Component used to detect the current Platform being used.