custom-html-widgets.js 15 KB

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