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


Generated : Sat Aug 8 08:20:21 2026 Cross-referenced by PHPXref