[ Index ] |
PHP Cross Reference of WordPress |
[Summary view] [Print] [Text view]
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 */ 11 wp.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( 'unload', 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 || ! /<\w+.*?>/.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 );
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Tue Oct 15 01:00:02 2024 | Cross-referenced by PHPXref 0.7.1 |