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 ); |
---|