[ Index ]

PHP Cross Reference of WordPress

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


Generated: Fri Apr 19 01:00:02 2024 Cross-referenced by PHPXref 0.7.1