[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-admin/js/ -> theme.js (source)

   1  /**
   2   * @output wp-admin/js/theme.js
   3   */
   4  
   5  /* global _wpThemeSettings, confirm, tb_position */
   6  window.wp = window.wp || {};
   7  
   8  ( function($) {
   9  
  10  // Set up our namespace...
  11  var themes, l10n;
  12  themes = wp.themes = wp.themes || {};
  13  
  14  // Store the theme data and settings for organized and quick access.
  15  // themes.data.settings, themes.data.themes, themes.data.l10n.
  16  themes.data = _wpThemeSettings;
  17  l10n = themes.data.l10n;
  18  
  19  /**
  20   * Announces to screen readers the theme shown after previous/next navigation.
  21   *
  22   * @since 7.1.0
  23   *
  24   * @param {Object} model The theme model.
  25   * @return {void}
  26   */
  27  themes.announceThemeDebounced = _.debounce( function( model ) {
  28      var name;
  29  
  30      if ( ! model ) {
  31          return;
  32      }
  33  
  34      name = model.get( 'name' ) || model.get( 'id' );
  35  
  36      if ( ! name ) {
  37          return;
  38      }
  39  
  40      wp.a11y.speak( l10n.themeViewed.replace( '%s', name ) );
  41  }, 500 );
  42  
  43  // Shortcut for isInstall check.
  44  themes.isInstall = !! themes.data.settings.isInstall;
  45  
  46  // Setup app structure.
  47  _.extend( themes, { model: {}, view: {}, routes: {}, router: {}, template: wp.template });
  48  
  49  themes.Model = Backbone.Model.extend({
  50      // Adds attributes to the default data coming through the .org themes api.
  51      // Map `id` to `slug` for shared code.
  52      initialize: function() {
  53          var description;
  54  
  55          if ( this.get( 'slug' ) ) {
  56              // If the theme is already installed, set an attribute.
  57              if ( _.indexOf( themes.data.installedThemes, this.get( 'slug' ) ) !== -1 ) {
  58                  this.set({ installed: true });
  59              }
  60  
  61              // If the theme is active, set an attribute.
  62              if ( themes.data.activeTheme === this.get( 'slug' ) ) {
  63                  this.set({ active: true });
  64              }
  65          }
  66  
  67          // Set the attributes.
  68          this.set({
  69              // `slug` is for installation, `id` is for existing.
  70              id: this.get( 'slug' ) || this.get( 'id' )
  71          });
  72  
  73          // Map `section.description` to `description`
  74          // as the API sometimes returns it differently.
  75          if ( this.has( 'sections' ) ) {
  76              description = this.get( 'sections' ).description;
  77              this.set({ description: description });
  78          }
  79      }
  80  });
  81  
  82  // Main view controller for themes.php.
  83  // Unifies and renders all available views.
  84  themes.view.Appearance = wp.Backbone.View.extend({
  85  
  86      el: '#wpbody-content .wrap .theme-browser',
  87  
  88      window: $( window ),
  89      // Pagination instance.
  90      page: 0,
  91  
  92      // Sets up a throttler for binding to 'scroll'.
  93      initialize: function( options ) {
  94          // Scroller checks how far the scroll position is.
  95          _.bindAll( this, 'scroller' );
  96  
  97          this.SearchView = options.SearchView ? options.SearchView : themes.view.Search;
  98          // Bind to the scroll event and throttle
  99          // the results from this.scroller.
 100          this.window.on( 'scroll', _.throttle( this.scroller, 300 ) );
 101      },
 102  
 103      // Main render control.
 104      render: function() {
 105          // Setup the main theme view
 106          // with the current theme collection.
 107          this.view = new themes.view.Themes({
 108              collection: this.collection,
 109              parent: this
 110          });
 111  
 112          // Render search form.
 113          this.search();
 114  
 115          this.$el.removeClass( 'search-loading' );
 116  
 117          // Render and append.
 118          this.view.render();
 119          this.$el.empty().append( this.view.el ).addClass( 'rendered' );
 120      },
 121  
 122      // Defines search element container.
 123      searchContainer: $( '.search-form' ),
 124  
 125      // Search input and view
 126      // for current theme collection.
 127      search: function() {
 128          var view,
 129              self = this;
 130  
 131          // Don't render the search if there is only one theme.
 132          if ( themes.data.themes.length === 1 ) {
 133              return;
 134          }
 135  
 136          view = new this.SearchView({
 137              collection: self.collection,
 138              parent: this
 139          });
 140          self.SearchView = view;
 141  
 142          // Render and append after screen title.
 143          view.render();
 144          this.searchContainer
 145              .find( '.search-box' )
 146              .append( $.parseHTML( '<label for="wp-filter-search-input">' + l10n.search + '</label>' ) )
 147              .append( view.el );
 148  
 149          this.searchContainer.on( 'submit', function( event ) {
 150              event.preventDefault();
 151          });
 152      },
 153  
 154      // Checks when the user gets close to the bottom
 155      // of the mage and triggers a theme:scroll event.
 156      scroller: function() {
 157          var self = this,
 158              bottom, threshold;
 159  
 160          bottom = this.window.scrollTop() + self.window.height();
 161          threshold = self.$el.offset().top + self.$el.outerHeight( false ) - self.window.height();
 162          threshold = Math.round( threshold * 0.9 );
 163  
 164          if ( bottom > threshold ) {
 165              this.trigger( 'theme:scroll' );
 166          }
 167      }
 168  });
 169  
 170  // Set up the Collection for our theme data.
 171  // @has 'id' 'name' 'screenshot' 'author' 'authorURI' 'version' 'active' ...
 172  themes.Collection = Backbone.Collection.extend({
 173  
 174      model: themes.Model,
 175  
 176      // Search terms.
 177      terms: '',
 178  
 179      // Controls searching on the current theme collection
 180      // and triggers an update event.
 181      doSearch: function( value ) {
 182  
 183          // Don't do anything if we've already done this search.
 184          // Useful because the Search handler fires multiple times per keystroke.
 185          if ( this.terms === value ) {
 186              return;
 187          }
 188  
 189          // Updates terms with the value passed.
 190          this.terms = value;
 191  
 192          // If we have terms, run a search...
 193          if ( this.terms.length > 0 ) {
 194              this.search( this.terms );
 195          }
 196  
 197          // If search is blank, show all themes.
 198          // Useful for resetting the views when you clean the input.
 199          if ( this.terms === '' ) {
 200              this.reset( themes.data.themes );
 201              $( 'body' ).removeClass( 'no-results' );
 202          }
 203  
 204          // Trigger a 'themes:update' event.
 205          this.trigger( 'themes:update' );
 206      },
 207  
 208      /**
 209       * Performs a search within the collection.
 210       *
 211       * @param {string} term The search term.
 212       */
 213      search: function( term ) {
 214          var match, results, haystack, name, description, author;
 215  
 216          // Start with a full collection.
 217          this.reset( themes.data.themes, { silent: true } );
 218  
 219          // Trim the term.
 220          term = term.trim();
 221  
 222          // Escape the term string for RegExp meta characters.
 223          term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
 224  
 225          // Consider spaces as word delimiters and match the whole string
 226          // so matching terms can be combined.
 227          term = term.replace( / /g, ')(?=.*' );
 228          match = new RegExp( '^(?=.*' + term + ').+', 'i' );
 229  
 230          // Find results.
 231          // _.filter() and .test().
 232          results = this.filter( function( data ) {
 233              name        = data.get( 'name' ).replace( /(<([^>]+)>)/ig, '' );
 234              description = data.get( 'description' ).replace( /(<([^>]+)>)/ig, '' );
 235              author      = data.get( 'author' ).replace( /(<([^>]+)>)/ig, '' );
 236  
 237              haystack = _.union( [ name, data.get( 'id' ), description, author, data.get( 'tags' ) ] );
 238  
 239              if ( match.test( data.get( 'author' ) ) && term.length > 2 ) {
 240                  data.set( 'displayAuthor', true );
 241              }
 242  
 243              return match.test( haystack );
 244          });
 245  
 246          if ( results.length === 0 ) {
 247              this.trigger( 'query:empty' );
 248          } else {
 249              $( 'body' ).removeClass( 'no-results' );
 250          }
 251  
 252          this.reset( results );
 253      },
 254  
 255      // Paginates the collection with a helper method
 256      // that slices the collection.
 257      paginate: function( instance ) {
 258          var collection = this;
 259          instance = instance || 0;
 260  
 261          // Themes per instance are set at 20.
 262          collection = _( collection.rest( 20 * instance ) );
 263          collection = _( collection.first( 20 ) );
 264  
 265          return collection;
 266      },
 267  
 268      count: false,
 269  
 270      /*
 271       * Handles requests for more themes and caches results.
 272       *
 273       *
 274       * When we are missing a cache object we fire an apiCall()
 275       * which triggers events of `query:success` or `query:fail`.
 276       */
 277      query: function( request ) {
 278          var queries = this.queries,
 279              self = this,
 280              query, isPaginated, count;
 281  
 282          // Store current query request args
 283          // for later use with the event `theme:end`.
 284          this.currentQuery.request = request;
 285  
 286          // Search the query cache for matches.
 287          query = _.find( queries, function( query ) {
 288              return _.isEqual( query.request, request );
 289          });
 290  
 291          // If the request matches the stored currentQuery.request
 292          // it means we have a paginated request.
 293          isPaginated = _.has( request, 'page' );
 294  
 295          // Reset the internal api page counter for non-paginated queries.
 296          if ( ! isPaginated ) {
 297              this.currentQuery.page = 1;
 298          }
 299  
 300          // Otherwise, send a new API call and add it to the cache.
 301          if ( ! query && ! isPaginated ) {
 302              query = this.apiCall( request ).done( function( data ) {
 303  
 304                  // Update the collection with the queried data.
 305                  if ( data.themes ) {
 306                      self.reset( data.themes );
 307                      count = data.info.results;
 308                      // Store the results and the query request.
 309                      queries.push( { themes: data.themes, request: request, total: count } );
 310                  }
 311  
 312                  // Trigger a collection refresh event
 313                  // and a `query:success` event with a `count` argument.
 314                  self.trigger( 'themes:update' );
 315                  self.trigger( 'query:success', count );
 316  
 317                  if ( data.themes && data.themes.length === 0 ) {
 318                      self.trigger( 'query:empty' );
 319                  }
 320  
 321              }).fail( function() {
 322                  self.trigger( 'query:fail' );
 323              });
 324          } else {
 325              // If it's a paginated request we need to fetch more themes...
 326              if ( isPaginated ) {
 327                  return this.apiCall( request, isPaginated ).done( function( data ) {
 328                      // Add the new themes to the current collection.
 329                      // @todo Update counter.
 330                      self.add( data.themes );
 331                      self.trigger( 'query:success' );
 332  
 333                      // We are done loading themes for now.
 334                      self.loadingThemes = false;
 335  
 336                  }).fail( function() {
 337                      self.trigger( 'query:fail' );
 338                  });
 339              }
 340  
 341              if ( query.themes.length === 0 ) {
 342                  self.trigger( 'query:empty' );
 343              } else {
 344                  $( 'body' ).removeClass( 'no-results' );
 345              }
 346  
 347              // Only trigger an update event since we already have the themes
 348              // on our cached object.
 349              if ( _.isNumber( query.total ) ) {
 350                  this.count = query.total;
 351              }
 352  
 353              this.reset( query.themes );
 354              if ( ! query.total ) {
 355                  this.count = this.length;
 356              }
 357  
 358              this.trigger( 'themes:update' );
 359              this.trigger( 'query:success', this.count );
 360          }
 361      },
 362  
 363      // Local cache array for API queries.
 364      queries: [],
 365  
 366      // Keep track of current query so we can handle pagination.
 367      currentQuery: {
 368          page: 1,
 369          request: {}
 370      },
 371  
 372      // Send request to api.wordpress.org/themes.
 373      apiCall: function( request, paginated ) {
 374          return wp.ajax.send( 'query-themes', {
 375              data: {
 376                  // Request data.
 377                  request: _.extend({
 378                      per_page: 100
 379                  }, request)
 380              },
 381  
 382              beforeSend: function() {
 383                  if ( ! paginated ) {
 384                      // Spin it.
 385                      $( 'body' ).addClass( 'loading-content' ).removeClass( 'no-results' );
 386                  }
 387              }
 388          });
 389      },
 390  
 391      // Static status controller for when we are loading themes.
 392      loadingThemes: false
 393  });
 394  
 395  // This is the view that controls each theme item
 396  // that will be displayed on the screen.
 397  themes.view.Theme = wp.Backbone.View.extend({
 398  
 399      // Wrap theme data on a div.theme element.
 400      className: 'theme',
 401  
 402      // Reflects which theme view we have.
 403      // 'grid' (default) or 'detail'.
 404      state: 'grid',
 405  
 406      // The HTML template for each element to be rendered.
 407      html: themes.template( 'theme' ),
 408  
 409      events: {
 410          'click': themes.isInstall ? 'preview': 'expand',
 411          'keydown': themes.isInstall ? 'preview': 'expand',
 412          'touchend': themes.isInstall ? 'preview': 'expand',
 413          'keyup': 'addFocus',
 414          'touchmove': 'preventExpand',
 415          'click .theme-install': 'installTheme',
 416          'click .update-message': 'updateTheme'
 417      },
 418  
 419      touchDrag: false,
 420  
 421      initialize: function() {
 422          this.model.on( 'change', this.render, this );
 423      },
 424  
 425      render: function() {
 426          var data = this.model.toJSON();
 427  
 428          // Render themes using the html template.
 429          this.$el.html( this.html( data ) ).attr( 'data-slug', data.id );
 430  
 431          // Renders active theme styles.
 432          this.activeTheme();
 433  
 434          if ( this.model.get( 'displayAuthor' ) ) {
 435              this.$el.addClass( 'display-author' );
 436          }
 437      },
 438  
 439      // Adds a class to the currently active theme
 440      // and to the overlay in detailed view mode.
 441      activeTheme: function() {
 442          if ( this.model.get( 'active' ) ) {
 443              this.$el.addClass( 'active' );
 444          }
 445      },
 446  
 447      // Add class of focus to the theme we are focused on.
 448      addFocus: function() {
 449          var $themeToFocus = ( $( ':focus' ).hasClass( 'theme' ) ) ? $( ':focus' ) : $(':focus').parents('.theme');
 450  
 451          $('.theme.focus').removeClass('focus');
 452          $themeToFocus.addClass('focus');
 453      },
 454  
 455      // Single theme overlay screen.
 456      // It's shown when clicking a theme.
 457      expand: function( event ) {
 458          var self = this;
 459  
 460          event = event || window.event;
 461  
 462          // 'Enter' and 'Space' keys expand the details view when a theme is :focused.
 463          if ( event.type === 'keydown' && ( event.which !== 13 && event.which !== 32 ) ) {
 464              return;
 465          }
 466  
 467          // Bail if the user scrolled on a touch device.
 468          if ( this.touchDrag === true ) {
 469              return this.touchDrag = false;
 470          }
 471  
 472          // Prevent the modal from showing when the user clicks
 473          // one of the direct action buttons.
 474          if ( $( event.target ).is( '.theme-actions a' ) ) {
 475              return;
 476          }
 477  
 478          // Prevent the modal from showing when the user clicks one of the direct action buttons.
 479          if ( $( event.target ).is( '.theme-actions a, .update-message, .button-link, .notice-dismiss' ) ) {
 480              return;
 481          }
 482  
 483          // Set focused theme to current element.
 484          themes.focusedTheme = this.$el;
 485  
 486          this.trigger( 'theme:expand', self.model.cid );
 487      },
 488  
 489      preventExpand: function() {
 490          this.touchDrag = true;
 491      },
 492  
 493      preview: function( event ) {
 494          var self = this,
 495              current, preview;
 496  
 497          event = event || window.event;
 498  
 499          // Bail if the user scrolled on a touch device.
 500          if ( this.touchDrag === true ) {
 501              return this.touchDrag = false;
 502          }
 503  
 504          // Allow direct link path to installing a theme.
 505          if ( $( event.target ).not( '.install-theme-preview' ).parents( '.theme-actions' ).length ) {
 506              return;
 507          }
 508  
 509          // 'Enter' and 'Space' keys expand the details view when a theme is :focused.
 510          if ( event.type === 'keydown' && ( event.which !== 13 && event.which !== 32 ) ) {
 511              return;
 512          }
 513  
 514          // Pressing Enter while focused on the buttons shouldn't open the preview.
 515          if ( event.type === 'keydown' && event.which !== 13 && $( ':focus' ).hasClass( 'button' ) ) {
 516              return;
 517          }
 518  
 519          event.preventDefault();
 520  
 521          event = event || window.event;
 522  
 523          // Set focus to current theme.
 524          themes.focusedTheme = this.$el;
 525  
 526          // Construct a new Preview view.
 527          themes.preview = preview = new themes.view.Preview({
 528              model: this.model
 529          });
 530  
 531          // Render the view and append it.
 532          preview.render();
 533          this.setNavButtonsState();
 534  
 535          // Hide previous/next navigation if there is only one theme.
 536          if ( this.model.collection.length === 1 ) {
 537              preview.$el.addClass( 'no-navigation' );
 538          } else {
 539              preview.$el.removeClass( 'no-navigation' );
 540          }
 541  
 542          // Append preview.
 543          $( 'div.wrap' ).append( preview.el );
 544  
 545          // Listen to our preview object
 546          // for `theme:next` and `theme:previous` events.
 547          this.listenTo( preview, 'theme:next', function() {
 548  
 549              // Keep local track of current theme model.
 550              current = self.model;
 551  
 552              // If we have ventured away from current model update the current model position.
 553              if ( ! _.isUndefined( self.current ) ) {
 554                  current = self.current;
 555              }
 556  
 557              // Get next theme model.
 558              self.current = self.model.collection.at( self.model.collection.indexOf( current ) + 1 );
 559  
 560              // If we have no more themes, bail.
 561              if ( _.isUndefined( self.current ) ) {
 562                  self.options.parent.parent.trigger( 'theme:end' );
 563                  return self.current = current;
 564              }
 565  
 566              preview.model = self.current;
 567  
 568              // Render and append.
 569              preview.render();
 570              this.setNavButtonsState();
 571              $( '.next-theme' ).trigger( 'focus' );
 572              themes.announceThemeDebounced( self.current );
 573          })
 574          .listenTo( preview, 'theme:previous', function() {
 575  
 576              // Keep track of current theme model.
 577              current = self.model;
 578  
 579              // Bail early if we are at the beginning of the collection.
 580              if ( self.model.collection.indexOf( self.current ) === 0 ) {
 581                  return;
 582              }
 583  
 584              // If we have ventured away from current model update the current model position.
 585              if ( ! _.isUndefined( self.current ) ) {
 586                  current = self.current;
 587              }
 588  
 589              // Get previous theme model.
 590              self.current = self.model.collection.at( self.model.collection.indexOf( current ) - 1 );
 591  
 592              // If we have no more themes, bail.
 593              if ( _.isUndefined( self.current ) ) {
 594                  return;
 595              }
 596  
 597              preview.model = self.current;
 598  
 599              // Render and append.
 600              preview.render();
 601              this.setNavButtonsState();
 602              $( '.previous-theme' ).trigger( 'focus' );
 603              themes.announceThemeDebounced( self.current );
 604          });
 605  
 606          this.listenTo( preview, 'preview:close', function() {
 607              self.current = self.model;
 608          });
 609  
 610      },
 611  
 612      // Handles .disabled classes for previous/next buttons in theme installer preview.
 613      setNavButtonsState: function() {
 614          var $themeInstaller = $( '.theme-install-overlay' ),
 615              current = _.isUndefined( this.current ) ? this.model : this.current,
 616              previousThemeButton = $themeInstaller.find( '.previous-theme' ),
 617              nextThemeButton = $themeInstaller.find( '.next-theme' );
 618  
 619          // Disable previous at the zero position.
 620          if ( 0 === this.model.collection.indexOf( current ) ) {
 621              previousThemeButton
 622                  .addClass( 'disabled' )
 623                  .prop( 'disabled', true );
 624  
 625              nextThemeButton.trigger( 'focus' );
 626          }
 627  
 628          // Disable next if the next model is undefined.
 629          if ( _.isUndefined( this.model.collection.at( this.model.collection.indexOf( current ) + 1 ) ) ) {
 630              nextThemeButton
 631                  .addClass( 'disabled' )
 632                  .prop( 'disabled', true );
 633  
 634              previousThemeButton.trigger( 'focus' );
 635          }
 636      },
 637  
 638      installTheme: function( event ) {
 639          var _this = this;
 640  
 641          event.preventDefault();
 642  
 643          wp.updates.maybeRequestFilesystemCredentials( event );
 644  
 645          $( document ).on( 'wp-theme-install-success', function( event, response ) {
 646              if ( _this.model.get( 'id' ) === response.slug ) {
 647                  _this.model.set( { 'installed': true } );
 648              }
 649              if ( response.blockTheme ) {
 650                  _this.model.set( { 'block_theme': true } );
 651              }
 652          } );
 653  
 654          wp.updates.installTheme( {
 655              slug: $( event.target ).data( 'slug' )
 656          } );
 657      },
 658  
 659      updateTheme: function( event ) {
 660          var _this = this;
 661  
 662          if ( ! this.model.get( 'hasPackage' ) ) {
 663              return;
 664          }
 665  
 666          event.preventDefault();
 667  
 668          wp.updates.maybeRequestFilesystemCredentials( event );
 669  
 670          $( document ).on( 'wp-theme-update-success', function( event, response ) {
 671              _this.model.off( 'change', _this.render, _this );
 672              if ( _this.model.get( 'id' ) === response.slug ) {
 673                  _this.model.set( {
 674                      hasUpdate: false,
 675                      version: response.newVersion
 676                  } );
 677              }
 678              _this.model.on( 'change', _this.render, _this );
 679          } );
 680  
 681          wp.updates.updateTheme( {
 682              slug: $( event.target ).parents( 'div.theme' ).first().data( 'slug' )
 683          } );
 684      }
 685  });
 686  
 687  // Theme Details view.
 688  // Sets up a modal overlay with the expanded theme data.
 689  themes.view.Details = wp.Backbone.View.extend({
 690  
 691      // Wrap theme data on a div.theme element.
 692      className: 'theme-overlay',
 693  
 694      events: {
 695          'click': 'collapse',
 696          'click .delete-theme': 'deleteTheme',
 697          'click .left': 'previousTheme',
 698          'click .right': 'nextTheme',
 699          'click #update-theme': 'updateTheme',
 700          'click .toggle-auto-update': 'autoupdateState'
 701      },
 702  
 703      // The HTML template for the theme overlay.
 704      html: themes.template( 'theme-single' ),
 705  
 706      render: function() {
 707          var data = this.model.toJSON();
 708          this.$el.html( this.html( data ) );
 709          // Renders active theme styles.
 710          this.activeTheme();
 711          // Set up navigation events.
 712          this.navigation();
 713          // Checks screenshot size.
 714          this.screenshotCheck( this.$el );
 715          // Contain "tabbing" inside the overlay.
 716          this.containFocus( this.$el );
 717      },
 718  
 719      // Adds a class to the currently active theme
 720      // and to the overlay in detailed view mode.
 721      activeTheme: function() {
 722          // Check the model has the active property.
 723          this.$el.toggleClass( 'active', this.model.get( 'active' ) );
 724      },
 725  
 726      // Set initial focus and constrain tabbing within the theme browser modal.
 727      containFocus: function( $el ) {
 728  
 729          // Set initial focus on the primary action control.
 730          _.delay( function() {
 731              $( '.theme-overlay' ).trigger( 'focus' );
 732          }, 100 );
 733  
 734          // Constrain tabbing within the modal.
 735          $el.on( 'keydown.wp-themes', function( event ) {
 736              var $firstFocusable = $el.find( '.theme-header button:not(.disabled)' ).first(),
 737                  $lastFocusable = $el.find( '.theme-actions a:visible' ).last();
 738  
 739              // Check for the Tab key.
 740              if ( 9 === event.which ) {
 741                  if ( $firstFocusable[0] === event.target && event.shiftKey ) {
 742                      $lastFocusable.trigger( 'focus' );
 743                      event.preventDefault();
 744                  } else if ( $lastFocusable[0] === event.target && ! event.shiftKey ) {
 745                      $firstFocusable.trigger( 'focus' );
 746                      event.preventDefault();
 747                  }
 748              }
 749          });
 750      },
 751  
 752      // Single theme overlay screen.
 753      // It's shown when clicking a theme.
 754      collapse: function( event ) {
 755          var self = this,
 756              scroll;
 757  
 758          event = event || window.event;
 759  
 760          // Prevent collapsing detailed view when there is only one theme available.
 761          if ( themes.data.themes.length === 1 ) {
 762              return;
 763          }
 764  
 765          // Detect if the click is inside the overlay and don't close it
 766          // unless the target was the div.back button.
 767          if ( $( event.target ).is( '.theme-backdrop' ) || $( event.target ).is( '.close' ) || event.keyCode === 27 ) {
 768  
 769              // Add a temporary closing class while overlay fades out.
 770              $( 'body' ).addClass( 'closing-overlay' );
 771  
 772              // With a quick fade out animation.
 773              this.$el.fadeOut( 130, function() {
 774                  // Clicking outside the modal box closes the overlay.
 775                  $( 'body' ).removeClass( 'closing-overlay' );
 776                  // Handle event cleanup.
 777                  self.closeOverlay();
 778  
 779                  // Get scroll position to avoid jumping to the top.
 780                  scroll = document.body.scrollTop;
 781  
 782                  // Clean the URL structure.
 783                  themes.router.navigate( themes.router.baseUrl( '' ) );
 784  
 785                  // Restore scroll position.
 786                  document.body.scrollTop = scroll;
 787  
 788                  // Return focus to the theme div.
 789                  if ( themes.focusedTheme ) {
 790                      themes.focusedTheme.find('.more-details').trigger( 'focus' );
 791                  }
 792              });
 793          }
 794  
 795          // Cancel any pending navigation announcement.
 796          themes.announceThemeDebounced.cancel();
 797      },
 798  
 799      // Handles .disabled classes for next/previous buttons.
 800      navigation: function() {
 801  
 802          // Disable Left/Right when at the start or end of the collection.
 803          if ( this.model.cid === this.model.collection.at(0).cid ) {
 804              this.$el.find( '.left' )
 805                  .addClass( 'disabled' )
 806                  .prop( 'disabled', true );
 807          }
 808          if ( this.model.cid === this.model.collection.at( this.model.collection.length - 1 ).cid ) {
 809              this.$el.find( '.right' )
 810                  .addClass( 'disabled' )
 811                  .prop( 'disabled', true );
 812          }
 813      },
 814  
 815      // Performs the actions to effectively close
 816      // the theme details overlay.
 817      closeOverlay: function() {
 818          $( 'body' ).removeClass( 'modal-open' );
 819          this.remove();
 820          this.unbind();
 821          this.trigger( 'theme:collapse' );
 822      },
 823  
 824      // Set state of the auto-update settings link after it has been changed and saved.
 825      autoupdateState: function() {
 826          var callback,
 827              _this = this;
 828  
 829          // Support concurrent clicks in different Theme Details overlays.
 830          callback = function( event, data ) {
 831              var autoupdate;
 832              if ( _this.model.get( 'id' ) === data.asset ) {
 833                  autoupdate = _this.model.get( 'autoupdate' );
 834                  autoupdate.enabled = 'enable' === data.state;
 835                  _this.model.set( { autoupdate: autoupdate } );
 836                  $( document ).off( 'wp-auto-update-setting-changed', callback );
 837              }
 838          };
 839  
 840          // Triggered in updates.js
 841          $( document ).on( 'wp-auto-update-setting-changed', callback );
 842      },
 843  
 844      updateTheme: function( event ) {
 845          var _this = this;
 846          event.preventDefault();
 847  
 848          wp.updates.maybeRequestFilesystemCredentials( event );
 849  
 850          $( document ).on( 'wp-theme-update-success', function( event, response ) {
 851              if ( _this.model.get( 'id' ) === response.slug ) {
 852                  _this.model.set( {
 853                      hasUpdate: false,
 854                      version: response.newVersion
 855                  } );
 856              }
 857              _this.render();
 858          } );
 859  
 860          wp.updates.updateTheme( {
 861              slug: $( event.target ).data( 'slug' )
 862          } );
 863      },
 864  
 865      deleteTheme: function( event ) {
 866          var _this = this,
 867              _collection = _this.model.collection,
 868              _themes = themes;
 869          event.preventDefault();
 870  
 871          // Confirmation dialog for deleting a theme.
 872          if ( ! window.confirm( wp.themes.data.settings.confirmDelete ) ) {
 873              return;
 874          }
 875  
 876          wp.updates.maybeRequestFilesystemCredentials( event );
 877  
 878          $( document ).one( 'wp-theme-delete-success', function( event, response ) {
 879              _this.$el.find( '.close' ).trigger( 'click' );
 880              $( '[data-slug="' + response.slug + '"]' ).css( { backgroundColor:'#faafaa' } ).fadeOut( 350, function() {
 881                  $( this ).remove();
 882                  _themes.data.themes = _.without( _themes.data.themes, _.findWhere( _themes.data.themes, { id: response.slug } ) );
 883  
 884                  $( '.wp-filter-search' ).val( '' );
 885                  _collection.doSearch( '' );
 886                  _collection.remove( _this.model );
 887                  _collection.trigger( 'themes:update' );
 888              } );
 889          } );
 890  
 891          wp.updates.deleteTheme( {
 892              slug: this.model.get( 'id' )
 893          } );
 894      },
 895  
 896      nextTheme: function() {
 897          var self = this;
 898          self.trigger( 'theme:next', self.model.cid );
 899          return false;
 900      },
 901  
 902      previousTheme: function() {
 903          var self = this;
 904          self.trigger( 'theme:previous', self.model.cid );
 905          return false;
 906      },
 907  
 908      // Checks if the theme screenshot is the old 300px width version
 909      // and adds a corresponding class if it's true.
 910      screenshotCheck: function( el ) {
 911          var screenshot, image;
 912  
 913          screenshot = el.find( '.screenshot img' );
 914          image = new Image();
 915          image.src = screenshot.attr( 'src' );
 916  
 917          // Width check.
 918          if ( image.width && image.width <= 300 ) {
 919              el.addClass( 'small-screenshot' );
 920          }
 921      }
 922  });
 923  
 924  // Theme Preview view.
 925  // Sets up a modal overlay with the expanded theme data.
 926  themes.view.Preview = themes.view.Details.extend({
 927  
 928      className: 'wp-full-overlay expanded',
 929      el: '.theme-install-overlay',
 930  
 931      events: {
 932          'click .close-full-overlay': 'close',
 933          'click .collapse-sidebar': 'collapse',
 934          'click .devices button': 'previewDevice',
 935          'click .previous-theme': 'previousTheme',
 936          'click .next-theme': 'nextTheme',
 937          'keydown': 'keyEvent',
 938          'click .theme-install': 'installTheme'
 939      },
 940  
 941      // The HTML template for the theme preview.
 942      html: themes.template( 'theme-preview' ),
 943  
 944      render: function() {
 945          var self = this,
 946              currentPreviewDevice,
 947              data = this.model.toJSON(),
 948              $body = $( document.body );
 949  
 950          $body.attr( 'aria-busy', 'true' );
 951  
 952          this.$el.removeClass( 'iframe-ready' ).html( this.html( data ) );
 953  
 954          currentPreviewDevice = this.$el.data( 'current-preview-device' );
 955          if ( currentPreviewDevice ) {
 956              self.togglePreviewDeviceButtons( currentPreviewDevice );
 957          }
 958  
 959          themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.get( 'id' ) ), { replace: false } );
 960  
 961          this.$el.fadeIn( 200, function() {
 962              $body.addClass( 'theme-installer-active full-overlay-active' );
 963          });
 964  
 965          this.$el.find( 'iframe' ).one( 'load', function() {
 966              self.iframeLoaded();
 967          });
 968      },
 969  
 970      iframeLoaded: function() {
 971          this.$el.addClass( 'iframe-ready' );
 972          $( document.body ).attr( 'aria-busy', 'false' );
 973      },
 974  
 975      close: function() {
 976          this.$el.fadeOut( 200, function() {
 977              $( 'body' ).removeClass( 'theme-installer-active full-overlay-active' );
 978  
 979              // Return focus to the theme div.
 980              if ( themes.focusedTheme ) {
 981                  themes.focusedTheme.find('.more-details').trigger( 'focus' );
 982              }
 983          }).removeClass( 'iframe-ready' );
 984  
 985          // Restore the previous browse tab if available.
 986          if ( themes.router.selectedTab ) {
 987              themes.router.navigate( themes.router.baseUrl( '?browse=' + themes.router.selectedTab ) );
 988              themes.router.selectedTab = false;
 989          } else {
 990              themes.router.navigate( themes.router.baseUrl( '' ) );
 991          }
 992          this.trigger( 'preview:close' );
 993          this.undelegateEvents();
 994          this.unbind();
 995  
 996          // Cancel any pending navigation announcement.
 997          themes.announceThemeDebounced.cancel();
 998          return false;
 999      },
1000  
1001      collapse: function( event ) {
1002          var $button = $( event.currentTarget );
1003          if ( 'true' === $button.attr( 'aria-expanded' ) ) {
1004              $button.attr({ 'aria-expanded': 'false', 'aria-label': l10n.expandSidebar });
1005          } else {
1006              $button.attr({ 'aria-expanded': 'true', 'aria-label': l10n.collapseSidebar });
1007          }
1008  
1009          this.$el.toggleClass( 'collapsed' ).toggleClass( 'expanded' );
1010          return false;
1011      },
1012  
1013      previewDevice: function( event ) {
1014          var device = $( event.currentTarget ).data( 'device' );
1015  
1016          this.$el
1017              .removeClass( 'preview-desktop preview-tablet preview-mobile' )
1018              .addClass( 'preview-' + device )
1019              .data( 'current-preview-device', device );
1020  
1021          this.togglePreviewDeviceButtons( device );
1022      },
1023  
1024      togglePreviewDeviceButtons: function( newDevice ) {
1025          var $devices = $( '.wp-full-overlay-footer .devices' );
1026  
1027          $devices.find( 'button' )
1028              .removeClass( 'active' )
1029              .attr( 'aria-pressed', false );
1030  
1031          $devices.find( 'button.preview-' + newDevice )
1032              .addClass( 'active' )
1033              .attr( 'aria-pressed', true );
1034      },
1035  
1036      keyEvent: function( event ) {
1037          // The escape key closes the preview.
1038          if ( event.keyCode === 27 ) {
1039              this.undelegateEvents();
1040              this.close();
1041          }
1042  
1043          // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation.
1044          if ( ! event.altKey ) {
1045              return;
1046          }
1047  
1048          // The right arrow key, next theme.
1049          if ( event.keyCode === 39 ) {
1050              event.preventDefault();
1051              this.nextTheme();
1052          }
1053  
1054          // The left arrow key, previous theme.
1055          if ( event.keyCode === 37 ) {
1056              event.preventDefault();
1057              this.previousTheme();
1058          }
1059      },
1060  
1061      installTheme: function( event ) {
1062          var _this   = this,
1063              $target = $( event.target );
1064          event.preventDefault();
1065  
1066          if ( $target.hasClass( 'disabled' ) ) {
1067              return;
1068          }
1069  
1070          wp.updates.maybeRequestFilesystemCredentials( event );
1071  
1072          $( document ).on( 'wp-theme-install-success', function() {
1073              _this.model.set( { 'installed': true } );
1074          } );
1075  
1076          wp.updates.installTheme( {
1077              slug: $target.data( 'slug' )
1078          } );
1079      }
1080  });
1081  
1082  // Controls the rendering of div.themes,
1083  // a wrapper that will hold all the theme elements.
1084  themes.view.Themes = wp.Backbone.View.extend({
1085  
1086      className: 'themes wp-clearfix',
1087      $overlay: $( 'div.theme-overlay' ),
1088  
1089      // Number to keep track of scroll position
1090      // while in theme-overlay mode.
1091      index: 0,
1092  
1093      // The theme count element.
1094      count: $( '.wrap .theme-count' ),
1095  
1096      // The live themes count.
1097      liveThemeCount: 0,
1098  
1099      initialize: function( options ) {
1100          var self = this;
1101  
1102          // Set up parent.
1103          this.parent = options.parent;
1104  
1105          // Set current view to [grid].
1106          this.setView( 'grid' );
1107  
1108          // Move the active theme to the beginning of the collection.
1109          self.currentTheme();
1110  
1111          // When the collection is updated by user input...
1112          this.listenTo( self.collection, 'themes:update', function() {
1113              self.parent.page = 0;
1114              self.currentTheme();
1115              self.render( this );
1116          } );
1117  
1118          // Update theme count to full result set when available.
1119          this.listenTo( self.collection, 'query:success', function( count ) {
1120              if ( _.isNumber( count ) ) {
1121                  self.count.text( count );
1122                  self.announceSearchResults( count );
1123              } else {
1124                  self.count.text( self.collection.length );
1125                  self.announceSearchResults( self.collection.length );
1126              }
1127          });
1128  
1129          this.listenTo( self.collection, 'query:empty', function() {
1130              $( 'body' ).addClass( 'no-results' );
1131          });
1132  
1133          this.listenTo( this.parent, 'theme:scroll', function() {
1134              self.renderThemes( self.parent.page );
1135          });
1136  
1137          this.listenTo( this.parent, 'theme:close', function() {
1138              if ( self.overlay ) {
1139                  self.overlay.closeOverlay();
1140              }
1141          } );
1142  
1143          // Bind keyboard events.
1144          $( 'body' ).on( 'keydown.wp-themes', function( event ) {
1145              if ( ! self.overlay ) {
1146                  return;
1147              }
1148  
1149              // Bail if the filesystem credentials dialog is shown.
1150              if ( $( '#request-filesystem-credentials-dialog' ).is( ':visible' ) ) {
1151                  return;
1152              }
1153  
1154              // Pressing the escape key fires a theme:collapse event.
1155              if ( event.keyCode === 27 ) {
1156                  self.overlay.collapse( event );
1157              }
1158  
1159              // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation.
1160              if ( ! event.altKey ) {
1161                  return;
1162              }
1163  
1164              // Pressing Alt + right arrow key fires a theme:next event.
1165              if ( event.keyCode === 39 ) {
1166                  event.preventDefault();
1167                  self.overlay.nextTheme();
1168              }
1169  
1170              // Pressing Alt + left arrow key fires a theme:previous event.
1171              if ( event.keyCode === 37 ) {
1172                  event.preventDefault();
1173                  self.overlay.previousTheme();
1174              }
1175          });
1176      },
1177  
1178      // Manages rendering of theme pages
1179      // and keeping theme count in sync.
1180      render: function() {
1181          // Clear the DOM, please.
1182          this.$el.empty();
1183  
1184          // If the user doesn't have switch capabilities or there is only one theme
1185          // in the collection, render the detailed view of the active theme.
1186          if ( themes.data.themes.length === 1 ) {
1187  
1188              // Constructs the view.
1189              this.singleTheme = new themes.view.Details({
1190                  model: this.collection.models[0]
1191              });
1192  
1193              // Render and apply a 'single-theme' class to our container.
1194              this.singleTheme.render();
1195              this.$el.addClass( 'single-theme' );
1196              this.$el.append( this.singleTheme.el );
1197          }
1198  
1199          // Generate the themes using page instance
1200          // while checking the collection has items.
1201          if ( this.options.collection.size() > 0 ) {
1202              this.renderThemes( this.parent.page );
1203          }
1204  
1205          // Display a live theme count for the collection.
1206          this.liveThemeCount = this.collection.count ? this.collection.count : this.collection.length;
1207          this.count.text( this.liveThemeCount );
1208  
1209          /*
1210           * In the theme installer the themes count is already announced
1211           * because `announceSearchResults` is called on `query:success`.
1212           */
1213          if ( ! themes.isInstall ) {
1214              this.announceSearchResults( this.liveThemeCount );
1215          }
1216      },
1217  
1218      // Iterates through each instance of the collection
1219      // and renders each theme module.
1220      renderThemes: function( page ) {
1221          var self = this;
1222  
1223          self.instance = self.collection.paginate( page );
1224  
1225          // If we have no more themes, bail.
1226          if ( self.instance.size() === 0 ) {
1227              // Fire a no-more-themes event.
1228              this.parent.trigger( 'theme:end' );
1229              return;
1230          }
1231  
1232          // Make sure the add-new stays at the end.
1233          if ( ! themes.isInstall && page >= 1 ) {
1234              $( '.add-new-theme' ).remove();
1235          }
1236  
1237          // Loop through the themes and setup each theme view.
1238          self.instance.each( function( theme ) {
1239              self.theme = new themes.view.Theme({
1240                  model: theme,
1241                  parent: self
1242              });
1243  
1244              // Render the views...
1245              self.theme.render();
1246              // ...and append them to div.themes.
1247              self.$el.append( self.theme.el );
1248  
1249              // Binds to theme:expand to show the modal box
1250              // with the theme details.
1251              self.listenTo( self.theme, 'theme:expand', self.expand, self );
1252          });
1253  
1254          // 'Add new theme' element shown at the end of the grid.
1255          if ( ! themes.isInstall && themes.data.settings.canInstall ) {
1256              this.$el.append( '<div class="theme add-new-theme"><a href="' + themes.data.settings.installURI + '"><div class="theme-screenshot"><span aria-hidden="true"></span></div><h2 class="theme-name">' + l10n.addNew + '</h2></a></div>' );
1257          }
1258  
1259          this.parent.page++;
1260      },
1261  
1262      // Grabs current theme and puts it at the beginning of the collection.
1263      currentTheme: function() {
1264          var self = this,
1265              current;
1266  
1267          current = self.collection.findWhere({ active: true });
1268  
1269          // Move the active theme to the beginning of the collection.
1270          if ( current ) {
1271              self.collection.remove( current );
1272              self.collection.add( current, { at:0 } );
1273          }
1274      },
1275  
1276      // Sets current view.
1277      setView: function( view ) {
1278          return view;
1279      },
1280  
1281      // Renders the overlay with the ThemeDetails view.
1282      // Uses the current model data.
1283      expand: function( id ) {
1284          var self = this, $card, $modal;
1285  
1286          // Set the current theme model.
1287          this.model = self.collection.get( id );
1288  
1289          // Trigger a route update for the current model.
1290          themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.id ) );
1291  
1292          // Sets this.view to 'detail'.
1293          this.setView( 'detail' );
1294          $( 'body' ).addClass( 'modal-open' );
1295  
1296          // Set up the theme details view.
1297          this.overlay = new themes.view.Details({
1298              model: self.model
1299          });
1300  
1301          this.overlay.render();
1302  
1303          if ( this.model.get( 'hasUpdate' ) ) {
1304              $card  = $( '[data-slug="' + this.model.id + '"]' );
1305              $modal = $( this.overlay.el );
1306  
1307              if ( $card.find( '.updating-message' ).length ) {
1308                  $modal.find( '.notice-warning h3' ).remove();
1309                  $modal.find( '.notice-warning' )
1310                      .removeClass( 'notice-large' )
1311                      .addClass( 'updating-message' )
1312                      .find( 'p' ).text( wp.updates.l10n.updating );
1313              } else if ( $card.find( '.notice-error' ).length ) {
1314                  $modal.find( '.notice-warning' ).remove();
1315              }
1316          }
1317  
1318          this.$overlay.html( this.overlay.el );
1319  
1320          // Bind to theme:next and theme:previous triggered by the arrow keys.
1321          // Keep track of the current model so we can infer an index position.
1322          this.listenTo( this.overlay, 'theme:next', function() {
1323              // Renders the next theme on the overlay.
1324              self.next( [ self.model.cid ] );
1325  
1326          })
1327          .listenTo( this.overlay, 'theme:previous', function() {
1328              // Renders the previous theme on the overlay.
1329              self.previous( [ self.model.cid ] );
1330          });
1331      },
1332  
1333      /*
1334       * This method renders the next theme on the overlay modal
1335       * based on the current position in the collection.
1336       *
1337       * @params [model cid]
1338       */
1339      next: function( args ) {
1340          var self = this,
1341              model, nextModel;
1342  
1343          // Get the current theme.
1344          model = self.collection.get( args[0] );
1345          // Find the next model within the collection.
1346          nextModel = self.collection.at( self.collection.indexOf( model ) + 1 );
1347  
1348          // Confidence check which also serves as a boundary test.
1349          if ( nextModel !== undefined ) {
1350  
1351              // We have a new theme...
1352              // Close the overlay.
1353              this.overlay.closeOverlay();
1354  
1355              // Trigger a route update for the current model.
1356              self.theme.trigger( 'theme:expand', nextModel.cid );
1357              themes.announceThemeDebounced( nextModel );
1358          }
1359      },
1360  
1361      /*
1362       * This method renders the previous theme on the overlay modal
1363       * based on the current position in the collection.
1364       *
1365       * @params [model cid]
1366       */
1367      previous: function( args ) {
1368          var self = this,
1369              model, previousModel, index;
1370  
1371          // Get the current theme.
1372          model = self.collection.get( args[0] );
1373  
1374          index = self.collection.indexOf( model );
1375  
1376          // Bail early if the current theme is the first one or the model does not exist.
1377          if ( index <= 0 ) {
1378              return;
1379          }
1380  
1381          // Find the previous model within the collection.
1382          previousModel = self.collection.at( index - 1 );
1383  
1384          if ( previousModel !== undefined ) {
1385  
1386              // We have a new theme...
1387              // Close the overlay.
1388              this.overlay.closeOverlay();
1389  
1390              // Trigger a route update for the current model.
1391              self.theme.trigger( 'theme:expand', previousModel.cid );
1392              themes.announceThemeDebounced( previousModel );
1393          }
1394      },
1395  
1396      // Dispatch audible search results feedback message.
1397      announceSearchResults: function( count ) {
1398          if ( 0 === count ) {
1399              wp.a11y.speak( l10n.noThemesFound );
1400          } else {
1401              wp.a11y.speak( l10n.themesFound.replace( '%d', count ) );
1402          }
1403      }
1404  });
1405  
1406  // Search input view controller.
1407  themes.view.Search = wp.Backbone.View.extend({
1408  
1409      tagName: 'input',
1410      className: 'wp-filter-search',
1411      id: 'wp-filter-search-input',
1412      searching: false,
1413  
1414      attributes: {
1415          type: 'search',
1416          'aria-describedby': 'live-search-desc'
1417      },
1418  
1419      events: {
1420          'input': 'search',
1421          'keyup': 'search',
1422          'blur': 'pushState'
1423      },
1424  
1425      initialize: function( options ) {
1426  
1427          this.parent = options.parent;
1428  
1429          this.listenTo( this.parent, 'theme:close', function() {
1430              this.searching = false;
1431          } );
1432  
1433      },
1434  
1435      search: function( event ) {
1436          // Clear on escape.
1437          if ( event.type === 'keyup' && event.which === 27 ) {
1438              event.target.value = '';
1439          }
1440  
1441          // Since doSearch is debounced, it will only run when user input comes to a rest.
1442          this.doSearch( event );
1443      },
1444  
1445      // Runs a search on the theme collection.
1446      doSearch: function( event ) {
1447          var options = {};
1448  
1449          this.collection.doSearch( event.target.value.replace( /\+/g, ' ' ) );
1450  
1451          // if search is initiated and key is not return.
1452          if ( this.searching && event.which !== 13 ) {
1453              options.replace = true;
1454          } else {
1455              this.searching = true;
1456          }
1457  
1458          // Update the URL hash.
1459          if ( event.target.value ) {
1460              themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + event.target.value ), options );
1461          } else {
1462              themes.router.navigate( themes.router.baseUrl( '' ) );
1463          }
1464      },
1465  
1466      pushState: function( event ) {
1467          var url = themes.router.baseUrl( '' );
1468  
1469          if ( event.target.value ) {
1470              url = themes.router.baseUrl( themes.router.searchPath + encodeURIComponent( event.target.value ) );
1471          }
1472  
1473          this.searching = false;
1474          themes.router.navigate( url );
1475  
1476      }
1477  });
1478  
1479  /**
1480   * Navigate router.
1481   *
1482   * @since 4.9.0
1483   *
1484   * @param {string} url   URL to navigate to.
1485   * @param {Object} state State.
1486   * @return {void}
1487   */
1488  function navigateRouter( url, state ) {
1489      var router = this;
1490      if ( Backbone.history._hasPushState ) {
1491          Backbone.Router.prototype.navigate.call( router, url, state );
1492      }
1493  }
1494  
1495  // Sets up the routes events for relevant url queries.
1496  // Listens to [theme] and [search] params.
1497  themes.Router = Backbone.Router.extend({
1498  
1499      routes: {
1500          'themes.php?theme=:slug': 'theme',
1501          'themes.php?search=:query': 'search',
1502          'themes.php?s=:query': 'search',
1503          'themes.php': 'themes',
1504          '': 'themes'
1505      },
1506  
1507      baseUrl: function( url ) {
1508          return 'themes.php' + url;
1509      },
1510  
1511      themePath: '?theme=',
1512      searchPath: '?search=',
1513  
1514      search: function( query ) {
1515          $( '.wp-filter-search' ).val( query.replace( /\+/g, ' ' ) );
1516      },
1517  
1518      themes: function() {
1519          $( '.wp-filter-search' ).val( '' );
1520      },
1521  
1522      navigate: navigateRouter
1523  
1524  });
1525  
1526  // Execute and setup the application.
1527  themes.Run = {
1528      init: function() {
1529          // Initializes the blog's theme library view.
1530          // Create a new collection with data.
1531          this.themes = new themes.Collection( themes.data.themes );
1532  
1533          // Set up the view.
1534          this.view = new themes.view.Appearance({
1535              collection: this.themes
1536          });
1537  
1538          this.render();
1539  
1540          // Start debouncing user searches after Backbone.history.start().
1541          this.view.SearchView.doSearch = _.debounce( this.view.SearchView.doSearch, 500 );
1542      },
1543  
1544      render: function() {
1545  
1546          // Render results.
1547          this.view.render();
1548          this.routes();
1549  
1550          if ( Backbone.History.started ) {
1551              Backbone.history.stop();
1552          }
1553          Backbone.history.start({
1554              root: themes.data.settings.adminUrl,
1555              pushState: true,
1556              hashChange: false
1557          });
1558      },
1559  
1560      routes: function() {
1561          var self = this;
1562          // Bind to our global thx object
1563          // so that the object is available to sub-views.
1564          themes.router = new themes.Router();
1565  
1566          // Handles theme details route event.
1567          themes.router.on( 'route:theme', function( slug ) {
1568              self.view.view.expand( slug );
1569          });
1570  
1571          themes.router.on( 'route:themes', function() {
1572              self.themes.doSearch( '' );
1573              self.view.trigger( 'theme:close' );
1574          });
1575  
1576          // Handles search route event.
1577          themes.router.on( 'route:search', function() {
1578              $( '.wp-filter-search' ).trigger( 'keyup' );
1579          });
1580  
1581          this.extraRoutes();
1582      },
1583  
1584      extraRoutes: function() {
1585          return false;
1586      }
1587  };
1588  
1589  // Extend the main Search view.
1590  themes.view.InstallerSearch =  themes.view.Search.extend({
1591  
1592      events: {
1593          'input': 'search',
1594          'keyup': 'search'
1595      },
1596  
1597      terms: '',
1598  
1599      // Handles Ajax request for searching through themes in public repo.
1600      search: function( event ) {
1601  
1602          // Tabbing or reverse tabbing into the search input shouldn't trigger a search.
1603          if ( event.type === 'keyup' && ( event.which === 9 || event.which === 16 ) ) {
1604              return;
1605          }
1606  
1607          this.collection = this.options.parent.view.collection;
1608  
1609          // Clear on escape.
1610          if ( event.type === 'keyup' && event.which === 27 ) {
1611              event.target.value = '';
1612          }
1613  
1614          this.doSearch( event.target.value );
1615      },
1616  
1617      doSearch: function( value ) {
1618          var request = {};
1619  
1620          // Don't do anything if the search terms haven't changed.
1621          if ( this.terms === value ) {
1622              return;
1623          }
1624  
1625          // Updates terms with the value passed.
1626          this.terms = value;
1627  
1628          request.search = value;
1629  
1630          /*
1631           * Intercept an [author] search.
1632           *
1633           * If input value starts with `author:` send a request
1634           * for `author` instead of a regular `search`.
1635           */
1636          if ( value.substring( 0, 7 ) === 'author:' ) {
1637              request.search = '';
1638              request.author = value.slice( 7 );
1639          }
1640  
1641          /*
1642           * Intercept a [tag] search.
1643           *
1644           * If input value starts with `tag:` send a request
1645           * for `tag` instead of a regular `search`.
1646           */
1647          if ( value.substring( 0, 4 ) === 'tag:' ) {
1648              request.search = '';
1649              request.tag = [ value.slice( 4 ) ];
1650          }
1651  
1652          $( '.filter-links li > a.current' )
1653              .removeClass( 'current' )
1654              .removeAttr( 'aria-current' );
1655  
1656          $( 'body' ).removeClass( 'show-filters filters-applied show-favorites-form' );
1657          $( '.drawer-toggle' ).attr( 'aria-expanded', 'false' );
1658  
1659          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1660          // or searching the local cache.
1661          this.collection.query( request );
1662  
1663          // Set route.
1664          themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + encodeURIComponent( value ) ), { replace: true } );
1665      }
1666  });
1667  
1668  themes.view.Installer = themes.view.Appearance.extend({
1669  
1670      el: '#wpbody-content .wrap',
1671  
1672      // Register events for sorting and filters in theme-navigation.
1673      events: {
1674          'click .filter-links li > a': 'onSort',
1675          'click .theme-filter': 'onFilter',
1676          'click .drawer-toggle': 'moreFilters',
1677          'click .filter-drawer .apply-filters': 'applyFilters',
1678          'click .filter-group [type="checkbox"]': 'addFilter',
1679          'click .filter-drawer .clear-filters': 'clearFilters',
1680          'click .edit-filters': 'backToFilters',
1681          'click .favorites-form-submit' : 'saveUsername',
1682          'keyup #wporg-username-input': 'saveUsername'
1683      },
1684  
1685      // Initial render method.
1686      render: function() {
1687          var self = this;
1688  
1689          this.search();
1690          this.uploader();
1691  
1692          this.collection = new themes.Collection();
1693  
1694          // Bump `collection.currentQuery.page` and request more themes if we hit the end of the page.
1695          this.listenTo( this, 'theme:end', function() {
1696  
1697              // Make sure we are not already loading.
1698              if ( self.collection.loadingThemes ) {
1699                  return;
1700              }
1701  
1702              // Set loadingThemes to true and bump page instance of currentQuery.
1703              self.collection.loadingThemes = true;
1704              self.collection.currentQuery.page++;
1705  
1706              // Use currentQuery.page to build the themes request.
1707              _.extend( self.collection.currentQuery.request, { page: self.collection.currentQuery.page } );
1708              self.collection.query( self.collection.currentQuery.request );
1709          });
1710  
1711          this.listenTo( this.collection, 'query:success', function() {
1712              $( 'body' ).removeClass( 'loading-content' );
1713              $( '.theme-browser' ).find( 'div.error' ).remove();
1714          });
1715  
1716          this.listenTo( this.collection, 'query:fail', function() {
1717              $( 'body' ).removeClass( 'loading-content' );
1718              $( '.theme-browser' ).find( 'div.error' ).remove();
1719              $( '.theme-browser' ).find( 'div.themes' ).before( '<div class="notice notice-error"><p>' + l10n.error + '</p><p><button class="button try-again">' + l10n.tryAgain + '</button></p></div>' );
1720              $( '.theme-browser .error .try-again' ).on( 'click', function( e ) {
1721                  e.preventDefault();
1722                  $( 'input.wp-filter-search' ).trigger( 'input' );
1723              } );
1724          });
1725  
1726          if ( this.view ) {
1727              this.view.remove();
1728          }
1729  
1730          // Sets up the view and passes the section argument.
1731          this.view = new themes.view.Themes({
1732              collection: this.collection,
1733              parent: this
1734          });
1735  
1736          // Reset pagination every time the install view handler is run.
1737          this.page = 0;
1738  
1739          // Render and append.
1740          this.$el.find( '.themes' ).remove();
1741          this.view.render();
1742          this.$el.find( '.theme-browser' ).append( this.view.el ).addClass( 'rendered' );
1743      },
1744  
1745      // Handles all the rendering of the public theme directory.
1746      browse: function( section ) {
1747          // Create a new collection with the proper theme data
1748          // for each section.
1749          if ( 'block-themes' === section ) {
1750              // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1751              // or searching the local cache.
1752              this.collection.query( { tag: 'full-site-editing' } );
1753          } else {
1754              this.collection.query( { browse: section } );
1755          }
1756      },
1757  
1758      // Sorting navigation.
1759      onSort: function( event ) {
1760          var $el = $( event.target ),
1761              sort = $el.data( 'sort' );
1762  
1763          event.preventDefault();
1764  
1765          $( 'body' ).removeClass( 'filters-applied show-filters' );
1766          $( '.drawer-toggle' ).attr( 'aria-expanded', 'false' );
1767  
1768          // Bail if this is already active.
1769          if ( $el.hasClass( this.activeClass ) ) {
1770              return;
1771          }
1772  
1773          this.sort( sort );
1774  
1775          // Trigger a router.navigate update.
1776          themes.router.navigate( themes.router.baseUrl( themes.router.browsePath + sort ) );
1777      },
1778  
1779      sort: function( sort ) {
1780          this.clearSearch();
1781  
1782          // Track sorting so we can restore the correct tab when closing preview.
1783          themes.router.selectedTab = sort;
1784  
1785          $( '.filter-links li > a, .theme-filter' )
1786              .removeClass( this.activeClass )
1787              .removeAttr( 'aria-current' );
1788  
1789          $( '[data-sort="' + sort + '"]' )
1790              .addClass( this.activeClass )
1791              .attr( 'aria-current', 'page' );
1792  
1793          if ( 'favorites' === sort ) {
1794              $( 'body' ).addClass( 'show-favorites-form' );
1795          } else {
1796              $( 'body' ).removeClass( 'show-favorites-form' );
1797          }
1798  
1799          this.browse( sort );
1800      },
1801  
1802      // Filters and Tags.
1803      onFilter: function( event ) {
1804          var request,
1805              $el = $( event.target ),
1806              filter = $el.data( 'filter' );
1807  
1808          // Bail if this is already active.
1809          if ( $el.hasClass( this.activeClass ) ) {
1810              return;
1811          }
1812  
1813          $( '.filter-links li > a, .theme-section' )
1814              .removeClass( this.activeClass )
1815              .removeAttr( 'aria-current' );
1816          $el
1817              .addClass( this.activeClass )
1818              .attr( 'aria-current', 'page' );
1819  
1820          if ( ! filter ) {
1821              return;
1822          }
1823  
1824          // Construct the filter request
1825          // using the default values.
1826          filter = _.union( [ filter, this.filtersChecked() ] );
1827          request = { tag: [ filter ] };
1828  
1829          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1830          // or searching the local cache.
1831          this.collection.query( request );
1832      },
1833  
1834      // Clicking on a checkbox to add another filter to the request.
1835      addFilter: function() {
1836          this.filtersChecked();
1837      },
1838  
1839      // Applying filters triggers a tag request.
1840      applyFilters: function( event ) {
1841          var name,
1842              tags = this.filtersChecked(),
1843              request = { tag: tags },
1844              filteringBy = $( '.filtered-by .tags' );
1845  
1846          if ( event ) {
1847              event.preventDefault();
1848          }
1849  
1850          if ( ! tags ) {
1851              wp.a11y.speak( l10n.selectFeatureFilter );
1852              return;
1853          }
1854  
1855          $( 'body' ).addClass( 'filters-applied' );
1856          $( '.filter-links li > a.current' )
1857              .removeClass( 'current' )
1858              .removeAttr( 'aria-current' );
1859  
1860          filteringBy.empty();
1861  
1862          _.each( tags, function( tag ) {
1863              name = $( 'label[for="filter-id-' + tag + '"]' ).text();
1864              filteringBy.append( '<span class="tag">' + name + '</span>' );
1865          });
1866  
1867          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1868          // or searching the local cache.
1869          this.collection.query( request );
1870      },
1871  
1872      // Save the user's WordPress.org username and get his favorite themes.
1873      saveUsername: function ( event ) {
1874          var username = $( '#wporg-username-input' ).val(),
1875              nonce = $( '#wporg-username-nonce' ).val(),
1876              request = { browse: 'favorites', user: username },
1877              that = this;
1878  
1879          if ( event ) {
1880              event.preventDefault();
1881          }
1882  
1883          // Save username on enter.
1884          if ( event.type === 'keyup' && event.which !== 13 ) {
1885              return;
1886          }
1887  
1888          return wp.ajax.send( 'save-wporg-username', {
1889              data: {
1890                  _wpnonce: nonce,
1891                  username: username
1892              },
1893              success: function () {
1894                  // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1895                  // or searching the local cache.
1896                  that.collection.query( request );
1897              }
1898          } );
1899      },
1900  
1901      /**
1902       * Get the checked filters.
1903       *
1904       * @return {Array} of tags or false
1905       */
1906      filtersChecked: function() {
1907          var items = $( '.filter-group' ).find( ':checkbox' ),
1908              tags = [];
1909  
1910          _.each( items.filter( ':checked' ), function( item ) {
1911              tags.push( $( item ).prop( 'value' ) );
1912          });
1913  
1914          // When no filters are checked, restore initial state and return.
1915          if ( tags.length === 0 ) {
1916              $( '.filter-drawer .apply-filters' ).find( 'span' ).text( '' );
1917              $( '.filter-drawer .clear-filters' ).hide();
1918              $( 'body' ).removeClass( 'filters-applied' );
1919              return false;
1920          }
1921  
1922          $( '.filter-drawer .apply-filters' ).find( 'span' ).text( tags.length );
1923          $( '.filter-drawer .clear-filters' ).css( 'display', 'inline-block' );
1924  
1925          return tags;
1926      },
1927  
1928      activeClass: 'current',
1929  
1930      /**
1931       * When users press the "Upload Theme" button, show the upload form in place.
1932       */
1933      uploader: function() {
1934          var uploadViewToggle = $( '.upload-view-toggle' ),
1935              $body = $( document.body );
1936  
1937          uploadViewToggle.on( 'click', function() {
1938              // Toggle the upload view.
1939              $body.toggleClass( 'show-upload-view' );
1940              // Toggle the `aria-expanded` button attribute.
1941              uploadViewToggle.attr( 'aria-expanded', $body.hasClass( 'show-upload-view' ) );
1942          });
1943      },
1944  
1945      // Toggle the full filters navigation.
1946      moreFilters: function( event ) {
1947          var $body = $( 'body' ),
1948              $toggleButton = $( '.drawer-toggle' );
1949  
1950          event.preventDefault();
1951  
1952          if ( $body.hasClass( 'filters-applied' ) ) {
1953              return this.backToFilters();
1954          }
1955  
1956          this.clearSearch();
1957  
1958          themes.router.navigate( themes.router.baseUrl( '' ) );
1959          // Toggle the feature filters view.
1960          $body.toggleClass( 'show-filters' );
1961          // Toggle the `aria-expanded` button attribute.
1962          $toggleButton.attr( 'aria-expanded', $body.hasClass( 'show-filters' ) );
1963      },
1964  
1965      /**
1966       * Clears all the checked filters.
1967       *
1968       * @param {Event} event The click event.
1969       */
1970      clearFilters: function( event ) {
1971          var items = $( '.filter-group' ).find( ':checkbox' ),
1972              self = this;
1973  
1974          event.preventDefault();
1975  
1976          _.each( items.filter( ':checked' ), function( item ) {
1977              $( item ).prop( 'checked', false );
1978              return self.filtersChecked();
1979          });
1980      },
1981  
1982      backToFilters: function( event ) {
1983          if ( event ) {
1984              event.preventDefault();
1985          }
1986  
1987          $( 'body' ).removeClass( 'filters-applied' );
1988      },
1989  
1990      clearSearch: function() {
1991          $( '#wp-filter-search-input').val( '' );
1992      }
1993  });
1994  
1995  themes.InstallerRouter = Backbone.Router.extend({
1996      routes: {
1997          'theme-install.php?theme=:slug': 'preview',
1998          'theme-install.php?browse=:sort': 'sort',
1999          'theme-install.php?search=:query': 'search',
2000          'theme-install.php': 'sort'
2001      },
2002  
2003      baseUrl: function( url ) {
2004          return 'theme-install.php' + url;
2005      },
2006  
2007      themePath: '?theme=',
2008      browsePath: '?browse=',
2009      searchPath: '?search=',
2010  
2011      search: function( query ) {
2012          $( '.wp-filter-search' ).val( query.replace( /\+/g, ' ' ) );
2013      },
2014  
2015      navigate: navigateRouter
2016  });
2017  
2018  
2019  themes.RunInstaller = {
2020  
2021      init: function() {
2022          // Set up the view.
2023          // Passes the default 'section' as an option.
2024          this.view = new themes.view.Installer({
2025              section: 'popular',
2026              SearchView: themes.view.InstallerSearch
2027          });
2028  
2029          // Render results.
2030          this.render();
2031  
2032          // Start debouncing user searches after Backbone.history.start().
2033          this.view.SearchView.doSearch = _.debounce( this.view.SearchView.doSearch, 500 );
2034      },
2035  
2036      render: function() {
2037  
2038          // Render results.
2039          this.view.render();
2040          this.routes();
2041  
2042          if ( Backbone.History.started ) {
2043              Backbone.history.stop();
2044          }
2045          Backbone.history.start({
2046              root: themes.data.settings.adminUrl,
2047              pushState: true,
2048              hashChange: false
2049          });
2050      },
2051  
2052      routes: function() {
2053          var self = this,
2054              request = {};
2055  
2056          // Bind to our global `wp.themes` object
2057          // so that the router is available to sub-views.
2058          themes.router = new themes.InstallerRouter();
2059  
2060          // Handles `theme` route event.
2061          // Queries the API for the passed theme slug.
2062          themes.router.on( 'route:preview', function( slug ) {
2063  
2064              // Remove existing handlers.
2065              if ( themes.preview ) {
2066                  themes.preview.undelegateEvents();
2067                  themes.preview.unbind();
2068              }
2069  
2070              // If the theme preview is active, set the current theme.
2071              if ( self.view.view.theme && self.view.view.theme.preview ) {
2072                  self.view.view.theme.model = self.view.collection.findWhere( { 'slug': slug } );
2073                  self.view.view.theme.preview();
2074              } else {
2075  
2076                  // Select the theme by slug.
2077                  request.theme = slug;
2078                  self.view.collection.query( request );
2079                  self.view.collection.trigger( 'update' );
2080  
2081                  // Open the theme preview. The slug comes from the URL, so escape it.
2082                  self.view.collection.once( 'query:success', function() {
2083                      $( 'div.theme[data-slug="' + $.escapeSelector( slug ) + '"]' ).trigger( 'click' );
2084                  });
2085  
2086              }
2087          });
2088  
2089          /*
2090           * Handles sorting / browsing routes.
2091           * Also handles the root URL triggering a sort request
2092           * for `popular`, the default view.
2093           */
2094          themes.router.on( 'route:sort', function( sort ) {
2095              if ( ! sort ) {
2096                  sort = 'popular';
2097                  themes.router.navigate( themes.router.baseUrl( '?browse=popular' ), { replace: true } );
2098              }
2099              self.view.sort( sort );
2100  
2101              // Close the preview if open.
2102              if ( themes.preview ) {
2103                  themes.preview.close();
2104              }
2105          });
2106  
2107          // The `search` route event. The router populates the input field.
2108          themes.router.on( 'route:search', function() {
2109              $( '.wp-filter-search' ).trigger( 'focus' ).trigger( 'keyup' );
2110          });
2111  
2112          this.extraRoutes();
2113      },
2114  
2115      extraRoutes: function() {
2116          return false;
2117      }
2118  };
2119  
2120  // Ready...
2121  $( function() {
2122      if ( themes.isInstall ) {
2123          themes.RunInstaller.init();
2124      } else {
2125          themes.Run.init();
2126      }
2127  
2128      // Update the return param just in time.
2129      $( document.body ).on( 'click', '.load-customize', function() {
2130          var link = $( this ), urlParser = document.createElement( 'a' );
2131          urlParser.href = link.prop( 'href' );
2132          urlParser.search = $.param( _.extend(
2133              wp.customize.utils.parseQueryString( urlParser.search.substr( 1 ) ),
2134              {
2135                  'return': window.location.href
2136              }
2137          ) );
2138          link.prop( 'href', urlParser.href );
2139      });
2140  
2141      $( '.broken-themes .delete-theme' ).on( 'click', function() {
2142          return confirm( _wpThemeSettings.settings.confirmDelete );
2143      });
2144  });
2145  
2146  })( jQuery );
2147  
2148  // Align theme browser thickbox.
2149  jQuery( function($) {
2150      window.tb_position = function() {
2151          var tbWindow = $('#TB_window'),
2152              width = $(window).width(),
2153              H = $(window).height(),
2154              W = ( 1040 < width ) ? 1040 : width,
2155              adminbar_height = 0;
2156  
2157          if ( $('#wpadminbar').length ) {
2158              adminbar_height = parseInt( $('#wpadminbar').css('height'), 10 );
2159          }
2160  
2161          if ( tbWindow.length >= 1 ) {
2162              tbWindow.width( W - 50 ).height( H - 45 - adminbar_height );
2163              $('#TB_iframeContent').width( W - 50 ).height( H - 75 - adminbar_height );
2164              tbWindow.css({'margin-left': '-' + parseInt( ( ( W - 50 ) / 2 ), 10 ) + 'px'});
2165              if ( typeof document.body.style.maxWidth !== 'undefined' ) {
2166                  tbWindow.css({'top': 20 + adminbar_height + 'px', 'margin-top': '0'});
2167              }
2168          }
2169      };
2170  
2171      $(window).on( 'resize', function(){ tb_position(); });
2172  });


Generated : Wed Oct 7 08:20:33 2026 Cross-referenced by PHPXref