wp/wp-admin/js/widgets/custom-html-widgets.js
changeset 7 cf61fcea0001
child 9 177826044cd9
equal deleted inserted replaced
6:490d5cc509ed 7:cf61fcea0001
       
     1 /* global wp */
       
     2 /* eslint consistent-this: [ "error", "control" ] */
       
     3 /* eslint no-magic-numbers: ["error", { "ignore": [0,1,-1] }] */
       
     4 wp.customHtmlWidgets = ( function( $ ) {
       
     5 	'use strict';
       
     6 
       
     7 	var component = {
       
     8 		idBases: [ 'custom_html' ],
       
     9 		codeEditorSettings: {},
       
    10 		l10n: {
       
    11 			errorNotice: {
       
    12 				singular: '',
       
    13 				plural: ''
       
    14 			}
       
    15 		}
       
    16 	};
       
    17 
       
    18 	/**
       
    19 	 * Text widget control.
       
    20 	 *
       
    21 	 * @class CustomHtmlWidgetControl
       
    22 	 * @constructor
       
    23 	 * @abstract
       
    24 	 */
       
    25 	component.CustomHtmlWidgetControl = Backbone.View.extend({
       
    26 
       
    27 		/**
       
    28 		 * View events.
       
    29 		 *
       
    30 		 * @type {Object}
       
    31 		 */
       
    32 		events: {},
       
    33 
       
    34 		/**
       
    35 		 * Initialize.
       
    36 		 *
       
    37 		 * @param {Object} options - Options.
       
    38 		 * @param {jQuery} options.el - Control field container element.
       
    39 		 * @param {jQuery} options.syncContainer - Container element where fields are synced for the server.
       
    40 		 * @returns {void}
       
    41 		 */
       
    42 		initialize: function initialize( options ) {
       
    43 			var control = this;
       
    44 
       
    45 			if ( ! options.el ) {
       
    46 				throw new Error( 'Missing options.el' );
       
    47 			}
       
    48 			if ( ! options.syncContainer ) {
       
    49 				throw new Error( 'Missing options.syncContainer' );
       
    50 			}
       
    51 
       
    52 			Backbone.View.prototype.initialize.call( control, options );
       
    53 			control.syncContainer = options.syncContainer;
       
    54 			control.widgetIdBase = control.syncContainer.parent().find( '.id_base' ).val();
       
    55 			control.widgetNumber = control.syncContainer.parent().find( '.widget_number' ).val();
       
    56 			control.customizeSettingId = 'widget_' + control.widgetIdBase + '[' + String( control.widgetNumber ) + ']';
       
    57 
       
    58 			control.$el.addClass( 'custom-html-widget-fields' );
       
    59 			control.$el.html( wp.template( 'widget-custom-html-control-fields' )( { codeEditorDisabled: component.codeEditorSettings.disabled } ) );
       
    60 
       
    61 			control.errorNoticeContainer = control.$el.find( '.code-editor-error-container' );
       
    62 			control.currentErrorAnnotations = [];
       
    63 			control.saveButton = control.syncContainer.add( control.syncContainer.parent().find( '.widget-control-actions' ) ).find( '.widget-control-save, #savewidget' );
       
    64 			control.saveButton.addClass( 'custom-html-widget-save-button' ); // To facilitate style targeting.
       
    65 
       
    66 			control.fields = {
       
    67 				title: control.$el.find( '.title' ),
       
    68 				content: control.$el.find( '.content' )
       
    69 			};
       
    70 
       
    71 			// Sync input fields to hidden sync fields which actually get sent to the server.
       
    72 			_.each( control.fields, function( fieldInput, fieldName ) {
       
    73 				fieldInput.on( 'input change', function updateSyncField() {
       
    74 					var syncInput = control.syncContainer.find( '.sync-input.' + fieldName );
       
    75 					if ( syncInput.val() !== fieldInput.val() ) {
       
    76 						syncInput.val( fieldInput.val() );
       
    77 						syncInput.trigger( 'change' );
       
    78 					}
       
    79 				});
       
    80 
       
    81 				// Note that syncInput cannot be re-used because it will be destroyed with each widget-updated event.
       
    82 				fieldInput.val( control.syncContainer.find( '.sync-input.' + fieldName ).val() );
       
    83 			});
       
    84 		},
       
    85 
       
    86 		/**
       
    87 		 * Update input fields from the sync fields.
       
    88 		 *
       
    89 		 * This function is called at the widget-updated and widget-synced events.
       
    90 		 * A field will only be updated if it is not currently focused, to avoid
       
    91 		 * overwriting content that the user is entering.
       
    92 		 *
       
    93 		 * @returns {void}
       
    94 		 */
       
    95 		updateFields: function updateFields() {
       
    96 			var control = this, syncInput;
       
    97 
       
    98 			if ( ! control.fields.title.is( document.activeElement ) ) {
       
    99 				syncInput = control.syncContainer.find( '.sync-input.title' );
       
   100 				control.fields.title.val( syncInput.val() );
       
   101 			}
       
   102 
       
   103 			/*
       
   104 			 * Prevent updating content when the editor is focused or if there are current error annotations,
       
   105 			 * to prevent the editor's contents from getting sanitized as soon as a user removes focus from
       
   106 			 * the editor. This is particularly important for users who cannot unfiltered_html.
       
   107 			 */
       
   108 			control.contentUpdateBypassed = control.fields.content.is( document.activeElement ) || control.editor && control.editor.codemirror.state.focused || 0 !== control.currentErrorAnnotations;
       
   109 			if ( ! control.contentUpdateBypassed ) {
       
   110 				syncInput = control.syncContainer.find( '.sync-input.content' );
       
   111 				control.fields.content.val( syncInput.val() ).trigger( 'change' );
       
   112 			}
       
   113 		},
       
   114 
       
   115 		/**
       
   116 		 * Show linting error notice.
       
   117 		 *
       
   118 		 * @param {Array} errorAnnotations - Error annotations.
       
   119 		 * @returns {void}
       
   120 		 */
       
   121 		updateErrorNotice: function( errorAnnotations ) {
       
   122 			var control = this, errorNotice, message = '', customizeSetting;
       
   123 
       
   124 			if ( 1 === errorAnnotations.length ) {
       
   125 				message = component.l10n.errorNotice.singular.replace( '%d', '1' );
       
   126 			} else if ( errorAnnotations.length > 1 ) {
       
   127 				message = component.l10n.errorNotice.plural.replace( '%d', String( errorAnnotations.length ) );
       
   128 			}
       
   129 
       
   130 			if ( control.fields.content[0].setCustomValidity ) {
       
   131 				control.fields.content[0].setCustomValidity( message );
       
   132 			}
       
   133 
       
   134 			if ( wp.customize && wp.customize.has( control.customizeSettingId ) ) {
       
   135 				customizeSetting = wp.customize( control.customizeSettingId );
       
   136 				customizeSetting.notifications.remove( 'htmlhint_error' );
       
   137 				if ( 0 !== errorAnnotations.length ) {
       
   138 					customizeSetting.notifications.add( 'htmlhint_error', new wp.customize.Notification( 'htmlhint_error', {
       
   139 						message: message,
       
   140 						type: 'error'
       
   141 					} ) );
       
   142 				}
       
   143 			} else if ( 0 !== errorAnnotations.length ) {
       
   144 				errorNotice = $( '<div class="inline notice notice-error notice-alt"></div>' );
       
   145 				errorNotice.append( $( '<p></p>', {
       
   146 					text: message
       
   147 				} ) );
       
   148 				control.errorNoticeContainer.empty();
       
   149 				control.errorNoticeContainer.append( errorNotice );
       
   150 				control.errorNoticeContainer.slideDown( 'fast' );
       
   151 				wp.a11y.speak( message );
       
   152 			} else {
       
   153 				control.errorNoticeContainer.slideUp( 'fast' );
       
   154 			}
       
   155 		},
       
   156 
       
   157 		/**
       
   158 		 * Initialize editor.
       
   159 		 *
       
   160 		 * @returns {void}
       
   161 		 */
       
   162 		initializeEditor: function initializeEditor() {
       
   163 			var control = this, settings;
       
   164 
       
   165 			if ( component.codeEditorSettings.disabled ) {
       
   166 				return;
       
   167 			}
       
   168 
       
   169 			settings = _.extend( {}, component.codeEditorSettings, {
       
   170 
       
   171 				/**
       
   172 				 * Handle tabbing to the field before the editor.
       
   173 				 *
       
   174 				 * @returns {void}
       
   175 				 */
       
   176 				onTabPrevious: function onTabPrevious() {
       
   177 					control.fields.title.focus();
       
   178 				},
       
   179 
       
   180 				/**
       
   181 				 * Handle tabbing to the field after the editor.
       
   182 				 *
       
   183 				 * @returns {void}
       
   184 				 */
       
   185 				onTabNext: function onTabNext() {
       
   186 					var tabbables = control.syncContainer.add( control.syncContainer.parent().find( '.widget-position, .widget-control-actions' ) ).find( ':tabbable' );
       
   187 					tabbables.first().focus();
       
   188 				},
       
   189 
       
   190 				/**
       
   191 				 * Disable save button and store linting errors for use in updateFields.
       
   192 				 *
       
   193 				 * @param {Array} errorAnnotations - Error notifications.
       
   194 				 * @returns {void}
       
   195 				 */
       
   196 				onChangeLintingErrors: function onChangeLintingErrors( errorAnnotations ) {
       
   197 					control.currentErrorAnnotations = errorAnnotations;
       
   198 				},
       
   199 
       
   200 				/**
       
   201 				 * Update error notice.
       
   202 				 *
       
   203 				 * @param {Array} errorAnnotations - Error annotations.
       
   204 				 * @returns {void}
       
   205 				 */
       
   206 				onUpdateErrorNotice: function onUpdateErrorNotice( errorAnnotations ) {
       
   207 					control.saveButton.toggleClass( 'validation-blocked disabled', errorAnnotations.length > 0 );
       
   208 					control.updateErrorNotice( errorAnnotations );
       
   209 				}
       
   210 			});
       
   211 
       
   212 			control.editor = wp.codeEditor.initialize( control.fields.content, settings );
       
   213 
       
   214 			// Improve the editor accessibility.
       
   215 			$( control.editor.codemirror.display.lineDiv )
       
   216 				.attr({
       
   217 					role: 'textbox',
       
   218 					'aria-multiline': 'true',
       
   219 					'aria-labelledby': control.fields.content[0].id + '-label',
       
   220 					'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4'
       
   221 				});
       
   222 
       
   223 			// Focus the editor when clicking on its label.
       
   224 			$( '#' + control.fields.content[0].id + '-label' ).on( 'click', function() {
       
   225 				control.editor.codemirror.focus();
       
   226 			});
       
   227 
       
   228 			control.fields.content.on( 'change', function() {
       
   229 				if ( this.value !== control.editor.codemirror.getValue() ) {
       
   230 					control.editor.codemirror.setValue( this.value );
       
   231 				}
       
   232 			});
       
   233 			control.editor.codemirror.on( 'change', function() {
       
   234 				var value = control.editor.codemirror.getValue();
       
   235 				if ( value !== control.fields.content.val() ) {
       
   236 					control.fields.content.val( value ).trigger( 'change' );
       
   237 				}
       
   238 			});
       
   239 
       
   240 			// Make sure the editor gets updated if the content was updated on the server (sanitization) but not updated in the editor since it was focused.
       
   241 			control.editor.codemirror.on( 'blur', function() {
       
   242 				if ( control.contentUpdateBypassed ) {
       
   243 					control.syncContainer.find( '.sync-input.content' ).trigger( 'change' );
       
   244 				}
       
   245 			});
       
   246 
       
   247 			// Prevent hitting Esc from collapsing the widget control.
       
   248 			if ( wp.customize ) {
       
   249 				control.editor.codemirror.on( 'keydown', function onKeydown( codemirror, event ) {
       
   250 					var escKeyCode = 27;
       
   251 					if ( escKeyCode === event.keyCode ) {
       
   252 						event.stopPropagation();
       
   253 					}
       
   254 				});
       
   255 			}
       
   256 		}
       
   257 	});
       
   258 
       
   259 	/**
       
   260 	 * Mapping of widget ID to instances of CustomHtmlWidgetControl subclasses.
       
   261 	 *
       
   262 	 * @type {Object.<string, wp.textWidgets.CustomHtmlWidgetControl>}
       
   263 	 */
       
   264 	component.widgetControls = {};
       
   265 
       
   266 	/**
       
   267 	 * Handle widget being added or initialized for the first time at the widget-added event.
       
   268 	 *
       
   269 	 * @param {jQuery.Event} event - Event.
       
   270 	 * @param {jQuery}       widgetContainer - Widget container element.
       
   271 	 * @returns {void}
       
   272 	 */
       
   273 	component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
       
   274 		var widgetForm, idBase, widgetControl, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone, fieldContainer, syncContainer;
       
   275 		widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.
       
   276 
       
   277 		idBase = widgetForm.find( '> .id_base' ).val();
       
   278 		if ( -1 === component.idBases.indexOf( idBase ) ) {
       
   279 			return;
       
   280 		}
       
   281 
       
   282 		// Prevent initializing already-added widgets.
       
   283 		widgetId = widgetForm.find( '.widget-id' ).val();
       
   284 		if ( component.widgetControls[ widgetId ] ) {
       
   285 			return;
       
   286 		}
       
   287 
       
   288 		/*
       
   289 		 * Create a container element for the widget control fields.
       
   290 		 * This is inserted into the DOM immediately before the the .widget-content
       
   291 		 * element because the contents of this element are essentially "managed"
       
   292 		 * by PHP, where each widget update cause the entire element to be emptied
       
   293 		 * and replaced with the rendered output of WP_Widget::form() which is
       
   294 		 * sent back in Ajax request made to save/update the widget instance.
       
   295 		 * To prevent a "flash of replaced DOM elements and re-initialized JS
       
   296 		 * components", the JS template is rendered outside of the normal form
       
   297 		 * container.
       
   298 		 */
       
   299 		fieldContainer = $( '<div></div>' );
       
   300 		syncContainer = widgetContainer.find( '.widget-content:first' );
       
   301 		syncContainer.before( fieldContainer );
       
   302 
       
   303 		widgetControl = new component.CustomHtmlWidgetControl({
       
   304 			el: fieldContainer,
       
   305 			syncContainer: syncContainer
       
   306 		});
       
   307 
       
   308 		component.widgetControls[ widgetId ] = widgetControl;
       
   309 
       
   310 		/*
       
   311 		 * Render the widget once the widget parent's container finishes animating,
       
   312 		 * as the widget-added event fires with a slideDown of the container.
       
   313 		 * This ensures that the textarea is visible and the editor can be initialized.
       
   314 		 */
       
   315 		renderWhenAnimationDone = function() {
       
   316 			if ( ! ( wp.customize ? widgetContainer.parent().hasClass( 'expanded' ) : widgetContainer.hasClass( 'open' ) ) ) { // Core merge: The wp.customize condition can be eliminated with this change being in core: https://github.com/xwp/wordpress-develop/pull/247/commits/5322387d
       
   317 				setTimeout( renderWhenAnimationDone, animatedCheckDelay );
       
   318 			} else {
       
   319 				widgetControl.initializeEditor();
       
   320 			}
       
   321 		};
       
   322 		renderWhenAnimationDone();
       
   323 	};
       
   324 
       
   325 	/**
       
   326 	 * Setup widget in accessibility mode.
       
   327 	 *
       
   328 	 * @returns {void}
       
   329 	 */
       
   330 	component.setupAccessibleMode = function setupAccessibleMode() {
       
   331 		var widgetForm, idBase, widgetControl, fieldContainer, syncContainer;
       
   332 		widgetForm = $( '.editwidget > form' );
       
   333 		if ( 0 === widgetForm.length ) {
       
   334 			return;
       
   335 		}
       
   336 
       
   337 		idBase = widgetForm.find( '> .widget-control-actions > .id_base' ).val();
       
   338 		if ( -1 === component.idBases.indexOf( idBase ) ) {
       
   339 			return;
       
   340 		}
       
   341 
       
   342 		fieldContainer = $( '<div></div>' );
       
   343 		syncContainer = widgetForm.find( '> .widget-inside' );
       
   344 		syncContainer.before( fieldContainer );
       
   345 
       
   346 		widgetControl = new component.CustomHtmlWidgetControl({
       
   347 			el: fieldContainer,
       
   348 			syncContainer: syncContainer
       
   349 		});
       
   350 
       
   351 		widgetControl.initializeEditor();
       
   352 	};
       
   353 
       
   354 	/**
       
   355 	 * Sync widget instance data sanitized from server back onto widget model.
       
   356 	 *
       
   357 	 * This gets called via the 'widget-updated' event when saving a widget from
       
   358 	 * the widgets admin screen and also via the 'widget-synced' event when making
       
   359 	 * a change to a widget in the customizer.
       
   360 	 *
       
   361 	 * @param {jQuery.Event} event - Event.
       
   362 	 * @param {jQuery}       widgetContainer - Widget container element.
       
   363 	 * @returns {void}
       
   364 	 */
       
   365 	component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) {
       
   366 		var widgetForm, widgetId, widgetControl, idBase;
       
   367 		widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
       
   368 
       
   369 		idBase = widgetForm.find( '> .id_base' ).val();
       
   370 		if ( -1 === component.idBases.indexOf( idBase ) ) {
       
   371 			return;
       
   372 		}
       
   373 
       
   374 		widgetId = widgetForm.find( '> .widget-id' ).val();
       
   375 		widgetControl = component.widgetControls[ widgetId ];
       
   376 		if ( ! widgetControl ) {
       
   377 			return;
       
   378 		}
       
   379 
       
   380 		widgetControl.updateFields();
       
   381 	};
       
   382 
       
   383 	/**
       
   384 	 * Initialize functionality.
       
   385 	 *
       
   386 	 * This function exists to prevent the JS file from having to boot itself.
       
   387 	 * When WordPress enqueues this script, it should have an inline script
       
   388 	 * attached which calls wp.textWidgets.init().
       
   389 	 *
       
   390 	 * @param {object} settings - Options for code editor, exported from PHP.
       
   391 	 * @returns {void}
       
   392 	 */
       
   393 	component.init = function init( settings ) {
       
   394 		var $document = $( document );
       
   395 		_.extend( component.codeEditorSettings, settings );
       
   396 
       
   397 		$document.on( 'widget-added', component.handleWidgetAdded );
       
   398 		$document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );
       
   399 
       
   400 		/*
       
   401 		 * Manually trigger widget-added events for media widgets on the admin
       
   402 		 * screen once they are expanded. The widget-added event is not triggered
       
   403 		 * for each pre-existing widget on the widgets admin screen like it is
       
   404 		 * on the customizer. Likewise, the customizer only triggers widget-added
       
   405 		 * when the widget is expanded to just-in-time construct the widget form
       
   406 		 * when it is actually going to be displayed. So the following implements
       
   407 		 * the same for the widgets admin screen, to invoke the widget-added
       
   408 		 * handler when a pre-existing media widget is expanded.
       
   409 		 */
       
   410 		$( function initializeExistingWidgetContainers() {
       
   411 			var widgetContainers;
       
   412 			if ( 'widgets' !== window.pagenow ) {
       
   413 				return;
       
   414 			}
       
   415 			widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' );
       
   416 			widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() {
       
   417 				var widgetContainer = $( this );
       
   418 				component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer );
       
   419 			});
       
   420 
       
   421 			// Accessibility mode.
       
   422 			$( window ).on( 'load', function() {
       
   423 				component.setupAccessibleMode();
       
   424 			});
       
   425 		});
       
   426 	};
       
   427 
       
   428 	return component;
       
   429 })( jQuery );