diff --git a/Gruntfile.js b/Gruntfile.js index 61f18481e23a8..1b05ec073ce4f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1403,6 +1403,7 @@ module.exports = function(grunt) { 'src/wp-includes/js/media/views/iframe.js' : 'src/js/media/views/iframe.js', 'src/wp-includes/js/media/views/image-details.js' : 'src/js/media/views/image-details.js', 'src/wp-includes/js/media/views/label.js' : 'src/js/media/views/label.js', + 'src/wp-includes/js/media/views/library-settings.js' : 'src/js/media/views/library-settings.js', 'src/wp-includes/js/media/views/media-details.js' : 'src/js/media/views/media-details.js', 'src/wp-includes/js/media/views/media-frame.js' : 'src/js/media/views/media-frame.js', 'src/wp-includes/js/media/views/menu-item.js' : 'src/js/media/views/menu-item.js', diff --git a/src/js/_enqueues/wp/media/views.js b/src/js/_enqueues/wp/media/views.js index d87046fcf0e2b..0cd31f73aca8f 100644 --- a/src/js/_enqueues/wp/media/views.js +++ b/src/js/_enqueues/wp/media/views.js @@ -128,6 +128,7 @@ media.view.AttachmentFilters = require( '../../../media/views/attachment-filters media.view.DateFilter = require( '../../../media/views/attachment-filters/date.js' ); media.view.AttachmentFilters.Uploaded = require( '../../../media/views/attachment-filters/uploaded.js' ); media.view.AttachmentFilters.All = require( '../../../media/views/attachment-filters/all.js' ); +media.view.LibrarySettings = require( '../../../media/views/library-settings.js' ); media.view.AttachmentsBrowser = require( '../../../media/views/attachments/browser.js' ); media.view.Selection = require( '../../../media/views/selection.js' ); media.view.Attachment.Selection = require( '../../../media/views/attachment/selection.js' ); diff --git a/src/js/media/views/attachments.js b/src/js/media/views/attachments.js index b2e91624cb159..6e6f9750e00dc 100644 --- a/src/js/media/views/attachments.js +++ b/src/js/media/views/attachments.js @@ -1,7 +1,6 @@ var View = wp.media.View, $ = jQuery, - Attachments, - infiniteScrolling = wp.media.view.settings.infiniteScrolling; + Attachments; Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ tagName: 'ul', @@ -54,7 +53,7 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ * calculating the total number of columns. */ _.defaults( this.options, { - infiniteScrolling: infiniteScrolling || false, + infiniteScrolling: !! wp.media.view.settings.infiniteScrolling, refreshSensitivity: wp.media.isTouchDevice ? 300 : 200, refreshThreshold: 3, AttachmentView: wp.media.view.Attachment, @@ -90,11 +89,12 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ this.controller.on( 'library:selection:add', this.attachmentFocus, this ); - if ( this.options.infiniteScrolling ) { - // Throttle the scroll handler and bind this. - this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value(); + // Throttle the scroll handler and bind this. Done even when infinite + // scrolling is off, since it can be turned on at any time. + this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value(); + this.options.scrollElement = this.options.scrollElement || this.el; - this.options.scrollElement = this.options.scrollElement || this.el; + if ( this.options.infiniteScrolling ) { $( this.options.scrollElement ).on( 'scroll', this.scroll ); } @@ -114,6 +114,28 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ } }, + /** + * Turns infinite scrolling on or off after the view has been created. + * + * @since 7.2.0 + * + * @param {boolean} enabled Whether to load more attachments on scroll. + * + * @return {void} + */ + setInfiniteScrolling: function( enabled ) { + this.options.infiniteScrolling = enabled; + + $( this.options.scrollElement ).off( 'scroll', this.scroll ); + + if ( enabled ) { + $( this.options.scrollElement ).on( 'scroll', this.scroll ); + + // The list may already be scrolled past the point where more are loaded. + this.scroll(); + } + }, + /** * Listens to the resizeEvent on the window. * @@ -231,6 +253,8 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ */ dispose: function() { this.collection.props.off( null, null, this ); + $( this.options.scrollElement ).off( 'scroll', this.scroll ); + if ( this.options.resize ) { this.$window.off( this.resizeEvent ); } diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js index 82b7359eb832a..6845ebd58cc3e 100644 --- a/src/js/media/views/attachments/browser.js +++ b/src/js/media/views/attachments/browser.js @@ -3,7 +3,8 @@ var View = wp.media.View, l10n = wp.media.view.l10n, $ = jQuery, AttachmentsBrowser, - infiniteScrolling = wp.media.view.settings.infiniteScrolling, + settings = wp.media.view.settings, + librarySettings = wp.media.view.settings.librarySettings, __ = wp.i18n.__, sprintf = wp.i18n.sprintf; @@ -34,6 +35,8 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro className: 'attachments-browser', initialize: function() { + var infiniteScrolling = !! settings.infiniteScrolling; + _.defaults( this.options, { filters: false, search: true, @@ -45,6 +48,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.controller.on( 'toggle:upload:attachment', this.toggleUploader, this ); this.controller.on( 'edit:selection', this.editSelection ); + this.controller.on( 'library:infinite-scrolling', this.setInfiniteScrolling, this ); // In the Media Library, the sidebar is used to display errors before the attachments grid. if ( this.options.sidebar && 'errors' === this.options.sidebar ) { @@ -111,6 +115,39 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.collection.on( 'attachments:received', this.announceSearchResults, this ); }, + /** + * Switches between infinite scrolling and the Load more button in place. + * + * The Load more view is created on demand and then kept, hidden by the + * `has-load-more` class, so that switching back and forth is cheap. + * + * @since 7.2.0 + * + * @param {boolean} enabled Whether to load more attachments on scroll. + * + * @return {void} + */ + setInfiniteScrolling: function( enabled ) { + if ( enabled === Boolean( this.attachments.options.infiniteScrolling ) ) { + return; + } + + this.attachments.setInfiniteScrolling( enabled ); + this.$el.toggleClass( 'has-load-more', ! enabled ); + + if ( enabled ) { + this.collection.off( 'add remove reset', this.updateLoadMoreView, this ); + return; + } + + if ( ! this.loadMoreWrapper ) { + this.createLoadMoreView(); + } + + this.collection.on( 'add remove reset', this.updateLoadMoreView, this ); + this.updateLoadMoreView(); + }, + /** * Updates the `wp.a11y.speak()` ARIA live region with a message to communicate * the number of search results to screen reader users. This function is @@ -125,7 +162,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro /* 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 ) { + if ( this.attachments.options.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.' ); } @@ -393,6 +430,14 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro model: this.collection.props, priority: 60 }).render() ); + + // Pointless when a `media_library_infinite_scrolling` filter callback overrides the user preference. + if ( librarySettings && ! librarySettings.isFiltered ) { + this.toolbar.set( 'librarySettings', new wp.media.view.LibrarySettings({ + controller: this.controller, + priority: 70 + }).render() ); + } } if ( this.options.dragInfo ) { diff --git a/src/js/media/views/library-settings.js b/src/js/media/views/library-settings.js new file mode 100644 index 0000000000000..1f0dd4c6ebe22 --- /dev/null +++ b/src/js/media/views/library-settings.js @@ -0,0 +1,172 @@ +var View = wp.media.View, + settings = wp.media.view.settings, + $ = jQuery, + __ = wp.i18n.__, + LibrarySettings; + +/** + * wp.media.view.LibrarySettings + * + * A toolbar control opening a modal dialog with the personal options for the + * Media Library. Each toggle is saved over Ajax, so there is no submit button. + * + * @since 7.2.0 + * + * @memberOf wp.media.view + * + * @class + * @augments wp.media.View + * @augments wp.Backbone.View + * @augments Backbone.View + */ +LibrarySettings = View.extend(/** @lends wp.media.view.LibrarySettings.prototype */{ + tagName: 'button', + className: 'button button-compact media-library-settings__toggle', + template: wp.template( 'media-library-settings-toggle' ), + + attributes: { + type: 'button', + 'aria-haspopup': 'dialog' + }, + + events: { + 'click': 'open' + }, + + /** + * Identifies the most recent request, so a slow response cannot overwrite the + * outcome of a later toggle. + * + * @type {number} + */ + requestId: 0, + + initialize: function() { + // Several media frames can be attached at once, so IDs are per instance. + this.uid = _.uniqueId( 'media-library-settings-' ); + }, + + prepare: function() { + return { + infiniteScrolling: !! settings.librarySettings.infiniteScrolling, + titleId: this.uid + '-title', + infiniteScrollingId: this.uid + '-infinite-scrolling' + }; + }, + + /** + * Removes the dialog along with the view. + * + * @return {wp.media.view.LibrarySettings} Returns itself to allow chaining. + */ + dispose: function() { + if ( this.dialog ) { + $( this.dialog ).remove(); + } + + return View.prototype.dispose.apply( this, arguments ); + }, + + /** + * Inserts the dialog next to the toggle. + * + * Done on the first open, when the toggle is known to be in the document. A + * closed dialog is `display: none`, so it does not take part in the layout. + * + * @return {void} + */ + createDialog: function() { + var $dialog = $( wp.template( 'media-library-settings-dialog' )( this.prepare() ) ); + + this.$el.after( $dialog ); + + this.dialog = $dialog[0]; + this.status = $dialog.find( '.media-library-settings__status' )[0]; + + $dialog.on( 'change', '.media-library-settings__checkbox', _.bind( this.updateInfiniteScrolling, this ) ); + + /* + * In the media modal, `wp.media.view.Modal` closes on Escape and + * `wp.media.view.FocusManager` constrains Tab. Neither must run while the + * dialog is open: it handles both itself, and the rest of the modal is inert. + */ + $dialog.on( 'keydown', function( event ) { + event.stopPropagation(); + } ); + }, + + /** + * Opens the dialog. + * + * `showModal()` moves it to the top layer, out of the toolbar's clipping, and + * brings the focus trap, Escape handling and focus restore a modal needs. + * + * @return {void} + */ + open: function() { + if ( ! this.dialog ) { + this.createDialog(); + } + + this.setStatus( '' ); + this.dialog.showModal(); + }, + + /** + * Saves the "Infinite scrolling" personal option for the current user. + * + * @param {Event} event The change event of the checkbox. + * @return {void} + */ + updateInfiniteScrolling: function( event ) { + var view = this, + checkbox = event.target, + enabled = checkbox.checked, + requestId = ++this.requestId; + + this.setStatus( __( 'Saving…' ) ); + + wp.ajax.post( 'set-media-library-settings', { + _ajax_nonce: settings.librarySettings.nonce, + infinite_scrolling: enabled ? 'true' : 'false' + } ).done( function() { + if ( requestId !== view.requestId ) { + return; + } + + settings.librarySettings.infiniteScrolling = enabled ? 1 : 0; + settings.infiniteScrolling = enabled ? 1 : 0; + + // Applied to the browser this toggle belongs to, without a reload. + view.controller.trigger( 'library:infinite-scrolling', enabled ); + + view.setStatus( enabled ? + __( 'Infinite scrolling is on.' ) : + __( 'Infinite scrolling is off.' ) + ); + } ).fail( function( response ) { + if ( requestId !== view.requestId ) { + return; + } + + // Put the checkbox back in sync with the stored value. + checkbox.checked = ! enabled; + + view.setStatus( ( response && response.message ) || __( 'The setting could not be saved.' ) ); + } ); + }, + + /** + * Updates the message below the controls. + * + * It sits in a `role="status"` region, so it is shown and announced at once. + * + * @param {string} message The message to display. An empty string clears it. + * @return {void} + */ + setStatus: function( message ) { + this.status.textContent = message; + } +}); + +module.exports = LibrarySettings; diff --git a/src/wp-admin/admin-ajax.php b/src/wp-admin/admin-ajax.php index 3ad60f95766e3..73190a24f92fd 100644 --- a/src/wp-admin/admin-ajax.php +++ b/src/wp-admin/admin-ajax.php @@ -80,6 +80,7 @@ 'closed-postboxes', 'hidden-columns', 'update-welcome-panel', + 'set-media-library-settings', 'menu-get-metabox', 'wp-link-ajax', 'menu-locations-save', diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css index 3d7b0c9455c83..21190af953ebf 100644 --- a/src/wp-admin/css/media.css +++ b/src/wp-admin/css/media.css @@ -571,7 +571,9 @@ border color while dragging a file over the uploader drop area */ min-height: 80px; } -.media-frame.mode-grid .media-toolbar label:not(.media-search-input-label) { +/* Hides the filter labels. Child selectors only, so labels inside toolbar views keep their styling. */ +.media-frame.mode-grid .media-toolbar-primary > label:not(.media-search-input-label), +.media-frame.mode-grid .media-toolbar-secondary > label:not(.media-search-input-label) { border: 0; clip-path: inset(50%); height: 1px; diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index c51751940a976..1c3ce07b5aea1 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -1891,6 +1891,30 @@ function wp_ajax_update_welcome_panel() { wp_die( 1 ); } +/** + * Handles saving the Media Library settings for the current user via AJAX. + * + * @since 7.2.0 + */ +function wp_ajax_set_media_library_settings() { + check_ajax_referer( 'media-library-settings' ); + + if ( ! current_user_can( 'upload_files' ) ) { + wp_send_json_error( array( 'message' => __( 'Sorry, you are not allowed to edit this setting.' ) ), 403 ); + } + + if ( ! isset( $_POST['infinite_scrolling'] ) ) { + wp_send_json_error( array( 'message' => __( 'The setting could not be saved.' ) ), 400 ); + } + + $infinite_scrolling = 'true' === wp_unslash( $_POST['infinite_scrolling'] ) ? 'true' : 'false'; + + // Plain user meta, without the site prefix update_user_option() would add, to match the personal option on the profile screen. + update_user_meta( get_current_user_id(), 'infinite_scrolling', $infinite_scrolling ); + + wp_send_json_success( array( 'infiniteScrolling' => 'true' === $infinite_scrolling ) ); +} + /** * Handles for retrieving menu meta boxes via AJAX. * diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 6748a50f00c57..053507f06eed4 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -984,6 +984,103 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { display: block; } +/** + * Media Library settings + */ +/* Square icon-only button, on top of the height `button-compact` gives it. */ +.wp-core-ui .button.media-library-settings__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + padding: 0; +} + +.wp-core-ui .button.media-library-settings__toggle .dashicons { + line-height: 1; +} + +/* The grid toolbar is a flex container with a column gap of its own. */ +.wp-core-ui .media-toolbar-primary > .media-library-settings__toggle { + margin-left: 8px; +} + +.wp-core-ui .mode-grid .media-toolbar-primary > .media-library-settings__toggle { + margin-left: 0; +} + +/* Matches the 40px min-height buttons.css gives buttons at this width. */ +@media screen and (max-width: 782px) { + .wp-core-ui .button.media-library-settings__toggle { + min-width: 40px; + width: 40px; + } +} + +.media-library-settings__dialog { + width: 320px; + max-width: calc(100vw - 32px); + padding: 0; + border: 1px solid #c3c4c7; + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + background: #fff; + color: #3c434a; + font-size: 13px; +} + +.media-library-settings__dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} + +.media-library-settings__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 8px 8px 16px; + border-bottom: 1px solid #dcdcde; +} + +.media-library-settings__header h2 { + margin: 0; + font-size: 14px; + line-height: 1.4; +} + +.media-library-settings__body { + padding: 16px; +} + +.media-library-settings__field { + margin: 0; +} + +/* The padding aligns the text with the label, past the checkbox. */ +.media-library-settings__body .description { + margin: 4px 0 0; + padding-left: 1.25rem; + color: #646970; +} + +.media-library-settings__status { + margin: 12px 0 0; + min-height: 2.4em; + color: #3c434a; +} + +.wp-core-ui .media-library-settings__close.button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + border: none; +} + +.wp-core-ui .media-library-settings__close.button .dashicons { + line-height: 1; +} + /** * Attachments */ @@ -1363,6 +1460,11 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { background: #dcdcde; } +/* Kept in the DOM when infinite scrolling is turned back on, so it can be reused. */ +.attachments-browser:not(.has-load-more) .load-more-wrapper { + display: none; +} + .load-more-wrapper { clear: both; display: flex; diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index 460cf3b3020e5..2d4f66047b99d 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -344,6 +344,50 @@ function wp_print_media_templates() { + + + + + +