Plugin Tabs noticias

This commit is contained in:
root
2026-04-14 13:50:04 -06:00
parent 299099d006
commit 19d08e5694
2334 changed files with 628926 additions and 113 deletions

View File

@@ -0,0 +1,719 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "areoi/media-grid",
"title": "Media Grid",
"category": "areoi-strips",
"description": "Display multiple medias in a rich gallery.",
"keywords": [ "images", "photos" ],
"textdomain": "default",
"example": {
"attributes": {
"preview": true
}
},
"attributes": {
"preview": {
"type": "boolean",
"default": false
},
"block_id": {
"type": "string",
"default": null
},
"exclude_divider": {
"type": "boolean",
"default": false
},
"exclude_pattern": {
"type": "boolean",
"default": false
},
"change_pattern_color": {
"type": "boolean",
"default": false
},
"pattern_color": {
"type": "object",
"default": {
"hex": "#fff"
}
},
"exclude_transition": {
"type": "boolean",
"default": false
},
"exclude_parallax": {
"type": "boolean",
"default": false
},
"layout": {
"type": "string",
"default": "grid"
},
"style": {
"type": "string",
"default": "flush"
},
"size": {
"type": "string",
"default": "areoi-medium"
},
"card_size": {
"type": "string",
"default": "areoi-card-small"
},
"media_fit": {
"type": "string",
"default": "cover"
},
"media_height": {
"type": "string",
"default": "50"
},
"media_width": {
"type": "string",
"default": "100"
},
"media_align": {
"type": "string",
"default": "center"
},
"image_layout": {
"type": "string",
"default": "cover"
},
"columns": {
"type": "string",
"default": "3"
},
"container": {
"type": "string",
"default": "container"
},
"prepend_display_heading": {
"type": "boolean",
"default": true
},
"prepend_heading_level": {
"type": "string",
"default": "h2"
},
"prepend_heading": {
"type": "string",
"default": null
},
"prepend_heading_color": {
"type": "string",
"default": null
},
"prepend_display_intro": {
"type": "boolean",
"default": true
},
"prepend_intro": {
"type": "string",
"default": null
},
"prepend_intro_color": {
"type": "string",
"default": null
},
"prepend_text_align_xs": {
"type": "string",
"default": null
},
"prepend_text_align_sm": {
"type": "string",
"default": null
},
"prepend_text_align_md": {
"type": "string",
"default": null
},
"prepend_text_align_lg": {
"type": "string",
"default": null
},
"prepend_text_align_xl": {
"type": "string",
"default": null
},
"prepend_text_align_xxl": {
"type": "string",
"default": null
},
"prepend_horizontal_align_xs": {
"type": "string",
"default": null
},
"prepend_horizontal_align_sm": {
"type": "string",
"default": null
},
"prepend_horizontal_align_md": {
"type": "string",
"default": null
},
"prepend_horizontal_align_lg": {
"type": "string",
"default": null
},
"prepend_horizontal_align_xl": {
"type": "string",
"default": null
},
"prepend_horizontal_align_xxl": {
"type": "string",
"default": null
},
"prepend_col_xs": {
"type": "string",
"default": null
},
"prepend_col_sm": {
"type": "string",
"default": null
},
"prepend_col_md": {
"type": "string",
"default": null
},
"prepend_col_lg": {
"type": "string",
"default": null
},
"prepend_col_xl": {
"type": "string",
"default": null
},
"prepend_col_xxl": {
"type": "string",
"default": null
},
"images": {
"type": "array",
"default": [],
"source": "query",
"selector": ".blocks-gallery-item",
"query": {
"url": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "src"
},
"fullUrl": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "data-full-url"
},
"link": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "data-link"
},
"alt": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "alt",
"default": ""
},
"id": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "data-id"
},
"caption": {
"type": "string",
"source": "html",
"selector": ".blocks-gallery-item__caption"
}
}
},
"ids": {
"type": "array",
"items": {
"type": "number"
},
"default": []
},
"shortCodeTransforms": {
"type": "array",
"default": [],
"items": {
"type": "object"
}
},
"caption": {
"type": "string",
"source": "html",
"selector": ".blocks-gallery-caption"
},
"imageCrop": {
"type": "boolean",
"default": true
},
"fixedHeight": {
"type": "boolean",
"default": true
},
"linkTarget": {
"type": "string"
},
"linkTo": {
"type": "string"
},
"sizeSlug": {
"type": "string",
"default": "large"
},
"allowResize": {
"type": "boolean",
"default": false
},
"vertical_align_xs": {
"type": "string",
"default": null
},
"vertical_align_sm": {
"type": "string",
"default": null
},
"vertical_align_md": {
"type": "string",
"default": null
},
"vertical_align_lg": {
"type": "string",
"default": null
},
"vertical_align_xl": {
"type": "string",
"default": null
},
"vertical_align_xxl": {
"type": "string",
"default": null
},
"horizontal_align_xs": {
"type": "string",
"default": null
},
"horizontal_align_sm": {
"type": "string",
"default": null
},
"horizontal_align_md": {
"type": "string",
"default": null
},
"horizontal_align_lg": {
"type": "string",
"default": null
},
"horizontal_align_xl": {
"type": "string",
"default": null
},
"horizontal_align_xxl": {
"type": "string",
"default": null
},
"utilities_bg": {
"type": "string",
"default": null
},
"utilities_text": {
"type": "string",
"default": null
},
"utilities_border": {
"type": "string",
"default": null
},
"background_display": {
"type": "boolean",
"default": false
},
"background_color": {
"type": "object",
"default": {
"rgb": {
"r": 0,
"g": 0,
"b": 0,
"a": 1
}
}
},
"background_image": {
"type": "object",
"default": null
},
"background_video": {
"type": "object",
"default": null
},
"background_display_overlay": {
"type": "boolean",
"default": false
},
"background_overlay": {
"type": "object",
"default": {
"rgb": {
"r": 0,
"g": 0,
"b": 0,
"a": 1
}
}
},
"background_horizontal_align": {
"type": "string",
"default": "justify-content-start"
},
"height_dimension_xs": {
"type": "string",
"default": null
},
"height_unit_xs": {
"type": "string",
"default": "px"
},
"padding_top_xs": {
"type": "string",
"default": null
},
"padding_right_xs": {
"type": "string",
"default": null
},
"padding_bottom_xs": {
"type": "string",
"default": null
},
"padding_left_xs": {
"type": "string",
"default": null
},
"margin_top_xs": {
"type": "string",
"default": null
},
"margin_right_xs": {
"type": "string",
"default": null
},
"margin_bottom_xs": {
"type": "string",
"default": null
},
"margin_left_xs": {
"type": "string",
"default": null
},
"hide_xs": {
"type": "boolean",
"default": false
},
"background_hide_xs": {
"type": "boolean",
"default": false
},
"background_col_xs": {
"type": "string",
"default": null
},
"height_dimension_sm": {
"type": "string",
"default": null
},
"height_unit_sm": {
"type": "string",
"default": "px"
},
"padding_top_sm": {
"type": "string",
"default": null
},
"padding_right_sm": {
"type": "string",
"default": null
},
"padding_bottom_sm": {
"type": "string",
"default": null
},
"padding_left_sm": {
"type": "string",
"default": null
},
"margin_top_sm": {
"type": "string",
"default": null
},
"margin_right_sm": {
"type": "string",
"default": null
},
"margin_bottom_sm": {
"type": "string",
"default": null
},
"margin_left_sm": {
"type": "string",
"default": null
},
"hide_sm": {
"type": "boolean",
"default": false
},
"background_hide_sm": {
"type": "boolean",
"default": false
},
"background_col_sm": {
"type": "string",
"default": null
},
"height_dimension_md": {
"type": "string",
"default": null
},
"height_unit_md": {
"type": "string",
"default": "px"
},
"padding_top_md": {
"type": "string",
"default": null
},
"padding_right_md": {
"type": "string",
"default": null
},
"padding_bottom_md": {
"type": "string",
"default": null
},
"padding_left_md": {
"type": "string",
"default": null
},
"margin_top_md": {
"type": "string",
"default": null
},
"margin_right_md": {
"type": "string",
"default": null
},
"margin_bottom_md": {
"type": "string",
"default": null
},
"margin_left_md": {
"type": "string",
"default": null
},
"hide_md": {
"type": "boolean",
"default": false
},
"background_hide_md": {
"type": "boolean",
"default": false
},
"background_col_md": {
"type": "string",
"default": null
},
"height_dimension_lg": {
"type": "string",
"default": null
},
"height_unit_lg": {
"type": "string",
"default": "px"
},
"padding_top_lg": {
"type": "string",
"default": null
},
"padding_right_lg": {
"type": "string",
"default": null
},
"padding_bottom_lg": {
"type": "string",
"default": null
},
"padding_left_lg": {
"type": "string",
"default": null
},
"margin_top_lg": {
"type": "string",
"default": null
},
"margin_right_lg": {
"type": "string",
"default": null
},
"margin_bottom_lg": {
"type": "string",
"default": null
},
"margin_left_lg": {
"type": "string",
"default": null
},
"hide_lg": {
"type": "boolean",
"default": false
},
"background_hide_lg": {
"type": "boolean",
"default": false
},
"background_col_lg": {
"type": "string",
"default": null
},
"height_dimension_xl": {
"type": "string",
"default": null
},
"height_unit_xl": {
"type": "string",
"default": "px"
},
"padding_top_xl": {
"type": "string",
"default": null
},
"padding_right_xl": {
"type": "string",
"default": null
},
"padding_bottom_xl": {
"type": "string",
"default": null
},
"padding_left_xl": {
"type": "string",
"default": null
},
"margin_top_xl": {
"type": "string",
"default": null
},
"margin_right_xl": {
"type": "string",
"default": null
},
"margin_bottom_xl": {
"type": "string",
"default": null
},
"margin_left_xl": {
"type": "string",
"default": null
},
"hide_xl": {
"type": "boolean",
"default": false
},
"background_hide_xl": {
"type": "boolean",
"default": false
},
"background_col_xl": {
"type": "string",
"default": null
},
"height_dimension_xxl": {
"type": "string",
"default": null
},
"height_unit_xxl": {
"type": "string",
"default": "px"
},
"padding_top_xxl": {
"type": "string",
"default": null
},
"padding_right_xxl": {
"type": "string",
"default": null
},
"padding_bottom_xxl": {
"type": "string",
"default": null
},
"padding_left_xxl": {
"type": "string",
"default": null
},
"margin_top_xxl": {
"type": "string",
"default": null
},
"margin_right_xxl": {
"type": "string",
"default": null
},
"margin_bottom_xxl": {
"type": "string",
"default": null
},
"margin_left_xxl": {
"type": "string",
"default": null
},
"hide_xxl": {
"type": "boolean",
"default": false
},
"background_hide_xxl": {
"type": "boolean",
"default": false
},
"background_col_xxl": {
"type": "string",
"default": null
}
},
"providesContext": {
"allowResize": "allowResize",
"imageCrop": "imageCrop",
"fixedHeight": "fixedHeight"
},
"supports": {
"anchor": true,
"align": true,
"html": false
},
"editorScript": "areoi-blocks",
"editorStyle": "file:./index.css",
"style": "file:../../build/style.css"
}

View File

@@ -0,0 +1,5 @@
export const LINK_DESTINATION_NONE = 'none';
export const LINK_DESTINATION_MEDIA = 'media';
export const LINK_DESTINATION_ATTACHMENT = 'attachment';
export const LINK_DESTINATION_MEDIA_WP_CORE = 'file';
export const LINK_DESTINATION_ATTACHMENT_WP_CORE = 'post';

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -0,0 +1,985 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { map, some, omit } from 'lodash';
/**
* WordPress dependencies
*/
import { RichText, useBlockProps } from '@wordpress/block-editor';
import { createBlock } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import {
LINK_DESTINATION_ATTACHMENT,
LINK_DESTINATION_MEDIA,
LINK_DESTINATION_NONE,
} from './constants';
import { isGalleryV2Enabled } from './shared';
const DEPRECATED_LINK_DESTINATION_MEDIA = 'file';
const DEPRECATED_LINK_DESTINATION_ATTACHMENT = 'post';
/**
* 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.
*/
export function defaultColumnsNumberV1( attributes ) {
return Math.min( 3, attributes?.images?.length );
}
/**
* Original function to determine new href and linkDestination values for an image block from the
* supplied Gallery link destination.
*
* Used in deprecations: v1-6.
*
* @param {Object} image Gallery image.
* @param {string} destination Gallery's selected link destination.
* @return {Object} New attributes to assign to image block.
*/
export function getHrefAndDestination( image, destination ) {
// Need to determine the URL that the selected destination maps to.
// Gutenberg and WordPress use different constants so the new link
// destination also needs to be tweaked.
switch ( destination ) {
case DEPRECATED_LINK_DESTINATION_MEDIA:
return {
href: image?.source_url || image?.url, // eslint-disable-line camelcase
linkDestination: LINK_DESTINATION_MEDIA,
};
case DEPRECATED_LINK_DESTINATION_ATTACHMENT:
return {
href: image?.link,
linkDestination: LINK_DESTINATION_ATTACHMENT,
};
case LINK_DESTINATION_MEDIA:
return {
href: image?.source_url || image?.url, // eslint-disable-line camelcase
linkDestination: LINK_DESTINATION_MEDIA,
};
case LINK_DESTINATION_ATTACHMENT:
return {
href: image?.link,
linkDestination: LINK_DESTINATION_ATTACHMENT,
};
case LINK_DESTINATION_NONE:
return {
href: undefined,
linkDestination: LINK_DESTINATION_NONE,
};
}
return {};
}
function runV2Migration( attributes ) {
let linkTo = attributes.linkTo ? attributes.linkTo : 'none';
if ( linkTo === 'post' ) {
linkTo = 'attachment';
} else if ( linkTo === 'file' ) {
linkTo = 'media';
}
const imageBlocks = attributes.images.map( ( image ) => {
return getImageBlock( image, attributes.sizeSlug, linkTo );
} );
return [
{
...omit( attributes, [ 'images', 'ids' ] ),
linkTo,
allowResize: false,
},
imageBlocks,
];
}
/**
* Gets an Image block from gallery image data
*
* Used to migrate Galleries to nested Image InnerBlocks.
*
* @param {Object} image Image properties.
* @param {string} sizeSlug Gallery sizeSlug attribute.
* @param {string} linkTo Gallery linkTo attribute.
* @return {Object} Image block.
*/
export function getImageBlock( image, sizeSlug, linkTo ) {
return createBlock( 'core/image', {
...( image.id && { id: parseInt( image.id ) } ),
url: image.url,
alt: image.alt,
caption: image.caption,
sizeSlug,
...getHrefAndDestination( image, linkTo ),
} );
}
const v6 = {
attributes: {
images: {
type: 'array',
default: [],
source: 'query',
selector: '.blocks-gallery-item',
query: {
url: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'src',
},
fullUrl: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'data-full-url',
},
link: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'data-link',
},
alt: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'alt',
default: '',
},
id: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'data-id',
},
caption: {
type: 'string',
source: 'html',
selector: '.blocks-gallery-item__caption',
},
},
},
ids: {
type: 'array',
items: {
type: 'number',
},
default: [],
},
columns: {
type: 'number',
minimum: 1,
maximum: 8,
},
caption: {
type: 'string',
source: 'html',
selector: '.blocks-gallery-caption',
},
imageCrop: {
type: 'boolean',
default: true,
},
fixedHeight: {
type: 'boolean',
default: true,
},
linkTo: {
type: 'string',
},
sizeSlug: {
type: 'string',
default: 'large',
},
},
supports: {
anchor: true,
align: true,
},
save( { attributes } ) {
const {
images,
columns = defaultColumnsNumberV1( attributes ),
imageCrop,
caption,
linkTo,
} = attributes;
const className = `columns-${ columns } ${
imageCrop ? 'is-cropped' : ''
}`;
return (
<figure { ...useBlockProps.save( { className } ) }>
<ul className="blocks-gallery-grid">
{ images.map( ( image ) => {
let href;
switch ( linkTo ) {
case DEPRECATED_LINK_DESTINATION_MEDIA:
href = image.fullUrl || image.url;
break;
case DEPRECATED_LINK_DESTINATION_ATTACHMENT:
href = image.link;
break;
}
const img = (
<img
src={ image.url }
alt={ image.alt }
data-id={ image.id }
data-full-url={ image.fullUrl }
data-link={ image.link }
className={
image.id ? `wp-image-${ image.id }` : null
}
/>
);
return (
<li
key={ image.id || image.url }
className="blocks-gallery-item"
>
<figure>
{ href ? (
<a href={ href }>{ img }</a>
) : (
img
) }
{ ! RichText.isEmpty( image.caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-item__caption"
value={ image.caption }
/>
) }
</figure>
</li>
);
} ) }
</ul>
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-caption"
value={ caption }
/>
) }
</figure>
);
},
migrate( attributes ) {
if ( isGalleryV2Enabled() ) {
return runV2Migration( attributes );
}
return attributes;
},
};
const v5 = {
attributes: {
images: {
type: 'array',
default: [],
source: 'query',
selector: '.blocks-gallery-item',
query: {
url: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'src',
},
fullUrl: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'data-full-url',
},
link: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'data-link',
},
alt: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'alt',
default: '',
},
id: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'data-id',
},
caption: {
type: 'string',
source: 'html',
selector: '.blocks-gallery-item__caption',
},
},
},
ids: {
type: 'array',
items: {
type: 'number',
},
default: [],
},
columns: {
type: 'number',
minimum: 1,
maximum: 8,
},
caption: {
type: 'string',
source: 'html',
selector: '.blocks-gallery-caption',
},
imageCrop: {
type: 'boolean',
default: true,
},
linkTo: {
type: 'string',
default: 'none',
},
sizeSlug: {
type: 'string',
default: 'large',
},
},
supports: {
align: true,
},
isEligible( { linkTo } ) {
return ! linkTo || linkTo === 'attachment' || linkTo === 'media';
},
migrate( attributes ) {
if ( isGalleryV2Enabled() ) {
return runV2Migration( attributes );
}
let linkTo = attributes.linkTo;
if ( ! attributes.linkTo ) {
linkTo = 'none';
} else if ( attributes.linkTo === 'attachment' ) {
linkTo = 'post';
} else if ( attributes.linkTo === 'media' ) {
linkTo = 'file';
}
return {
...attributes,
linkTo,
};
},
save( { attributes } ) {
const {
images,
columns = defaultColumnsNumberV1( attributes ),
imageCrop,
caption,
linkTo,
} = attributes;
return (
<figure
className={ `columns-${ columns } ${
imageCrop ? 'is-cropped' : ''
}` }
>
<ul className="blocks-gallery-grid">
{ images.map( ( image ) => {
let href;
switch ( linkTo ) {
case 'media':
href = image.fullUrl || image.url;
break;
case 'attachment':
href = image.link;
break;
}
const img = (
<img
src={ image.url }
alt={ image.alt }
data-id={ image.id }
data-full-url={ image.fullUrl }
data-link={ image.link }
className={
image.id ? `wp-image-${ image.id }` : null
}
/>
);
return (
<li
key={ image.id || image.url }
className="blocks-gallery-item"
>
<figure>
{ href ? (
<a href={ href }>{ img }</a>
) : (
img
) }
{ ! RichText.isEmpty( image.caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-item__caption"
value={ image.caption }
/>
) }
</figure>
</li>
);
} ) }
</ul>
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-caption"
value={ caption }
/>
) }
</figure>
);
},
};
const v4 = {
attributes: {
images: {
type: 'array',
default: [],
source: 'query',
selector: '.blocks-gallery-item',
query: {
url: {
source: 'attribute',
selector: 'img',
attribute: 'src',
},
fullUrl: {
source: 'attribute',
selector: 'img',
attribute: 'data-full-url',
},
link: {
source: 'attribute',
selector: 'img',
attribute: 'data-link',
},
alt: {
source: 'attribute',
selector: 'img',
attribute: 'alt',
default: '',
},
id: {
source: 'attribute',
selector: 'img',
attribute: 'data-id',
},
caption: {
type: 'string',
source: 'html',
selector: '.blocks-gallery-item__caption',
},
},
},
ids: {
type: 'array',
default: [],
},
columns: {
type: 'number',
},
caption: {
type: 'string',
source: 'html',
selector: '.blocks-gallery-caption',
},
imageCrop: {
type: 'boolean',
default: true,
},
linkTo: {
type: 'string',
default: 'none',
},
},
supports: {
align: true,
},
isEligible( { ids } ) {
return ids && ids.some( ( id ) => typeof id === 'string' );
},
migrate( attributes ) {
if ( isGalleryV2Enabled() ) {
return runV2Migration( attributes );
}
return {
...attributes,
ids: map( attributes.ids, ( id ) => {
const parsedId = parseInt( id, 10 );
return Number.isInteger( parsedId ) ? parsedId : null;
} ),
};
},
save( { attributes } ) {
const {
images,
columns = defaultColumnsNumberV1( attributes ),
imageCrop,
caption,
linkTo,
} = attributes;
return (
<figure
className={ `columns-${ columns } ${
imageCrop ? 'is-cropped' : ''
}` }
>
<ul className="blocks-gallery-grid">
{ images.map( ( image ) => {
let href;
switch ( linkTo ) {
case 'media':
href = image.fullUrl || image.url;
break;
case 'attachment':
href = image.link;
break;
}
const img = (
<img
src={ image.url }
alt={ image.alt }
data-id={ image.id }
data-full-url={ image.fullUrl }
data-link={ image.link }
className={
image.id ? `wp-image-${ image.id }` : null
}
/>
);
return (
<li
key={ image.id || image.url }
className="blocks-gallery-item"
>
<figure>
{ href ? (
<a href={ href }>{ img }</a>
) : (
img
) }
{ ! RichText.isEmpty( image.caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-item__caption"
value={ image.caption }
/>
) }
</figure>
</li>
);
} ) }
</ul>
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-caption"
value={ caption }
/>
) }
</figure>
);
},
};
const v3 = {
attributes: {
images: {
type: 'array',
default: [],
source: 'query',
selector: 'ul.wp-block-gallery .blocks-gallery-item',
query: {
url: {
source: 'attribute',
selector: 'img',
attribute: 'src',
},
fullUrl: {
source: 'attribute',
selector: 'img',
attribute: 'data-full-url',
},
alt: {
source: 'attribute',
selector: 'img',
attribute: 'alt',
default: '',
},
id: {
source: 'attribute',
selector: 'img',
attribute: 'data-id',
},
link: {
source: 'attribute',
selector: 'img',
attribute: 'data-link',
},
caption: {
type: 'array',
source: 'children',
selector: 'figcaption',
},
},
},
ids: {
type: 'array',
default: [],
},
columns: {
type: 'number',
},
imageCrop: {
type: 'boolean',
default: true,
},
linkTo: {
type: 'string',
default: 'none',
},
},
supports: {
align: true,
},
save( { attributes } ) {
const {
images,
columns = defaultColumnsNumberV1( attributes ),
imageCrop,
linkTo,
} = attributes;
return (
<ul
className={ `columns-${ columns } ${
imageCrop ? 'is-cropped' : ''
}` }
>
{ images.map( ( image ) => {
let href;
switch ( linkTo ) {
case 'media':
href = image.fullUrl || image.url;
break;
case 'attachment':
href = image.link;
break;
}
const img = (
<img
src={ image.url }
alt={ image.alt }
data-id={ image.id }
data-full-url={ image.fullUrl }
data-link={ image.link }
className={
image.id ? `wp-image-${ image.id }` : null
}
/>
);
return (
<li
key={ image.id || image.url }
className="blocks-gallery-item"
>
<figure>
{ href ? <a href={ href }>{ img }</a> : img }
{ image.caption && image.caption.length > 0 && (
<RichText.Content
tagName="figcaption"
value={ image.caption }
/>
) }
</figure>
</li>
);
} ) }
</ul>
);
},
migrate( attributes ) {
if ( isGalleryV2Enabled() ) {
return runV2Migration( attributes );
}
return attributes;
},
};
const v2 = {
attributes: {
images: {
type: 'array',
default: [],
source: 'query',
selector: 'ul.wp-block-gallery .blocks-gallery-item',
query: {
url: {
source: 'attribute',
selector: 'img',
attribute: 'src',
},
alt: {
source: 'attribute',
selector: 'img',
attribute: 'alt',
default: '',
},
id: {
source: 'attribute',
selector: 'img',
attribute: 'data-id',
},
link: {
source: 'attribute',
selector: 'img',
attribute: 'data-link',
},
caption: {
type: 'array',
source: 'children',
selector: 'figcaption',
},
},
},
columns: {
type: 'number',
},
imageCrop: {
type: 'boolean',
default: true,
},
linkTo: {
type: 'string',
default: 'none',
},
},
isEligible( { images, ids } ) {
return (
images &&
images.length > 0 &&
( ( ! ids && images ) ||
( ids && images && ids.length !== images.length ) ||
some( images, ( id, index ) => {
if ( ! id && ids[ index ] !== null ) {
return true;
}
return parseInt( id, 10 ) !== ids[ index ];
} ) )
);
},
migrate( attributes ) {
if ( isGalleryV2Enabled() ) {
return runV2Migration( attributes );
}
return {
...attributes,
ids: map( attributes.images, ( { id } ) => {
if ( ! id ) {
return null;
}
return parseInt( id, 10 );
} ),
};
},
supports: {
align: true,
},
save( { attributes } ) {
const {
images,
columns = defaultColumnsNumberV1( attributes ),
imageCrop,
linkTo,
} = attributes;
return (
<ul
className={ `columns-${ columns } ${
imageCrop ? 'is-cropped' : ''
}` }
>
{ images.map( ( image ) => {
let href;
switch ( linkTo ) {
case 'media':
href = image.url;
break;
case 'attachment':
href = image.link;
break;
}
const img = (
<img
src={ image.url }
alt={ image.alt }
data-id={ image.id }
data-link={ image.link }
className={
image.id ? `wp-image-${ image.id }` : null
}
/>
);
return (
<li
key={ image.id || image.url }
className="blocks-gallery-item"
>
<figure>
{ href ? <a href={ href }>{ img }</a> : img }
{ image.caption && image.caption.length > 0 && (
<RichText.Content
tagName="figcaption"
value={ image.caption }
/>
) }
</figure>
</li>
);
} ) }
</ul>
);
},
};
const v1 = {
attributes: {
images: {
type: 'array',
default: [],
source: 'query',
selector: 'div.wp-block-gallery figure.blocks-gallery-image img',
query: {
url: {
source: 'attribute',
attribute: 'src',
},
alt: {
source: 'attribute',
attribute: 'alt',
default: '',
},
id: {
source: 'attribute',
attribute: 'data-id',
},
},
},
columns: {
type: 'number',
},
imageCrop: {
type: 'boolean',
default: true,
},
linkTo: {
type: 'string',
default: 'none',
},
align: {
type: 'string',
default: 'none',
},
},
supports: {
align: true,
},
save( { attributes } ) {
const {
images,
columns = defaultColumnsNumberV1( attributes ),
align,
imageCrop,
linkTo,
} = attributes;
const className = classnames( `columns-${ columns }`, {
alignnone: align === 'none',
'is-cropped': imageCrop,
} );
return (
<div className={ className }>
{ images.map( ( image ) => {
let href;
switch ( linkTo ) {
case 'media':
href = image.url;
break;
case 'attachment':
href = image.link;
break;
}
const img = (
<img
src={ image.url }
alt={ image.alt }
data-id={ image.id }
/>
);
return (
<figure
key={ image.id || image.url }
className="blocks-gallery-image"
>
{ href ? <a href={ href }>{ img }</a> : img }
</figure>
);
} ) }
</div>
);
},
migrate( attributes ) {
if ( isGalleryV2Enabled() ) {
return runV2Migration( attributes );
}
return attributes;
},
};
export default [ v6, v5, v4, v3, v2, v1 ];

View File

@@ -0,0 +1,27 @@
/**
* WordPress dependencies
*/
import { compose } from '@wordpress/compose';
import { withNotices } from '@wordpress/components';
/**
* Internal dependencies
*/
import EditWithInnerBlocks from './edit';
import EditWithoutInnerBlocks from './v1/edit';
import { isGalleryV2Enabled } from './shared';
/*
* 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.
*/
function GalleryEditWrapper( props ) {
if ( ! isGalleryV2Enabled() ) {
return <EditWithoutInnerBlocks { ...props } />;
}
return <EditWithInnerBlocks { ...props } />;
}
export default compose( [ withNotices ] )( GalleryEditWrapper );

View File

@@ -0,0 +1,116 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { RichText, useInnerBlocksProps } from '@wordpress/block-editor';
import { VisuallyHidden } from '@wordpress/components';
import { useState, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { createBlock } from '@wordpress/blocks';
import { View } from '@wordpress/primitives';
const allowedBlocks = [ 'areoi/media-grid-image' ];
export const Gallery = ( props ) => {
const {
attributes,
isSelected,
setAttributes,
mediaPlaceholder,
insertBlocksAfter,
blockProps,
} = props;
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 );
function onFocusCaption() {
if ( ! captionFocused ) {
setCaptionFocused( true );
}
}
function removeCaptionFocus() {
if ( captionFocused ) {
setCaptionFocused( false );
}
}
useEffect( () => {
if ( ! isSelected ) {
setCaptionFocused( false );
}
}, [ isSelected ] );
return (
<>
{ children }
{ isSelected && (
<View
className="blocks-gallery-media-placeholder-wrapper"
onClick={ removeCaptionFocus }
>
{ mediaPlaceholder }
</View>
) }
<RichTextVisibilityHelper
isHidden={ ! isSelected && RichText.isEmpty( caption ) }
captionFocused={ captionFocused }
onFocusCaption={ onFocusCaption }
tagName="figcaption"
className="blocks-gallery-caption"
aria-label={ __( 'Gallery caption text' ) }
placeholder={ __( 'Write gallery caption…' ) }
value={ caption }
onChange={ ( value ) => setAttributes( { caption: value } ) }
inlineToolbar
__unstableOnSplitAtEnd={ () =>
insertBlocksAfter( createBlock( 'core/paragraph' ) )
}
/>
</>
);
};
function RichTextVisibilityHelper( {
isHidden,
captionFocused,
onFocusCaption,
className,
value,
placeholder,
tagName,
captionRef,
...richTextProps
} ) {
if ( isHidden ) {
return <VisuallyHidden as={ RichText } { ...richTextProps } />;
}
return (
<RichText
ref={ captionRef }
value={ value }
placeholder={ placeholder }
className={ className }
tagName={ tagName }
isSelected={ captionFocused }
onClick={ onFocusCaption }
{ ...richTextProps }
/>
);
}
export default Gallery;

View File

@@ -0,0 +1,123 @@
/**
* External dependencies
*/
import { View } from 'react-native';
import { isEmpty } from 'lodash';
/**
* Internal dependencies
*/
import { defaultColumnsNumber } from './shared';
import styles from './gallery-styles.scss';
/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { BlockCaption, useInnerBlocksProps } from '@wordpress/block-editor';
import { useState, useEffect } from '@wordpress/element';
import { mediaUploadSync } from '@wordpress/react-native-bridge';
import { WIDE_ALIGNMENTS } from '@wordpress/components';
import { useResizeObserver } from '@wordpress/compose';
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;
export const Gallery = ( props ) => {
const [ isCaptionSelected, setIsCaptionSelected ] = useState( false );
const [ resizeObserver, sizes ] = useResizeObserver();
const [ maxWidth, setMaxWidth ] = useState( 0 );
useEffect( mediaUploadSync, [] );
const {
mediaPlaceholder,
attributes,
images,
isNarrow,
onBlur,
insertBlocksAfter,
clientId,
} = props;
useEffect( () => {
const { width } = sizes || {};
if ( width ) {
setMaxWidth( width );
}
}, [ sizes ] );
const {
align,
columns = defaultColumnsNumber( images.length ),
} = attributes;
const displayedColumns = Math.min(
columns,
isNarrow ? MAX_DISPLAYED_COLUMNS_NARROW : MAX_DISPLAYED_COLUMNS
);
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 );
}
};
const isFullWidth = align === WIDE_ALIGNMENTS.alignments.full;
return (
<View style={ isFullWidth && styles.fullWidth }>
{ resizeObserver }
<View { ...innerBlocksProps } />
<View
style={ [
isFullWidth && styles.fullWidth,
styles.galleryAppender,
] }
>
{ mediaPlaceholder }
</View>
<BlockCaption
clientId={ clientId }
isSelected={ isCaptionSelected }
accessible={ true }
accessibilityLabelCreator={ ( caption ) =>
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 }
/>
</View>
);
};
export default Gallery;

View File

@@ -0,0 +1,20 @@
/**
* WordPress dependencies
*/
import { useStyleOverride } from '@wordpress/block-editor';
export default function GapStyles({ blockGap, clientId }) {
// Build the CSS scoped to this block instance
const css = `#block-${clientId} { --wp--style--unstable-gallery-gap: ${
blockGap ?? 'var(--wp--style--block-gap, 0.5em)'
} }`;
// Insert/update a <style> in the editor canvas (handles iframe, etc.)
useStyleOverride({
id: `gap-styles-${clientId}`, // stable id so it updates instead of duplicating
css,
});
// Nothing to render
return null;
}

View File

@@ -0,0 +1 @@
@import url( '../content-grid/index.css' );

View File

@@ -0,0 +1,57 @@
/**
* WordPress dependencies
*/
import { gallery as icon } from '@wordpress/icons';
import * as areoi from '../_components/Core.js';
/**
* Internal dependencies
*/
import deprecated from './deprecated';
import edit from './edit-wrapper';
import metadata from './block.json';
import save from './save';
import transforms from './transforms';
const { name } = metadata;
export { metadata, name };
export const settings = {
icon,
example: {
attributes: {
columns: 2,
},
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,
save,
deprecated,
};
areoi.blocks.registerBlockType( metadata, {
icon: icon,
edit: edit,
save: () => {
return (
<areoi.editor.InnerBlocks.Content/>
);
},
});

View File

@@ -0,0 +1,85 @@
<?php
/**
* Server-side rendering of the `core/gallery` block.
*
* @package WordPress
*/
/**
* Handles backwards compatibility for Gallery Blocks,
* whose images feature a `data-id` attribute.
*
* Now that the Gallery Block contains inner Image Blocks,
* we add a custom `data-id` attribute before rendering the gallery
* so that the Image Block can pick it up in its render_callback.
*
* @param array $parsed_block The block being rendered.
* @return array The migrated block object.
*/
function block_core_gallery_data_id_backcompatibility( $parsed_block ) {
if ( 'core/gallery' === $parsed_block['blockName'] ) {
foreach ( $parsed_block['innerBlocks'] as $key => $inner_block ) {
if ( 'core/image' === $inner_block['blockName'] ) {
if ( ! isset( $parsed_block['innerBlocks'][ $key ]['attrs']['data-id'] ) && isset( $inner_block['attrs']['id'] ) ) {
$parsed_block['innerBlocks'][ $key ]['attrs']['data-id'] = esc_attr( $inner_block['attrs']['id'] );
}
}
}
}
return $parsed_block;
}
add_filter( 'render_block_data', 'block_core_gallery_data_id_backcompatibility' );
/**
* Adds a style tag for the --wp--style--unstable-gallery-gap var.
*
* The Gallery block needs to recalculate Image block width based on
* the current gap setting in order to maintain the number of flex columns
* so a css var is added to allow this.
*
* @param array $attributes Attributes of the block being rendered.
* @param string $content Content of the block being rendered.
* @return string The content of the block being rendered.
*/
function block_core_gallery_render( $attributes, $content ) {
$gap = _wp_array_get( $attributes, array( 'style', 'spacing', 'blockGap' ) );
// Skip if gap value contains unsupported characters.
// Regex for CSS value borrowed from `safecss_filter_attr`, and used here
// because we only want to match against the value, not the CSS attribute.
$gap = preg_match( '%[\\\(&=}]|/\*%', $gap ) ? null : $gap;
$id = uniqid();
$class = 'wp-block-gallery-' . $id;
$content = preg_replace(
'/' . preg_quote( 'class="', '/' ) . '/',
'class="' . $class . ' ',
$content,
1
);
$gap_value = $gap ? $gap : 'var( --wp--style--block-gap, 0.5em )';
$style = '.' . $class . '{ --wp--style--unstable-gallery-gap: ' . $gap_value . '}';
// Ideally styles should be loaded in the head, but blocks may be parsed
// after that, so loading in the footer for now.
// See https://core.trac.wordpress.org/ticket/53494.
add_action(
'wp_footer',
function () use ( $style ) {
echo '<style> ' . $style . '</style>';
}
);
return $content;
}
/**
* Registers the `core/gallery` block on server.
*/
function register_block_core_gallery() {
register_block_type_from_metadata(
__DIR__ . '/gallery',
array(
'render_callback' => 'block_core_gallery_render',
)
);
}
add_action( 'init', 'register_block_core_gallery' );

View File

@@ -0,0 +1,184 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import * as areoi from '../_components/Core.js';
/**
* WordPress dependencies
*/
import {
RichText,
useBlockProps,
useInnerBlocksProps,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import saveWithoutInnerBlocks from './v1/save';
import { isGalleryV2Enabled } from './shared';
export default function saveWithInnerBlocks( { attributes } ) {
if ( ! isGalleryV2Enabled() ) {
return saveWithoutInnerBlocks( { attributes } );
}
const { caption, imageCrop } = attributes;
let layout = attributes['layout'] ? attributes['layout'] : 'grid',
container = attributes['container'] ? attributes['container'] : 'container',
columns = attributes['columns'] ? attributes['columns'] : '3',
size = attributes['card_size'] && attributes['card_size'] != 'Default' ? attributes['card_size'] : '',
style = attributes['style'] && attributes['style'] != 'Default' ? attributes['style'] : '';
const classes = [
'block-' + attributes['block_id'],
'areoi-media-grid',
'areoi-content-grid',
'areoi-content-grid-' + layout,
size,
'areoi-content-grid-' + style,
attributes.vertical_align_xs,
attributes.vertical_align_sm,
attributes.vertical_align_md,
attributes.vertical_align_lg,
attributes.vertical_align_xl,
attributes.vertical_align_xxl,
'position-relative',
'd-flex'
];
const className = classnames( classes );
const blockProps = useBlockProps.save( { className } );
const innerBlocksProps = useInnerBlocksProps.save( blockProps );
var background_row_class = areoi.helper.GetClassNameStr( [
attributes['background_horizontal_align'] ? attributes['background_horizontal_align'] : ''
]);
var background_col_class = areoi.helper.GetClassNameStr( [
attributes['background_col_xs'] ? attributes['background_col_xs'] : '',
attributes['background_col_sm'] ? attributes['background_col_sm'] : '',
attributes['background_col_md'] ? attributes['background_col_md'] : '',
attributes['background_col_lg'] ? attributes['background_col_lg'] : '',
attributes['background_col_xl'] ? attributes['background_col_xl'] : '',
attributes['background_col_xxl'] ? attributes['background_col_xxl'] : ''
]);
var background = '';
if ( attributes['background_display'] ) {
background =
<div className="areoi-background">
<div className="container-fluid p-0">
<div className={'row' + background_row_class }>
<div className={ 'col' + background_col_class }>
{ attributes['background_color'] &&
<div className={ areoi.helper.GetClassNameStr( [
'areoi-background__color'
] ) }
style={ { 'background': areoi.helper.GetRGB( attributes['background_color']['rgb'] ) } }>
</div>
}
{ attributes['background_image'] &&
<div className="areoi-background__image" style={ { 'background-image': 'url(' + attributes['background_image']['url'] + ')' } }></div>
}
{ attributes['background_video'] &&
<video autoplay loop playsinline muted>
<source src={ attributes['background_video']['url'] } />
</video>
}
{ attributes['background_display_overlay'] && attributes['background_overlay'] &&
<div className={ areoi.helper.GetClassNameStr( [
'areoi-background__overlay'
] ) }
style={ { 'background': areoi.helper.GetRGB( attributes['background_overlay']['rgb'] ) } }>
</div>
}
</div>
</div>
</div>
</div>;
}
var prepend = '';
var prepend_row_class = areoi.helper.GetClassNameStr( [
'row',
attributes['prepend_horizontal_align_xs'] ? attributes['prepend_horizontal_align_xs'] : '',
attributes['prepend_horizontal_align_sm'] ? attributes['prepend_horizontal_align_sm'] : '',
attributes['prepend_horizontal_align_md'] ? attributes['prepend_horizontal_align_md'] : '',
attributes['prepend_horizontal_align_lg'] ? attributes['prepend_horizontal_align_lg'] : '',
attributes['prepend_horizontal_align_xl'] ? attributes['prepend_horizontal_align_xl'] : '',
attributes['prepend_horizontal_align_xxl'] ? attributes['prepend_horizontal_align_xxl'] : '',
]
);
var prepend_col_class = areoi.helper.GetClassNameStr( [
'col',
attributes['prepend_col_xs'] ? attributes['prepend_col_xs'] : '',
attributes['prepend_col_sm'] ? attributes['prepend_col_sm'] : '',
attributes['prepend_col_md'] ? attributes['prepend_col_md'] : '',
attributes['prepend_col_lg'] ? attributes['prepend_col_lg'] : '',
attributes['prepend_col_xl'] ? attributes['prepend_col_xl'] : '',
attributes['prepend_col_xxl'] ? attributes['prepend_col_xxl'] : '',
attributes['prepend_text_align_xs'] ? attributes['prepend_text_align_xs'] : '',
attributes['prepend_text_align_sm'] ? attributes['prepend_text_align_sm'] : '',
attributes['prepend_text_align_md'] ? attributes['prepend_text_align_md'] : '',
attributes['prepend_text_align_lg'] ? attributes['prepend_text_align_lg'] : '',
attributes['prepend_text_align_xl'] ? attributes['prepend_text_align_xl'] : '',
attributes['prepend_text_align_xxl'] ? attributes['prepend_text_align_xxl'] : '',
]
);
var heading_color = attributes['prepend_heading_color'] ? attributes['prepend_heading_color'] : '';
var intro_color = attributes['prepend_intro_color'] ? attributes['prepend_intro_color'] : '';
if ( attributes['prepend_display_heading'] || attributes['prepend_display_intro'] ) {
var prepend_heading = attributes['prepend_display_heading'] && attributes['prepend_heading'] ? '<' + attributes['prepend_heading_level'] + ' class="' + heading_color + '">' + attributes['prepend_heading'] + '</' + attributes['prepend_heading_level'] + '>' : '';
var prepend_intro = attributes['prepend_intro'] && attributes['prepend_intro'] ? '<p class="' + intro_color + '">' + attributes['prepend_intro'] + '</p>' : '';
function createMarkup() {
return {__html: prepend_heading + prepend_intro };
}
prepend =
<div className={ prepend_row_class }>
<div className={ prepend_col_class } dangerouslySetInnerHTML={createMarkup()}></div>
</div>;
}
// return (
// <div { ...innerBlocksProps }>
// { background }
// <div className={ areoi.helper.GetClassNameStr( [ container, 'position-relative' ] ) }>
// <div className="row h-100">
// <div className="col">
// { prepend }
// <div className={ areoi.helper.GetClassNameStr( [ 'row', 'areoi-content-grid-columns', 'areoi-content-grid-columns-' + columns ] ) }>
// { innerBlocksProps.children }
// { ! RichText.isEmpty( caption ) && (
// <RichText.Content
// tagName="figcaption"
// className="blocks-gallery-caption"
// value={ caption }
// />
// ) }
// </div>
// </div>
// </div>
// </div>
// </div>
// );
return (
<areoi.editor.InnerBlocks.Content/>
);
}

View File

@@ -0,0 +1,7 @@
/**
* WordPress dependencies
*/
import { BlockIcon } from '@wordpress/block-editor';
import { gallery as icon } from '@wordpress/icons';
export const sharedIcon = <BlockIcon icon={ icon } />;

View File

@@ -0,0 +1,23 @@
/**
* WordPress dependencies
*/
import { Icon } from '@wordpress/components';
import { withPreferredColorScheme } from '@wordpress/compose';
import { gallery as icon } from '@wordpress/icons';
/**
* Internal dependencies
*/
import styles from './styles.scss';
const IconWithColorScheme = withPreferredColorScheme(
( { getStylesFromColorScheme } ) => {
const colorSchemeStyles = getStylesFromColorScheme(
styles.icon,
styles.iconDark
);
return <Icon icon={ icon } { ...colorSchemeStyles } />;
}
);
export const sharedIcon = <IconWithColorScheme />;

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { get, pick } from 'lodash';
export function defaultColumnsNumber( imageCount ) {
return imageCount ? Math.min( 3, imageCount ) : 3;
}
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;
};
/**
* The new gallery block format is not compatible with the use_BalanceTags option
* in WP versions <= 5.8 https://core.trac.wordpress.org/ticket/54130. The
* window.wp.galleryBlockV2Enabled flag is set in lib/compat.php. This method
* can be removed when minimum supported WP version >=5.9.
*/
export function isGalleryV2Enabled() {
// Only run the Gallery version compat check if the plugin is running, otherwise
// assume we are in 5.9 core and enable by default.
if ( process.env.IS_GUTENBERG_PLUGIN ) {
// We want to fail early here, at least during beta testing phase, to ensure
// there aren't instances where undefined values cause false negatives.
if (
! window.wp ||
typeof window.wp.galleryBlockV2Enabled !== 'boolean'
) {
throw 'window.wp.galleryBlockV2Enabled is not defined';
}
return window.wp.galleryBlockV2Enabled;
}
return true;
}

View File

@@ -0,0 +1 @@
@import url( '../content-grid/style.css' );

View File

@@ -0,0 +1,171 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Gallery block Columns setting decrements columns 1`] = `
"<!-- wp:gallery {\\"columns\\":2,\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-2 is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2001} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2001\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2002} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-3.jpeg\\" alt=\\"\\" class=\\"wp-image-2002\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block Columns setting does not increment due to maximum value 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2001} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2001\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2002} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-3.jpeg\\" alt=\\"\\" class=\\"wp-image-2002\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block cancels uploads 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":null} -->
<figure class=\\"wp-block-image\\"><img alt=\\"\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":null} -->
<figure class=\\"wp-block-image\\"><img alt=\\"\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block disables crop images setting 1`] = `
"<!-- wp:gallery {\\"imageCrop\\":false,\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block finishes pending uploads upon opening the editor 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2001} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2001\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block handles failed uploads 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":1} -->
<figure class=\\"wp-block-image\\"><img src=\\"file:///local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-1\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2} -->
<figure class=\\"wp-block-image\\"><img src=\\"file:///local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block inserts block 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block overrides "Link to" setting of gallery items 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"media\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":1,\\"linkDestination\\":\\"media\\"} -->
<figure class=\\"wp-block-image\\"><img src=\\"file:///local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-1\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2,\\"linkDestination\\":\\"media\\"} -->
<figure class=\\"wp-block-image\\"><img src=\\"file:///local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block rearranges gallery items 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2002} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-3.jpeg\\" alt=\\"\\" class=\\"wp-image-2002\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2001} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2001\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block sets caption to gallery 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image --><figcaption class=\\"blocks-gallery-caption\\"><strong>Bold</strong> <em>italic</em> <s>strikethrough</s> gallery caption</figcaption></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block sets caption to gallery items 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/><figcaption><strong>Bold</strong> <em>italic</em> <s>strikethrough</s> image caption</figcaption></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block successfully uploads items 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2001} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2001\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block takes a photo 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block uploads from free photo library 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2001} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2001\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;
exports[`Gallery block uploads from other apps 1`] = `
"<!-- wp:gallery {\\"linkTo\\":\\"none\\"} -->
<figure class=\\"wp-block-gallery has-nested-images columns-default is-cropped\\"><!-- wp:image {\\"id\\":2000} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-1.jpeg\\" alt=\\"\\" class=\\"wp-image-2000\\"/></figure>
<!-- /wp:image -->
<!-- wp:image {\\"id\\":2001} -->
<figure class=\\"wp-block-image\\"><img src=\\"https://test-site.files.wordpress.com/local-image-2.jpeg\\" alt=\\"\\" class=\\"wp-image-2001\\"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->"
`;

View File

@@ -0,0 +1,293 @@
/**
* External dependencies
*/
import {
act,
initializeEditor,
fireEvent,
waitFor,
within,
waitForStoreResolvers,
} from 'test/helpers';
/**
* WordPress dependencies
*/
import {
requestMediaPicker,
subscribeMediaUpload,
} from '@wordpress/react-native-bridge';
import {
MEDIA_UPLOAD_STATE_UPLOADING,
MEDIA_UPLOAD_STATE_SUCCEEDED,
MEDIA_UPLOAD_STATE_FAILED,
MEDIA_UPLOAD_STATE_RESET,
} from '@wordpress/block-editor';
/**
* Adds a Gallery block via the block picker.
*
* @return {import('@testing-library/react-native').RenderAPI} A Testing Library screen.
*/
export const addGalleryBlock = async () => {
const screen = await initializeEditor();
const { getByA11yLabel, getByTestId, getByText } = screen;
fireEvent.press( getByA11yLabel( 'Add block' ) );
const blockList = getByTestId( 'InserterUI-Blocks' );
// onScroll event used to force the FlatList to render all items
fireEvent.scroll( blockList, {
nativeEvent: {
contentOffset: { y: 0, x: 0 },
contentSize: { width: 100, height: 100 },
layoutMeasurement: { width: 100, height: 100 },
},
} );
fireEvent.press( await waitFor( () => getByText( 'Gallery' ) ) );
return screen;
};
/**
* The gallery items are rendered via the FlatList of the inner block list.
* In order to render the items of a FlatList, it's required to trigger the
* "onLayout" event. Additionally, the call is wrapped over "waitForStoreResolvers"
* because the gallery items request the media data associated with the image to
* be rendered via the "getMedia" selector.
*
* @param {import('react-test-renderer').ReactTestInstance} galleryBlock Gallery block instance to trigger layout event.
* @param {Object} [options] Configuration options for the event.
* @param {number} [options.width] Width value to be passed to the event.
*/
export const triggerGalleryLayout = async (
galleryBlock,
{ width = 320 } = {}
) =>
waitForStoreResolvers( () =>
fireEvent(
within( galleryBlock ).getByTestId( 'block-list-wrapper' ),
'layout',
{
nativeEvent: {
layout: {
width,
},
},
}
)
);
/**
* Initialize the editor with HTML generated of Gallery block.
*
* @param {Object} [options] Configuration options for the initialization.
* @param {string} [options.html] String of block editor HTML to parse and render.
* @param {number} [options.numberOfItems] Number of gallery items to generate or already included in the provided block editor HTML.
* @param {Object} [options.media] Contains media data to be used in the generation.
* @param {number} [options.width] Width to be passed when triggering the "onLayout" event on the Gallery block.
* @param {boolean} [options.selected] Specifies if the Gallery block included in the initial HTML should be automatically selected.
* @param {boolean} [options.useLocalUrl] Specifies if the items should use the local URL instead of the server URL.
*
* @return {import('@testing-library/react-native').RenderAPI} The Testing Library screen plus the Gallery block React Test instance.
*/
export const initializeWithGalleryBlock = async ( {
html,
numberOfItems = 0,
media = [],
width = 320,
selected = true,
useLocalUrl = false,
} = {} ) => {
const initialHtml =
html ||
generateGalleryBlock( numberOfItems, media, {
useLocalUrl,
} );
const screen = await initializeEditor( { initialHtml } );
const { getByA11yLabel } = screen;
const galleryBlock = getByA11yLabel( /Gallery Block\. Row 1/ );
if ( numberOfItems > 0 ) {
await triggerGalleryLayout( galleryBlock, { width } );
}
if ( selected ) {
fireEvent.press( galleryBlock );
}
return { ...screen, galleryBlock };
};
/**
* Gets a gallery item within a Gallery block.
*
* @param {import('react-test-renderer').ReactTestInstance} galleryBlock Gallery block instance.
* @param {number} rowPosition Row position within the Gallery block.
* @return {import('react-test-renderer').ReactTestInstance} Gallery item.
*/
export const getGalleryItem = ( galleryBlock, rowPosition ) => {
return within( galleryBlock ).getByA11yLabel(
new RegExp( `Image Block\\. Row ${ rowPosition }` )
);
};
/**
* Sets up the media upload mock functions for testing.
*
* @typedef {Object} MediaUploadMockFunctions
* @property {Function} notifyUploadingState Notify uploading state for a media item.
* @property {Function} notifySucceedState Notify succeed state for a media item.
* @property {Function} notifyFailedState Notify failed state for a media item.
* @property {Function} notifyResetState Notify reset state for a media item.
*
* @return {MediaUploadMockFunctions} Notify state functions.
*/
export const setupMediaUpload = () => {
const mediaUploadListeners = [];
subscribeMediaUpload.mockImplementation( ( callback ) => {
mediaUploadListeners.push( callback );
return { remove: jest.fn() };
} );
const notifyMediaUpload = ( payload ) =>
mediaUploadListeners.forEach( ( listener ) => listener( payload ) );
return {
notifyUploadingState: async ( mediaItem ) =>
act( async () => {
notifyMediaUpload( {
state: MEDIA_UPLOAD_STATE_UPLOADING,
mediaId: mediaItem.localId,
progress: 0.25,
} );
} ),
notifySucceedState: async ( mediaItem ) =>
act( async () => {
notifyMediaUpload( {
state: MEDIA_UPLOAD_STATE_SUCCEEDED,
mediaId: mediaItem.localId,
mediaUrl: mediaItem.serverUrl,
mediaServerId: mediaItem.serverId,
} );
} ),
notifyFailedState: async ( mediaItem ) =>
act( async () => {
notifyMediaUpload( {
state: MEDIA_UPLOAD_STATE_FAILED,
mediaId: mediaItem.localId,
progress: 0.5,
} );
} ),
notifyResetState: async ( mediaItem ) =>
act( async () => {
notifyMediaUpload( {
state: MEDIA_UPLOAD_STATE_RESET,
mediaId: mediaItem.localId,
progress: 0,
} );
} ),
};
};
/**
*
* Sets up Media Picker mock functions.
*
* @typedef {Object} MediaPickerMockFunctions
* @property {Function} expectMediaPickerCall Checks if the request media picker function has been called with specific arguments.
* @property {Function} mediaPickerCallback Callback function to notify the media items picked from the media picker.
*
* @return {MediaPickerMockFunctions} Media picker mock functions.
*/
export const setupMediaPicker = () => {
let mediaPickerCallback;
requestMediaPicker.mockImplementation(
( source, filter, multiple, callback ) => {
mediaPickerCallback = callback;
}
);
return {
expectMediaPickerCall: ( source, filter, multiple ) =>
expect( requestMediaPicker ).toHaveBeenCalledWith(
source,
filter,
multiple,
mediaPickerCallback
),
mediaPickerCallback: async ( ...mediaItems ) =>
act( async () =>
mediaPickerCallback(
mediaItems.map( ( { localId, localUrl } ) => ( {
type: 'image',
url: localUrl,
id: localId,
} ) )
)
),
};
};
/**
* Generates the HTML of a Gallery block.
*
* @param {number} numberOfItems Number of gallery items to generate.
* @param {Object} media Contains media data to be used in the generation.
* @param {Object} [options] Configuration options for the generation.
* @param {boolean} [options.useLocalUrl] Specifies if the items should use the local URL instead of the server URL.
* @return {string} Gallery block HTML.
*/
export const generateGalleryBlock = (
numberOfItems,
media,
{ useLocalUrl = false } = {}
) => {
const galleryItems = [ ...Array( numberOfItems ) ]
.map( ( _, index ) => {
const id = useLocalUrl
? media[ index ].localId
: media[ index ].serverId;
const url = useLocalUrl
? media[ index ].localUrl
: media[ index ].serverUrl;
return `<!-- wp:image {"id":${ id }} -->
<figure class="wp-block-image"><img src="${ url }" alt="" class="wp-image-${ id }"/></figure>
<!-- /wp:image -->`;
} )
.join( '\n\n' );
return `<!-- wp:gallery {"linkTo":"none"} -->
<figure class="wp-block-gallery has-nested-images columns-default is-cropped">${ galleryItems }</figure>
<!-- /wp:gallery -->`;
};
/**
* Sets the text of a caption.
*
* @param {import('react-test-renderer').ReactTestInstance} element Caption test instance.
* @param {string} text Text to be set.
*/
export const setCaption = ( element, text ) => {
fireEvent( element, 'focus' );
fireEvent( element, 'onChange', {
nativeEvent: {
eventCount: 1,
target: undefined,
text,
},
} );
};
/**
* Opens the block settings of the current selected block.
*
* @param {import('@testing-library/react-native').RenderAPI} screen The Testing Library screen.
*/
export const openBlockSettings = async ( screen ) => {
const { getByA11yLabel, getByTestId } = screen;
fireEvent.press( getByA11yLabel( 'Open Settings' ) );
await waitFor(
() => getByTestId( 'block-settings-modal' ).props.isVisible
);
};

View File

@@ -0,0 +1,646 @@
/**
* External dependencies
*/
import {
act,
getEditorHtml,
initializeEditor,
fireEvent,
within,
} from 'test/helpers';
/**
* WordPress dependencies
*/
import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks';
import { registerCoreBlocks } from '@wordpress/block-library';
import { Platform } from '@wordpress/element';
import {
getOtherMediaOptions,
requestImageFailedRetryDialog,
requestImageUploadCancelDialog,
} from '@wordpress/react-native-bridge';
/**
* Internal dependencies
*/
import {
addGalleryBlock,
initializeWithGalleryBlock,
getGalleryItem,
setupMediaUpload,
generateGalleryBlock,
setCaption,
setupMediaPicker,
triggerGalleryLayout,
openBlockSettings,
} from './helpers';
const media = [
{
localId: 1,
localUrl: 'file:///local-image-1.jpeg',
serverId: 2000,
serverUrl: 'https://test-site.files.wordpress.com/local-image-1.jpeg',
},
{
localId: 2,
localUrl: 'file:///local-image-2.jpeg',
serverId: 2001,
serverUrl: 'https://test-site.files.wordpress.com/local-image-2.jpeg',
},
{
localId: 3,
localUrl: 'file:///local-image-3.jpeg',
serverId: 2002,
serverUrl: 'https://test-site.files.wordpress.com/local-image-3.jpeg',
},
];
beforeAll( () => {
// Register all core blocks
registerCoreBlocks();
} );
afterAll( () => {
// Clean up registered blocks
getBlockTypes().forEach( ( block ) => {
unregisterBlockType( block.name );
} );
} );
describe( 'Gallery block', () => {
it( 'inserts block', async () => {
const { getByA11yLabel } = await addGalleryBlock();
expect( getByA11yLabel( /Gallery Block\. Row 1/ ) ).toBeVisible();
expect( getEditorHtml() ).toMatchSnapshot();
} );
it( 'selects a gallery item', async () => {
const { galleryBlock } = await initializeWithGalleryBlock( {
numberOfItems: 1,
media,
selected: false,
} );
const galleryItem = getGalleryItem( galleryBlock, 1 );
fireEvent.press( galleryItem );
expect( galleryItem ).toBeVisible();
} );
it( 'shows appender button when gallery has images', async () => {
const { galleryBlock, getByText } = await initializeWithGalleryBlock( {
numberOfItems: 1,
media,
} );
const appenderButton = within( galleryBlock ).getByTestId(
'media-placeholder-appender-icon'
);
fireEvent.press( appenderButton );
expect( getByText( 'Choose from device' ) ).toBeVisible();
expect( getByText( 'Take a Photo' ) ).toBeVisible();
expect( getByText( 'WordPress Media Library' ) ).toBeVisible();
} );
// This case is disabled until the issue (https://github.com/WordPress/gutenberg/issues/38444)
// is addressed.
it.skip( 'displays media options picker when selecting the block', async () => {
// Initialize with an empty gallery
const {
getByA11yLabel,
getByText,
getByTestId,
} = await initializeEditor( {
initialHtml: generateGalleryBlock( 0 ),
} );
// Tap on Gallery block
fireEvent.press( getByText( 'ADD MEDIA' ) );
// Observe that media options picker is displayed
expect( getByText( 'Choose images' ) ).toBeVisible();
expect( getByText( 'WordPress Media Library' ) ).toBeVisible();
// Dimiss the picker
if ( Platform.isIOS ) {
fireEvent.press( getByText( 'Cancel' ) );
} else {
fireEvent( getByTestId( 'media-options-picker' ), 'backdropPress' );
}
// Observe that the block is selected, this is done by checking if the block settings
// button is visible
const blockActionsButton = getByA11yLabel( /Open Block Actions Menu/ );
expect( blockActionsButton ).toBeVisible();
} );
// Test case related to TC001 - Close/Re-open post with an ongoing image upload
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc001
it( 'finishes pending uploads upon opening the editor', async () => {
const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
// Initialize with a gallery that contains two items that are being uploaded
const { galleryBlock } = await initializeWithGalleryBlock( {
numberOfItems: 2,
media,
useLocalUrl: true,
} );
// Notify that the media items are uploading
await notifyUploadingState( media[ 0 ] );
await notifyUploadingState( media[ 1 ] );
// Check that images are showing a loading state
expect(
within( getGalleryItem( galleryBlock, 1 ) ).getByTestId( 'spinner' )
).toBeVisible();
expect(
within( getGalleryItem( galleryBlock, 2 ) ).getByTestId( 'spinner' )
).toBeVisible();
// Notify that the media items upload succeeded
await notifySucceedState( media[ 0 ] );
await notifySucceedState( media[ 1 ] );
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC003 - Add caption to gallery
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc003
it( 'sets caption to gallery', async () => {
// Initialize with a gallery that contains one item
const { getByA11yLabel } = await initializeWithGalleryBlock( {
numberOfItems: 1,
media,
} );
// Check gallery item caption is not visible
const galleryItemCaption = getByA11yLabel( /Image caption. Empty/ );
expect( galleryItemCaption ).not.toBeVisible();
// Set gallery caption
const captionField = within(
getByA11yLabel( /Gallery caption. Empty/ )
).getByPlaceholderText( 'Add caption' );
setCaption(
captionField,
'<strong>Bold</strong> <em>italic</em> <s>strikethrough</s> gallery caption'
);
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC004 - Add caption to gallery images
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc004
it( 'sets caption to gallery items', async () => {
// Initialize with a gallery that contains one item
const { galleryBlock } = await initializeWithGalleryBlock( {
numberOfItems: 1,
media,
} );
// Select gallery item
const galleryItem = getGalleryItem( galleryBlock, 1 );
fireEvent.press( galleryItem );
// Set gallery item caption
const captionField = within( galleryItem ).getByPlaceholderText(
'Add caption'
);
setCaption(
captionField,
'<strong>Bold</strong> <em>italic</em> <s>strikethrough</s> image caption'
);
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC005 - Choose from device (stay in editor) - Successful upload
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc005
it( 'successfully uploads items', async () => {
const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
const {
expectMediaPickerCall,
mediaPickerCallback,
} = setupMediaPicker();
// Initialize with an empty gallery
const { galleryBlock, getByText } = await initializeWithGalleryBlock();
// Upload images from device
fireEvent.press( getByText( 'ADD MEDIA' ) );
fireEvent.press( getByText( 'Choose from device' ) );
expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true );
// Return media items picked
await mediaPickerCallback( media[ 0 ], media[ 1 ] );
// Check that gallery items are visible
await triggerGalleryLayout( galleryBlock );
const galleryItem1 = getGalleryItem( galleryBlock, 1 );
const galleryItem2 = getGalleryItem( galleryBlock, 2 );
expect( galleryItem1 ).toBeVisible();
expect( galleryItem2 ).toBeVisible();
// Check that images are showing a loading state
await notifyUploadingState( media[ 0 ] );
await notifyUploadingState( media[ 1 ] );
expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
// Notify that the media items upload succeeded
await notifySucceedState( media[ 0 ] );
await notifySucceedState( media[ 1 ] );
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC006 - Choose from device (stay in editor) - Failed upload
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc006
it( 'handles failed uploads', async () => {
const { notifyUploadingState, notifyFailedState } = setupMediaUpload();
const {
expectMediaPickerCall,
mediaPickerCallback,
} = setupMediaPicker();
// Initialize with an empty gallery
const { galleryBlock, getByText } = await initializeWithGalleryBlock();
// Upload images from device
fireEvent.press( getByText( 'ADD MEDIA' ) );
fireEvent.press( getByText( 'Choose from device' ) );
expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true );
// Return media items picked
await mediaPickerCallback( media[ 0 ], media[ 1 ] );
// Check that gallery items are visible
await triggerGalleryLayout( galleryBlock );
const galleryItem1 = getGalleryItem( galleryBlock, 1 );
const galleryItem2 = getGalleryItem( galleryBlock, 2 );
expect( galleryItem1 ).toBeVisible();
expect( galleryItem2 ).toBeVisible();
// Check that images are showing a loading state
await notifyUploadingState( media[ 0 ] );
await notifyUploadingState( media[ 1 ] );
expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
// Notify that the media items uploads failed
await notifyFailedState( media[ 0 ] );
await notifyFailedState( media[ 1 ] );
// Check that failed images provide the option to retry the upload
fireEvent.press( galleryItem1 );
fireEvent.press(
within( galleryItem1 ).getByText( /Failed to insert media/ )
);
expect( requestImageFailedRetryDialog ).toHaveBeenCalledWith(
media[ 0 ].localId
);
fireEvent.press( galleryItem2 );
fireEvent.press(
within( galleryItem2 ).getByText( /Failed to insert media/ )
);
expect( requestImageFailedRetryDialog ).toHaveBeenCalledWith(
media[ 1 ].localId
);
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC007 - Take a photo
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc007
it( 'takes a photo', async () => {
const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
const {
expectMediaPickerCall,
mediaPickerCallback,
} = setupMediaPicker();
// Initialize with an empty gallery
const { galleryBlock, getByText } = await initializeWithGalleryBlock();
// Take a photo
fireEvent.press( getByText( 'ADD MEDIA' ) );
fireEvent.press( getByText( 'Take a Photo' ) );
expectMediaPickerCall( 'DEVICE_CAMERA', [ 'image' ], true );
// Return media item from photo taken
await mediaPickerCallback( media[ 0 ] );
// Check gallery item is visible
await triggerGalleryLayout( galleryBlock );
const galleryItem = getGalleryItem( galleryBlock, 1 );
expect( galleryItem ).toBeVisible();
// Check image is showing a loading state
await notifyUploadingState( media[ 0 ] );
expect( within( galleryItem ).getByTestId( 'spinner' ) ).toBeVisible();
// Notify that the media item upload succeeded
await notifySucceedState( media[ 0 ] );
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC008 - Choose from the free photo library
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc008
it( 'uploads from free photo library', async () => {
const freePhotoMedia = [ ...media ].map( ( item, index ) => ( {
...item,
localUrl: `https://images.pexels.com/photos/110854/pexels-photo-${
index + 1
}.jpeg`,
} ) );
const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
const {
expectMediaPickerCall,
mediaPickerCallback,
} = setupMediaPicker();
let otherMediaOptionsCallback;
getOtherMediaOptions.mockImplementation( ( filter, callback ) => {
otherMediaOptionsCallback = callback;
} );
// Initialize with an empty gallery
const { galleryBlock, getByText } = await initializeWithGalleryBlock();
// Notify other media options
act( () =>
otherMediaOptionsCallback( [
{
label: 'Free Photo Library',
value: 'stock-photo-library',
},
] )
);
// Upload images from free photo library
fireEvent.press( getByText( 'ADD MEDIA' ) );
fireEvent.press( getByText( 'Free Photo Library' ) );
expectMediaPickerCall( 'stock-photo-library', [ 'image' ], true );
// Return media items picked
await act( async () =>
mediaPickerCallback( freePhotoMedia[ 0 ], freePhotoMedia[ 1 ] )
);
// Check that gallery items are visible
await triggerGalleryLayout( galleryBlock );
const galleryItem1 = getGalleryItem( galleryBlock, 1 );
const galleryItem2 = getGalleryItem( galleryBlock, 2 );
expect( galleryItem1 ).toBeVisible();
expect( galleryItem2 ).toBeVisible();
// Check that images are showing a loading state
await notifyUploadingState( freePhotoMedia[ 0 ] );
await notifyUploadingState( freePhotoMedia[ 1 ] );
expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
// Notify that the media items upload succeeded
await notifySucceedState( freePhotoMedia[ 0 ] );
await notifySucceedState( freePhotoMedia[ 1 ] );
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC009 - Choose from device (stay in editor) - Cancel upload
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc009
it( 'cancels uploads', async () => {
const { notifyUploadingState, notifyResetState } = setupMediaUpload();
const {
expectMediaPickerCall,
mediaPickerCallback,
} = setupMediaPicker();
// Initialize with an empty gallery
const { galleryBlock, getByText } = await initializeWithGalleryBlock();
// Upload images from device
fireEvent.press( getByText( 'ADD MEDIA' ) );
fireEvent.press( getByText( 'Choose from device' ) );
expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true );
// Return media items picked
await mediaPickerCallback( media[ 0 ], media[ 1 ] );
// Check that gallery items are visible
await triggerGalleryLayout( galleryBlock );
const galleryItem1 = getGalleryItem( galleryBlock, 1 );
const galleryItem2 = getGalleryItem( galleryBlock, 2 );
expect( galleryItem1 ).toBeVisible();
expect( galleryItem2 ).toBeVisible();
// Check that images are showing a loading state
await notifyUploadingState( media[ 0 ] );
await notifyUploadingState( media[ 1 ] );
expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
// Cancel uploads
fireEvent.press( galleryItem1 );
fireEvent.press( within( galleryItem1 ).getByTestId( 'spinner' ) );
expect( requestImageUploadCancelDialog ).toHaveBeenCalledWith(
media[ 0 ].localId
);
await notifyResetState( media[ 0 ] );
fireEvent.press( galleryItem2 );
fireEvent.press( within( galleryItem2 ).getByTestId( 'spinner' ) );
expect( requestImageUploadCancelDialog ).toHaveBeenCalledWith(
media[ 1 ].localId
);
await notifyResetState( media[ 1 ] );
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC010 - Rearrange images in Gallery
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc010
it( 'rearranges gallery items', async () => {
// Initialize with a gallery that contains three items
const { galleryBlock } = await initializeWithGalleryBlock( {
numberOfItems: 3,
media,
} );
// Rearrange items (final disposition will be: Image 3 - Image 1 - Image 2)
const galleryItem1 = getGalleryItem( galleryBlock, 1 );
const galleryItem3 = getGalleryItem( galleryBlock, 3 );
fireEvent.press( galleryItem3 );
await act( () =>
fireEvent.press(
within( galleryItem3 ).getByA11yLabel(
/Move block left from position 3 to position 2/
)
)
);
fireEvent.press( galleryItem1 );
await act( () =>
fireEvent.press(
within( galleryItem1 ).getByA11yLabel(
/Move block right from position 1 to position 2/
)
)
);
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC011 - Choose from Other Apps (iOS Files App)
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc011
it( 'uploads from other apps', async () => {
const otherAppsMedia = [ ...media ].map( ( item, index ) => ( {
...item,
localUrl: `file:///IMG_${ index + 1 }.JPG`,
} ) );
const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
const {
expectMediaPickerCall,
mediaPickerCallback,
} = setupMediaPicker();
let otherMediaOptionsCallback;
getOtherMediaOptions.mockImplementation( ( filter, callback ) => {
otherMediaOptionsCallback = callback;
} );
// Initialize with an empty gallery
const { galleryBlock, getByText } = await initializeWithGalleryBlock();
// Notify other media options
act( () =>
otherMediaOptionsCallback( [
{ label: 'Other Apps', value: 'other-files' },
] )
);
// Upload images from other apps
fireEvent.press( getByText( 'ADD MEDIA' ) );
fireEvent.press( getByText( 'Other Apps' ) );
expectMediaPickerCall( 'other-files', [ 'image' ], true );
// Return media items picked
await mediaPickerCallback( otherAppsMedia[ 0 ], otherAppsMedia[ 1 ] );
// Check that gallery items are visible
await triggerGalleryLayout( galleryBlock );
const galleryItem1 = getGalleryItem( galleryBlock, 1 );
const galleryItem2 = getGalleryItem( galleryBlock, 2 );
expect( galleryItem1 ).toBeVisible();
expect( galleryItem2 ).toBeVisible();
// Check that images are showing a loading state
await notifyUploadingState( otherAppsMedia[ 0 ] );
await notifyUploadingState( otherAppsMedia[ 1 ] );
expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
// Notify that the media items upload succeeded
await notifySucceedState( otherAppsMedia[ 0 ] );
await notifySucceedState( otherAppsMedia[ 1 ] );
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test case related to TC012 - Settings - Link to
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc012
it( 'overrides "Link to" setting of gallery items', async () => {
// Initialize with a gallery that contains two items, the latter includes "linkDestination" attribute
const screen = await initializeWithGalleryBlock( {
html: `<!-- wp:gallery {"linkTo":"none"} -->
<figure class="wp-block-gallery has-nested-images columns-default is-cropped"><!-- wp:image {"id":${ media[ 0 ].localId }} -->
<figure class="wp-block-image"><img src="${ media[ 0 ].localUrl }" alt="" class="wp-image-${ media[ 0 ].localId }"/></figure>
<!-- /wp:image -->
<!-- wp:image {"id":${ media[ 1 ].localId },"linkDestination":"attachment"} -->
<figure class="wp-block-image"><img src="${ media[ 1 ].localUrl }" alt="" class="wp-image-${ media[ 1 ].localId }"/></figure>
<!-- /wp:image --></figure>
<!-- /wp:gallery -->`,
numberOfItems: 2,
} );
const { getByText } = screen;
// Set "Link to" setting via Gallery block settings
await openBlockSettings( screen );
fireEvent.press( getByText( 'Link to' ) );
fireEvent.press( getByText( 'Media File' ) );
expect( getEditorHtml() ).toMatchSnapshot();
} );
// Test cases related to TC013 - Settings - Columns
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc013
describe( 'Columns setting', () => {
it( 'does not increment due to maximum value', async () => {
// Initialize with a gallery that contains three items
const screen = await initializeWithGalleryBlock( {
numberOfItems: 3,
media,
} );
const { getByA11yLabel } = screen;
await openBlockSettings( screen );
// Can't increment due to maximum value
// NOTE: Default columns value is 3
fireEvent(
getByA11yLabel( /Columns\. Value is 3/ ),
'accessibilityAction',
{
nativeEvent: { actionName: 'increment' },
}
);
expect( getEditorHtml() ).toMatchSnapshot();
} );
it( 'decrements columns', async () => {
// Initialize with a gallery that contains three items
const screen = await initializeWithGalleryBlock( {
numberOfItems: 3,
media,
} );
const { getByA11yLabel } = screen;
await openBlockSettings( screen );
// Decrement columns
fireEvent(
getByA11yLabel( /Columns\. Value is 3/ ),
'accessibilityAction',
{
nativeEvent: { actionName: 'decrement' },
}
);
expect( getEditorHtml() ).toMatchSnapshot();
} );
} );
// Test case related to TC014 - Settings - Crop images
// Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc014
it( 'disables crop images setting', async () => {
// Initialize with a gallery that contains one item
const screen = await initializeWithGalleryBlock( {
numberOfItems: 1,
media,
} );
const { getByText } = screen;
await openBlockSettings( screen );
// Disable crop images setting
fireEvent.press( getByText( 'Crop images' ) );
expect( getEditorHtml() ).toMatchSnapshot();
} );
} );

View File

@@ -0,0 +1,93 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* Internal dependencies
*/
import useGetNewImages from '../use-get-new-images';
const TestComponent = ( { images, imageData } ) => {
const newImages = useGetNewImages( images, imageData );
return <div title="testResult">{ JSON.stringify( newImages ) }</div>;
};
describe( 'gallery block useGetNewImages hook', () => {
it( 'returns null if no images currently in the gallery', () => {
render( <TestComponent images={ [] } imageData={ [] } /> );
expect( screen.getByTitle( 'testResult' ) ).toHaveTextContent( 'null' );
} );
it( 'should not return images that have been loaded from the saved post content', () => {
render(
<TestComponent
images={ [
{ clientId: 'abc123', id: 1, fromSavedContent: true },
] }
imageData={ [ { id: 1 } ] }
/>
);
expect( screen.getByTitle( 'testResult' ) ).toHaveTextContent( 'null' );
} );
it( 'returns array of new images that have been added since the last render', () => {
const { rerender } = render(
<TestComponent
images={ [ { clientId: 'abc123', id: 1 } ] }
imageData={ [ { id: 1 } ] }
/>
);
expect( screen.getByTitle( 'testResult' ) ).toHaveTextContent(
'[{"clientId":"abc123","id":1}]'
);
rerender(
<TestComponent
images={ [
{ clientId: 'abc123', id: 1 },
{ clientId: 'efg456', id: 2 },
] }
imageData={ [ { id: 1 }, { id: 2 } ] }
/>
);
expect( screen.getByTitle( 'testResult' ) ).toHaveTextContent(
'[{"clientId":"efg456","id":2}]'
);
} );
it( 'sees an image as new if it has been deleted and added again', () => {
const { rerender } = render(
<TestComponent
images={ [
{ clientId: 'abc123', id: 1 },
{ clientId: 'efg456', id: 2 },
] }
imageData={ [ { id: 1 }, { id: 2 } ] }
/>
);
rerender(
<TestComponent
images={ [ { clientId: 'abc123', id: 1 } ] }
imageData={ [ { id: 1 } ] }
/>
);
rerender(
<TestComponent
images={ [
{ clientId: 'abc123', id: 1 },
{ clientId: 'efg456', id: 2 },
] }
imageData={ [ { id: 1 }, { id: 2 } ] }
/>
);
expect( screen.getByTitle( 'testResult' ) ).toHaveTextContent(
'[{"clientId":"efg456","id":2}]'
);
} );
} );

View File

@@ -0,0 +1,333 @@
/**
* External dependencies
*/
import { filter, every, toString } from 'lodash';
/**
* WordPress dependencies
*/
import { createBlock } from '@wordpress/blocks';
import { createBlobURL } from '@wordpress/blob';
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, isGalleryV2Enabled } from './shared';
const parseShortcodeIds = ( ids ) => {
if ( ! ids ) {
return [];
}
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 ) {
if (
isGalleryV2Enabled() &&
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: [
{
type: 'block',
isMultiBlock: true,
blocks: [ 'core/image' ],
transform: ( attributes ) => {
// Init the align and size from the first item which may be either the placeholder or an image.
let { align, sizeSlug } = attributes[ 0 ];
// Loop through all the images and check if they have the same align and size.
align = every( attributes, [ 'align', align ] )
? align
: undefined;
sizeSlug = every( attributes, [ 'sizeSlug', sizeSlug ] )
? sizeSlug
: undefined;
const validImages = filter( attributes, ( { url } ) => url );
if ( isGalleryV2Enabled() ) {
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 } ) => ( {
id: toString( id ),
url,
alt,
caption,
} )
),
ids: validImages.map( ( { id } ) => parseInt( id, 10 ) ),
align,
sizeSlug,
} );
},
},
{
type: 'shortcode',
tag: 'gallery',
attributes: {
images: {
type: 'array',
shortcode: ( { named: { ids } } ) => {
if ( ! isGalleryV2Enabled() ) {
return parseShortcodeIds( ids ).map( ( id ) => ( {
id: toString( id ),
} ) );
}
},
},
ids: {
type: 'array',
shortcode: ( { named: { ids } } ) => {
if ( ! isGalleryV2Enabled() ) {
return parseShortcodeIds( ids );
}
},
},
shortCodeTransforms: {
type: 'array',
shortcode: ( { named: { ids } } ) => {
if ( isGalleryV2Enabled() ) {
return parseShortcodeIds( ids ).map( ( id ) => ( {
id: parseInt( id ),
} ) );
}
},
},
columns: {
type: 'number',
shortcode: ( { named: { columns = '3' } } ) => {
return parseInt( columns, 10 );
},
},
linkTo: {
type: 'string',
shortcode: ( { named: { link } } ) => {
if ( ! isGalleryV2Enabled() ) {
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;
}
},
},
},
isMatch( { named } ) {
return undefined !== named.ids;
},
},
{
// 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 &&
every(
files,
( file ) => file.type.indexOf( 'image/' ) === 0
)
);
},
transform( files ) {
if ( isGalleryV2Enabled() ) {
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( {
url: createBlobURL( file ),
} )
),
} );
return block;
},
},
],
to: [
{
type: 'block',
blocks: [ 'core/image' ],
transform: ( { align, images, ids, sizeSlug }, innerBlocks ) => {
if ( isGalleryV2Enabled() ) {
if ( innerBlocks.length > 0 ) {
return innerBlocks.map(
( {
attributes: {
id,
url,
alt,
caption,
sizeSlug: imageSizeSlug,
linkDestination,
href,
linkTarget,
},
} ) =>
createBlock( 'core/image', {
id,
url,
alt,
caption,
sizeSlug: imageSizeSlug,
align,
linkDestination,
href,
linkTarget,
} )
);
}
return createBlock( 'core/image', { align } );
}
if ( images.length > 0 ) {
return images.map( ( { url, alt, caption }, index ) =>
createBlock( 'core/image', {
id: ids[ index ],
url,
alt,
caption,
align,
sizeSlug,
} )
);
}
return createBlock( 'core/image', { align } );
},
},
],
};
export default transforms;

View File

@@ -0,0 +1,12 @@
/**
* Internal dependencies
*/
import webTransforms from './transforms.js';
import transformationCategories from '../transformationCategories';
const transforms = {
...webTransforms,
supportedMobileTransforms: transformationCategories.media,
};
export default transforms;

View File

@@ -0,0 +1,54 @@
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
/**
* Retrieves the extended media info for each gallery image from the store. This is used to
* determine which image size options are available for the current gallery.
*
* @param {Array} innerBlockImages An array of the innerBlock images currently in the gallery.
*
* @return {Array} An array of media info options for each gallery image.
*/
export default function useGetMedia( innerBlockImages ) {
const [ currentImageMedia, setCurrentImageMedia ] = useState( [] );
const imageMedia = useSelect(
( select ) => {
if ( ! innerBlockImages?.length ) {
return currentImageMedia;
}
const imageIds = innerBlockImages
.map( ( imageBlock ) => imageBlock.attributes.id )
.filter( ( id ) => id !== undefined );
if ( imageIds.length === 0 ) {
return currentImageMedia;
}
return select( coreStore ).getMediaItems( {
include: imageIds.join( ',' ),
per_page: -1,
} );
},
[ innerBlockImages ]
);
if (
imageMedia?.length !== currentImageMedia?.length ||
imageMedia?.some(
( newImage ) =>
! currentImageMedia.find(
( currentImage ) => currentImage.id === newImage.id
)
)
) {
setCurrentImageMedia( imageMedia );
return imageMedia;
}
return currentImageMedia;
}

View File

@@ -0,0 +1,68 @@
/**
* WordPress dependencies
*/
import { useMemo, useState } from '@wordpress/element';
/**
* Keeps track of images already in the gallery to allow new innerBlocks to be identified. This
* is required so default gallery attributes can be applied without overwriting any custom
* attributes applied to existing images.
*
* @param {Array} images Basic image block data taken from current gallery innerBlock
* @param {Array} imageData The related image data for each of the current gallery images.
*
* @return {Array} An array of any new images that have been added to the gallery.
*/
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;
}
}

View File

@@ -0,0 +1,66 @@
/**
* External dependencies
*/
import { get, some } from 'lodash';
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Calculates the image sizes that are avaible for the current gallery images in order to
* populate the 'Image size' selector.
*
* @param {Array} images Basic image block data taken from current gallery innerBlock
* @param {boolean} isSelected Is the block currently selected in the editor.
* @param {Function} getSettings Block editor store selector.
*
* @return {Array} An array of image size options.
*/
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 } ) );
}
}

View File

@@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { every } from 'lodash';
/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
/**
* Shortcode transforms don't currently have a tranform method and so can't use a selector to
* retrieve the data for each image being transformer, so this selector handle this post transformation.
*
* @param {Array} shortCodeTransforms An array of image data passed from the shortcode transform.
*
* @return {Array} An array of extended image data objects for each of the shortcode transform images.
*/
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;
}
}

View File

@@ -0,0 +1,50 @@
/**
* Internal dependencies
*/
import {
LINK_DESTINATION_ATTACHMENT,
LINK_DESTINATION_MEDIA,
LINK_DESTINATION_NONE,
LINK_DESTINATION_MEDIA_WP_CORE,
LINK_DESTINATION_ATTACHMENT_WP_CORE,
} 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 '../media-grid-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 ) {
// Gutenberg and WordPress use different constants so if image_default_link_type
// option is set we need to map from the WP Core values.
switch ( destination ) {
case LINK_DESTINATION_MEDIA_WP_CORE:
case LINK_DESTINATION_MEDIA:
return {
href: image?.source_url || image?.url, // eslint-disable-line camelcase
linkDestination: IMAGE_LINK_DESTINATION_MEDIA,
};
case LINK_DESTINATION_ATTACHMENT_WP_CORE:
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 {};
}

View File

@@ -0,0 +1,3 @@
export const LINK_DESTINATION_NONE = 'none';
export const LINK_DESTINATION_MEDIA = 'file';
export const LINK_DESTINATION_ATTACHMENT = 'post';

View File

@@ -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 = (
<MediaPlaceholder
addToGallery={ hasImageIds }
isAppender={ hasImages }
disableMediaButtons={ hasImages && ! isSelected }
icon={ ! hasImages && sharedIcon }
labels={ {
title: ! hasImages && __( 'Gallery' ),
instructions: ! hasImages && PLACEHOLDER_TEXT,
} }
onSelect={ onSelectImages }
accept="image/*"
allowedTypes={ ALLOWED_MEDIA_TYPES }
multiple
value={ hasImageIds ? images : {} }
onError={ onUploadError }
notices={ hasImages ? undefined : noticeUI }
onFocus={ onFocus }
autoOpenMediaUpload={
! hasImages && isSelected && wasBlockJustInserted
}
/>
);
const blockProps = useBlockProps();
if ( ! hasImages ) {
return <View { ...blockProps }>{ mediaPlaceholder }</View>;
}
const imageSizeOptions = getImagesSizeOptions();
const shouldShowSizeOptions = hasImages && ! isEmpty( imageSizeOptions );
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Gallery settings' ) }>
{ images.length > 1 && (
<RangeControl
label={ __( 'Columns' ) }
value={ columns }
onChange={ setColumnsNumber }
min={ 1 }
max={ Math.min( MAX_COLUMNS, images.length ) }
{ ...MOBILE_CONTROL_PROPS_RANGE_CONTROL }
required
/>
) }
<ToggleControl
label={ __( 'Crop images' ) }
checked={ !! imageCrop }
onChange={ toggleImageCrop }
help={ getImageCropHelp }
/>
<SelectControl
label={ __( 'Link to' ) }
value={ linkTo }
onChange={ setLinkTo }
options={ linkOptions }
hideCancelButton={ true }
/>
{ shouldShowSizeOptions && (
<SelectControl
label={ __( 'Image size' ) }
value={ sizeSlug }
options={ imageSizeOptions }
onChange={ updateImagesSize }
hideCancelButton={ true }
/>
) }
</PanelBody>
</InspectorControls>
{ noticeUI }
<Gallery
{ ...props }
selectedImage={ selectedImage }
mediaPlaceholder={ mediaPlaceholder }
onMoveBackward={ onMoveBackward }
onMoveForward={ onMoveForward }
onRemoveImage={ onRemoveImage }
onSelectImage={ onSelectImage }
onDeselectImage={ onDeselectImage }
onSetImageAttributes={ setImageAttributes }
blockProps={ blockProps }
// This prop is used by gallery.native.js.
onFocusGalleryCaption={ onFocusGalleryCaption }
/>
</>
);
}
export default compose( [
withNotices,
withViewportMatch( { isNarrow: '< small' } ),
] )( GalleryEdit );

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { StyleSheet, TouchableOpacity } from 'react-native';
/**
* WordPress dependencies
*/
import { Icon } from '@wordpress/components';
/**
* Internal dependencies
*/
import style from './gallery-image-style.scss';
export function Button( props ) {
const {
icon,
iconSize = 24,
onClick,
disabled,
'aria-disabled': ariaDisabled,
accessibilityLabel = 'button',
style: customStyle,
} = props;
const buttonStyle = StyleSheet.compose( style.buttonActive, customStyle );
const isDisabled = disabled || ariaDisabled;
const { fill } = isDisabled ? style.buttonDisabled : style.button;
return (
<TouchableOpacity
style={ buttonStyle }
activeOpacity={ 0.7 }
accessibilityLabel={ accessibilityLabel }
accessibilityRole={ 'button' }
onPress={ onClick }
disabled={ isDisabled }
>
<Icon icon={ icon } fill={ fill } size={ iconSize } />
</TouchableOpacity>
);
}
export default Button;

View File

@@ -0,0 +1,109 @@
$gallery-image-container-height: 150px;
$overlay-border-width: 2px;
$caption-background-color: rgba(0, 0, 0, 0.4);
.galleryImageContainer {
flex: 1;
height: $gallery-image-container-height;
overflow: hidden;
background-color: $gray-lighten-30;
}
.galleryImageContainerDark {
background-color: $gray-90;
}
.image {
height: 100%;
}
.button {
fill: $gray-0;
width: 30px;
}
.buttonDisabled {
fill: $gray-30;
}
.buttonActive {
flex-direction: row;
justify-content: center;
align-items: center;
border-radius: 6px;
border-color: $gray-70;
background-color: $gray-70;
}
.moverButtonContainer {
flex-direction: row;
align-items: center;
border-radius: 3px;
background-color: $gray-70;
}
.separator {
border-right-color: $gray-30;
border-right-width: 1px;
height: 20px;
}
.toolbarContainer {
position: absolute;
}
.toolbar {
padding: 5px;
flex-direction: row;
justify-content: space-between;
}
.captionContainer {
flex: 1;
flex-direction: row;
align-items: flex-end;
position: absolute;
bottom: $overlay-border-width;
left: $overlay-border-width;
right: $overlay-border-width;
top: 45;
}
@mixin caption-shared {
font-size: 12px;
background-color: #0000;
color: #fff;
font-family: $default-regular-font;
text-align: center;
}
.caption {
@include caption-shared;
background-color: $caption-background-color;
padding-top: $grid-unit;
padding-bottom: $grid-unit;
}
.captionPlaceholder {
color: #ccc;
}
// expand caption container to compensate for overlay border
.captionExpandedContainer {
// constrain height to gallery image height for caption scroll
max-height: $gallery-image-container-height;
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding-top: $grid-unit;
padding-bottom: $overlay-border-width + $grid-unit;
padding-left: $overlay-border-width;
padding-right: $overlay-border-width;
// use caption background color on container when expanded
background-color: $caption-background-color;
}
.captionExpanded {
@include caption-shared;
}

View File

@@ -0,0 +1,283 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { get, omit } from 'lodash';
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { Button, Spinner, ButtonGroup } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { BACKSPACE, DELETE } from '@wordpress/keycodes';
import { withSelect, withDispatch } from '@wordpress/data';
import {
RichText,
MediaPlaceholder,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { isBlobURL } from '@wordpress/blob';
import { compose } from '@wordpress/compose';
import {
closeSmall,
chevronLeft,
chevronRight,
edit,
image as imageIcon,
} from '@wordpress/icons';
import { store as coreStore } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { pickRelevantMediaFiles } from './shared';
import {
LINK_DESTINATION_ATTACHMENT,
LINK_DESTINATION_MEDIA,
} from './constants';
const isTemporaryImage = ( id, url ) => ! id && isBlobURL( url );
class GalleryImage extends Component {
constructor() {
super( ...arguments );
this.onSelectImage = this.onSelectImage.bind( this );
this.onRemoveImage = this.onRemoveImage.bind( this );
this.bindContainer = this.bindContainer.bind( this );
this.onEdit = this.onEdit.bind( this );
this.onSelectImageFromLibrary = this.onSelectImageFromLibrary.bind(
this
);
this.onSelectCustomURL = this.onSelectCustomURL.bind( this );
this.state = {
isEditing: false,
};
}
bindContainer( ref ) {
this.container = ref;
}
onSelectImage() {
if ( ! this.props.isSelected ) {
this.props.onSelect();
}
}
onRemoveImage( event ) {
if (
this.container === this.container.ownerDocument.activeElement &&
this.props.isSelected &&
[ BACKSPACE, DELETE ].indexOf( event.keyCode ) !== -1
) {
event.preventDefault();
this.props.onRemove();
}
}
onEdit() {
this.setState( {
isEditing: true,
} );
}
componentDidUpdate() {
const {
image,
url,
__unstableMarkNextChangeAsNotPersistent,
} = this.props;
if ( image && ! url ) {
__unstableMarkNextChangeAsNotPersistent();
this.props.setAttributes( {
url: image.source_url,
alt: image.alt_text,
} );
}
}
deselectOnBlur() {
this.props.onDeselect();
}
onSelectImageFromLibrary( media ) {
const { setAttributes, id, url, alt, caption, sizeSlug } = this.props;
if ( ! media || ! media.url ) {
return;
}
let mediaAttributes = pickRelevantMediaFiles( media, sizeSlug );
// If the current image is temporary but an alt text was meanwhile
// written by the user, make sure the text is not overwritten.
if ( isTemporaryImage( id, url ) ) {
if ( alt ) {
mediaAttributes = omit( mediaAttributes, [ 'alt' ] );
}
}
// If a caption text was meanwhile written by the user,
// make sure the text is not overwritten by empty captions.
if ( caption && ! get( mediaAttributes, [ 'caption' ] ) ) {
mediaAttributes = omit( mediaAttributes, [ 'caption' ] );
}
setAttributes( mediaAttributes );
this.setState( {
isEditing: false,
} );
}
onSelectCustomURL( newURL ) {
const { setAttributes, url } = this.props;
if ( newURL !== url ) {
setAttributes( {
url: newURL,
id: undefined,
} );
this.setState( {
isEditing: false,
} );
}
}
render() {
const {
url,
alt,
id,
linkTo,
link,
isFirstItem,
isLastItem,
isSelected,
caption,
onRemove,
onMoveForward,
onMoveBackward,
setAttributes,
'aria-label': ariaLabel,
} = this.props;
const { isEditing } = this.state;
let href;
switch ( linkTo ) {
case LINK_DESTINATION_MEDIA:
href = url;
break;
case LINK_DESTINATION_ATTACHMENT:
href = link;
break;
}
const img = (
// Disable reason: Image itself is not meant to be interactive, but should
// direct image selection and unfocus caption fields.
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
<>
<img
src={ url }
alt={ alt }
data-id={ id }
onKeyDown={ this.onRemoveImage }
tabIndex="0"
aria-label={ ariaLabel }
ref={ this.bindContainer }
/>
{ isBlobURL( url ) && <Spinner /> }
</>
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions */
);
const className = classnames( {
'is-selected': isSelected,
'is-transient': isBlobURL( url ),
} );
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<figure
className={ className }
onClick={ this.onSelectImage }
onFocus={ this.onSelectImage }
>
{ ! isEditing && ( href ? <a href={ href }>{ img }</a> : img ) }
{ isEditing && (
<MediaPlaceholder
labels={ { title: __( 'Edit gallery image' ) } }
icon={ imageIcon }
onSelect={ this.onSelectImageFromLibrary }
onSelectURL={ this.onSelectCustomURL }
accept="image/*"
allowedTypes={ [ 'image' ] }
value={ { id, src: url } }
/>
) }
<ButtonGroup className="block-library-gallery-item__inline-menu is-left">
<Button
icon={ chevronLeft }
onClick={ isFirstItem ? undefined : onMoveBackward }
label={ __( 'Move image backward' ) }
aria-disabled={ isFirstItem }
disabled={ ! isSelected }
/>
<Button
icon={ chevronRight }
onClick={ isLastItem ? undefined : onMoveForward }
label={ __( 'Move image forward' ) }
aria-disabled={ isLastItem }
disabled={ ! isSelected }
/>
</ButtonGroup>
<ButtonGroup className="block-library-gallery-item__inline-menu is-right">
<Button
icon={ edit }
onClick={ this.onEdit }
label={ __( 'Replace image' ) }
disabled={ ! isSelected }
/>
<Button
icon={ closeSmall }
onClick={ onRemove }
label={ __( 'Remove image' ) }
disabled={ ! isSelected }
/>
</ButtonGroup>
{ ! isEditing && ( isSelected || caption ) && (
<RichText
tagName="figcaption"
aria-label={ __( 'Image caption text' ) }
placeholder={ isSelected ? __( 'Add caption' ) : null }
value={ caption }
onChange={ ( newCaption ) =>
setAttributes( { caption: newCaption } )
}
inlineToolbar
/>
) }
</figure>
);
}
}
export default compose( [
withSelect( ( select, ownProps ) => {
const { getMedia } = select( coreStore );
const { id } = ownProps;
return {
image: id ? getMedia( parseInt( id, 10 ) ) : null,
};
} ),
withDispatch( ( dispatch ) => {
const { __unstableMarkNextChangeAsNotPersistent } = dispatch(
blockEditorStore
);
return {
__unstableMarkNextChangeAsNotPersistent,
};
} ),
] )( GalleryImage );

View File

@@ -0,0 +1,358 @@
/**
* External dependencies
*/
import {
StyleSheet,
View,
ScrollView,
TouchableWithoutFeedback,
} from 'react-native';
import { isEmpty } from 'lodash';
/**
* WordPress dependencies
*/
import {
requestImageFailedRetryDialog,
requestImageUploadCancelDialog,
requestImageFullscreenPreview,
} from '@wordpress/react-native-bridge';
import { Component } from '@wordpress/element';
import { Image } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { Caption, MediaUploadProgress } from '@wordpress/block-editor';
import { getProtocol } from '@wordpress/url';
import { withPreferredColorScheme } from '@wordpress/compose';
import { arrowLeft, arrowRight, warning } from '@wordpress/icons';
/**
* Internal dependencies
*/
import Button from './gallery-button';
import style from './gallery-image-style.scss';
const { compose } = StyleSheet;
const separatorStyle = compose( style.separator, {
borderRightWidth: StyleSheet.hairlineWidth,
} );
const buttonStyle = compose( style.button, { aspectRatio: 1 } );
const ICON_SIZE_ARROW = 15;
class GalleryImage extends Component {
constructor() {
super( ...arguments );
this.onSelectImage = this.onSelectImage.bind( this );
this.onSelectCaption = this.onSelectCaption.bind( this );
this.onMediaPressed = this.onMediaPressed.bind( this );
this.onCaptionChange = this.onCaptionChange.bind( this );
this.onSelectMedia = this.onSelectMedia.bind( this );
this.updateMediaProgress = this.updateMediaProgress.bind( this );
this.finishMediaUploadWithSuccess = this.finishMediaUploadWithSuccess.bind(
this
);
this.finishMediaUploadWithFailure = this.finishMediaUploadWithFailure.bind(
this
);
this.renderContent = this.renderContent.bind( this );
this.state = {
captionSelected: false,
isUploadInProgress: false,
didUploadFail: false,
};
}
onSelectCaption() {
if ( ! this.state.captionSelected ) {
this.setState( {
captionSelected: true,
} );
}
if ( ! this.props.isSelected ) {
this.props.onSelect();
}
}
onMediaPressed() {
const { id, url, isSelected } = this.props;
const {
captionSelected,
isUploadInProgress,
didUploadFail,
} = this.state;
this.onSelectImage();
if ( isUploadInProgress ) {
requestImageUploadCancelDialog( id );
} else if (
didUploadFail ||
( id && getProtocol( url ) === 'file:' )
) {
requestImageFailedRetryDialog( id );
} else if ( isSelected && ! captionSelected ) {
requestImageFullscreenPreview( url );
}
}
onSelectImage() {
if ( ! this.props.isBlockSelected ) {
this.props.onSelectBlock();
}
if ( ! this.props.isSelected ) {
this.props.onSelect();
}
if ( this.state.captionSelected ) {
this.setState( {
captionSelected: false,
} );
}
}
onSelectMedia( media ) {
const { setAttributes } = this.props;
setAttributes( media );
}
onCaptionChange( caption ) {
const { setAttributes } = this.props;
setAttributes( { caption } );
}
componentDidUpdate( prevProps ) {
const { isSelected, image, url } = this.props;
if ( image && ! url ) {
this.props.setAttributes( {
url: image.source_url,
alt: image.alt_text,
} );
}
// Unselect the caption so when the user selects other image and comeback
// the caption is not immediately selected.
if (
this.state.captionSelected &&
! isSelected &&
prevProps.isSelected
) {
this.setState( {
captionSelected: false,
} );
}
}
updateMediaProgress() {
if ( ! this.state.isUploadInProgress ) {
this.setState( { isUploadInProgress: true } );
}
}
finishMediaUploadWithSuccess( payload ) {
this.setState( {
isUploadInProgress: false,
didUploadFail: false,
} );
this.props.setAttributes( {
id: payload.mediaServerId,
url: payload.mediaUrl,
} );
}
finishMediaUploadWithFailure() {
this.setState( {
isUploadInProgress: false,
didUploadFail: true,
} );
}
renderContent( params ) {
const {
url,
isFirstItem,
isLastItem,
isSelected,
caption,
onRemove,
onMoveForward,
onMoveBackward,
'aria-label': ariaLabel,
isCropped,
getStylesFromColorScheme,
isRTL,
} = this.props;
const { isUploadInProgress, captionSelected } = this.state;
const { isUploadFailed, retryMessage } = params;
const resizeMode = isCropped ? 'cover' : 'contain';
const captionPlaceholderStyle = getStylesFromColorScheme(
style.captionPlaceholder,
style.captionPlaceholderDark
);
const shouldShowCaptionEditable = ! isUploadFailed && isSelected;
const shouldShowCaptionExpanded =
! isUploadFailed && ! isSelected && !! caption;
const captionContainerStyle = shouldShowCaptionExpanded
? style.captionExpandedContainer
: style.captionContainer;
const captionStyle = shouldShowCaptionExpanded
? style.captionExpanded
: style.caption;
const mediaPickerOptions = [
{
destructiveButton: true,
id: 'removeImage',
label: __( 'Remove' ),
onPress: onRemove,
separated: true,
value: 'removeImage',
},
];
return (
<>
<Image
alt={ ariaLabel }
height={ style.image.height }
isSelected={ isSelected }
isUploadFailed={ isUploadFailed }
isUploadInProgress={ isUploadInProgress }
mediaPickerOptions={ mediaPickerOptions }
onSelectMediaUploadOption={ this.onSelectMedia }
resizeMode={ resizeMode }
url={ url }
retryMessage={ retryMessage }
retryIcon={ warning }
/>
{ ! isUploadInProgress && isSelected && (
<View style={ style.toolbarContainer }>
<View style={ style.toolbar }>
<View style={ style.moverButtonContainer }>
<Button
style={ buttonStyle }
icon={ isRTL ? arrowRight : arrowLeft }
iconSize={ ICON_SIZE_ARROW }
onClick={
isFirstItem ? undefined : onMoveBackward
}
accessibilityLabel={ __(
'Move Image Backward'
) }
aria-disabled={ isFirstItem }
disabled={ ! isSelected }
/>
<View style={ separatorStyle } />
<Button
style={ buttonStyle }
icon={ isRTL ? arrowLeft : arrowRight }
iconSize={ ICON_SIZE_ARROW }
onClick={
isLastItem ? undefined : onMoveForward
}
accessibilityLabel={ __(
'Move Image Forward'
) }
aria-disabled={ isLastItem }
disabled={ ! isSelected }
/>
</View>
</View>
</View>
) }
{ ! isUploadInProgress &&
( shouldShowCaptionEditable ||
shouldShowCaptionExpanded ) && (
<View style={ captionContainerStyle }>
<ScrollView
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
bounces={ false }
>
<Caption
inlineToolbar
isSelected={ isSelected && captionSelected }
onChange={ this.onCaptionChange }
onFocus={ this.onSelectCaption }
placeholder={
isSelected ? __( 'Add caption' ) : null
}
placeholderTextColor={
captionPlaceholderStyle.color
}
style={ captionStyle }
value={ caption }
/>
</ScrollView>
</View>
) }
</>
);
}
render() {
const {
id,
onRemove,
getStylesFromColorScheme,
isSelected,
} = this.props;
const containerStyle = getStylesFromColorScheme(
style.galleryImageContainer,
style.galleryImageContainerDark
);
return (
<TouchableWithoutFeedback
onPress={ this.onMediaPressed }
accessible={ ! isSelected } // We need only child views to be accessible after the selection.
accessibilityLabel={ this.accessibilityLabelImageContainer() } // if we don't set this explicitly it reads system provided accessibilityLabels of all child components and those include pretty technical words which don't make sense
accessibilityRole={ 'imagebutton' } // this makes VoiceOver to read a description of image provided by system on iOS and lets user know this is a button which conveys the message of tappablity
>
<View style={ containerStyle }>
<MediaUploadProgress
mediaId={ id }
onUpdateMediaProgress={ this.updateMediaProgress }
onFinishMediaUploadWithSuccess={
this.finishMediaUploadWithSuccess
}
onFinishMediaUploadWithFailure={
this.finishMediaUploadWithFailure
}
onMediaUploadStateReset={ onRemove }
renderContent={ this.renderContent }
/>
</View>
</TouchableWithoutFeedback>
);
}
accessibilityLabelImageContainer() {
const { caption, 'aria-label': ariaLabel } = this.props;
return isEmpty( caption )
? ariaLabel
: ariaLabel +
'. ' +
sprintf(
/* translators: accessibility text. %s: image caption. */
__( 'Image caption. %s' ),
caption
);
}
}
export default withPreferredColorScheme( GalleryImage );

View File

@@ -0,0 +1,8 @@
.galleryTilesContainerSelected {
margin-bottom: 16px;
}
.fullWidth {
margin-left: $block-edge-to-content;
margin-right: $block-edge-to-content;
}

View File

@@ -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 (
<figure
{ ...blockProps }
className={ classnames( blockProps.className, {
[ `align${ align }` ]: align,
[ `columns-${ columns }` ]: columns,
'is-cropped': imageCrop,
} ) }
>
<ul className="blocks-gallery-grid">
{ 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 (
<li
className="blocks-gallery-item"
key={ img.id ? `${ img.id }-${ index }` : img.url }
>
<GalleryImage
url={ img.url }
alt={ img.alt }
id={ img.id }
isFirstItem={ index === 0 }
isLastItem={ index + 1 === images.length }
isSelected={
isSelected && selectedImage === index
}
onMoveBackward={ onMoveBackward( index ) }
onMoveForward={ onMoveForward( index ) }
onRemove={ onRemoveImage( index ) }
onSelect={ onSelectImage( index ) }
onDeselect={ onDeselectImage( index ) }
setAttributes={ ( attrs ) =>
onSetImageAttributes( index, attrs )
}
caption={ img.caption }
aria-label={ ariaLabel }
sizeSlug={ attributes.sizeSlug }
/>
</li>
);
} ) }
</ul>
{ mediaPlaceholder }
<RichTextVisibilityHelper
isHidden={ ! isSelected && RichText.isEmpty( caption ) }
tagName="figcaption"
className="blocks-gallery-caption"
aria-label={ __( 'Gallery caption text' ) }
placeholder={ __( 'Write gallery caption…' ) }
value={ caption }
onChange={ ( value ) => setAttributes( { caption: value } ) }
inlineToolbar
__unstableOnSplitAtEnd={ () =>
insertBlocksAfter( createBlock( 'core/paragraph' ) )
}
/>
</figure>
);
};
function RichTextVisibilityHelper( { isHidden, ...richTextProps } ) {
return isHidden ? (
<VisuallyHidden as={ RichText } { ...richTextProps } />
) : (
<RichText { ...richTextProps } />
);
}
export default Gallery;

View File

@@ -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 (
<View style={ { flex: 1 } }>
<Tiles
columns={ displayedColumns }
spacing={ TILE_SPACING }
style={
isSelected
? styles.galleryTilesContainerSelected
: undefined
}
>
{ 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 (
<GalleryImage
key={ img.id ? `${ img.id }-${ index }` : img.url }
url={ img.url }
alt={ img.alt }
id={ parseInt( img.id, 10 ) } // make id an integer explicitly
isCropped={ imageCrop }
isFirstItem={ index === 0 }
isLastItem={ index + 1 === images.length }
isSelected={ isSelected && selectedImage === index }
isBlockSelected={ isSelected }
onMoveBackward={ onMoveBackward( index ) }
onMoveForward={ onMoveForward( index ) }
onRemove={ onRemoveImage( index ) }
onSelect={ selectImage( index ) }
onSelectBlock={ onFocus }
setAttributes={ ( attrs ) =>
onSetImageAttributes( index, attrs )
}
caption={ img.caption }
aria-label={ ariaLabel }
isRTL={ isRTL }
/>
);
} ) }
</Tiles>
<View style={ isFullWidth( align ) && styles.fullWidth }>
{ mediaPlaceholder }
</View>
<BlockCaption
clientId={ clientId }
isSelected={ isCaptionSelected }
accessible={ true }
accessibilityLabelCreator={ ( caption ) =>
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 }
/>
</View>
);
};
export default Gallery;

View File

@@ -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 (
<figure { ...useBlockProps.save( { className } ) }>
<ul className="blocks-gallery-grid">
{ 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 = (
<img
src={ image.url }
alt={ image.alt }
data-id={ image.id }
data-full-url={ image.fullUrl }
data-link={ image.link }
className={
image.id ? `wp-image-${ image.id }` : null
}
/>
);
return (
<li
key={ image.id || image.url }
className="blocks-gallery-item"
>
<figure>
{ href ? <a href={ href }>{ img }</a> : img }
{ ! RichText.isEmpty( image.caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-item__caption"
value={ image.caption }
/>
) }
</figure>
</li>
);
} ) }
</ul>
{ ! RichText.isEmpty( caption ) && (
<RichText.Content
tagName="figcaption"
className="blocks-gallery-caption"
value={ caption }
/>
) }
</figure>
);
}

View File

@@ -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;
};

View File

@@ -0,0 +1,11 @@
.containerStyle {
flex-direction: row;
flex-wrap: wrap;
}
.tileStyle {
overflow: hidden;
flex-direction: row;
align-items: center;
border-color: transparent;
}

View File

@@ -0,0 +1,79 @@
/**
* External dependencies
*/
import { View, StyleSheet } from 'react-native';
/**
* WordPress dependencies
*/
import { Children } from '@wordpress/element';
/**
* Internal dependencies
*/
import styles from './tiles-styles.scss';
function Tiles( props ) {
const { columns, children, spacing = 10, style } = props;
const { compose } = StyleSheet;
const tileCount = Children.count( children );
const lastTile = tileCount - 1;
const lastRow = Math.floor( lastTile / columns );
const wrappedChildren = Children.map( children, ( child, index ) => {
/**
* 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 row = Math.floor( index / columns );
const rowLength =
row === lastRow ? ( lastTile % columns ) + 1 : columns;
const indexInRow = index % columns;
return (
<View
style={ [
styles.tileStyle,
{
width: `${ 100 / rowLength }%`,
paddingLeft: spacing * ( indexInRow / rowLength ),
paddingRight:
spacing * ( 1 - ( indexInRow + 1 ) / rowLength ),
paddingTop: row === 0 ? 0 : spacing / 2,
paddingBottom: row === lastRow ? 0 : spacing / 2,
},
] }
>
{ child }
</View>
);
} );
const containerStyle = compose( styles.containerStyle, style );
return <View style={ containerStyle }>{ wrappedChildren }</View>;
}
export default Tiles;