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() {
+
+
+
+
+
+