text-widgets.js 17 KB

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