[ 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          // Store the theme that had focus when expand is called.
 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          // Store the theme that had focus when preview is called.
 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                  .attr( 'aria-disabled', true )
 624                  .trigger( 'focus' );
 625          }
 626  
 627          // Disable next if the next model is undefined.
 628          if ( _.isUndefined( this.model.collection.at( this.model.collection.indexOf( current ) + 1 ) ) ) {
 629              nextThemeButton
 630                  .addClass( 'disabled' )
 631                  .attr( 'aria-disabled', true )
 632                  .trigger( 'focus' );
 633          }
 634      },
 635  
 636      installTheme: function( event ) {
 637          var _this = this;
 638  
 639          event.preventDefault();
 640  
 641          wp.updates.maybeRequestFilesystemCredentials( event );
 642  
 643          $( document ).on( 'wp-theme-install-success', function( event, response ) {
 644              if ( _this.model.get( 'id' ) === response.slug ) {
 645                  _this.model.set( { 'installed': true } );
 646              }
 647              if ( response.blockTheme ) {
 648                  _this.model.set( { 'block_theme': true } );
 649              }
 650          } );
 651  
 652          wp.updates.installTheme( {
 653              slug: $( event.target ).data( 'slug' )
 654          } );
 655      },
 656  
 657      updateTheme: function( event ) {
 658          var _this = this;
 659  
 660          if ( ! this.model.get( 'hasPackage' ) ) {
 661              return;
 662          }
 663  
 664          event.preventDefault();
 665  
 666          wp.updates.maybeRequestFilesystemCredentials( event );
 667  
 668          $( document ).on( 'wp-theme-update-success', function( event, response ) {
 669              _this.model.off( 'change', _this.render, _this );
 670              if ( _this.model.get( 'id' ) === response.slug ) {
 671                  _this.model.set( {
 672                      hasUpdate: false,
 673                      version: response.newVersion
 674                  } );
 675              }
 676              _this.model.on( 'change', _this.render, _this );
 677          } );
 678  
 679          wp.updates.updateTheme( {
 680              slug: $( event.target ).parents( 'div.theme' ).first().data( 'slug' )
 681          } );
 682      }
 683  });
 684  
 685  // Theme Details view.
 686  // Sets up a modal overlay with the expanded theme data.
 687  themes.view.Details = wp.Backbone.View.extend({
 688  
 689      // Wrap theme data on a div.theme element.
 690      className: 'theme-overlay',
 691  
 692      events: {
 693          'click': 'collapse',
 694          'click .delete-theme': 'deleteTheme',
 695          'click .left': 'previousTheme',
 696          'click .right': 'nextTheme',
 697          'click #update-theme': 'updateTheme',
 698          'click .toggle-auto-update': 'autoupdateState'
 699      },
 700  
 701      // The HTML template for the theme overlay.
 702      html: themes.template( 'theme-single' ),
 703  
 704      render: function() {
 705          var data = this.model.toJSON();
 706          this.$el.html( this.html( data ) );
 707          // Renders active theme styles.
 708          this.activeTheme();
 709          // Set up navigation events.
 710          this.navigation();
 711          // Checks screenshot size.
 712          this.screenshotCheck( this.$el );
 713          // Contain "tabbing" inside the overlay.
 714          this.containFocus( this.$el );
 715      },
 716  
 717      // Adds a class to the currently active theme
 718      // and to the overlay in detailed view mode.
 719      activeTheme: function() {
 720          // Check the model has the active property.
 721          this.$el.toggleClass( 'active', this.model.get( 'active' ) );
 722      },
 723  
 724      // Set initial focus and constrain tabbing within the theme browser modal.
 725      containFocus: function( $el ) {
 726          // Set initial focus on the theme overlay modal dialog, only on first render.
 727          var $themeOverlay = $( '.theme-overlay' );
 728          if ( $themeOverlay.hasClass( 'theme-overlay-initial' ) ) {
 729              _.delay( function() {
 730                  $themeOverlay.trigger( 'focus' ).removeClass( 'theme-overlay-initial' );
 731              }, 100 );
 732          }
 733  
 734  
 735          // Constrain tabbing within the modal.
 736          $el.on( 'keydown.wp-themes', function( event ) {
 737              var $firstFocusable = $el.find( '.theme-header button:not(.disabled)' ).first(),
 738                  $lastFocusable = $el.find( '.theme-actions a:visible' ).last();
 739  
 740              // Check for the Tab key.
 741              if ( 9 === event.which ) {
 742                  if ( $firstFocusable[0] === event.target && event.shiftKey ) {
 743                      $lastFocusable.trigger( 'focus' );
 744                      event.preventDefault();
 745                  } else if ( $lastFocusable[0] === event.target && ! event.shiftKey ) {
 746                      $firstFocusable.trigger( 'focus' );
 747                      event.preventDefault();
 748                  }
 749              }
 750          });
 751      },
 752  
 753      // Single theme overlay screen.
 754      // It's shown when clicking a theme.
 755      collapse: function( event ) {
 756          var self = this,
 757              scroll;
 758  
 759          event = event || window.event;
 760  
 761          // Prevent collapsing detailed view when there is only one theme available.
 762          if ( themes.data.themes.length === 1 ) {
 763              return;
 764          }
 765  
 766          // Detect if the click is inside the overlay and don't close it
 767          // unless the target was the div.back button.
 768          if ( $( event.target ).is( '.theme-backdrop' ) || $( event.target ).is( '.close' ) || event.keyCode === 27 ) {
 769  
 770              // Add a temporary closing class while overlay fades out.
 771              $( 'body' ).addClass( 'closing-overlay' );
 772  
 773              // With a quick fade out animation.
 774              this.$el.fadeOut( 130, function() {
 775                  // Clicking outside the modal box closes the overlay.
 776                  $( 'body' ).removeClass( 'closing-overlay' );
 777                  // Handle event cleanup.
 778                  self.closeOverlay();
 779  
 780                  // Get scroll position to avoid jumping to the top.
 781                  scroll = document.body.scrollTop;
 782  
 783                  // Clean the URL structure.
 784                  themes.router.navigate( themes.router.baseUrl( '' ) );
 785  
 786                  // Restore scroll position.
 787                  document.body.scrollTop = scroll;
 788  
 789                  // Return focus to the opener theme 'more-details' button.
 790                  if ( themes.focusedTheme ) {
 791                      themes.focusedTheme.find( '.more-details' ).trigger( 'focus' );
 792                  }
 793  
 794                  // Add back the `theme-overlay-initial` class for the theme overlay.
 795                  $( '.theme-overlay' ).addClass( 'theme-overlay-initial' );
 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                  .attr( 'aria-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                  .attr( 'aria-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          if ( ! screenshot.length ) {
 919              return;
 920          }
 921  
 922          image = new Image();
 923          image.src = screenshot.attr( 'src' );
 924  
 925          // Width check.
 926          if ( image.width && image.width <= 300 ) {
 927              el.addClass( 'small-screenshot' );
 928          }
 929      }
 930  });
 931  
 932  // Theme Preview view.
 933  // Sets up a modal overlay with the expanded theme data.
 934  themes.view.Preview = themes.view.Details.extend({
 935  
 936      className: 'wp-full-overlay expanded',
 937      el: '.theme-install-overlay',
 938  
 939      events: {
 940          'click .close-full-overlay': 'close',
 941          'click .collapse-sidebar': 'collapse',
 942          'click .devices button': 'previewDevice',
 943          'click .previous-theme': 'previousTheme',
 944          'click .next-theme': 'nextTheme',
 945          'keydown': 'keyEvent',
 946          'click .theme-install': 'installTheme'
 947      },
 948  
 949      // The HTML template for the theme preview.
 950      html: themes.template( 'theme-preview' ),
 951  
 952      render: function() {
 953          var self = this,
 954              currentPreviewDevice,
 955              data = this.model.toJSON(),
 956              $body = $( document.body );
 957  
 958          $body.attr( 'aria-busy', 'true' );
 959  
 960          this.$el.removeClass( 'iframe-ready' ).html( this.html( data ) );
 961  
 962          currentPreviewDevice = this.$el.data( 'current-preview-device' );
 963          if ( currentPreviewDevice ) {
 964              self.togglePreviewDeviceButtons( currentPreviewDevice );
 965          }
 966  
 967          themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.get( 'id' ) ), { replace: false } );
 968  
 969          this.$el.fadeIn( 200, function() {
 970              $body.addClass( 'theme-installer-active full-overlay-active' );
 971              // Set initial focus on the theme installer overlay modal dialog.
 972              self.$el.trigger( 'focus' );
 973          });
 974  
 975          this.$el.find( 'iframe' ).one( 'load', function() {
 976              self.iframeLoaded();
 977          });
 978      },
 979  
 980      iframeLoaded: function() {
 981          this.$el.addClass( 'iframe-ready' );
 982          $( document.body ).attr( 'aria-busy', 'false' );
 983      },
 984  
 985      close: function() {
 986          this.$el.fadeOut( 200, function() {
 987              $( 'body' ).removeClass( 'theme-installer-active full-overlay-active' );
 988  
 989              // Return focus to the opener theme 'more-details' button.
 990              if ( themes.focusedTheme ) {
 991                  themes.focusedTheme.find( '.more-details' ).trigger( 'focus' );
 992              }
 993          }).removeClass( 'iframe-ready' );
 994  
 995          // Restore the previous browse tab if available.
 996          if ( themes.router.selectedTab ) {
 997              themes.router.navigate( themes.router.baseUrl( '?browse=' + themes.router.selectedTab ) );
 998              themes.router.selectedTab = false;
 999          } else {
1000              themes.router.navigate( themes.router.baseUrl( '' ) );
1001          }
1002          this.trigger( 'preview:close' );
1003          this.undelegateEvents();
1004          this.unbind();
1005  
1006          // Cancel any pending navigation announcement.
1007          themes.announceThemeDebounced.cancel();
1008          return false;
1009      },
1010  
1011      collapse: function( event ) {
1012          var $button = $( event.currentTarget );
1013          if ( 'true' === $button.attr( 'aria-expanded' ) ) {
1014              $button.attr({ 'aria-expanded': 'false', 'aria-label': l10n.expandSidebar });
1015          } else {
1016              $button.attr({ 'aria-expanded': 'true', 'aria-label': l10n.collapseSidebar });
1017          }
1018  
1019          this.$el.toggleClass( 'collapsed' ).toggleClass( 'expanded' );
1020          return false;
1021      },
1022  
1023      previewDevice: function( event ) {
1024          var device = $( event.currentTarget ).data( 'device' );
1025  
1026          this.$el
1027              .removeClass( 'preview-desktop preview-tablet preview-mobile' )
1028              .addClass( 'preview-' + device )
1029              .data( 'current-preview-device', device );
1030  
1031          this.togglePreviewDeviceButtons( device );
1032      },
1033  
1034      togglePreviewDeviceButtons: function( newDevice ) {
1035          var $devices = $( '.wp-full-overlay-footer .devices' );
1036  
1037          $devices.find( 'button' )
1038              .removeClass( 'active' )
1039              .attr( 'aria-pressed', false );
1040  
1041          $devices.find( 'button.preview-' + newDevice )
1042              .addClass( 'active' )
1043              .attr( 'aria-pressed', true );
1044      },
1045  
1046      keyEvent: function( event ) {
1047          // The escape key closes the preview.
1048          if ( event.keyCode === 27 ) {
1049              this.undelegateEvents();
1050              this.close();
1051          }
1052  
1053          // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation.
1054          if ( ! event.altKey ) {
1055              return;
1056          }
1057  
1058          // The right arrow key, next theme.
1059          if ( event.keyCode === 39 ) {
1060              event.preventDefault();
1061              this.nextTheme();
1062          }
1063  
1064          // The left arrow key, previous theme.
1065          if ( event.keyCode === 37 ) {
1066              event.preventDefault();
1067              this.previousTheme();
1068          }
1069      },
1070  
1071      installTheme: function( event ) {
1072          var _this   = this,
1073              $target = $( event.target );
1074          event.preventDefault();
1075  
1076          if ( $target.hasClass( 'disabled' ) ) {
1077              return;
1078          }
1079  
1080          wp.updates.maybeRequestFilesystemCredentials( event );
1081  
1082          $( document ).on( 'wp-theme-install-success', function() {
1083              _this.model.set( { 'installed': true } );
1084          } );
1085  
1086          wp.updates.installTheme( {
1087              slug: $target.data( 'slug' )
1088          } );
1089      }
1090  });
1091  
1092  // Controls the rendering of div.themes,
1093  // a wrapper that will hold all the theme elements.
1094  themes.view.Themes = wp.Backbone.View.extend({
1095  
1096      className: 'themes wp-clearfix',
1097      $overlay: $( 'div.theme-overlay' ),
1098  
1099      // Number to keep track of scroll position
1100      // while in theme-overlay mode.
1101      index: 0,
1102  
1103      // The theme count element.
1104      count: $( '.wrap .theme-count' ),
1105  
1106      // The live themes count.
1107      liveThemeCount: 0,
1108  
1109      initialize: function( options ) {
1110          var self = this;
1111  
1112          // Set up parent.
1113          this.parent = options.parent;
1114  
1115          // Set current view to [grid].
1116          this.setView( 'grid' );
1117  
1118          // Move the active theme to the beginning of the collection.
1119          self.currentTheme();
1120  
1121          // When the collection is updated by user input...
1122          this.listenTo( self.collection, 'themes:update', function() {
1123              self.parent.page = 0;
1124              self.currentTheme();
1125              self.render( this );
1126          } );
1127  
1128          // Update theme count to full result set when available.
1129          this.listenTo( self.collection, 'query:success', function( count ) {
1130              if ( _.isNumber( count ) ) {
1131                  self.count.text( count );
1132                  self.announceSearchResults( count );
1133              } else {
1134                  self.count.text( self.collection.length );
1135                  self.announceSearchResults( self.collection.length );
1136              }
1137          });
1138  
1139          this.listenTo( self.collection, 'query:empty', function() {
1140              $( 'body' ).addClass( 'no-results' );
1141          });
1142  
1143          this.listenTo( this.parent, 'theme:scroll', function() {
1144              self.renderThemes( self.parent.page );
1145          });
1146  
1147          this.listenTo( this.parent, 'theme:close', function() {
1148              if ( self.overlay ) {
1149                  self.overlay.closeOverlay();
1150              }
1151          } );
1152  
1153          // Bind keyboard events.
1154          $( 'body' ).on( 'keydown.wp-themes', function( event ) {
1155              if ( ! self.overlay ) {
1156                  return;
1157              }
1158  
1159              // Bail if the filesystem credentials dialog is shown.
1160              if ( $( '#request-filesystem-credentials-dialog' ).is( ':visible' ) ) {
1161                  return;
1162              }
1163  
1164              // Pressing the escape key fires a theme:collapse event.
1165              if ( event.keyCode === 27 ) {
1166                  self.overlay.collapse( event );
1167              }
1168  
1169              // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation.
1170              if ( ! event.altKey ) {
1171                  return;
1172              }
1173  
1174              // Pressing Alt + right arrow key fires a theme:next event.
1175              if ( event.keyCode === 39 ) {
1176                  event.preventDefault();
1177                  self.overlay.nextTheme();
1178              }
1179  
1180              // Pressing Alt + left arrow key fires a theme:previous event.
1181              if ( event.keyCode === 37 ) {
1182                  event.preventDefault();
1183                  self.overlay.previousTheme();
1184              }
1185          });
1186      },
1187  
1188      // Manages rendering of theme pages
1189      // and keeping theme count in sync.
1190      render: function() {
1191          // Clear the DOM, please.
1192          this.$el.empty();
1193  
1194          // If the user doesn't have switch capabilities or there is only one theme
1195          // in the collection, render the detailed view of the active theme.
1196          if ( themes.data.themes.length === 1 ) {
1197  
1198              // Constructs the view.
1199              this.singleTheme = new themes.view.Details({
1200                  model: this.collection.models[0]
1201              });
1202  
1203              // Render and apply a 'single-theme' class to our container.
1204              this.singleTheme.render();
1205              this.$el.addClass( 'single-theme' );
1206              this.$el.append( this.singleTheme.el );
1207          }
1208  
1209          // Generate the themes using page instance
1210          // while checking the collection has items.
1211          if ( this.options.collection.size() > 0 ) {
1212              this.renderThemes( this.parent.page );
1213          }
1214  
1215          // Display a live theme count for the collection.
1216          this.liveThemeCount = this.collection.count ? this.collection.count : this.collection.length;
1217          this.count.text( this.liveThemeCount );
1218  
1219          /*
1220           * In the theme installer the themes count is already announced
1221           * because `announceSearchResults` is called on `query:success`.
1222           */
1223          if ( ! themes.isInstall ) {
1224              this.announceSearchResults( this.liveThemeCount );
1225          }
1226      },
1227  
1228      // Iterates through each instance of the collection
1229      // and renders each theme module.
1230      renderThemes: function( page ) {
1231          var self = this;
1232  
1233          self.instance = self.collection.paginate( page );
1234  
1235          // If we have no more themes, bail.
1236          if ( self.instance.size() === 0 ) {
1237              // Fire a no-more-themes event.
1238              this.parent.trigger( 'theme:end' );
1239              return;
1240          }
1241  
1242          // Make sure the add-new stays at the end.
1243          if ( ! themes.isInstall && page >= 1 ) {
1244              $( '.add-new-theme' ).remove();
1245          }
1246  
1247          // Loop through the themes and setup each theme view.
1248          self.instance.each( function( theme ) {
1249              self.theme = new themes.view.Theme({
1250                  model: theme,
1251                  parent: self
1252              });
1253  
1254              // Render the views...
1255              self.theme.render();
1256              // ...and append them to div.themes.
1257              self.$el.append( self.theme.el );
1258  
1259              // Binds to theme:expand to show the modal box
1260              // with the theme details.
1261              self.listenTo( self.theme, 'theme:expand', self.expand, self );
1262          });
1263  
1264          // 'Add new theme' element shown at the end of the grid.
1265          if ( ! themes.isInstall && themes.data.settings.canInstall ) {
1266              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>' );
1267          }
1268  
1269          this.parent.page++;
1270      },
1271  
1272      // Grabs current theme and puts it at the beginning of the collection.
1273      currentTheme: function() {
1274          var self = this,
1275              current;
1276  
1277          current = self.collection.findWhere({ active: true });
1278  
1279          // Move the active theme to the beginning of the collection.
1280          if ( current ) {
1281              self.collection.remove( current );
1282              self.collection.add( current, { at:0 } );
1283          }
1284      },
1285  
1286      // Sets current view.
1287      setView: function( view ) {
1288          return view;
1289      },
1290  
1291      // Renders the overlay with the ThemeDetails view.
1292      // Uses the current model data.
1293      expand: function( id ) {
1294          var self = this, $card, $modal;
1295  
1296          // Set the current theme model.
1297          this.model = self.collection.get( id );
1298  
1299          // Trigger a route update for the current model.
1300          themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.id ) );
1301  
1302          // Sets this.view to 'detail'.
1303          this.setView( 'detail' );
1304          $( 'body' ).addClass( 'modal-open' );
1305  
1306          // Set up the theme details view.
1307          this.overlay = new themes.view.Details({
1308              model: self.model
1309          });
1310  
1311          this.overlay.render();
1312  
1313          if ( this.model.get( 'hasUpdate' ) ) {
1314              $card  = $( '[data-slug="' + this.model.id + '"]' );
1315              $modal = $( this.overlay.el );
1316  
1317              if ( $card.find( '.updating-message' ).length ) {
1318                  $modal.find( '.notice-warning h3' ).remove();
1319                  $modal.find( '.notice-warning' )
1320                      .removeClass( 'notice-large' )
1321                      .addClass( 'updating-message' )
1322                      .find( 'p' ).text( wp.updates.l10n.updating );
1323              } else if ( $card.find( '.notice-error' ).length ) {
1324                  $modal.find( '.notice-warning' ).remove();
1325              }
1326          }
1327  
1328          this.$overlay.html( this.overlay.el );
1329  
1330          // Bind to theme:next and theme:previous triggered by the arrow keys.
1331          // Keep track of the current model so we can infer an index position.
1332          this.listenTo( this.overlay, 'theme:next', function() {
1333              // Renders the next theme on the overlay.
1334              self.next( [ self.model.cid ] );
1335  
1336          })
1337          .listenTo( this.overlay, 'theme:previous', function() {
1338              // Renders the previous theme on the overlay.
1339              self.previous( [ self.model.cid ] );
1340          });
1341      },
1342  
1343      /*
1344       * This method renders the next theme on the overlay modal
1345       * based on the current position in the collection.
1346       *
1347       * @params [model cid]
1348       */
1349      next: function( args ) {
1350          var self = this,
1351              model, nextModel;
1352  
1353          // Get the current theme.
1354          model = self.collection.get( args[0] );
1355          // Find the next model within the collection.
1356          nextModel = self.collection.at( self.collection.indexOf( model ) + 1 );
1357  
1358          // Confidence check which also serves as a boundary test.
1359          if ( nextModel !== undefined ) {
1360  
1361              // We have a new theme...
1362              // Close the overlay.
1363              this.overlay.closeOverlay();
1364  
1365              // Trigger a route update for the current model.
1366              self.theme.trigger( 'theme:expand', nextModel.cid );
1367              // Set focus again to the 'Next' navigation button after the view fully re-rendeers.
1368              $( '.theme-header .right' ).trigger( 'focus' );
1369              themes.announceThemeDebounced( nextModel );
1370          }
1371      },
1372  
1373      /*
1374       * This method renders the previous theme on the overlay modal
1375       * based on the current position in the collection.
1376       *
1377       * @params [model cid]
1378       */
1379      previous: function( args ) {
1380          var self = this,
1381              model, previousModel, index;
1382  
1383          // Get the current theme.
1384          model = self.collection.get( args[0] );
1385  
1386          index = self.collection.indexOf( model );
1387  
1388          // Bail early if the current theme is the first one or the model does not exist.
1389          if ( index <= 0 ) {
1390              return;
1391          }
1392  
1393          // Find the previous model within the collection.
1394          previousModel = self.collection.at( index - 1 );
1395  
1396          if ( previousModel !== undefined ) {
1397  
1398              // We have a new theme...
1399              // Close the overlay.
1400              this.overlay.closeOverlay();
1401  
1402              // Trigger a route update for the current model.
1403              self.theme.trigger( 'theme:expand', previousModel.cid );
1404              // Set focus again to the 'Previous' navigation button after the view fully re-rendeers.
1405              $( '.theme-header .left' ).trigger( 'focus' );
1406              themes.announceThemeDebounced( previousModel );
1407          }
1408      },
1409  
1410      // Dispatch audible search results feedback message.
1411      announceSearchResults: function( count ) {
1412          if ( 0 === count ) {
1413              wp.a11y.speak( l10n.noThemesFound );
1414          } else {
1415              wp.a11y.speak( l10n.themesFound.replace( '%d', count ) );
1416          }
1417      }
1418  });
1419  
1420  // Search input view controller.
1421  themes.view.Search = wp.Backbone.View.extend({
1422  
1423      tagName: 'input',
1424      className: 'wp-filter-search',
1425      id: 'wp-filter-search-input',
1426      searching: false,
1427  
1428      attributes: {
1429          type: 'search',
1430          'aria-describedby': 'live-search-desc'
1431      },
1432  
1433      events: {
1434          'input': 'search',
1435          'keyup': 'search',
1436          'blur': 'pushState'
1437      },
1438  
1439      initialize: function( options ) {
1440  
1441          this.parent = options.parent;
1442  
1443          this.listenTo( this.parent, 'theme:close', function() {
1444              this.searching = false;
1445          } );
1446  
1447      },
1448  
1449      search: function( event ) {
1450          // Clear on escape.
1451          if ( event.type === 'keyup' && event.which === 27 ) {
1452              event.target.value = '';
1453          }
1454  
1455          // Since doSearch is debounced, it will only run when user input comes to a rest.
1456          this.doSearch( event );
1457      },
1458  
1459      // Runs a search on the theme collection.
1460      doSearch: function( event ) {
1461          var options = {};
1462  
1463          this.collection.doSearch( event.target.value.replace( /\+/g, ' ' ) );
1464  
1465          // if search is initiated and key is not return.
1466          if ( this.searching && event.which !== 13 ) {
1467              options.replace = true;
1468          } else {
1469              this.searching = true;
1470          }
1471  
1472          // Update the URL hash.
1473          if ( event.target.value ) {
1474              themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + event.target.value ), options );
1475          } else {
1476              themes.router.navigate( themes.router.baseUrl( '' ) );
1477          }
1478      },
1479  
1480      pushState: function( event ) {
1481          var url = themes.router.baseUrl( '' );
1482  
1483          if ( event.target.value ) {
1484              url = themes.router.baseUrl( themes.router.searchPath + encodeURIComponent( event.target.value ) );
1485          }
1486  
1487          this.searching = false;
1488          themes.router.navigate( url );
1489  
1490      }
1491  });
1492  
1493  /**
1494   * Navigate router.
1495   *
1496   * @since 4.9.0
1497   *
1498   * @param {string} url   URL to navigate to.
1499   * @param {Object} state State.
1500   * @return {void}
1501   */
1502  function navigateRouter( url, state ) {
1503      var router = this;
1504      if ( Backbone.history._hasPushState ) {
1505          Backbone.Router.prototype.navigate.call( router, url, state );
1506      }
1507  }
1508  
1509  // Sets up the routes events for relevant url queries.
1510  // Listens to [theme] and [search] params.
1511  themes.Router = Backbone.Router.extend({
1512  
1513      routes: {
1514          'themes.php?theme=:slug': 'theme',
1515          'themes.php?search=:query': 'search',
1516          'themes.php?s=:query': 'search',
1517          'themes.php': 'themes',
1518          '': 'themes'
1519      },
1520  
1521      baseUrl: function( url ) {
1522          return 'themes.php' + url;
1523      },
1524  
1525      themePath: '?theme=',
1526      searchPath: '?search=',
1527  
1528      search: function( query ) {
1529          $( '.wp-filter-search' ).val( query.replace( /\+/g, ' ' ) );
1530      },
1531  
1532      themes: function() {
1533          $( '.wp-filter-search' ).val( '' );
1534      },
1535  
1536      navigate: navigateRouter
1537  
1538  });
1539  
1540  // Execute and setup the application.
1541  themes.Run = {
1542      init: function() {
1543          // Initializes the blog's theme library view.
1544          // Create a new collection with data.
1545          this.themes = new themes.Collection( themes.data.themes );
1546  
1547          // Set up the view.
1548          this.view = new themes.view.Appearance({
1549              collection: this.themes
1550          });
1551  
1552          this.render();
1553  
1554          // Start debouncing user searches after Backbone.history.start().
1555          this.view.SearchView.doSearch = _.debounce( this.view.SearchView.doSearch, 500 );
1556      },
1557  
1558      render: function() {
1559  
1560          // Render results.
1561          this.view.render();
1562          this.routes();
1563  
1564          if ( Backbone.History.started ) {
1565              Backbone.history.stop();
1566          }
1567          Backbone.history.start({
1568              root: themes.data.settings.adminUrl,
1569              pushState: true,
1570              hashChange: false
1571          });
1572      },
1573  
1574      routes: function() {
1575          var self = this;
1576          // Bind to our global thx object
1577          // so that the object is available to sub-views.
1578          themes.router = new themes.Router();
1579  
1580          // Handles theme details route event.
1581          themes.router.on( 'route:theme', function( slug ) {
1582              self.view.view.expand( slug );
1583          });
1584  
1585          themes.router.on( 'route:themes', function() {
1586              self.themes.doSearch( '' );
1587              self.view.trigger( 'theme:close' );
1588          });
1589  
1590          // Handles search route event.
1591          themes.router.on( 'route:search', function() {
1592              $( '.wp-filter-search' ).trigger( 'keyup' );
1593          });
1594  
1595          this.extraRoutes();
1596      },
1597  
1598      extraRoutes: function() {
1599          return false;
1600      }
1601  };
1602  
1603  // Extend the main Search view.
1604  themes.view.InstallerSearch =  themes.view.Search.extend({
1605  
1606      events: {
1607          'input': 'search',
1608          'keyup': 'search'
1609      },
1610  
1611      terms: '',
1612  
1613      // Handles Ajax request for searching through themes in public repo.
1614      search: function( event ) {
1615  
1616          // Tabbing or reverse tabbing into the search input shouldn't trigger a search.
1617          if ( event.type === 'keyup' && ( event.which === 9 || event.which === 16 ) ) {
1618              return;
1619          }
1620  
1621          this.collection = this.options.parent.view.collection;
1622  
1623          // Clear on escape.
1624          if ( event.type === 'keyup' && event.which === 27 ) {
1625              event.target.value = '';
1626          }
1627  
1628          this.doSearch( event.target.value );
1629      },
1630  
1631      doSearch: function( value ) {
1632          var request = {};
1633  
1634          // Don't do anything if the search terms haven't changed.
1635          if ( this.terms === value ) {
1636              return;
1637          }
1638  
1639          // Updates terms with the value passed.
1640          this.terms = value;
1641  
1642          request.search = value;
1643  
1644          /*
1645           * Intercept an [author] search.
1646           *
1647           * If input value starts with `author:` send a request
1648           * for `author` instead of a regular `search`.
1649           */
1650          if ( value.substring( 0, 7 ) === 'author:' ) {
1651              request.search = '';
1652              request.author = value.slice( 7 );
1653          }
1654  
1655          /*
1656           * Intercept a [tag] search.
1657           *
1658           * If input value starts with `tag:` send a request
1659           * for `tag` instead of a regular `search`.
1660           */
1661          if ( value.substring( 0, 4 ) === 'tag:' ) {
1662              request.search = '';
1663              request.tag = [ value.slice( 4 ) ];
1664          }
1665  
1666          $( '.filter-links li > a.current' )
1667              .removeClass( 'current' )
1668              .removeAttr( 'aria-current' );
1669  
1670          $( 'body' ).removeClass( 'show-filters filters-applied show-favorites-form' );
1671          $( '.drawer-toggle' ).attr( 'aria-expanded', 'false' );
1672  
1673          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1674          // or searching the local cache.
1675          this.collection.query( request );
1676  
1677          // Set route.
1678          themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + encodeURIComponent( value ) ), { replace: true } );
1679      }
1680  });
1681  
1682  themes.view.Installer = themes.view.Appearance.extend({
1683  
1684      el: '#wpbody-content .wrap',
1685  
1686      // Register events for sorting and filters in theme-navigation.
1687      events: {
1688          'click .filter-links li > a': 'onSort',
1689          'click .theme-filter': 'onFilter',
1690          'click .drawer-toggle': 'moreFilters',
1691          'click .filter-drawer .apply-filters': 'applyFilters',
1692          'click .filter-group [type="checkbox"]': 'addFilter',
1693          'click .filter-drawer .clear-filters': 'clearFilters',
1694          'click .edit-filters': 'backToFilters',
1695          'click .favorites-form-submit' : 'saveUsername',
1696          'keyup #wporg-username-input': 'saveUsername'
1697      },
1698  
1699      // Initial render method.
1700      render: function() {
1701          var self = this;
1702  
1703          this.search();
1704          this.uploader();
1705  
1706          this.collection = new themes.Collection();
1707  
1708          // Bump `collection.currentQuery.page` and request more themes if we hit the end of the page.
1709          this.listenTo( this, 'theme:end', function() {
1710  
1711              // Make sure we are not already loading.
1712              if ( self.collection.loadingThemes ) {
1713                  return;
1714              }
1715  
1716              // Set loadingThemes to true and bump page instance of currentQuery.
1717              self.collection.loadingThemes = true;
1718              self.collection.currentQuery.page++;
1719  
1720              // Use currentQuery.page to build the themes request.
1721              _.extend( self.collection.currentQuery.request, { page: self.collection.currentQuery.page } );
1722              self.collection.query( self.collection.currentQuery.request );
1723          });
1724  
1725          this.listenTo( this.collection, 'query:success', function() {
1726              $( 'body' ).removeClass( 'loading-content' );
1727              $( '.theme-browser' ).find( 'div.error' ).remove();
1728          });
1729  
1730          this.listenTo( this.collection, 'query:fail', function() {
1731              $( 'body' ).removeClass( 'loading-content' );
1732              $( '.theme-browser' ).find( 'div.error' ).remove();
1733              $( '.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>' );
1734              $( '.theme-browser .error .try-again' ).on( 'click', function( e ) {
1735                  e.preventDefault();
1736                  $( 'input.wp-filter-search' ).trigger( 'input' );
1737              } );
1738          });
1739  
1740          if ( this.view ) {
1741              this.view.remove();
1742          }
1743  
1744          // Sets up the view and passes the section argument.
1745          this.view = new themes.view.Themes({
1746              collection: this.collection,
1747              parent: this
1748          });
1749  
1750          // Reset pagination every time the install view handler is run.
1751          this.page = 0;
1752  
1753          // Render and append.
1754          this.$el.find( '.themes' ).remove();
1755          this.view.render();
1756          this.$el.find( '.theme-browser' ).append( this.view.el ).addClass( 'rendered' );
1757      },
1758  
1759      // Handles all the rendering of the public theme directory.
1760      browse: function( section ) {
1761          // Create a new collection with the proper theme data
1762          // for each section.
1763          if ( 'block-themes' === section ) {
1764              // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1765              // or searching the local cache.
1766              this.collection.query( { tag: 'full-site-editing' } );
1767          } else {
1768              this.collection.query( { browse: section } );
1769          }
1770      },
1771  
1772      // Sorting navigation.
1773      onSort: function( event ) {
1774          var $el = $( event.target ),
1775              sort = $el.data( 'sort' );
1776  
1777          event.preventDefault();
1778  
1779          $( 'body' ).removeClass( 'filters-applied show-filters' );
1780          $( '.drawer-toggle' ).attr( 'aria-expanded', 'false' );
1781  
1782          // Bail if this is already active.
1783          if ( $el.hasClass( this.activeClass ) ) {
1784              return;
1785          }
1786  
1787          this.sort( sort );
1788  
1789          // Trigger a router.navigate update.
1790          themes.router.navigate( themes.router.baseUrl( themes.router.browsePath + sort ) );
1791      },
1792  
1793      sort: function( sort ) {
1794          this.clearSearch();
1795  
1796          // Track sorting so we can restore the correct tab when closing preview.
1797          themes.router.selectedTab = sort;
1798  
1799          $( '.filter-links li > a, .theme-filter' )
1800              .removeClass( this.activeClass )
1801              .removeAttr( 'aria-current' );
1802  
1803          $( '[data-sort="' + sort + '"]' )
1804              .addClass( this.activeClass )
1805              .attr( 'aria-current', 'page' );
1806  
1807          if ( 'favorites' === sort ) {
1808              $( 'body' ).addClass( 'show-favorites-form' );
1809          } else {
1810              $( 'body' ).removeClass( 'show-favorites-form' );
1811          }
1812  
1813          this.browse( sort );
1814      },
1815  
1816      // Filters and Tags.
1817      onFilter: function( event ) {
1818          var request,
1819              $el = $( event.target ),
1820              filter = $el.data( 'filter' );
1821  
1822          // Bail if this is already active.
1823          if ( $el.hasClass( this.activeClass ) ) {
1824              return;
1825          }
1826  
1827          $( '.filter-links li > a, .theme-section' )
1828              .removeClass( this.activeClass )
1829              .removeAttr( 'aria-current' );
1830          $el
1831              .addClass( this.activeClass )
1832              .attr( 'aria-current', 'page' );
1833  
1834          if ( ! filter ) {
1835              return;
1836          }
1837  
1838          // Construct the filter request
1839          // using the default values.
1840          filter = _.union( [ filter, this.filtersChecked() ] );
1841          request = { tag: [ filter ] };
1842  
1843          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1844          // or searching the local cache.
1845          this.collection.query( request );
1846      },
1847  
1848      // Clicking on a checkbox to add another filter to the request.
1849      addFilter: function() {
1850          this.filtersChecked();
1851      },
1852  
1853      // Applying filters triggers a tag request.
1854      applyFilters: function( event ) {
1855          var name,
1856              tags = this.filtersChecked(),
1857              request = { tag: tags },
1858              filteringBy = $( '.filtered-by .tags' );
1859  
1860          if ( event ) {
1861              event.preventDefault();
1862          }
1863  
1864          if ( ! tags ) {
1865              wp.a11y.speak( l10n.selectFeatureFilter );
1866              return;
1867          }
1868  
1869          $( 'body' ).addClass( 'filters-applied' );
1870          $( '.filter-links li > a.current' )
1871              .removeClass( 'current' )
1872              .removeAttr( 'aria-current' );
1873  
1874          filteringBy.empty();
1875  
1876          _.each( tags, function( tag ) {
1877              name = $( 'label[for="filter-id-' + tag + '"]' ).text();
1878              filteringBy.append( '<span class="tag">' + name + '</span>' );
1879          });
1880  
1881          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1882          // or searching the local cache.
1883          this.collection.query( request );
1884      },
1885  
1886      // Save the user's WordPress.org username and get his favorite themes.
1887      saveUsername: function ( event ) {
1888          var username = $( '#wporg-username-input' ).val(),
1889              nonce = $( '#wporg-username-nonce' ).val(),
1890              request = { browse: 'favorites', user: username },
1891              that = this;
1892  
1893          if ( event ) {
1894              event.preventDefault();
1895          }
1896  
1897          // Save username on enter.
1898          if ( event.type === 'keyup' && event.which !== 13 ) {
1899              return;
1900          }
1901  
1902          return wp.ajax.send( 'save-wporg-username', {
1903              data: {
1904                  _wpnonce: nonce,
1905                  username: username
1906              },
1907              success: function () {
1908                  // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1909                  // or searching the local cache.
1910                  that.collection.query( request );
1911              }
1912          } );
1913      },
1914  
1915      /**
1916       * Get the checked filters.
1917       *
1918       * @return {Array} of tags or false
1919       */
1920      filtersChecked: function() {
1921          var items = $( '.filter-group' ).find( ':checkbox' ),
1922              tags = [];
1923  
1924          _.each( items.filter( ':checked' ), function( item ) {
1925              tags.push( $( item ).prop( 'value' ) );
1926          });
1927  
1928          // When no filters are checked, restore initial state and return.
1929          if ( tags.length === 0 ) {
1930              $( '.filter-drawer .apply-filters' ).find( 'span' ).text( '' );
1931              $( '.filter-drawer .clear-filters' ).hide();
1932              $( 'body' ).removeClass( 'filters-applied' );
1933              return false;
1934          }
1935  
1936          $( '.filter-drawer .apply-filters' ).find( 'span' ).text( tags.length );
1937          $( '.filter-drawer .clear-filters' ).css( 'display', 'inline-block' );
1938  
1939          return tags;
1940      },
1941  
1942      activeClass: 'current',
1943  
1944      /**
1945       * When users press the "Upload Theme" button, show the upload form in place.
1946       */
1947      uploader: function() {
1948          var uploadViewToggle = $( '.upload-view-toggle' ),
1949              $body = $( document.body );
1950  
1951          uploadViewToggle.on( 'click', function() {
1952              // Toggle the upload view.
1953              $body.toggleClass( 'show-upload-view' );
1954              // Toggle the `aria-expanded` button attribute.
1955              uploadViewToggle.attr( 'aria-expanded', $body.hasClass( 'show-upload-view' ) );
1956          });
1957      },
1958  
1959      // Toggle the full filters navigation.
1960      moreFilters: function( event ) {
1961          var $body = $( 'body' ),
1962              $toggleButton = $( '.drawer-toggle' );
1963  
1964          event.preventDefault();
1965  
1966          if ( $body.hasClass( 'filters-applied' ) ) {
1967              return this.backToFilters();
1968          }
1969  
1970          this.clearSearch();
1971  
1972          themes.router.navigate( themes.router.baseUrl( '' ) );
1973          // Toggle the feature filters view.
1974          $body.toggleClass( 'show-filters' );
1975          // Toggle the `aria-expanded` button attribute.
1976          $toggleButton.attr( 'aria-expanded', $body.hasClass( 'show-filters' ) );
1977      },
1978  
1979      /**
1980       * Clears all the checked filters.
1981       *
1982       * @param {Event} event The click event.
1983       */
1984      clearFilters: function( event ) {
1985          var items = $( '.filter-group' ).find( ':checkbox' ),
1986              self = this;
1987  
1988          event.preventDefault();
1989  
1990          _.each( items.filter( ':checked' ), function( item ) {
1991              $( item ).prop( 'checked', false );
1992              return self.filtersChecked();
1993          });
1994      },
1995  
1996      backToFilters: function( event ) {
1997          if ( event ) {
1998              event.preventDefault();
1999          }
2000  
2001          $( 'body' ).removeClass( 'filters-applied' );
2002      },
2003  
2004      clearSearch: function() {
2005          $( '#wp-filter-search-input').val( '' );
2006      }
2007  });
2008  
2009  themes.InstallerRouter = Backbone.Router.extend({
2010      routes: {
2011          'theme-install.php?theme=:slug': 'preview',
2012          'theme-install.php?browse=:sort': 'sort',
2013          'theme-install.php?search=:query': 'search',
2014          'theme-install.php': 'sort'
2015      },
2016  
2017      baseUrl: function( url ) {
2018          return 'theme-install.php' + url;
2019      },
2020  
2021      themePath: '?theme=',
2022      browsePath: '?browse=',
2023      searchPath: '?search=',
2024  
2025      search: function( query ) {
2026          $( '.wp-filter-search' ).val( query.replace( /\+/g, ' ' ) );
2027      },
2028  
2029      navigate: navigateRouter
2030  });
2031  
2032  
2033  themes.RunInstaller = {
2034  
2035      init: function() {
2036          // Set up the view.
2037          // Passes the default 'section' as an option.
2038          this.view = new themes.view.Installer({
2039              section: 'popular',
2040              SearchView: themes.view.InstallerSearch
2041          });
2042  
2043          // Render results.
2044          this.render();
2045  
2046          // Start debouncing user searches after Backbone.history.start().
2047          this.view.SearchView.doSearch = _.debounce( this.view.SearchView.doSearch, 500 );
2048      },
2049  
2050      render: function() {
2051  
2052          // Render results.
2053          this.view.render();
2054          this.routes();
2055  
2056          if ( Backbone.History.started ) {
2057              Backbone.history.stop();
2058          }
2059          Backbone.history.start({
2060              root: themes.data.settings.adminUrl,
2061              pushState: true,
2062              hashChange: false
2063          });
2064      },
2065  
2066      routes: function() {
2067          var self = this,
2068              request = {};
2069  
2070          // Bind to our global `wp.themes` object
2071          // so that the router is available to sub-views.
2072          themes.router = new themes.InstallerRouter();
2073  
2074          // Handles `theme` route event.
2075          // Queries the API for the passed theme slug.
2076          themes.router.on( 'route:preview', function( slug ) {
2077  
2078              // Remove existing handlers.
2079              if ( themes.preview ) {
2080                  themes.preview.undelegateEvents();
2081                  themes.preview.unbind();
2082              }
2083  
2084              // If the theme preview is active, set the current theme.
2085              if ( self.view.view.theme && self.view.view.theme.preview ) {
2086                  self.view.view.theme.model = self.view.collection.findWhere( { 'slug': slug } );
2087                  self.view.view.theme.preview();
2088              } else {
2089  
2090                  // Select the theme by slug.
2091                  request.theme = slug;
2092                  self.view.collection.query( request );
2093                  self.view.collection.trigger( 'update' );
2094  
2095                  // Open the theme preview. The slug comes from the URL, so escape it.
2096                  self.view.collection.once( 'query:success', function() {
2097                      $( 'div.theme[data-slug="' + $.escapeSelector( slug ) + '"]' ).trigger( 'click' );
2098                  });
2099  
2100              }
2101          });
2102  
2103          /*
2104           * Handles sorting / browsing routes.
2105           * Also handles the root URL triggering a sort request
2106           * for `popular`, the default view.
2107           */
2108          themes.router.on( 'route:sort', function( sort ) {
2109              if ( ! sort ) {
2110                  sort = 'popular';
2111                  themes.router.navigate( themes.router.baseUrl( '?browse=popular' ), { replace: true } );
2112              }
2113              self.view.sort( sort );
2114  
2115              // Close the preview if open.
2116              if ( themes.preview ) {
2117                  themes.preview.close();
2118              }
2119          });
2120  
2121          // The `search` route event. The router populates the input field.
2122          themes.router.on( 'route:search', function() {
2123              $( '.wp-filter-search' ).trigger( 'focus' ).trigger( 'keyup' );
2124          });
2125  
2126          this.extraRoutes();
2127      },
2128  
2129      extraRoutes: function() {
2130          return false;
2131      }
2132  };
2133  
2134  // Ready...
2135  $( function() {
2136      if ( themes.isInstall ) {
2137          themes.RunInstaller.init();
2138      } else {
2139          themes.Run.init();
2140      }
2141  
2142      // Update the return param just in time.
2143      $( document.body ).on( 'click', '.load-customize', function() {
2144          var link = $( this ), urlParser = document.createElement( 'a' );
2145          urlParser.href = link.prop( 'href' );
2146          urlParser.search = $.param( _.extend(
2147              wp.customize.utils.parseQueryString( urlParser.search.substr( 1 ) ),
2148              {
2149                  'return': window.location.href
2150              }
2151          ) );
2152          link.prop( 'href', urlParser.href );
2153      });
2154  
2155      $( '.broken-themes .delete-theme' ).on( 'click', function() {
2156          return confirm( _wpThemeSettings.settings.confirmDelete );
2157      });
2158  });
2159  
2160  })( jQuery );
2161  
2162  // Align theme browser thickbox.
2163  jQuery( function($) {
2164      window.tb_position = function() {
2165          var tbWindow = $('#TB_window'),
2166              width = $(window).width(),
2167              H = $(window).height(),
2168              W = ( 1040 < width ) ? 1040 : width,
2169              adminbar_height = 0;
2170  
2171          if ( $('#wpadminbar').length ) {
2172              adminbar_height = parseInt( $('#wpadminbar').css('height'), 10 );
2173          }
2174  
2175          if ( tbWindow.length >= 1 ) {
2176              tbWindow.width( W - 50 ).height( H - 45 - adminbar_height );
2177              $('#TB_iframeContent').width( W - 50 ).height( H - 75 - adminbar_height );
2178              tbWindow.css({'margin-left': '-' + parseInt( ( ( W - 50 ) / 2 ), 10 ) + 'px'});
2179              if ( typeof document.body.style.maxWidth !== 'undefined' ) {
2180                  tbWindow.css({'top': 20 + adminbar_height + 'px', 'margin-top': '0'});
2181              }
2182          }
2183      };
2184  
2185      $(window).on( 'resize', function(){ tb_position(); });
2186  });


Generated : Sat Oct 17 08:20:33 2026 Cross-referenced by PHPXref