diff --git a/extensions/blocks/logo-gallery/edit.js b/extensions/blocks/logo-gallery/edit.js
new file mode 100644
index 0000000000000..a30ee294c8dd5
--- /dev/null
+++ b/extensions/blocks/logo-gallery/edit.js
@@ -0,0 +1,306 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+import { filter, pick, map, get } from 'lodash';
+import {
+ IconButton,
+ PanelBody,
+ Toolbar,
+ withNotices,
+ DropZone,
+ FormFileUpload,
+} from '@wordpress/components';
+import {
+ BlockControls,
+ BlockIcon,
+ MediaPlaceholder,
+ MediaUpload,
+ mediaUpload,
+ InspectorControls,
+} from '@wordpress/editor';
+import { compose } from '@wordpress/compose';
+import { withDispatch } from '@wordpress/data';
+import { isBlobURL } from '@wordpress/blob';
+import { Component, Fragment } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import Logo from './logo';
+import { default as icon, smallSizeIcon, mediumSizeIcon, largeSizeIcon } from './icons';
+import './editor.scss';
+
+const ALLOWED_MEDIA_TYPES = [ 'image' ];
+
+export const pickRelevantMediaFiles = image => {
+ const imageProps = pick( image, [ 'alt', 'id', 'link' ] );
+ imageProps.url =
+ get( image, [ 'sizes', 'large', 'url' ] ) ||
+ get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) ||
+ image.url;
+ return imageProps;
+};
+
+class GalleryEdit extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.onSelectImage = this.onSelectImage.bind( this );
+ this.onSelectImages = this.onSelectImages.bind( this );
+ this.onRemoveImage = this.onRemoveImage.bind( this );
+ this.setImageAttributes = this.setImageAttributes.bind( this );
+ this.setAttributes = this.setAttributes.bind( this );
+ this.addFiles = this.addFiles.bind( this );
+ this.uploadFromFiles = this.uploadFromFiles.bind( this );
+
+ this.state = {
+ selectedImage: null,
+ };
+ }
+
+ setAttributes( attributes ) {
+ if ( attributes.ids ) {
+ throw new Error(
+ 'The "ids" attribute should not be changed directly. It is managed automatically when "images" attribute changes'
+ );
+ }
+
+ if ( attributes.images ) {
+ attributes = {
+ ...attributes,
+ ids: map( attributes.images, 'id' ),
+ };
+ }
+
+ this.props.setAttributes( attributes );
+ }
+
+ onSelectImage( index ) {
+ return () => {
+ if ( this.state.selectedImage !== index ) {
+ this.setState( {
+ selectedImage: index,
+ } );
+ }
+ };
+ }
+
+ onRemoveImage( index ) {
+ return () => {
+ const images = filter( this.props.attributes.images, ( img, i ) => index !== i );
+ this.setState( { selectedImage: null } );
+ this.setAttributes( {
+ images,
+ } );
+ };
+ }
+
+ onSelectImages( images ) {
+ this.setAttributes( {
+ images: images.map( image => pickRelevantMediaFiles( image ) ),
+ } );
+ }
+
+ addFiles = files => {
+ const currentImages = this.props.attributes.images || [];
+ const { lockPostSaving, unlockPostSaving, noticeOperations, setAttributes } = this.props;
+ const lockName = 'logoGalleryBlockLock';
+ lockPostSaving( lockName );
+ mediaUpload( {
+ allowedTypes: ALLOWED_MEDIA_TYPES,
+ filesList: files,
+ onFileChange: images => {
+ const imagesNormalized = images.map( image => pickRelevantMediaFiles( image ) );
+ setAttributes( {
+ images: [ ...currentImages, ...imagesNormalized ],
+ } );
+ if ( ! imagesNormalized.every( image => isBlobURL( image.url ) ) ) {
+ unlockPostSaving( lockName );
+ }
+ },
+ onError: noticeOperations.createErrorNotice,
+ } );
+ };
+
+ uploadFromFiles = event => this.addFiles( event.target.files );
+
+ setImageAttributes( index, attributes ) {
+ const {
+ attributes: { images },
+ } = this.props;
+ const { setAttributes } = this;
+ if ( ! images[ index ] ) {
+ return;
+ }
+ setAttributes( {
+ images: [
+ ...images.slice( 0, index ),
+ {
+ ...images[ index ],
+ ...attributes,
+ },
+ ...images.slice( index + 1 ),
+ ],
+ } );
+ }
+
+ componentDidUpdate( prevProps ) {
+ // Deselect images when deselecting the block
+ if ( ! this.props.isSelected && prevProps.isSelected ) {
+ // https://reactjs.org/docs/react-component.html#componentdidupdate
+ // eslint-disable-next-line react/no-did-update-set-state
+ this.setState( {
+ selectedImage: null,
+ } );
+ }
+ }
+
+ render() {
+ const {
+ attributes,
+ isSelected,
+ className,
+ noticeOperations,
+ noticeUI,
+ setAttributes,
+ } = this.props;
+ const { logoSize, images } = attributes;
+ const hasImages = !! images.length;
+
+ const controls = (
+
+ { hasImages && (
+
+ img.id ) }
+ render={ ( { open } ) => (
+
+ ) }
+ />
+
+ ) }
+
+ );
+
+ const toolbarControls = [
+ {
+ icon: smallSizeIcon,
+ title: __( 'Small size', 'jetpack' ),
+ isActive: logoSize === 'small',
+ onClick: () => setAttributes( { logoSize: 'small' } ),
+ },
+ {
+ icon: mediumSizeIcon,
+ title: __( 'Medium size', 'jetpack' ),
+ isActive: logoSize === 'medium',
+ onClick: () => setAttributes( { logoSize: 'medium' } ),
+ },
+ {
+ icon: largeSizeIcon,
+ title: __( 'Large size', 'jetpack' ),
+ isActive: logoSize === 'large',
+ onClick: () => setAttributes( { logoSize: 'large' } ),
+ },
+ ];
+
+ if ( ! hasImages ) {
+ return (
+
+ { controls }
+ }
+ className={ className }
+ labels={ {
+ title: __( 'Logo Gallery', 'jetpack' ),
+ instructions: __(
+ 'Drag images, upload new ones or select files from your library.',
+ 'jetpack'
+ ),
+ } }
+ onSelect={ this.onSelectImages }
+ accept="image/*"
+ allowedTypes={ ALLOWED_MEDIA_TYPES }
+ multiple
+ notices={ noticeUI }
+ onError={ noticeOperations.createErrorNotice }
+ />
+
+ );
+ }
+
+ return (
+
+ { controls }
+
+
+ { __( 'Logo size', 'jetpack' ) }
+
+
+
+ { noticeUI }
+
+ { images.map( ( img, index ) => {
+ /* translators: %1$d is the order number of the image, %2$d is the total number of images. */
+ const ariaLabel = sprintf(
+ __( 'image %1$d of %2$d in gallery', 'jetpack' ),
+ index + 1,
+ images.length
+ );
+
+ return (
+
+ this.setImageAttributes( index, attrs ) }
+ aria-label={ ariaLabel }
+ />
+
+ );
+ } ) }
+
+
+ { isSelected && (
+
+
+ { __( 'Add a logo', 'jetpack' ) }
+
+
+ ) }
+
+ );
+ }
+}
+
+export default compose(
+ withDispatch( dispatch => {
+ const { lockPostSaving, unlockPostSaving } = dispatch( 'core/editor' );
+ return {
+ lockPostSaving,
+ unlockPostSaving,
+ };
+ } ),
+ withNotices
+)( GalleryEdit );
diff --git a/extensions/blocks/logo-gallery/editor.js b/extensions/blocks/logo-gallery/editor.js
new file mode 100644
index 0000000000000..babee275335fe
--- /dev/null
+++ b/extensions/blocks/logo-gallery/editor.js
@@ -0,0 +1,7 @@
+/**
+ * Internal dependencies
+ */
+import registerJetpackBlock from '../../utils/register-jetpack-block';
+import { name, settings } from '.';
+
+registerJetpackBlock( name, settings );
diff --git a/extensions/blocks/logo-gallery/editor.scss b/extensions/blocks/logo-gallery/editor.scss
new file mode 100644
index 0000000000000..7ae5943b0ce30
--- /dev/null
+++ b/extensions/blocks/logo-gallery/editor.scss
@@ -0,0 +1,92 @@
+@import './style.scss';
+
+// Override the default list style type _only in the editor_
+// to avoid :not() selector specificity issues.
+// See https://github.com/WordPress/gutenberg/pull/10358
+ul.wp-block-jetpack-logo-gallery li {
+ list-style-type: none;
+}
+
+.logo-gallery-item {
+ position: relative;
+
+ // Hide the focus outline that otherwise briefly appears when selecting a block.
+ figure:not(.is-selected):focus {
+ outline: none;
+ }
+
+ img:focus,
+ .is-selected {
+ outline: 4px solid $logo-gallery-selection;
+ }
+
+ .is-transient img {
+ opacity: 0.3;
+ }
+
+}
+
+.wp-block-jetpack-logo-gallery__add-item {
+ margin-top: 4px;
+ width: 100%;
+
+ .components-form-file-upload,
+ .components-button.wp-block-jetpack-logo-gallery__add-item-button {
+ width: 100%;
+ height: 100%;
+ }
+
+ .components-button.wp-block-jetpack-logo-gallery__add-item-button {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ box-shadow: none;
+ border: none;
+ border-radius: 0;
+ min-height: 100px;
+
+ .dashicon {
+ margin-top: 10px;
+ }
+
+ &:hover,
+ &:focus {
+ border: 1px solid $logo-gallery-button-border;
+ }
+ }
+}
+
+.logo-gallery-item__inline-menu {
+ padding: 2px;
+ position: absolute;
+ top: -2px;
+ right: -2px;
+ background-color: $logo-gallery-selection;
+ display: inline-flex;
+ z-index: 20;
+
+ .components-button {
+ color: $logo-gallery-selection-color;
+
+ &:hover,
+ &:focus {
+ color: $logo-gallery-selection-color;
+ }
+ }
+}
+
+.logo-gallery-item__remove {
+ padding: 0;
+
+ &.components-button:focus {
+ color: inherit;
+ }
+}
+
+.logo-gallery-item .components-spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -9px;
+ margin-left: -9px;
+}
diff --git a/extensions/blocks/logo-gallery/icons.js b/extensions/blocks/logo-gallery/icons.js
new file mode 100644
index 0000000000000..651396196e3ec
--- /dev/null
+++ b/extensions/blocks/logo-gallery/icons.js
@@ -0,0 +1,32 @@
+/**
+ * External dependencies
+ */
+import { Path, SVG } from '@wordpress/components';
+
+export default (
+
+
+
+
+);
+
+export const smallSizeIcon = (
+
+
+
+
+);
+
+export const mediumSizeIcon = (
+
+
+
+
+);
+
+export const largeSizeIcon = (
+
+
+
+
+);
diff --git a/extensions/blocks/logo-gallery/index.js b/extensions/blocks/logo-gallery/index.js
new file mode 100644
index 0000000000000..4e9999afa2774
--- /dev/null
+++ b/extensions/blocks/logo-gallery/index.js
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+import transforms from './transforms';
+import { default as icon } from './icons';
+
+const blockAttributes = {
+ logoSize: {
+ type: 'string',
+ default: 'medium',
+ },
+ ids: {
+ default: [],
+ type: 'array',
+ },
+ images: {
+ type: 'array',
+ default: [],
+ source: 'query',
+ selector: 'ul.wp-block-jetpack-logo-gallery .logo-gallery-item',
+ query: {
+ url: {
+ source: 'attribute',
+ selector: 'img',
+ attribute: 'src',
+ },
+ link: {
+ source: 'attribute',
+ selector: 'img',
+ attribute: 'data-link',
+ },
+ alt: {
+ source: 'attribute',
+ selector: 'img',
+ attribute: 'alt',
+ default: '',
+ },
+ id: {
+ source: 'attribute',
+ selector: 'img',
+ attribute: 'data-id',
+ },
+ },
+ },
+};
+
+export const name = 'logo-gallery';
+
+export const settings = {
+ title: __( 'Logo Gallery', 'jetpack' ),
+ category: 'jetpack',
+ keywords: [ __( 'logo', 'jetpack' ), __( 'gallery', 'jetpack' ), __( 'image', 'jetpack' ) ],
+ description: __( 'Display multiple logos in a row.', 'jetpack' ),
+ attributes: blockAttributes,
+ supports: {
+ align: [ 'wide', 'full' ],
+ html: false,
+ },
+ icon,
+ edit,
+ save,
+ transforms,
+};
diff --git a/extensions/blocks/logo-gallery/logo-gallery.php b/extensions/blocks/logo-gallery/logo-gallery.php
new file mode 100644
index 0000000000000..ef6cf9c33e625
--- /dev/null
+++ b/extensions/blocks/logo-gallery/logo-gallery.php
@@ -0,0 +1,31 @@
+ 'jetpack_logo_gallery_block_load_assets',
+ )
+);
+
+/**
+ * Logo gallery block registration/dependency declaration.
+ *
+ * @param array $attr Array containing the logo gallery block attributes.
+ * @param string $content String containing the logo gallery block content.
+ *
+ * @return string
+ */
+function jetpack_logo_gallery_block_load_assets( $attr, $content ) {
+ $dependencies = array();
+
+ Jetpack_Gutenberg::load_assets_as_required( 'logo-gallery', $dependencies );
+
+ return $content;
+}
diff --git a/extensions/blocks/logo-gallery/logo.js b/extensions/blocks/logo-gallery/logo.js
new file mode 100644
index 0000000000000..758328a71178b
--- /dev/null
+++ b/extensions/blocks/logo-gallery/logo.js
@@ -0,0 +1,117 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { Component, Fragment } from '@wordpress/element';
+import { IconButton, Spinner } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { BACKSPACE, DELETE } from '@wordpress/keycodes';
+import { withSelect } from '@wordpress/data';
+import { isBlobURL } from '@wordpress/blob';
+
+class Logo extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.onImageClick = this.onImageClick.bind( this );
+ this.onKeyDown = this.onKeyDown.bind( this );
+ this.bindContainer = this.bindContainer.bind( this );
+ }
+
+ bindContainer( ref ) {
+ this.container = ref;
+ }
+
+ onImageClick() {
+ if ( ! this.props.isSelected ) {
+ this.props.onSelect();
+ }
+ }
+
+ onKeyDown( event ) {
+ if (
+ this.container === document.activeElement &&
+ this.props.isSelected &&
+ [ BACKSPACE, DELETE ].indexOf( event.keyCode ) !== -1
+ ) {
+ event.stopPropagation();
+ event.preventDefault();
+ this.props.onRemove();
+ }
+ }
+
+ componentDidUpdate() {
+ const { image, url } = this.props;
+ if ( image && ! url ) {
+ this.props.setAttributes( {
+ url: image.source_url,
+ alt: image.alt_text,
+ } );
+ }
+ }
+
+ render() {
+ const { url, alt, id, isSelected, onRemove, 'aria-label': ariaLabel } = this.props;
+
+ const img = (
+ // Disable reason: Image itself is not meant to be interactive, but should
+ // direct image selection and unfocus link fields.
+ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
+
+
+ { isBlobURL( url ) && }
+
+ /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
+ );
+
+ const className = classnames( {
+ 'is-selected': isSelected,
+ 'is-transient': isBlobURL( url ),
+ } );
+
+ // Disable reason: Each block can be selected by clicking on it and we should keep the same saved markup
+ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */
+ return (
+
+ { isSelected && (
+
+
+
+ ) }
+ { img }
+
+ );
+ /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */
+ }
+}
+
+export default withSelect( ( select, ownProps ) => {
+ const { getMedia } = select( 'core' );
+ const { id } = ownProps;
+
+ return {
+ image: id ? getMedia( id ) : null,
+ };
+} )( Logo );
diff --git a/extensions/blocks/logo-gallery/save.js b/extensions/blocks/logo-gallery/save.js
new file mode 100644
index 0000000000000..590dcfa11df57
--- /dev/null
+++ b/extensions/blocks/logo-gallery/save.js
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import classNames from 'classnames';
+
+export default ( { attributes: { images, logoSize }, className } ) => (
+
+ { images.map( image => {
+ const img = (
+
+ );
+
+ return (
+
+ { img }
+
+ );
+ } ) }
+
+);
diff --git a/extensions/blocks/logo-gallery/style.scss b/extensions/blocks/logo-gallery/style.scss
new file mode 100644
index 0000000000000..f22e301d1090f
--- /dev/null
+++ b/extensions/blocks/logo-gallery/style.scss
@@ -0,0 +1,41 @@
+//block variables
+$logo-gallery-selection: #0085ba; // Gutenberg primary theme color (https://github.com/WordPress/gutenberg/blob/6928e41c8afd7daa3a709afdda7eee48218473b7/bin/packages/post-css-config.js#L4)
+$logo-gallery-selection-color: #fff;
+$logo-gallery-button-border: #555d66;
+
+.wp-block-jetpack-logo-gallery {
+ list-style-type: none;
+ padding: 0;
+ text-align: center;
+
+ .logo-gallery-item {
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0 16px 16px 0; // Add space between thumbnails
+
+ figure {
+ margin: 0;
+ height: 100%;
+ }
+
+ img {
+ display: block;
+ width: 100%;
+ max-width: 180px;
+ height: auto;
+ }
+ }
+
+ // Last item always needs margins reset.
+ .logo-gallery-item:last-child {
+ margin-right: 0;
+ }
+}
+
+.wp-block-jetpack-logo-gallery.is-small .logo-gallery-item img {
+ max-width: 120px;
+}
+
+.wp-block-jetpack-logo-gallery.is-large .logo-gallery-item img {
+ max-width: 240px;
+}
\ No newline at end of file
diff --git a/extensions/blocks/logo-gallery/transforms.js b/extensions/blocks/logo-gallery/transforms.js
new file mode 100644
index 0000000000000..5b2b31091cd81
--- /dev/null
+++ b/extensions/blocks/logo-gallery/transforms.js
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { filter } from 'lodash';
+import { createBlock } from '@wordpress/blocks';
+
+const transforms = {
+ from: [
+ {
+ type: 'block',
+ blocks: [ 'core/gallery', 'jetpack/tiled-gallery', 'jetpack/slideshow' ],
+ transform: attributes => {
+ const validImages = filter( attributes.images, ( { id, url } ) => id && url );
+ if ( validImages.length > 0 ) {
+ return createBlock( 'jetpack/logo-gallery', {
+ images: validImages.map( ( { id, url, alt } ) => ( {
+ id,
+ url,
+ alt,
+ } ) ),
+ } );
+ }
+ return createBlock( 'jetpack/logo-gallery' );
+ },
+ },
+ ],
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'core/gallery' ],
+ transform: ( { images } ) => createBlock( 'core/gallery', { images } ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/tiled-gallery' ],
+ transform: ( { images } ) => createBlock( 'jetpack/tiled-gallery', { images }, [] ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'jetpack/slideshow' ],
+ transform: ( { images } ) => createBlock( 'jetpack/slideshow', { images }, [] ),
+ },
+ {
+ type: 'block',
+ blocks: [ 'core/image' ],
+ transform: ( { images } ) => {
+ if ( images.length > 0 ) {
+ return images.map( ( { id, url, alt } ) =>
+ createBlock( 'core/image', { id, url, alt } )
+ );
+ }
+ return createBlock( 'core/image' );
+ },
+ },
+ ],
+};
+
+export default transforms;
diff --git a/extensions/blocks/logo-gallery/view.js b/extensions/blocks/logo-gallery/view.js
new file mode 100644
index 0000000000000..6a6dda31712c5
--- /dev/null
+++ b/extensions/blocks/logo-gallery/view.js
@@ -0,0 +1,4 @@
+/**
+ * Internal dependencies
+ */
+import './style.scss';
diff --git a/extensions/setup/index.json b/extensions/setup/index.json
index 108839f8eb36c..263649cf5c826 100644
--- a/extensions/setup/index.json
+++ b/extensions/setup/index.json
@@ -18,5 +18,5 @@
"videopress",
"wordads"
],
- "beta": [ "likes", "sharing", "seo" ]
+ "beta": [ "likes", "sharing", "seo", "logo-gallery" ]
}