angelovcom.net

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

custom-html-widgets.js (15765B)


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