diff --git a/src/js/media/models/attachments.js b/src/js/media/models/attachments.js index b0c58d93a5..7698034134 100644 --- a/src/js/media/models/attachments.js +++ b/src/js/media/models/attachments.js @@ -347,21 +347,49 @@ var Attachments = Backbone.Collection.extend(/** @lends wp.media.model.Attachmen hasMore: function() { return this.mirroring ? this.mirroring.hasMore() : false; }, + /** + * Holds the total number of attachments. + * + * @since 5.7.0 + */ + totalAttachments: 0, + + /** + * Gets the total number of attachments. + * + * @since 5.7.0 + * + * @return {number} The total number of attachments. + */ + getTotalAttachments: function() { + return this.mirroring ? this.mirroring.totalAttachments : 0; + }, + /** * A custom Ajax-response parser. * * See trac ticket #24753 * - * @param {Object|Array} resp The raw response Object/Array. + * Called automatically by Backbone whenever a collection's models are returned + * by the server, in fetch. The default implementation is a no-op, simply + * passing through the JSON response. We override this to add attributes to + * the collection items. + * + * Since WordPress 5.5, the response returns the attachments under `response.attachments` + * and `response.totalAttachments` holds the total number of attachments found. + * + * @param {Object|Array} response The raw response Object/Array. * @param {Object} xhr * @return {Array} The array of model attributes to be added to the collection */ - parse: function( resp, xhr ) { - if ( ! _.isArray( resp ) ) { - resp = [resp]; + parse: function( response, xhr ) { + if ( ! _.isArray( response.attachments ) ) { + response = [response.attachments]; } - return _.map( resp, function( attrs ) { + this.totalAttachments = parseInt( response.totalAttachments, 10 ); + + return _.map( response.attachments, function( attrs ) { var id, attachment, newAttributes; if ( attrs instanceof Backbone.Model ) { diff --git a/src/js/media/models/query.js b/src/js/media/models/query.js index c29a638b10..471d828ac6 100644 --- a/src/js/media/models/query.js +++ b/src/js/media/models/query.js @@ -112,8 +112,11 @@ Query = Attachments.extend(/** @lends wp.media.model.Query.prototype */{ options = options || {}; options.remove = false; - return this._more = this.fetch( options ).done( function( resp ) { - if ( _.isEmpty( resp ) || -1 === this.args.posts_per_page || resp.length < this.args.posts_per_page ) { + return this._more = this.fetch( options ).done( function( response ) { + // Since WordPress 5.5, the response returns the attachments under `response.attachments`. + var attachments = response.attachments; + + if ( _.isEmpty( attachments ) || -1 === this.args.posts_per_page || attachments.length < this.args.posts_per_page ) { query._hasMore = false; } }); diff --git a/src/js/media/views/attachments.js b/src/js/media/views/attachments.js index cb81ea3072..1fc9d777d6 100644 --- a/src/js/media/views/attachments.js +++ b/src/js/media/views/attachments.js @@ -1,6 +1,7 @@ var View = wp.media.View, $ = jQuery, - Attachments; + Attachments, + infiniteScrolling = wp.media.view.settings.infiniteScrolling; Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ tagName: 'ul', @@ -35,6 +36,8 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ this.el.id = _.uniqueId('__attachments-view-'); /** + * @param infiniteScrolling Whether to enable infinite scrolling or use + * the default "load more" button. * @param refreshSensitivity The time in milliseconds to throttle the scroll * handler. * @param refreshThreshold The amount of pixels that should be scrolled before @@ -49,6 +52,7 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ * calculating the total number of columns. */ _.defaults( this.options, { + infiniteScrolling: infiniteScrolling || false, refreshSensitivity: wp.media.isTouchDevice ? 300 : 200, refreshThreshold: 3, AttachmentView: wp.media.view.Attachment, @@ -84,11 +88,13 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ this.controller.on( 'library:selection:add', this.attachmentFocus, this ); - // Throttle the scroll handler and bind this. - this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value(); + if ( this.options.infiniteScrolling ) { + // Throttle the scroll handler and bind this. + this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value(); - this.options.scrollElement = this.options.scrollElement || this.el; - $( this.options.scrollElement ).on( 'scroll', this.scroll ); + this.options.scrollElement = this.options.scrollElement || this.el; + $( this.options.scrollElement ).on( 'scroll', this.scroll ); + } this.initSortable(); @@ -387,7 +393,9 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ this.views.set( this.collection.map( this.createAttachmentView, this ) ); } else { this.views.unset(); - this.collection.more().done( this.scroll ); + if ( this.options.infiniteScrolling ) { + this.collection.more().done( this.scroll ); + } } }, @@ -400,7 +408,9 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ * @return {void} */ ready: function() { - this.scroll(); + if ( this.options.infiniteScrolling ) { + this.scroll(); + } }, /** diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js index b29090832a..ccf26c710a 100644 --- a/src/js/media/views/attachments/browser.js +++ b/src/js/media/views/attachments/browser.js @@ -2,7 +2,10 @@ var View = wp.media.View, mediaTrash = wp.media.view.settings.mediaTrash, l10n = wp.media.view.l10n, $ = jQuery, - AttachmentsBrowser; + AttachmentsBrowser, + infiniteScrolling = wp.media.view.settings.infiniteScrolling, + __ = wp.i18n.__, + sprintf = wp.i18n.sprintf; /** * wp.media.view.AttachmentsBrowser @@ -68,12 +71,16 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.createUploader(); } - // Add a heading before the attachments list. this.createAttachmentsHeading(); - // Create the list of attachments. - this.createAttachments(); + // Create the attachments wrapper view. + this.createAttachmentsWrapperView(); + + if ( ! infiniteScrolling ) { + this.$el.addClass( 'has-load-more' ); + this.createLoadMoreView(); + } // For accessibility reasons, place the normal sidebar after the attachments, see ticket #36909. if ( this.options.sidebar && 'errors' !== this.options.sidebar ) { @@ -92,6 +99,10 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.collection.on( 'add remove reset', this.updateContent, this ); + if ( ! infiniteScrolling ) { + this.collection.on( 'add remove reset', this.updateLoadMoreView, this ); + } + // The non-cached or cached attachments query has completed. this.collection.on( 'attachments:received', this.announceSearchResults, this ); }, @@ -106,7 +117,14 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro * @return {void} */ announceSearchResults: _.debounce( function() { - var count; + var count, + /* translators: Accessibility text. %d: Number of attachments found in a search. */ + mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Click load more for more results.' ); + + if ( infiniteScrolling ) { + /* translators: Accessibility text. %d: Number of attachments found in a search. */ + mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Scroll the page for more results.' ); + } if ( this.collection.mirroring.args.s ) { count = this.collection.length; @@ -117,7 +135,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro } if ( this.collection.hasMore() ) { - wp.a11y.speak( l10n.mediaFoundHasMoreResults.replace( '%d', count ) ); + wp.a11y.speak( mediaFoundHasMoreResultsMessage.replace( '%d', count ) ); return; } @@ -392,8 +410,10 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro noItemsView; if ( this.controller.isModeActive( 'grid' ) ) { + // Usually the media library. noItemsView = view.attachmentsNoResults; } else { + // Usually the media modal. noItemsView = view.uploader; } @@ -433,6 +453,23 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro } }, + /** + * Creates the Attachments wrapper view. + * + * @since 5.7.0 + * + * @return {void} + */ + createAttachmentsWrapperView: function() { + this.attachmentsWrapper = new wp.media.View( { + className: 'attachments-wrapper' + } ); + + // Create the list of attachments. + this.views.add( this.attachmentsWrapper ); + this.createAttachments(); + }, + createAttachments: function() { this.attachments = new wp.media.view.Attachments({ controller: this.controller, @@ -451,8 +488,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.controller.on( 'attachment:keydown:arrow', _.bind( this.attachments.arrowEvent, this.attachments ) ); this.controller.on( 'attachment:details:shift-tab', _.bind( this.attachments.restoreFocus, this.attachments ) ); - this.views.add( this.attachments ); - + this.views.add( '.attachments-wrapper', this.attachments ); if ( this.controller.isModeActive( 'grid' ) ) { this.attachmentsNoResults = new View({ @@ -467,6 +503,157 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro } }, + /** + * Creates the load more button and attachments counter view. + * + * @since 5.7.0 + * + * @return {void} + */ + createLoadMoreView: function() { + var view = this; + + this.loadMoreWrapper = new View( { + controller: this.controller, + className: 'load-more-wrapper' + } ); + + this.loadMoreCount = new View( { + controller: this.controller, + tagName: 'p', + className: 'load-more-count hidden' + } ); + + this.loadMoreButton = new wp.media.view.Button( { + text: __( 'Load more' ), + className: 'load-more hidden', + style: 'primary', + size: '', + click: function() { + view.loadMoreAttachments(); + } + } ); + + this.loadMoreSpinner = new wp.media.view.Spinner(); + + this.loadMoreJumpToFirst = new wp.media.view.Button( { + text: __( 'Jump to first loaded item' ), + className: 'load-more-jump hidden', + size: '', + click: function() { + view.jumpToFirstAddedItem(); + } + } ); + + this.views.add( '.attachments-wrapper', this.loadMoreWrapper ); + this.views.add( '.load-more-wrapper', this.loadMoreSpinner ); + this.views.add( '.load-more-wrapper', this.loadMoreCount ); + this.views.add( '.load-more-wrapper', this.loadMoreButton ); + this.views.add( '.load-more-wrapper', this.loadMoreJumpToFirst ); + }, + + /** + * Updates the Load More view. This function is debounced because the + * collection updates multiple times at the add, remove, and reset events. + * We need it to run only once, after all attachments are added or removed. + * + * @since 5.7.0 + * + * @return {void} + */ + updateLoadMoreView: _.debounce( function() { + // Ensure the load more view elements are initially hidden at each update. + this.loadMoreButton.$el.addClass( 'hidden' ); + this.loadMoreCount.$el.addClass( 'hidden' ); + this.loadMoreJumpToFirst.$el.addClass( 'hidden' ).prop( 'disabled', true ); + + if ( ! this.collection.getTotalAttachments() ) { + return; + } + + if ( this.collection.length ) { + this.loadMoreCount.$el.text( + /* translators: 1: Number of displayed attachments, 2: Number of total attachments. */ + sprintf( + __( 'Showing %1$s of %2$s media items' ), + this.collection.length, + this.collection.getTotalAttachments() + ) + ); + + this.loadMoreCount.$el.removeClass( 'hidden' ); + } + + /* + * Notice that while the collection updates multiple times hasMore() may + * return true when it's actually not true. + */ + if ( this.collection.hasMore() ) { + this.loadMoreButton.$el.removeClass( 'hidden' ); + } + + // Find the media item to move focus to. The jQuery `eq()` index is zero-based. + this.firstAddedMediaItem = this.$el.find( '.attachment' ).eq( this.firstAddedMediaItemIndex ); + + // If there's a media item to move focus to, make the "Jump to" button available. + if ( this.firstAddedMediaItem.length ) { + this.firstAddedMediaItem.addClass( 'new-media' ); + this.loadMoreJumpToFirst.$el.removeClass( 'hidden' ).prop( 'disabled', false ); + } + + // If there are new items added, but no more to be added, move focus to Jump button. + if ( this.firstAddedMediaItem.length && ! this.collection.hasMore() ) { + this.loadMoreJumpToFirst.$el.trigger( 'focus' ); + } + }, 10 ), + + /** + * Loads more attachments. + * + * @since 5.7.0 + * + * @return {void} + */ + loadMoreAttachments: function() { + var view = this; + + if ( ! this.collection.hasMore() ) { + return; + } + + /* + * The collection index is zero-based while the length counts the actual + * amount of items. Thus the length is equivalent to the position of the + * first added item. + */ + this.firstAddedMediaItemIndex = this.collection.length; + + this.$el.addClass( 'more-loaded' ); + this.collection.each( function( attachment ) { + var attach_id = attachment.attributes.id; + $( '[data-id="' + attach_id + '"]' ).addClass( 'found-media' ); + }); + + view.loadMoreSpinner.show(); + + this.collection.more().done( function() { + // Within done(), `this` is the returned collection. + view.loadMoreSpinner.hide(); + } ); + }, + + /** + * Moves focus to the first new added item. . + * + * @since 5.7.0 + * + * @return {void} + */ + jumpToFirstAddedItem: function() { + // Set focus on first added item. + this.firstAddedMediaItem.focus(); + }, + createAttachmentsHeading: function() { this.attachmentsHeading = new wp.media.view.Heading( { text: l10n.attachmentsList, diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css index f13268c44b..b8d9ec6324 100644 --- a/src/wp-admin/css/media.css +++ b/src/wp-admin/css/media.css @@ -420,7 +420,8 @@ border color while dragging a file over the uploader drop area */ .media-frame.mode-grid, .media-frame.mode-grid .media-frame-content, -.media-frame.mode-grid .attachments-browser .attachments, +.media-frame.mode-grid .attachments-browser:not(.has-load-more) .attachments, +.media-frame.mode-grid .attachments-browser.has-load-more .attachments-wrapper, .media-frame.mode-grid .uploader-inline-content { position: static; } @@ -498,7 +499,8 @@ border color while dragging a file over the uploader drop area */ border: 4px dashed #c3c4c7; } -.media-frame.mode-select .attachments-browser.fixed .attachments { +.media-frame.mode-select .attachments-browser.fixed:not(.has-load-more) .attachments, +.media-frame.mode-select .attachments-browser.has-load-more.fixed .attachments-wrapper { position: relative; top: 94px; /* prevent jumping up when the toolbar becomes fixed */ padding-bottom: 94px; /* offset for above so the bottom doesn't get cut off */ diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 4a14260008..968a5a3985 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -2993,7 +2993,12 @@ function wp_ajax_query_attachments() { $posts = array_map( 'wp_prepare_attachment_for_js', $query->posts ); $posts = array_filter( $posts ); - wp_send_json_success( $posts ); + $result = array( + 'attachments' => $posts, + 'totalAttachments' => $query->found_posts, + ); + + wp_send_json_success( $result ); } /** diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index efb19a16b2..784903db6d 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -1188,7 +1188,8 @@ padding: 2px 8px 8px; } -.attachments-browser .attachments, +.attachments-browser:not(.has-load-more) .attachments, +.attachments-browser.has-load-more .attachments-wrapper, .attachments-browser .uploader-inline { position: absolute; top: 72px; @@ -1267,6 +1268,96 @@ padding: 2em 0 0 2em; } +.more-loaded .attachment:not(.found-media) { + background: #dcdcde; +} + +.load-more-wrapper { + clear: both; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + padding: 1em 0; +} + +.load-more-wrapper .load-more-count { + min-width: 100%; + margin: 0 0 1em; + text-align: center; +} + +.load-more-wrapper .load-more { + margin: 0; +} + +/* Needs high specificity. */ +.media-frame .load-more-wrapper .load-more + .spinner { + float: none; + margin: 0 -30px 0 10px; +} + +/* Reset spinner margin when the button is hidden to avoid horizontal scrollbar. */ +.media-frame .load-more-wrapper .load-more.hidden + .spinner { + margin: 0; +} + +/* Force a new row within the flex container. */ +.load-more-wrapper::after { + content: ""; + min-width: 100%; + order: 1; +} + +.load-more-wrapper .load-more-jump { + margin: 0 0 0 12px; +} + +.attachment.new-media { + outline: 2px dotted #c3c4c7; +} + +.load-more-wrapper { + clear: both; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + padding: 1em 0; +} + +.load-more-wrapper .load-more-count { + min-width: 100%; + margin: 0 0 1em; + text-align: center; +} + +.load-more-wrapper .load-more { + margin: 0; +} + +/* Needs high specificity. */ +.media-frame .load-more-wrapper .load-more + .spinner { + float: none; + margin: 0 -30px 0 10px; +} + +/* Reset spinner margin when the button is hidden to avoid horizontal scrollbar. */ +.media-frame .load-more-wrapper .load-more.hidden + .spinner { + margin: 0; +} + +/* Force a new row within the flex container. */ +.load-more-wrapper::after { + content: ""; + min-width: 100%; + order: 1; +} + +.load-more-wrapper .load-more-jump { + margin: 0 0 0 12px; +} + /** * Progress Bar */ @@ -2818,6 +2909,12 @@ .media-frame-content .media-toolbar .instructions { display: none; } + + /* Change margin direction on load more button in responsive views. */ + .load-more-wrapper .load-more-jump { + margin: 12px 0 0 0; + } + } @media only screen and (min-width: 901px) and (max-height: 400px) { @@ -2826,6 +2923,12 @@ top: 0; padding-top: 44px; } + + /* Change margin direction on load more button in responsive views. */ + .load-more-wrapper .load-more-jump { + margin: 12px 0 0 0; + } + } @media only screen and (max-width: 480px) { diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 5aef552a3c..b3dfeb0c6f 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4306,29 +4306,39 @@ function wp_enqueue_media( $args = array() ) { ); } + /** + * Filters whether the Media Library grid has infinite scrolling. Default `false`. + * + * @since 5.7.0 + * + * @param bool $value The filtered value, defaults to `false`. + */ + $infinite_scrolling = apply_filters( 'media_library_infinite_scrolling', false ); + $settings = array( - 'tabs' => $tabs, - 'tabUrl' => add_query_arg( array( 'chromeless' => true ), admin_url( 'media-upload.php' ) ), - 'mimeTypes' => wp_list_pluck( get_post_mime_types(), 0 ), + 'tabs' => $tabs, + 'tabUrl' => add_query_arg( array( 'chromeless' => true ), admin_url( 'media-upload.php' ) ), + 'mimeTypes' => wp_list_pluck( get_post_mime_types(), 0 ), /** This filter is documented in wp-admin/includes/media.php */ - 'captions' => ! apply_filters( 'disable_captions', '' ), - 'nonce' => array( + 'captions' => ! apply_filters( 'disable_captions', '' ), + 'nonce' => array( 'sendToEditor' => wp_create_nonce( 'media-send-to-editor' ), ), - 'post' => array( + 'post' => array( 'id' => 0, ), - 'defaultProps' => $props, - 'attachmentCounts' => array( + 'defaultProps' => $props, + 'attachmentCounts' => array( 'audio' => ( $show_audio_playlist ) ? 1 : 0, 'video' => ( $show_video_playlist ) ? 1 : 0, ), - 'oEmbedProxyUrl' => rest_url( 'oembed/1.0/proxy' ), - 'embedExts' => $exts, - 'embedMimes' => $ext_mimes, - 'contentWidth' => $content_width, - 'months' => $months, - 'mediaTrash' => MEDIA_TRASH ? 1 : 0, + 'oEmbedProxyUrl' => rest_url( 'oembed/1.0/proxy' ), + 'embedExts' => $exts, + 'embedMimes' => $ext_mimes, + 'contentWidth' => $content_width, + 'months' => $months, + 'mediaTrash' => MEDIA_TRASH ? 1 : 0, + 'infiniteScrolling' => ( $infinite_scrolling ) ? 1 : 0, ); $post = null; @@ -4412,8 +4422,8 @@ function wp_enqueue_media( $args = array() ) { 'searchLabel' => __( 'Search' ), 'searchMediaLabel' => __( 'Search media' ), // Backward compatibility pre-5.3. 'searchMediaPlaceholder' => __( 'Search media items...' ), // Placeholder (no ellipsis), backward compatibility pre-5.3. + /* translators: %d: Number of attachments found in a search. */ 'mediaFound' => __( 'Number of media items found: %d' ), - 'mediaFoundHasMoreResults' => __( 'Number of media items displayed: %d. Scroll the page for more results.' ), 'noMedia' => __( 'No media items found.' ), 'noMediaTryNewSearch' => __( 'No media items found. Try a different search.' ),