run:R W Run
15.41 KB
2026-03-11 16:18:51
R W Run
5.45 KB
2026-03-11 16:18:51
R W Run
4.18 KB
2026-03-11 16:18:51
R W Run
1.41 KB
2026-03-11 16:18:51
R W Run
10.11 KB
2026-03-11 16:18:51
R W Run
3.68 KB
2026-03-11 16:18:51
R W Run
5.34 KB
2026-03-11 16:18:51
R W Run
1.98 KB
2026-03-11 16:18:51
R W Run
6.86 KB
2026-03-11 16:18:51
R W Run
2.64 KB
2026-03-11 16:18:51
R W Run
41.86 KB
2026-03-11 16:18:51
R W Run
13.91 KB
2026-03-11 16:18:51
R W Run
17.63 KB
2026-03-11 16:18:51
R W Run
5.72 KB
2026-03-11 16:18:51
R W Run
error_log
📄text-widgets.js
1/**
2 * @output wp-admin/js/widgets/text-widgets.js
3 */
4
5/* global tinymce, switchEditors */
6/* eslint consistent-this: [ "error", "control" ] */
7
8/**
9 * @namespace wp.textWidgets
10 */
11wp.textWidgets = ( function( $ ) {
12 'use strict';
13
14 var component = {
15 dismissedPointers: [],
16 idBases: [ 'text' ]
17 };
18
19 component.TextWidgetControl = Backbone.View.extend(/** @lends wp.textWidgets.TextWidgetControl.prototype */{
20
21 /**
22 * View events.
23 *
24 * @type {Object}
25 */
26 events: {},
27
28 /**
29 * Text widget control.
30 *
31 * @constructs wp.textWidgets.TextWidgetControl
32 * @augments Backbone.View
33 * @abstract
34 *
35 * @param {Object} options - Options.
36 * @param {jQuery} options.el - Control field container element.
37 * @param {jQuery} options.syncContainer - Container element where fields are synced for the server.
38 *
39 * @return {void}
40 */
41 initialize: function initialize( options ) {
42 var control = this;
43
44 if ( ! options.el ) {
45 throw new Error( 'Missing options.el' );
46 }
47 if ( ! options.syncContainer ) {
48 throw new Error( 'Missing options.syncContainer' );
49 }
50
51 Backbone.View.prototype.initialize.call( control, options );
52 control.syncContainer = options.syncContainer;
53
54 control.$el.addClass( 'text-widget-fields' );
55 control.$el.html( wp.template( 'widget-text-control-fields' ) );
56
57 control.customHtmlWidgetPointer = control.$el.find( '.wp-pointer.custom-html-widget-pointer' );
58 if ( control.customHtmlWidgetPointer.length ) {
59 control.customHtmlWidgetPointer.find( '.close' ).on( 'click', function( event ) {
60 event.preventDefault();
61 control.customHtmlWidgetPointer.hide();
62 $( '#' + control.fields.text.attr( 'id' ) + '-html' ).trigger( 'focus' );
63 control.dismissPointers( [ 'text_widget_custom_html' ] );
64 });
65 control.customHtmlWidgetPointer.find( '.add-widget' ).on( 'click', function( event ) {
66 event.preventDefault();
67 control.customHtmlWidgetPointer.hide();
68 control.openAvailableWidgetsPanel();
69 });
70 }
71
72 control.pasteHtmlPointer = control.$el.find( '.wp-pointer.paste-html-pointer' );
73 if ( control.pasteHtmlPointer.length ) {
74 control.pasteHtmlPointer.find( '.close' ).on( 'click', function( event ) {
75 event.preventDefault();
76 control.pasteHtmlPointer.hide();
77 control.editor.focus();
78 control.dismissPointers( [ 'text_widget_custom_html', 'text_widget_paste_html' ] );
79 });
80 }
81
82 control.fields = {
83 title: control.$el.find( '.title' ),
84 text: control.$el.find( '.text' )
85 };
86
87 // Sync input fields to hidden sync fields which actually get sent to the server.
88 _.each( control.fields, function( fieldInput, fieldName ) {
89 fieldInput.on( 'input change', function updateSyncField() {
90 var syncInput = control.syncContainer.find( '.sync-input.' + fieldName );
91 if ( syncInput.val() !== fieldInput.val() ) {
92 syncInput.val( fieldInput.val() );
93 syncInput.trigger( 'change' );
94 }
95 });
96
97 // Note that syncInput cannot be re-used because it will be destroyed with each widget-updated event.
98 fieldInput.val( control.syncContainer.find( '.sync-input.' + fieldName ).val() );
99 });
100 },
101
102 /**
103 * Dismiss pointers for Custom HTML widget.
104 *
105 * @since 4.8.1
106 *
107 * @param {Array} pointers Pointer IDs to dismiss.
108 * @return {void}
109 */
110 dismissPointers: function dismissPointers( pointers ) {
111 _.each( pointers, function( pointer ) {
112 wp.ajax.post( 'dismiss-wp-pointer', {
113 pointer: pointer
114 });
115 component.dismissedPointers.push( pointer );
116 });
117 },
118
119 /**
120 * Open available widgets panel.
121 *
122 * @since 4.8.1
123 * @return {void}
124 */
125 openAvailableWidgetsPanel: function openAvailableWidgetsPanel() {
126 var sidebarControl;
127 wp.customize.section.each( function( section ) {
128 if ( section.extended( wp.customize.Widgets.SidebarSection ) && section.expanded() ) {
129 sidebarControl = wp.customize.control( 'sidebars_widgets[' + section.params.sidebarId + ']' );
130 }
131 });
132 if ( ! sidebarControl ) {
133 return;
134 }
135 setTimeout( function() { // Timeout to prevent click event from causing panel to immediately collapse.
136 wp.customize.Widgets.availableWidgetsPanel.open( sidebarControl );
137 wp.customize.Widgets.availableWidgetsPanel.$search.val( 'HTML' ).trigger( 'keyup' );
138 });
139 },
140
141 /**
142 * Update input fields from the sync fields.
143 *
144 * This function is called at the widget-updated and widget-synced events.
145 * A field will only be updated if it is not currently focused, to avoid
146 * overwriting content that the user is entering.
147 *
148 * @return {void}
149 */
150 updateFields: function updateFields() {
151 var control = this, syncInput;
152
153 if ( ! control.fields.title.is( document.activeElement ) ) {
154 syncInput = control.syncContainer.find( '.sync-input.title' );
155 control.fields.title.val( syncInput.val() );
156 }
157
158 syncInput = control.syncContainer.find( '.sync-input.text' );
159 if ( control.fields.text.is( ':visible' ) ) {
160 if ( ! control.fields.text.is( document.activeElement ) ) {
161 control.fields.text.val( syncInput.val() );
162 }
163 } else if ( control.editor && ! control.editorFocused && syncInput.val() !== control.fields.text.val() ) {
164 control.editor.setContent( wp.oldEditor.autop( syncInput.val() ) );
165 }
166 },
167
168 /**
169 * Initialize editor.
170 *
171 * @return {void}
172 */
173 initializeEditor: function initializeEditor() {
174 var control = this, changeDebounceDelay = 1000, id, textarea, triggerChangeIfDirty, restoreTextMode = false, needsTextareaChangeTrigger = false, previousValue;
175 textarea = control.fields.text;
176 id = textarea.attr( 'id' );
177 previousValue = textarea.val();
178
179 /**
180 * Trigger change if dirty.
181 *
182 * @return {void}
183 */
184 triggerChangeIfDirty = function() {
185 var updateWidgetBuffer = 300; // See wp.customize.Widgets.WidgetControl._setupUpdateUI() which uses 250ms for updateWidgetDebounced.
186 if ( control.editor.isDirty() ) {
187
188 /*
189 * Account for race condition in customizer where user clicks Save & Publish while
190 * focus was just previously given to the editor. Since updates to the editor
191 * are debounced at 1 second and since widget input changes are only synced to
192 * settings after 250ms, the customizer needs to be put into the processing
193 * state during the time between the change event is triggered and updateWidget
194 * logic starts. Note that the debounced update-widget request should be able
195 * to be removed with the removal of the update-widget request entirely once
196 * widgets are able to mutate their own instance props directly in JS without
197 * having to make server round-trips to call the respective WP_Widget::update()
198 * callbacks. See <https://core.trac.wordpress.org/ticket/33507>.
199 */
200 if ( wp.customize && wp.customize.state ) {
201 wp.customize.state( 'processing' ).set( wp.customize.state( 'processing' ).get() + 1 );
202 _.delay( function() {
203 wp.customize.state( 'processing' ).set( wp.customize.state( 'processing' ).get() - 1 );
204 }, updateWidgetBuffer );
205 }
206
207 if ( ! control.editor.isHidden() ) {
208 control.editor.save();
209 }
210 }
211
212 // Trigger change on textarea when it has changed so the widget can enter a dirty state.
213 if ( needsTextareaChangeTrigger && previousValue !== textarea.val() ) {
214 textarea.trigger( 'change' );
215 needsTextareaChangeTrigger = false;
216 previousValue = textarea.val();
217 }
218 };
219
220 // Just-in-time force-update the hidden input fields.
221 control.syncContainer.closest( '.widget' ).find( '[name=savewidget]:first' ).on( 'click', function onClickSaveButton() {
222 triggerChangeIfDirty();
223 });
224
225 /**
226 * Build (or re-build) the visual editor.
227 *
228 * @return {void}
229 */
230 function buildEditor() {
231 var editor, onInit, showPointerElement;
232
233 // Abort building if the textarea is gone, likely due to the widget having been deleted entirely.
234 if ( ! document.getElementById( id ) ) {
235 return;
236 }
237
238 // The user has disabled TinyMCE.
239 if ( typeof window.tinymce === 'undefined' ) {
240 wp.oldEditor.initialize( id, {
241 quicktags: true,
242 mediaButtons: true
243 });
244
245 return;
246 }
247
248 // Destroy any existing editor so that it can be re-initialized after a widget-updated event.
249 if ( tinymce.get( id ) ) {
250 restoreTextMode = tinymce.get( id ).isHidden();
251 wp.oldEditor.remove( id );
252 }
253
254 // Add or enable the `wpview` plugin.
255 $( document ).one( 'wp-before-tinymce-init.text-widget-init', function( event, init ) {
256 // If somebody has removed all plugins, they must have a good reason.
257 // Keep it that way.
258 if ( ! init.plugins ) {
259 return;
260 } else if ( ! /\bwpview\b/.test( init.plugins ) ) {
261 init.plugins += ',wpview';
262 }
263 } );
264
265 wp.oldEditor.initialize( id, {
266 tinymce: {
267 wpautop: true
268 },
269 quicktags: true,
270 mediaButtons: true
271 });
272
273 /**
274 * Show a pointer, focus on dismiss, and speak the contents for a11y.
275 *
276 * @param {jQuery} pointerElement Pointer element.
277 * @return {void}
278 */
279 showPointerElement = function( pointerElement ) {
280 pointerElement.show();
281 pointerElement.find( '.close' ).trigger( 'focus' );
282 wp.a11y.speak( pointerElement.find( 'h3, p' ).map( function() {
283 return $( this ).text();
284 } ).get().join( '\n\n' ) );
285 };
286
287 editor = window.tinymce.get( id );
288 if ( ! editor ) {
289 throw new Error( 'Failed to initialize editor' );
290 }
291 onInit = function() {
292
293 // When a widget is moved in the DOM the dynamically-created TinyMCE iframe will be destroyed and has to be re-built.
294 $( editor.getWin() ).on( 'pagehide', function() {
295 _.defer( buildEditor );
296 });
297
298 // If a prior mce instance was replaced, and it was in text mode, toggle to text mode.
299 if ( restoreTextMode ) {
300 switchEditors.go( id, 'html' );
301 }
302
303 // Show the pointer.
304 $( '#' + id + '-html' ).on( 'click', function() {
305 control.pasteHtmlPointer.hide(); // Hide the HTML pasting pointer.
306
307 if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_custom_html' ) ) {
308 return;
309 }
310 showPointerElement( control.customHtmlWidgetPointer );
311 });
312
313 // Hide the pointer when switching tabs.
314 $( '#' + id + '-tmce' ).on( 'click', function() {
315 control.customHtmlWidgetPointer.hide();
316 });
317
318 // Show pointer when pasting HTML.
319 editor.on( 'pastepreprocess', function( event ) {
320 var content = event.content;
321 if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_paste_html' ) || ! content || ! /&lt;\w+.*?&gt;/.test( content ) ) {
322 return;
323 }
324
325 // Show the pointer after a slight delay so the user sees what they pasted.
326 _.delay( function() {
327 showPointerElement( control.pasteHtmlPointer );
328 }, 250 );
329 });
330 };
331
332 if ( editor.initialized ) {
333 onInit();
334 } else {
335 editor.on( 'init', onInit );
336 }
337
338 control.editorFocused = false;
339
340 editor.on( 'focus', function onEditorFocus() {
341 control.editorFocused = true;
342 });
343 editor.on( 'paste', function onEditorPaste() {
344 editor.setDirty( true ); // Because pasting doesn't currently set the dirty state.
345 triggerChangeIfDirty();
346 });
347 editor.on( 'NodeChange', function onNodeChange() {
348 needsTextareaChangeTrigger = true;
349 });
350 editor.on( 'NodeChange', _.debounce( triggerChangeIfDirty, changeDebounceDelay ) );
351 editor.on( 'blur hide', function onEditorBlur() {
352 control.editorFocused = false;
353 triggerChangeIfDirty();
354 });
355
356 control.editor = editor;
357 }
358
359 buildEditor();
360 }
361 });
362
363 /**
364 * Mapping of widget ID to instances of TextWidgetControl subclasses.
365 *
366 * @memberOf wp.textWidgets
367 *
368 * @type {Object.<string, wp.textWidgets.TextWidgetControl>}
369 */
370 component.widgetControls = {};
371
372 /**
373 * Handle widget being added or initialized for the first time at the widget-added event.
374 *
375 * @memberOf wp.textWidgets
376 *
377 * @param {jQuery.Event} event - Event.
378 * @param {jQuery} widgetContainer - Widget container element.
379 *
380 * @return {void}
381 */
382 component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
383 var widgetForm, idBase, widgetControl, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone, fieldContainer, syncContainer;
384 widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.
385
386 idBase = widgetForm.find( '> .id_base' ).val();
387 if ( -1 === component.idBases.indexOf( idBase ) ) {
388 return;
389 }
390
391 // Prevent initializing already-added widgets.
392 widgetId = widgetForm.find( '.widget-id' ).val();
393 if ( component.widgetControls[ widgetId ] ) {
394 return;
395 }
396
397 // Bypass using TinyMCE when widget is in legacy mode.
398 if ( ! widgetForm.find( '.visual' ).val() ) {
399 return;
400 }
401
402 /*
403 * Create a container element for the widget control fields.
404 * This is inserted into the DOM immediately before the .widget-content
405 * element because the contents of this element are essentially "managed"
406 * by PHP, where each widget update cause the entire element to be emptied
407 * and replaced with the rendered output of WP_Widget::form() which is
408 * sent back in Ajax request made to save/update the widget instance.
409 * To prevent a "flash of replaced DOM elements and re-initialized JS
410 * components", the JS template is rendered outside of the normal form
411 * container.
412 */
413 fieldContainer = $( '<div></div>' );
414 syncContainer = widgetContainer.find( '.widget-content:first' );
415 syncContainer.before( fieldContainer );
416
417 widgetControl = new component.TextWidgetControl({
418 el: fieldContainer,
419 syncContainer: syncContainer
420 });
421
422 component.widgetControls[ widgetId ] = widgetControl;
423
424 /*
425 * Render the widget once the widget parent's container finishes animating,
426 * as the widget-added event fires with a slideDown of the container.
427 * This ensures that the textarea is visible and an iframe can be embedded
428 * with TinyMCE being able to set contenteditable on it.
429 */
430 renderWhenAnimationDone = function() {
431 if ( ! widgetContainer.hasClass( 'open' ) ) {
432 setTimeout( renderWhenAnimationDone, animatedCheckDelay );
433 } else {
434 widgetControl.initializeEditor();
435 }
436 };
437 renderWhenAnimationDone();
438 };
439
440 /**
441 * Setup widget in accessibility mode.
442 *
443 * @memberOf wp.textWidgets
444 *
445 * @return {void}
446 */
447 component.setupAccessibleMode = function setupAccessibleMode() {
448 var widgetForm, idBase, widgetControl, fieldContainer, syncContainer;
449 widgetForm = $( '.editwidget > form' );
450 if ( 0 === widgetForm.length ) {
451 return;
452 }
453
454 idBase = widgetForm.find( '.id_base' ).val();
455 if ( -1 === component.idBases.indexOf( idBase ) ) {
456 return;
457 }
458
459 // Bypass using TinyMCE when widget is in legacy mode.
460 if ( ! widgetForm.find( '.visual' ).val() ) {
461 return;
462 }
463
464 fieldContainer = $( '<div></div>' );
465 syncContainer = widgetForm.find( '> .widget-inside' );
466 syncContainer.before( fieldContainer );
467
468 widgetControl = new component.TextWidgetControl({
469 el: fieldContainer,
470 syncContainer: syncContainer
471 });
472
473 widgetControl.initializeEditor();
474 };
475
476 /**
477 * Sync widget instance data sanitized from server back onto widget model.
478 *
479 * This gets called via the 'widget-updated' event when saving a widget from
480 * the widgets admin screen and also via the 'widget-synced' event when making
481 * a change to a widget in the customizer.
482 *
483 * @memberOf wp.textWidgets
484 *
485 * @param {jQuery.Event} event - Event.
486 * @param {jQuery} widgetContainer - Widget container element.
487 * @return {void}
488 */
489 component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) {
490 var widgetForm, widgetId, widgetControl, idBase;
491 widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
492
493 idBase = widgetForm.find( '> .id_base' ).val();
494 if ( -1 === component.idBases.indexOf( idBase ) ) {
495 return;
496 }
497
498 widgetId = widgetForm.find( '> .widget-id' ).val();
499 widgetControl = component.widgetControls[ widgetId ];
500 if ( ! widgetControl ) {
501 return;
502 }
503
504 widgetControl.updateFields();
505 };
506
507 /**
508 * Initialize functionality.
509 *
510 * This function exists to prevent the JS file from having to boot itself.
511 * When WordPress enqueues this script, it should have an inline script
512 * attached which calls wp.textWidgets.init().
513 *
514 * @memberOf wp.textWidgets
515 *
516 * @return {void}
517 */
518 component.init = function init() {
519 var $document = $( document );
520 $document.on( 'widget-added', component.handleWidgetAdded );
521 $document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );
522
523 /*
524 * Manually trigger widget-added events for media widgets on the admin
525 * screen once they are expanded. The widget-added event is not triggered
526 * for each pre-existing widget on the widgets admin screen like it is
527 * on the customizer. Likewise, the customizer only triggers widget-added
528 * when the widget is expanded to just-in-time construct the widget form
529 * when it is actually going to be displayed. So the following implements
530 * the same for the widgets admin screen, to invoke the widget-added
531 * handler when a pre-existing media widget is expanded.
532 */
533 $( function initializeExistingWidgetContainers() {
534 var widgetContainers;
535 if ( 'widgets' !== window.pagenow ) {
536 return;
537 }
538 widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' );
539 widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() {
540 var widgetContainer = $( this );
541 component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer );
542 });
543
544 // Accessibility mode.
545 component.setupAccessibleMode();
546 });
547 };
548
549 return component;
550})( jQuery );
551