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 } + + + { 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 } ) => ( + +); 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" ] }