[ 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;
  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.
  16  themes.data = _wpThemeSettings;
  17  
  18  /**
  19   * Announces to screen readers the theme shown after previous/next navigation.
  20   *
  21   * @since 7.1.0
  22   *
  23   * @param {Object} model The theme model.
  24   * @return {void}
  25   */
  26  themes.announceThemeDebounced = _.debounce( function( model ) {
  27      var name;
  28  
  29      if ( ! model ) {
  30          return;
  31      }
  32  
  33      name = model.get( 'name' ) || model.get( 'id' );
  34  
  35      if ( ! name ) {
  36          return;
  37      }
  38  
  39      /* translators: %s: Theme name. */
  40      wp.a11y.speak( wp.i18n.sprintf( wp.i18n.__( 'Theme details: %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">' + ( themes.isInstall ? wp.i18n.__( 'Search Themes' ) : wp.i18n.__( 'Search installed themes' ) ) + '</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': wp.i18n.__( 'Expand Sidebar' ) });
1015          } else {
1016              $button.attr({ 'aria-expanded': 'true', 'aria-label': wp.i18n.__( 'Collapse Sidebar' ) });
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">' + wp.i18n.__( 'Add Theme' ) + '</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.i18n.__( '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-renders.
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-renders.
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( wp.i18n.__( 'No themes found. Try a different search.' ) );
1414          } else {
1415              /* translators: %d: Number of themes. */
1416              wp.a11y.speak( wp.i18n.sprintf( wp.i18n.__( 'Number of Themes found: %d' ), count ) );
1417          }
1418      }
1419  });
1420  
1421  // Search input view controller.
1422  themes.view.Search = wp.Backbone.View.extend({
1423  
1424      tagName: 'input',
1425      className: 'wp-filter-search',
1426      id: 'wp-filter-search-input',
1427      searching: false,
1428  
1429      attributes: {
1430          type: 'search',
1431          'aria-describedby': 'live-search-desc'
1432      },
1433  
1434      events: {
1435          'input': 'search',
1436          'keyup': 'search',
1437          'blur': 'pushState'
1438      },
1439  
1440      initialize: function( options ) {
1441  
1442          this.parent = options.parent;
1443  
1444          this.listenTo( this.parent, 'theme:close', function() {
1445              this.searching = false;
1446          } );
1447  
1448      },
1449  
1450      search: function( event ) {
1451          // Clear on escape.
1452          if ( event.type === 'keyup' && event.which === 27 ) {
1453              event.target.value = '';
1454          }
1455  
1456          // Since doSearch is debounced, it will only run when user input comes to a rest.
1457          this.doSearch( event );
1458      },
1459  
1460      // Runs a search on the theme collection.
1461      doSearch: function( event ) {
1462          var options = {};
1463  
1464          this.collection.doSearch( event.target.value.replace( /\+/g, ' ' ) );
1465  
1466          // if search is initiated and key is not return.
1467          if ( this.searching && event.which !== 13 ) {
1468              options.replace = true;
1469          } else {
1470              this.searching = true;
1471          }
1472  
1473          // Update the URL hash.
1474          if ( event.target.value ) {
1475              themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + event.target.value ), options );
1476          } else {
1477              themes.router.navigate( themes.router.baseUrl( '' ) );
1478          }
1479      },
1480  
1481      pushState: function( event ) {
1482          var url = themes.router.baseUrl( '' );
1483  
1484          if ( event.target.value ) {
1485              url = themes.router.baseUrl( themes.router.searchPath + encodeURIComponent( event.target.value ) );
1486          }
1487  
1488          this.searching = false;
1489          themes.router.navigate( url );
1490  
1491      }
1492  });
1493  
1494  /**
1495   * Navigate router.
1496   *
1497   * @since 4.9.0
1498   *
1499   * @param {string} url   URL to navigate to.
1500   * @param {Object} state State.
1501   * @return {void}
1502   */
1503  function navigateRouter( url, state ) {
1504      var router = this;
1505      if ( Backbone.history._hasPushState ) {
1506          Backbone.Router.prototype.navigate.call( router, url, state );
1507      }
1508  }
1509  
1510  // Sets up the routes events for relevant url queries.
1511  // Listens to [theme] and [search] params.
1512  themes.Router = Backbone.Router.extend({
1513  
1514      routes: {
1515          'themes.php?theme=:slug': 'theme',
1516          'themes.php?search=:query': 'search',
1517          'themes.php?s=:query': 'search',
1518          'themes.php': 'themes',
1519          '': 'themes'
1520      },
1521  
1522      baseUrl: function( url ) {
1523          return 'themes.php' + url;
1524      },
1525  
1526      themePath: '?theme=',
1527      searchPath: '?search=',
1528  
1529      search: function( query ) {
1530          $( '.wp-filter-search' ).val( query.replace( /\+/g, ' ' ) );
1531      },
1532  
1533      themes: function() {
1534          $( '.wp-filter-search' ).val( '' );
1535      },
1536  
1537      navigate: navigateRouter
1538  
1539  });
1540  
1541  // Execute and setup the application.
1542  themes.Run = {
1543      init: function() {
1544          // Initializes the blog's theme library view.
1545          // Create a new collection with data.
1546          this.themes = new themes.Collection( themes.data.themes );
1547  
1548          // Set up the view.
1549          this.view = new themes.view.Appearance({
1550              collection: this.themes
1551          });
1552  
1553          this.render();
1554  
1555          // Start debouncing user searches after Backbone.history.start().
1556          this.view.SearchView.doSearch = _.debounce( this.view.SearchView.doSearch, 500 );
1557      },
1558  
1559      render: function() {
1560  
1561          // Render results.
1562          this.view.render();
1563          this.routes();
1564  
1565          if ( Backbone.History.started ) {
1566              Backbone.history.stop();
1567          }
1568          Backbone.history.start({
1569              root: themes.data.settings.adminUrl,
1570              pushState: true,
1571              hashChange: false
1572          });
1573      },
1574  
1575      routes: function() {
1576          var self = this;
1577          // Bind to our global thx object
1578          // so that the object is available to sub-views.
1579          themes.router = new themes.Router();
1580  
1581          // Handles theme details route event.
1582          themes.router.on( 'route:theme', function( slug ) {
1583              self.view.view.expand( slug );
1584          });
1585  
1586          themes.router.on( 'route:themes', function() {
1587              self.themes.doSearch( '' );
1588              self.view.trigger( 'theme:close' );
1589          });
1590  
1591          // Handles search route event.
1592          themes.router.on( 'route:search', function() {
1593              $( '.wp-filter-search' ).trigger( 'keyup' );
1594          });
1595  
1596          this.extraRoutes();
1597      },
1598  
1599      extraRoutes: function() {
1600          return false;
1601      }
1602  };
1603  
1604  // Extend the main Search view.
1605  themes.view.InstallerSearch =  themes.view.Search.extend({
1606  
1607      events: {
1608          'input': 'search',
1609          'keyup': 'search'
1610      },
1611  
1612      terms: '',
1613  
1614      // Handles Ajax request for searching through themes in public repo.
1615      search: function( event ) {
1616  
1617          // Tabbing or reverse tabbing into the search input shouldn't trigger a search.
1618          if ( event.type === 'keyup' && ( event.which === 9 || event.which === 16 ) ) {
1619              return;
1620          }
1621  
1622          this.collection = this.options.parent.view.collection;
1623  
1624          // Clear on escape.
1625          if ( event.type === 'keyup' && event.which === 27 ) {
1626              event.target.value = '';
1627          }
1628  
1629          this.doSearch( event.target.value );
1630      },
1631  
1632      doSearch: function( value ) {
1633          var request = {};
1634  
1635          // Don't do anything if the search terms haven't changed.
1636          if ( this.terms === value ) {
1637              return;
1638          }
1639  
1640          // Updates terms with the value passed.
1641          this.terms = value;
1642  
1643          request.search = value;
1644  
1645          /*
1646           * Intercept an [author] search.
1647           *
1648           * If input value starts with `author:` send a request
1649           * for `author` instead of a regular `search`.
1650           */
1651          if ( value.substring( 0, 7 ) === 'author:' ) {
1652              request.search = '';
1653              request.author = value.slice( 7 );
1654          }
1655  
1656          /*
1657           * Intercept a [tag] search.
1658           *
1659           * If input value starts with `tag:` send a request
1660           * for `tag` instead of a regular `search`.
1661           */
1662          if ( value.substring( 0, 4 ) === 'tag:' ) {
1663              request.search = '';
1664              request.tag = [ value.slice( 4 ) ];
1665          }
1666  
1667          $( '.filter-links li > a.current' )
1668              .removeClass( 'current' )
1669              .removeAttr( 'aria-current' );
1670  
1671          $( 'body' ).removeClass( 'show-filters filters-applied show-favorites-form' );
1672          $( '.drawer-toggle' ).attr( 'aria-expanded', 'false' );
1673  
1674          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1675          // or searching the local cache.
1676          this.collection.query( request );
1677  
1678          // Set route.
1679          themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + encodeURIComponent( value ) ), { replace: true } );
1680      }
1681  });
1682  
1683  themes.view.Installer = themes.view.Appearance.extend({
1684  
1685      el: '#wpbody-content .wrap',
1686  
1687      // Register events for sorting and filters in theme-navigation.
1688      events: {
1689          'click .filter-links li > a': 'onSort',
1690          'click .theme-filter': 'onFilter',
1691          'click .drawer-toggle': 'moreFilters',
1692          'click .filter-drawer .apply-filters': 'applyFilters',
1693          'click .filter-group [type="checkbox"]': 'addFilter',
1694          'click .filter-drawer .clear-filters': 'clearFilters',
1695          'click .edit-filters': 'backToFilters',
1696          'click .favorites-form-submit' : 'saveUsername',
1697          'keyup #wporg-username-input': 'saveUsername'
1698      },
1699  
1700      // Initial render method.
1701      render: function() {
1702          var self = this;
1703  
1704          this.search();
1705          this.uploader();
1706  
1707          this.collection = new themes.Collection();
1708  
1709          // Bump `collection.currentQuery.page` and request more themes if we hit the end of the page.
1710          this.listenTo( this, 'theme:end', function() {
1711  
1712              // Make sure we are not already loading.
1713              if ( self.collection.loadingThemes ) {
1714                  return;
1715              }
1716  
1717              // Set loadingThemes to true and bump page instance of currentQuery.
1718              self.collection.loadingThemes = true;
1719              self.collection.currentQuery.page++;
1720  
1721              // Use currentQuery.page to build the themes request.
1722              _.extend( self.collection.currentQuery.request, { page: self.collection.currentQuery.page } );
1723              self.collection.query( self.collection.currentQuery.request );
1724          });
1725  
1726          this.listenTo( this.collection, 'query:success', function() {
1727              $( 'body' ).removeClass( 'loading-content' );
1728              $( '.theme-browser' ).find( 'div.error' ).remove();
1729          });
1730  
1731          this.listenTo( this.collection, 'query:fail', function() {
1732              $( 'body' ).removeClass( 'loading-content' );
1733              $( '.theme-browser' ).find( 'div.error' ).remove();
1734              /* translators: %s: Support forums URL. */
1735              $( '.theme-browser' ).find( 'div.themes' ).before( '<div class="notice notice-error"><p>' + wp.i18n.sprintf( wp.i18n.__( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server&#8217;s configuration. If you continue to have problems, please try the <a href="%s">support forums</a>.' ), wp.i18n.__( 'https://wordpress.org/support/forums/' ) ) + '</p><p><button class="button try-again">' + wp.i18n.__( 'Try Again' ) + '</button></p></div>' );
1736              $( '.theme-browser .error .try-again' ).on( 'click', function( e ) {
1737                  e.preventDefault();
1738                  $( 'input.wp-filter-search' ).trigger( 'input' );
1739              } );
1740          });
1741  
1742          if ( this.view ) {
1743              this.view.remove();
1744          }
1745  
1746          // Sets up the view and passes the section argument.
1747          this.view = new themes.view.Themes({
1748              collection: this.collection,
1749              parent: this
1750          });
1751  
1752          // Reset pagination every time the install view handler is run.
1753          this.page = 0;
1754  
1755          // Render and append.
1756          this.$el.find( '.themes' ).remove();
1757          this.view.render();
1758          this.$el.find( '.theme-browser' ).append( this.view.el ).addClass( 'rendered' );
1759      },
1760  
1761      // Handles all the rendering of the public theme directory.
1762      browse: function( section ) {
1763          // Create a new collection with the proper theme data
1764          // for each section.
1765          if ( 'block-themes' === section ) {
1766              // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1767              // or searching the local cache.
1768              this.collection.query( { tag: 'full-site-editing' } );
1769          } else {
1770              this.collection.query( { browse: section } );
1771          }
1772      },
1773  
1774      // Sorting navigation.
1775      onSort: function( event ) {
1776          var $el = $( event.target ),
1777              sort = $el.data( 'sort' );
1778  
1779          event.preventDefault();
1780  
1781          $( 'body' ).removeClass( 'filters-applied show-filters' );
1782          $( '.drawer-toggle' ).attr( 'aria-expanded', 'false' );
1783  
1784          // Bail if this is already active.
1785          if ( $el.hasClass( this.activeClass ) ) {
1786              return;
1787          }
1788  
1789          this.sort( sort );
1790  
1791          // Trigger a router.navigate update.
1792          themes.router.navigate( themes.router.baseUrl( themes.router.browsePath + sort ) );
1793      },
1794  
1795      sort: function( sort ) {
1796          this.clearSearch();
1797  
1798          // Track sorting so we can restore the correct tab when closing preview.
1799          themes.router.selectedTab = sort;
1800  
1801          $( '.filter-links li > a, .theme-filter' )
1802              .removeClass( this.activeClass )
1803              .removeAttr( 'aria-current' );
1804  
1805          $( '[data-sort="' + sort + '"]' )
1806              .addClass( this.activeClass )
1807              .attr( 'aria-current', 'page' );
1808  
1809          if ( 'favorites' === sort ) {
1810              $( 'body' ).addClass( 'show-favorites-form' );
1811          } else {
1812              $( 'body' ).removeClass( 'show-favorites-form' );
1813          }
1814  
1815          this.browse( sort );
1816      },
1817  
1818      // Filters and Tags.
1819      onFilter: function( event ) {
1820          var request,
1821              $el = $( event.target ),
1822              filter = $el.data( 'filter' );
1823  
1824          // Bail if this is already active.
1825          if ( $el.hasClass( this.activeClass ) ) {
1826              return;
1827          }
1828  
1829          $( '.filter-links li > a, .theme-section' )
1830              .removeClass( this.activeClass )
1831              .removeAttr( 'aria-current' );
1832          $el
1833              .addClass( this.activeClass )
1834              .attr( 'aria-current', 'page' );
1835  
1836          if ( ! filter ) {
1837              return;
1838          }
1839  
1840          // Construct the filter request
1841          // using the default values.
1842          filter = _.union( [ filter, this.filtersChecked() ] );
1843          request = { tag: [ filter ] };
1844  
1845          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1846          // or searching the local cache.
1847          this.collection.query( request );
1848      },
1849  
1850      // Clicking on a checkbox to add another filter to the request.
1851      addFilter: function() {
1852          this.filtersChecked();
1853      },
1854  
1855      // Applying filters triggers a tag request.
1856      applyFilters: function( event ) {
1857          var name,
1858              tags = this.filtersChecked(),
1859              request = { tag: tags },
1860              filteringBy = $( '.filtered-by .tags' );
1861  
1862          if ( event ) {
1863              event.preventDefault();
1864          }
1865  
1866          if ( ! tags ) {
1867              /* translators: Hidden accessibility text. */
1868              wp.a11y.speak( wp.i18n.__( 'Select one or more Theme features to filter by' ) );
1869              return;
1870          }
1871  
1872          $( 'body' ).addClass( 'filters-applied' );
1873          $( '.filter-links li > a.current' )
1874              .removeClass( 'current' )
1875              .removeAttr( 'aria-current' );
1876  
1877          filteringBy.empty();
1878  
1879          _.each( tags, function( tag ) {
1880              name = $( 'label[for="filter-id-' + tag + '"]' ).text();
1881              filteringBy.append( '<span class="tag">' + name + '</span>' );
1882          });
1883  
1884          // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1885          // or searching the local cache.
1886          this.collection.query( request );
1887      },
1888  
1889      // Save the user's WordPress.org username and get his favorite themes.
1890      saveUsername: function ( event ) {
1891          var username = $( '#wporg-username-input' ).val(),
1892              nonce = $( '#wporg-username-nonce' ).val(),
1893              request = { browse: 'favorites', user: username },
1894              that = this;
1895  
1896          if ( event ) {
1897              event.preventDefault();
1898          }
1899  
1900          // Save username on enter.
1901          if ( event.type === 'keyup' && event.which !== 13 ) {
1902              return;
1903          }
1904  
1905          return wp.ajax.send( 'save-wporg-username', {
1906              data: {
1907                  _wpnonce: nonce,
1908                  username: username
1909              },
1910              success: function () {
1911                  // Get the themes by sending Ajax POST request to api.wordpress.org/themes
1912                  // or searching the local cache.
1913                  that.collection.query( request );
1914              }
1915          } );
1916      },
1917  
1918      /**
1919       * Get the checked filters.
1920       *
1921       * @return {Array} of tags or false
1922       */
1923      filtersChecked: function() {
1924          var items = $( '.filter-group' ).find( ':checkbox' ),
1925              tags = [];
1926  
1927          _.each( items.filter( ':checked' ), function( item ) {
1928              tags.push( $( item ).prop( 'value' ) );
1929          });
1930  
1931          // When no filters are checked, restore initial state and return.
1932          if ( tags.length === 0 ) {
1933              $( '.filter-drawer .apply-filters' ).find( 'span' ).text( '' );
1934              $( '.filter-drawer .clear-filters' ).hide();
1935              $( 'body' ).removeClass( 'filters-applied' );
1936              return false;
1937          }
1938  
1939          $( '.filter-drawer .apply-filters' ).find( 'span' ).text( tags.length );
1940          $( '.filter-drawer .clear-filters' ).css( 'display', 'inline-block' );
1941  
1942          return tags;
1943      },
1944  
1945      activeClass: 'current',
1946  
1947      /**
1948       * When users press the "Upload Theme" button, show the upload form in place.
1949       */
1950      uploader: function() {
1951          var uploadViewToggle = $( '.upload-view-toggle' ),
1952              $body = $( document.body );
1953  
1954          uploadViewToggle.on( 'click', function() {
1955              // Toggle the upload view.
1956              $body.toggleClass( 'show-upload-view' );
1957              // Toggle the `aria-expanded` button attribute.
1958              uploadViewToggle.attr( 'aria-expanded', $body.hasClass( 'show-upload-view' ) );
1959          });
1960      },
1961  
1962      // Toggle the full filters navigation.
1963      moreFilters: function( event ) {
1964          var $body = $( 'body' ),
1965              $toggleButton = $( '.drawer-toggle' );
1966  
1967          event.preventDefault();
1968  
1969          if ( $body.hasClass( 'filters-applied' ) ) {
1970              return this.backToFilters();
1971          }
1972  
1973          this.clearSearch();
1974  
1975          themes.router.navigate( themes.router.baseUrl( '' ) );
1976          // Toggle the feature filters view.
1977          $body.toggleClass( 'show-filters' );
1978          // Toggle the `aria-expanded` button attribute.
1979          $toggleButton.attr( 'aria-expanded', $body.hasClass( 'show-filters' ) );
1980      },
1981  
1982      /**
1983       * Clears all the checked filters.
1984       *
1985       * @param {Event} event The click event.
1986       */
1987      clearFilters: function( event ) {
1988          var items = $( '.filter-group' ).find( ':checkbox' ),
1989              self = this;
1990  
1991          event.preventDefault();
1992  
1993          _.each( items.filter( ':checked' ), function( item ) {
1994              $( item ).prop( 'checked', false );
1995              return self.filtersChecked();
1996          });
1997      },
1998  
1999      backToFilters: function( event ) {
2000          if ( event ) {
2001              event.preventDefault();
2002          }
2003  
2004          $( 'body' ).removeClass( 'filters-applied' );
2005      },
2006  
2007      clearSearch: function() {
2008          $( '#wp-filter-search-input').val( '' );
2009      }
2010  });
2011  
2012  themes.InstallerRouter = Backbone.Router.extend({
2013      routes: {
2014          'theme-install.php?theme=:slug': 'preview',
2015          'theme-install.php?browse=:sort': 'sort',
2016          'theme-install.php?search=:query': 'search',
2017          'theme-install.php': 'sort'
2018      },
2019  
2020      baseUrl: function( url ) {
2021          return 'theme-install.php' + url;
2022      },
2023  
2024      themePath: '?theme=',
2025      browsePath: '?browse=',
2026      searchPath: '?search=',
2027  
2028      search: function( query ) {
2029          $( '.wp-filter-search' ).val( query.replace( /\+/g, ' ' ) );
2030      },
2031  
2032      navigate: navigateRouter
2033  });
2034  
2035  
2036  themes.RunInstaller = {
2037  
2038      init: function() {
2039          // Set up the view.
2040          // Passes the default 'section' as an option.
2041          this.view = new themes.view.Installer({
2042              section: 'popular',
2043              SearchView: themes.view.InstallerSearch
2044          });
2045  
2046          // Render results.
2047          this.render();
2048  
2049          // Start debouncing user searches after Backbone.history.start().
2050          this.view.SearchView.doSearch = _.debounce( this.view.SearchView.doSearch, 500 );
2051      },
2052  
2053      render: function() {
2054  
2055          // Render results.
2056          this.view.render();
2057          this.routes();
2058  
2059          if ( Backbone.History.started ) {
2060              Backbone.history.stop();
2061          }
2062          Backbone.history.start({
2063              root: themes.data.settings.adminUrl,
2064              pushState: true,
2065              hashChange: false
2066          });
2067      },
2068  
2069      routes: function() {
2070          var self = this,
2071              request = {};
2072  
2073          // Bind to our global `wp.themes` object
2074          // so that the router is available to sub-views.
2075          themes.router = new themes.InstallerRouter();
2076  
2077          // Handles `theme` route event.
2078          // Queries the API for the passed theme slug.
2079          themes.router.on( 'route:preview', function( slug ) {
2080  
2081              // Remove existing handlers.
2082              if ( themes.preview ) {
2083                  themes.preview.undelegateEvents();
2084                  themes.preview.unbind();
2085              }
2086  
2087              // If the theme preview is active, set the current theme.
2088              if ( self.view.view.theme && self.view.view.theme.preview ) {
2089                  self.view.view.theme.model = self.view.collection.findWhere( { 'slug': slug } );
2090                  self.view.view.theme.preview();
2091              } else {
2092  
2093                  // Select the theme by slug.
2094                  request.theme = slug;
2095                  self.view.collection.query( request );
2096                  self.view.collection.trigger( 'update' );
2097  
2098                  // Open the theme preview. The slug comes from the URL, so escape it.
2099                  self.view.collection.once( 'query:success', function() {
2100                      $( 'div.theme[data-slug="' + $.escapeSelector( slug ) + '"]' ).trigger( 'click' );
2101                  });
2102  
2103              }
2104          });
2105  
2106          /*
2107           * Handles sorting / browsing routes.
2108           * Also handles the root URL triggering a sort request
2109           * for `popular`, the default view.
2110           */
2111          themes.router.on( 'route:sort', function( sort ) {
2112              if ( ! sort ) {
2113                  sort = 'popular';
2114                  themes.router.navigate( themes.router.baseUrl( '?browse=popular' ), { replace: true } );
2115              }
2116              self.view.sort( sort );
2117  
2118              // Close the preview if open.
2119              if ( themes.preview ) {
2120                  themes.preview.close();
2121              }
2122          });
2123  
2124          // The `search` route event. The router populates the input field.
2125          themes.router.on( 'route:search', function() {
2126              $( '.wp-filter-search' ).trigger( 'focus' ).trigger( 'keyup' );
2127          });
2128  
2129          this.extraRoutes();
2130      },
2131  
2132      extraRoutes: function() {
2133          return false;
2134      }
2135  };
2136  
2137  // Ready...
2138  $( function() {
2139      if ( themes.isInstall ) {
2140          themes.RunInstaller.init();
2141      } else {
2142          themes.Run.init();
2143      }
2144  
2145      // Update the return param just in time.
2146      $( document.body ).on( 'click', '.load-customize', function() {
2147          var link = $( this ), urlParser = document.createElement( 'a' );
2148          urlParser.href = link.prop( 'href' );
2149          urlParser.search = $.param( _.extend(
2150              wp.customize.utils.parseQueryString( urlParser.search.substr( 1 ) ),
2151              {
2152                  'return': window.location.href
2153              }
2154          ) );
2155          link.prop( 'href', urlParser.href );
2156      });
2157  
2158      $( '.broken-themes .delete-theme' ).on( 'click', function() {
2159          return confirm( _wpThemeSettings.settings.confirmDelete );
2160      });
2161  });
2162  
2163  })( jQuery );
2164  
2165  // Align theme browser thickbox.
2166  jQuery( function($) {
2167      window.tb_position = function() {
2168          var tbWindow = $('#TB_window'),
2169              width = $(window).width(),
2170              H = $(window).height(),
2171              W = ( 1040 < width ) ? 1040 : width,
2172              adminbar_height = 0;
2173  
2174          if ( $('#wpadminbar').length ) {
2175              adminbar_height = parseInt( $('#wpadminbar').css('height'), 10 );
2176          }
2177  
2178          if ( tbWindow.length >= 1 ) {
2179              tbWindow.width( W - 50 ).height( H - 45 - adminbar_height );
2180              $('#TB_iframeContent').width( W - 50 ).height( H - 75 - adminbar_height );
2181              tbWindow.css({'margin-left': '-' + parseInt( ( ( W - 50 ) / 2 ), 10 ) + 'px'});
2182              if ( typeof document.body.style.maxWidth !== 'undefined' ) {
2183                  tbWindow.css({'top': 20 + adminbar_height + 'px', 'margin-top': '0'});
2184              }
2185          }
2186      };
2187  
2188      $(window).on( 'resize', function(){ tb_position(); });
2189  });


Generated : Fri Nov 6 08:20:37 2026 Cross-referenced by PHPXref