diff --git a/src/wp-admin/css/widgets.css b/src/wp-admin/css/widgets.css index f31b63c881..e291590e70 100644 --- a/src/wp-admin/css/widgets.css +++ b/src/wp-admin/css/widgets.css @@ -87,7 +87,7 @@ .media-widget-control .placeholder { border: 1px dashed #b4b9be; box-sizing: border-box; - cursor: default; + cursor: pointer; line-height: 20px; padding: 9px 0; position: relative; @@ -162,6 +162,71 @@ margin: 1em 0; } +.media-widget-gallery-preview { + display: -webkit-box; + display: flex; + -webkit-box-pack: start; + justify-content: flex-start; + flex-wrap: wrap; +} + +.media-widget-preview.media_gallery, +.media-widget-preview.media_image { + cursor: pointer; +} + +.media-widget-gallery-preview .gallery-item { + box-sizing: border-box; + width: 50%; + margin: 0; + padding: 1.79104477%; +} + +/* + * Use targeted nth-last-child selectors to control the size of each image + * based on how many gallery items are present in the grid. + * See: https://alistapart.com/article/quantity-queries-for-css + */ +.media-widget-gallery-preview .gallery-item:nth-last-child(3):first-child, +.media-widget-gallery-preview .gallery-item:nth-last-child(3):first-child ~ .gallery-item, +.media-widget-gallery-preview .gallery-item:nth-last-child(n+5), +.media-widget-gallery-preview .gallery-item:nth-last-child(n+5) ~ .gallery-item, +.media-widget-gallery-preview .gallery-item:nth-last-child(n+6), +.media-widget-gallery-preview .gallery-item:nth-last-child(n+6) ~ .gallery-item { + max-width: 33.33%; +} + +.media-widget-gallery-preview .gallery-item img { + height: auto; + vertical-align: bottom; +} + +.media-widget-gallery-preview .gallery-icon { + position: relative; +} + +.media-widget-gallery-preview .gallery-icon-placeholder { + position: absolute; + top: 0; + bottom: 0; + width: 100%; + box-sizing: border-box; + display: -webkit-box; + display: flex; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: center; + justify-content: center; + background-color: rgba( 0, 0, 0, .5 ); +} + +.media-widget-gallery-preview .gallery-icon-placeholder-text { + font-weight: 600; + font-size: 2em; + color: white; +} + + /* Widget Dragging Helpers */ .widget.ui-draggable-dragging { min-width: 100%; diff --git a/src/wp-admin/js/widgets/media-gallery-widget.js b/src/wp-admin/js/widgets/media-gallery-widget.js new file mode 100644 index 0000000000..f569968e89 --- /dev/null +++ b/src/wp-admin/js/widgets/media-gallery-widget.js @@ -0,0 +1,325 @@ +/* eslint consistent-this: [ "error", "control" ] */ +(function( component ) { + 'use strict'; + + var GalleryWidgetModel, GalleryWidgetControl, GalleryDetailsMediaFrame; + + /** + * Custom gallery details frame. + * + * @since 4.9.0 + * @class GalleryDetailsMediaFrame + * @constructor + */ + GalleryDetailsMediaFrame = wp.media.view.MediaFrame.Post.extend( { + + /** + * Create the default states. + * + * @since 4.9.0 + * @returns {void} + */ + createStates: function createStates() { + this.states.add([ + new wp.media.controller.Library({ + id: 'gallery', + title: wp.media.view.l10n.createGalleryTitle, + priority: 40, + toolbar: 'main-gallery', + filterable: 'uploaded', + multiple: 'add', + editable: true, + + library: wp.media.query( _.defaults({ + type: 'image' + }, this.options.library ) ) + }), + + // Gallery states. + new wp.media.controller.GalleryEdit({ + library: this.options.selection, + editing: this.options.editing, + menu: 'gallery' + }), + + new wp.media.controller.GalleryAdd() + ]); + } + } ); + + /** + * Gallery widget model. + * + * See WP_Widget_Gallery::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @since 4.9.0 + * @class GalleryWidgetModel + * @constructor + */ + GalleryWidgetModel = component.MediaWidgetModel.extend( {} ); + + /** + * Gallery widget control. + * + * See WP_Widget_Gallery::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @since 4.9.0 + * @class GalleryWidgetControl + * @constructor + */ + GalleryWidgetControl = component.MediaWidgetControl.extend( { + + /** + * View events. + * + * @since 4.9.0 + * @type {object} + */ + events: _.extend( {}, component.MediaWidgetControl.prototype.events, { + 'click .media-widget-gallery-preview': 'editMedia' + } ), + + /** + * Initialize. + * + * @since 4.9.0 + * @param {Object} options - Options. + * @param {Backbone.Model} options.model - Model. + * @param {jQuery} options.el - Control field container element. + * @param {jQuery} options.syncContainer - Container element where fields are synced for the server. + * @returns {void} + */ + initialize: function initialize( options ) { + var control = this; + + component.MediaWidgetControl.prototype.initialize.call( control, options ); + + _.bindAll( control, 'updateSelectedAttachments', 'handleAttachmentDestroy' ); + control.selectedAttachments = new wp.media.model.Attachments(); + control.model.on( 'change:ids', control.updateSelectedAttachments ); + control.selectedAttachments.on( 'change', control.renderPreview ); + control.selectedAttachments.on( 'reset', control.renderPreview ); + control.updateSelectedAttachments(); + }, + + /** + * Update the selected attachments if necessary. + * + * @since 4.9.0 + * @returns {void} + */ + updateSelectedAttachments: function updateSelectedAttachments() { + var control = this, newIds, oldIds, removedIds, addedIds, addedQuery; + + newIds = control.model.get( 'ids' ); + oldIds = _.pluck( control.selectedAttachments.models, 'id' ); + + removedIds = _.difference( oldIds, newIds ); + _.each( removedIds, function( removedId ) { + control.selectedAttachments.remove( control.selectedAttachments.get( removedId ) ); + }); + + addedIds = _.difference( newIds, oldIds ); + if ( addedIds.length ) { + addedQuery = wp.media.query({ + order: 'ASC', + orderby: 'post__in', + perPage: -1, + post__in: newIds, + query: true, + type: 'image' + }); + addedQuery.more().done( function() { + control.selectedAttachments.reset( addedQuery.models ); + }); + } + }, + + /** + * Render preview. + * + * @since 4.9.0 + * @returns {void} + */ + renderPreview: function renderPreview() { + var control = this, previewContainer, previewTemplate, data; + + previewContainer = control.$el.find( '.media-widget-preview' ); + previewTemplate = wp.template( 'wp-media-widget-gallery-preview' ); + + data = control.previewTemplateProps.toJSON(); + data.attachments = {}; + control.selectedAttachments.each( function( attachment ) { + data.attachments[ attachment.id ] = attachment.toJSON(); + } ); + + previewContainer.html( previewTemplate( data ) ); + }, + + /** + * Determine whether there are selected attachments. + * + * @since 4.9.0 + * @returns {boolean} Selected. + */ + isSelected: function isSelected() { + var control = this; + + if ( control.model.get( 'error' ) ) { + return false; + } + + return control.model.get( 'ids' ).length > 0; + }, + + /** + * Open the media select frame to edit images. + * + * @since 4.9.0 + * @returns {void} + */ + editMedia: function editMedia() { + var control = this, selection, mediaFrame, mediaFrameProps; + + selection = new wp.media.model.Selection( control.selectedAttachments.models, { + multiple: true + }); + + mediaFrameProps = control.mapModelToMediaFrameProps( control.model.toJSON() ); + selection.gallery = new Backbone.Model( _.pick( mediaFrameProps, 'columns', 'link', 'size', '_orderbyRandom' ) ); + if ( mediaFrameProps.size ) { + control.displaySettings.set( 'size', mediaFrameProps.size ); + } + mediaFrame = new GalleryDetailsMediaFrame({ + frame: 'manage', + text: control.l10n.add_to_widget, + selection: selection, + mimeType: control.mime_type, + selectedDisplaySettings: control.displaySettings, + showDisplaySettings: control.showDisplaySettings, + metadata: mediaFrameProps, + editing: true, + multiple: true, + state: 'gallery-edit' + }); + wp.media.frame = mediaFrame; // See wp.media(). + + // Handle selection of a media item. + mediaFrame.on( 'update', function onUpdate( newSelection ) { + var state = mediaFrame.state(), resultSelection; + + resultSelection = newSelection || state.get( 'selection' ); + if ( ! resultSelection ) { + return; + } + + // Copy orderby_random from gallery state. + if ( resultSelection.gallery ) { + control.model.set( control.mapMediaToModelProps( resultSelection.gallery.toJSON() ) ); + } + + // Directly update selectedAttachments to prevent needing to do additional request. + control.selectedAttachments.reset( resultSelection.models ); + + // Update models in the widget instance. + control.model.set( { + ids: _.pluck( resultSelection.models, 'id' ) + } ); + } ); + + mediaFrame.$el.addClass( 'media-widget' ); + mediaFrame.open(); + + if ( selection ) { + selection.on( 'destroy', control.handleAttachmentDestroy ); + } + }, + + /** + * Open the media select frame to chose an item. + * + * @since 4.9.0 + * @returns {void} + */ + selectMedia: function selectMedia() { + var control = this, selection, mediaFrame, mediaFrameProps; + selection = new wp.media.model.Selection( control.selectedAttachments.models, { + multiple: true + }); + + mediaFrameProps = control.mapModelToMediaFrameProps( control.model.toJSON() ); + if ( mediaFrameProps.size ) { + control.displaySettings.set( 'size', mediaFrameProps.size ); + } + mediaFrame = new GalleryDetailsMediaFrame({ + frame: 'select', + text: control.l10n.add_to_widget, + selection: selection, + mimeType: control.mime_type, + selectedDisplaySettings: control.displaySettings, + showDisplaySettings: control.showDisplaySettings, + metadata: mediaFrameProps, + state: 'gallery' + }); + wp.media.frame = mediaFrame; // See wp.media(). + + // Handle selection of a media item. + mediaFrame.on( 'update', function onUpdate( newSelection ) { + var state = mediaFrame.state(), resultSelection; + + resultSelection = newSelection || state.get( 'selection' ); + if ( ! resultSelection ) { + return; + } + + // Copy orderby_random from gallery state. + if ( resultSelection.gallery ) { + control.model.set( control.mapMediaToModelProps( resultSelection.gallery.toJSON() ) ); + } + + // Directly update selectedAttachments to prevent needing to do additional request. + control.selectedAttachments.reset( resultSelection.models ); + + // Update widget instance. + control.model.set( { + ids: _.pluck( resultSelection.models, 'id' ) + } ); + } ); + + mediaFrame.$el.addClass( 'media-widget' ); + mediaFrame.open(); + + if ( selection ) { + selection.on( 'destroy', control.handleAttachmentDestroy ); + } + + /* + * Make sure focus is set inside of modal so that hitting Esc will close + * the modal and not inadvertently cause the widget to collapse in the customizer. + */ + mediaFrame.$el.find( ':focusable:first' ).focus(); + }, + + /** + * Clear the selected attachment when it is deleted in the media select frame. + * + * @since 4.9.0 + * @param {wp.media.models.Attachment} attachment - Attachment. + * @returns {void} + */ + handleAttachmentDestroy: function handleAttachmentDestroy( attachment ) { + var control = this; + control.model.set( { + ids: _.difference( + control.model.get( 'ids' ), + [ attachment.id ] + ) + } ); + } + } ); + + // Exports. + component.controlConstructors.media_gallery = GalleryWidgetControl; + component.modelConstructors.media_gallery = GalleryWidgetModel; + +})( wp.mediaWidgets ); diff --git a/src/wp-admin/js/widgets/media-image-widget.js b/src/wp-admin/js/widgets/media-image-widget.js index ddbe6b3e24..78b257feae 100644 --- a/src/wp-admin/js/widgets/media-image-widget.js +++ b/src/wp-admin/js/widgets/media-image-widget.js @@ -24,6 +24,15 @@ */ ImageWidgetControl = component.MediaWidgetControl.extend({ + /** + * View events. + * + * @type {object} + */ + events: _.extend( {}, component.MediaWidgetControl.prototype.events, { + 'click .media-widget-preview.populated': 'editMedia' + } ), + /** * Render preview. * @@ -38,6 +47,7 @@ previewContainer = control.$el.find( '.media-widget-preview' ); previewTemplate = wp.template( 'wp-media-widget-image-preview' ); previewContainer.html( previewTemplate( control.previewTemplateProps.toJSON() ) ); + previewContainer.addClass( 'populated' ); linkInput = control.$el.find( '.link' ); if ( ! linkInput.is( document.activeElement ) ) { diff --git a/src/wp-admin/js/widgets/media-widgets.js b/src/wp-admin/js/widgets/media-widgets.js index e3bc41c480..5e7383c6fc 100644 --- a/src/wp-admin/js/widgets/media-widgets.js +++ b/src/wp-admin/js/widgets/media-widgets.js @@ -429,6 +429,7 @@ wp.mediaWidgets = ( function( $ ) { events: { 'click .notice-missing-attachment a': 'handleMediaLibraryLinkClick', 'click .select-media': 'selectMedia', + 'click .placeholder': 'selectMedia', 'click .edit-media': 'editMedia' }, @@ -591,17 +592,25 @@ wp.mediaWidgets = ( function( $ ) { syncModelToInputs: function syncModelToInputs() { var control = this; control.syncContainer.find( '.media-widget-instance-property' ).each( function() { - var input = $( this ), value; - value = control.model.get( input.data( 'property' ) ); + var input = $( this ), value, propertyName; + propertyName = input.data( 'property' ); + value = control.model.get( propertyName ); if ( _.isUndefined( value ) ) { return; } - value = String( value ); - if ( input.val() === value ) { - return; + + if ( 'array' === control.model.schema[ propertyName ].type && _.isArray( value ) ) { + value = value.join( ',' ); + } else if ( 'boolean' === control.model.schema[ propertyName ].type ) { + value = value ? '1' : ''; // Because in PHP, strval( true ) === '1' && strval( false ) === ''. + } else { + value = String( value ); + } + + if ( input.val() !== value ) { + input.val( value ); + input.trigger( 'change' ); } - input.val( value ); - input.trigger( 'change' ); }); }, @@ -1002,7 +1011,22 @@ wp.mediaWidgets = ( function( $ ) { return; } type = model.schema[ name ].type; - if ( 'integer' === type ) { + if ( 'array' === type ) { + castedAttrs[ name ] = value; + if ( ! _.isArray( castedAttrs[ name ] ) ) { + castedAttrs[ name ] = castedAttrs[ name ].split( /,/ ); // Good enough for parsing an ID list. + } + if ( model.schema[ name ].items && 'integer' === model.schema[ name ].items.type ) { + castedAttrs[ name ] = _.filter( + _.map( castedAttrs[ name ], function( id ) { + return parseInt( id, 10 ); + }, + function( id ) { + return 'number' === typeof id; + } + ) ); + } + } else if ( 'integer' === type ) { castedAttrs[ name ] = parseInt( value, 10 ); } else if ( 'boolean' === type ) { castedAttrs[ name ] = ! ( ! value || '0' === value || 'false' === value ); diff --git a/src/wp-includes/default-widgets.php b/src/wp-includes/default-widgets.php index 7c8a903c56..767002b642 100644 --- a/src/wp-includes/default-widgets.php +++ b/src/wp-includes/default-widgets.php @@ -31,6 +31,9 @@ require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-media-image.php' ); /** WP_Widget_Media_Video class */ require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-media-video.php' ); +/** WP_Widget_Media_Gallery class */ +require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-media-gallery.php' ); + /** WP_Widget_Meta class */ require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-meta.php' ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index c85822c807..fa1ada70fb 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -699,6 +699,7 @@ function wp_default_scripts( &$scripts ) { $scripts->add( 'media-audio-widget', "/wp-admin/js/widgets/media-audio-widget$suffix.js", array( 'media-widgets', 'media-audiovideo' ) ); $scripts->add( 'media-image-widget', "/wp-admin/js/widgets/media-image-widget$suffix.js", array( 'media-widgets' ) ); + $scripts->add( 'media-gallery-widget', "/wp-admin/js/widgets/media-gallery-widget$suffix.js", array( 'media-widgets' ) ); $scripts->add( 'media-video-widget', "/wp-admin/js/widgets/media-video-widget$suffix.js", array( 'media-widgets', 'media-audiovideo', 'wp-api-request' ) ); $scripts->add( 'text-widgets', "/wp-admin/js/widgets/text-widgets$suffix.js", array( 'jquery', 'backbone', 'editor', 'wp-util', 'wp-a11y' ) ); $scripts->add( 'custom-html-widgets', "/wp-admin/js/widgets/custom-html-widgets$suffix.js", array( 'code-editor', 'jquery', 'backbone', 'wp-util', 'jquery-ui-core', 'wp-a11y' ) ); diff --git a/src/wp-includes/widgets.php b/src/wp-includes/widgets.php index fe0e058d9a..e4fcc527b7 100644 --- a/src/wp-includes/widgets.php +++ b/src/wp-includes/widgets.php @@ -1609,6 +1609,8 @@ function wp_widgets_init() { register_widget( 'WP_Widget_Media_Image' ); + register_widget( 'WP_Widget_Media_Gallery' ); + register_widget( 'WP_Widget_Media_Video' ); register_widget( 'WP_Widget_Meta' ); diff --git a/src/wp-includes/widgets/class-wp-widget-media-gallery.php b/src/wp-includes/widgets/class-wp-widget-media-gallery.php new file mode 100644 index 0000000000..5c07c4fc6b --- /dev/null +++ b/src/wp-includes/widgets/class-wp-widget-media-gallery.php @@ -0,0 +1,224 @@ + __( 'Displays an image gallery.' ), + 'mime_type' => 'image', + ) ); + + $this->l10n = array_merge( $this->l10n, array( + 'no_media_selected' => __( 'No images selected' ), + 'add_media' => _x( 'Select Images', 'label for button in the gallery widget; should not be longer than ~13 characters long' ), + 'replace_media' => _x( 'Replace Gallery', 'label for button in the gallery widget; should not be longer than ~13 characters long' ), + 'edit_media' => _x( 'Edit Gallery', 'label for button in the gallery widget; should not be longer than ~13 characters long' ), + ) ); + } + + /** + * Get schema for properties of a widget instance (item). + * + * @since 4.9.0 + * + * @see WP_REST_Controller::get_item_schema() + * @see WP_REST_Controller::get_additional_fields() + * @link https://core.trac.wordpress.org/ticket/35574 + * @return array Schema for properties. + */ + public function get_instance_schema() { + return array( + 'title' => array( + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => __( 'Title for the widget' ), + 'should_preview_update' => false, + ), + 'ids' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + 'sanitize_callback' => 'wp_parse_id_list', + ), + 'columns' => array( + 'type' => 'integer', + 'default' => 3, + 'minimum' => 1, + 'maximum' => 9, + ), + 'size' => array( + 'type' => 'string', + 'enum' => array_merge( get_intermediate_image_sizes(), array( 'full', 'custom' ) ), + 'default' => 'thumbnail', + ), + 'link_type' => array( + 'type' => 'string', + 'enum' => array( 'none', 'file', 'post' ), + 'default' => 'none', + 'media_prop' => 'link', + 'should_preview_update' => false, + ), + 'orderby_random' => array( + 'type' => 'boolean', + 'default' => false, + 'media_prop' => '_orderbyRandom', + 'should_preview_update' => false, + ), + ); + } + + /** + * Render the media on the frontend. + * + * @since 4.9.0 + * + * @param array $instance Widget instance props. + * @return void + */ + public function render_media( $instance ) { + $instance = array_merge( wp_list_pluck( $this->get_instance_schema(), 'default' ), $instance ); + + $shortcode_atts = array( + 'ids' => $instance['ids'], + 'columns' => $instance['columns'], + 'link' => $instance['link_type'], + 'size' => $instance['size'], + ); + + // @codeCoverageIgnoreStart + if ( $instance['orderby_random'] ) { + $shortcode_atts['orderby'] = 'rand'; + } + + // @codeCoverageIgnoreEnd + echo gallery_shortcode( $shortcode_atts ); + } + + /** + * Loads the required media files for the media manager and scripts for media widgets. + * + * @since 4.9.0 + */ + public function enqueue_admin_scripts() { + parent::enqueue_admin_scripts(); + + $handle = 'media-gallery-widget'; + wp_enqueue_script( $handle ); + + $exported_schema = array(); + foreach ( $this->get_instance_schema() as $field => $field_schema ) { + $exported_schema[ $field ] = wp_array_slice_assoc( $field_schema, array( 'type', 'default', 'enum', 'minimum', 'format', 'media_prop', 'should_preview_update', 'items' ) ); + } + wp_add_inline_script( + $handle, + sprintf( + 'wp.mediaWidgets.modelConstructors[ %s ].prototype.schema = %s;', + wp_json_encode( $this->id_base ), + wp_json_encode( $exported_schema ) + ) + ); + + wp_add_inline_script( + $handle, + sprintf( + ' + wp.mediaWidgets.controlConstructors[ %1$s ].prototype.mime_type = %2$s; + _.extend( wp.mediaWidgets.controlConstructors[ %1$s ].prototype.l10n, %3$s ); + ', + wp_json_encode( $this->id_base ), + wp_json_encode( $this->widget_options['mime_type'] ), + wp_json_encode( $this->l10n ) + ) + ); + } + + /** + * Render form template scripts. + * + * @since 4.9.0 + */ + public function render_control_template_scripts() { + parent::render_control_template_scripts(); + ?> + + get_field_name( $name ) ); ?>" id="get_field_id( $name ) ); // Needed specifically by wpWidgets.appendTitle(). ?>" - value="" + value="" />
- - + + + +