You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1462 lines
38 KiB

1 year ago
  1. /**
  2. * The functions necessary for editing images.
  3. *
  4. * @since 2.9.0
  5. * @output wp-admin/js/image-edit.js
  6. */
  7. /* global ajaxurl, confirm */
  8. (function($) {
  9. var __ = wp.i18n.__;
  10. /**
  11. * Contains all the methods to initialize and control the image editor.
  12. *
  13. * @namespace imageEdit
  14. */
  15. var imageEdit = window.imageEdit = {
  16. iasapi : {},
  17. hold : {},
  18. postid : '',
  19. _view : false,
  20. /**
  21. * Enable crop tool.
  22. */
  23. toggleCropTool: function( postid, nonce, cropButton ) {
  24. var img = $( '#image-preview-' + postid ),
  25. selection = this.iasapi.getSelection();
  26. imageEdit.toggleControls( cropButton );
  27. var $el = $( cropButton );
  28. var state = ( $el.attr( 'aria-expanded' ) === 'true' ) ? 'true' : 'false';
  29. // Crop tools have been closed.
  30. if ( 'false' === state ) {
  31. // Cancel selection, but do not unset inputs.
  32. this.iasapi.cancelSelection();
  33. imageEdit.setDisabled($('.imgedit-crop-clear'), 0);
  34. } else {
  35. imageEdit.setDisabled($('.imgedit-crop-clear'), 1);
  36. // Get values from inputs to restore previous selection.
  37. var startX = ( $( '#imgedit-start-x-' + postid ).val() ) ? $('#imgedit-start-x-' + postid).val() : 0;
  38. var startY = ( $( '#imgedit-start-y-' + postid ).val() ) ? $('#imgedit-start-y-' + postid).val() : 0;
  39. var width = ( $( '#imgedit-sel-width-' + postid ).val() ) ? $('#imgedit-sel-width-' + postid).val() : img.innerWidth();
  40. var height = ( $( '#imgedit-sel-height-' + postid ).val() ) ? $('#imgedit-sel-height-' + postid).val() : img.innerHeight();
  41. // Ensure selection is available, otherwise reset to full image.
  42. if ( isNaN( selection.x1 ) ) {
  43. this.setCropSelection( postid, { 'x1': startX, 'y1': startY, 'x2': width, 'y2': height, 'width': width, 'height': height } );
  44. selection = this.iasapi.getSelection();
  45. }
  46. // If we don't already have a selection, select the entire image.
  47. if ( 0 === selection.x1 && 0 === selection.y1 && 0 === selection.x2 && 0 === selection.y2 ) {
  48. this.iasapi.setSelection( 0, 0, img.innerWidth(), img.innerHeight(), true );
  49. this.iasapi.setOptions( { show: true } );
  50. this.iasapi.update();
  51. } else {
  52. this.iasapi.setSelection( startX, startY, width, height, true );
  53. this.iasapi.setOptions( { show: true } );
  54. this.iasapi.update();
  55. }
  56. }
  57. },
  58. /**
  59. * Handle crop tool clicks.
  60. */
  61. handleCropToolClick: function( postid, nonce, cropButton ) {
  62. if ( cropButton.classList.contains( 'imgedit-crop-clear' ) ) {
  63. this.iasapi.cancelSelection();
  64. imageEdit.setDisabled($('.imgedit-crop-apply'), 0);
  65. $('#imgedit-sel-width-' + postid).val('');
  66. $('#imgedit-sel-height-' + postid).val('');
  67. $('#imgedit-start-x-' + postid).val('0');
  68. $('#imgedit-start-y-' + postid).val('0');
  69. $('#imgedit-selection-' + postid).val('');
  70. } else {
  71. // Otherwise, perform the crop.
  72. imageEdit.crop( postid, nonce , cropButton );
  73. }
  74. },
  75. /**
  76. * Converts a value to an integer.
  77. *
  78. * @since 2.9.0
  79. *
  80. * @memberof imageEdit
  81. *
  82. * @param {number} f The float value that should be converted.
  83. *
  84. * @return {number} The integer representation from the float value.
  85. */
  86. intval : function(f) {
  87. /*
  88. * Bitwise OR operator: one of the obscure ways to truncate floating point figures,
  89. * worth reminding JavaScript doesn't have a distinct "integer" type.
  90. */
  91. return f | 0;
  92. },
  93. /**
  94. * Adds the disabled attribute and class to a single form element or a field set.
  95. *
  96. * @since 2.9.0
  97. *
  98. * @memberof imageEdit
  99. *
  100. * @param {jQuery} el The element that should be modified.
  101. * @param {boolean|number} s The state for the element. If set to true
  102. * the element is disabled,
  103. * otherwise the element is enabled.
  104. * The function is sometimes called with a 0 or 1
  105. * instead of true or false.
  106. *
  107. * @return {void}
  108. */
  109. setDisabled : function( el, s ) {
  110. /*
  111. * `el` can be a single form element or a fieldset. Before #28864, the disabled state on
  112. * some text fields was handled targeting $('input', el). Now we need to handle the
  113. * disabled state on buttons too so we can just target `el` regardless if it's a single
  114. * element or a fieldset because when a fieldset is disabled, its descendants are disabled too.
  115. */
  116. if ( s ) {
  117. el.removeClass( 'disabled' ).prop( 'disabled', false );
  118. } else {
  119. el.addClass( 'disabled' ).prop( 'disabled', true );
  120. }
  121. },
  122. /**
  123. * Initializes the image editor.
  124. *
  125. * @since 2.9.0
  126. *
  127. * @memberof imageEdit
  128. *
  129. * @param {number} postid The post ID.
  130. *
  131. * @return {void}
  132. */
  133. init : function(postid) {
  134. var t = this, old = $('#image-editor-' + t.postid),
  135. x = t.intval( $('#imgedit-x-' + postid).val() ),
  136. y = t.intval( $('#imgedit-y-' + postid).val() );
  137. if ( t.postid !== postid && old.length ) {
  138. t.close(t.postid);
  139. }
  140. t.hold.w = t.hold.ow = x;
  141. t.hold.h = t.hold.oh = y;
  142. t.hold.xy_ratio = x / y;
  143. t.hold.sizer = parseFloat( $('#imgedit-sizer-' + postid).val() );
  144. t.postid = postid;
  145. $('#imgedit-response-' + postid).empty();
  146. $('#imgedit-panel-' + postid).on( 'keypress', function(e) {
  147. var nonce = $( '#imgedit-nonce-' + postid ).val();
  148. if ( e.which === 26 && e.ctrlKey ) {
  149. imageEdit.undo( postid, nonce );
  150. }
  151. if ( e.which === 25 && e.ctrlKey ) {
  152. imageEdit.redo( postid, nonce );
  153. }
  154. });
  155. $('#imgedit-panel-' + postid).on( 'keypress', 'input[type="text"]', function(e) {
  156. var k = e.keyCode;
  157. // Key codes 37 through 40 are the arrow keys.
  158. if ( 36 < k && k < 41 ) {
  159. $(this).trigger( 'blur' );
  160. }
  161. // The key code 13 is the Enter key.
  162. if ( 13 === k ) {
  163. e.preventDefault();
  164. e.stopPropagation();
  165. return false;
  166. }
  167. });
  168. $( document ).on( 'image-editor-ui-ready', this.focusManager );
  169. },
  170. /**
  171. * Toggles the wait/load icon in the editor.
  172. *
  173. * @since 2.9.0
  174. * @since 5.5.0 Added the triggerUIReady parameter.
  175. *
  176. * @memberof imageEdit
  177. *
  178. * @param {number} postid The post ID.
  179. * @param {number} toggle Is 0 or 1, fades the icon in when 1 and out when 0.
  180. * @param {boolean} triggerUIReady Whether to trigger a custom event when the UI is ready. Default false.
  181. *
  182. * @return {void}
  183. */
  184. toggleEditor: function( postid, toggle, triggerUIReady ) {
  185. var wait = $('#imgedit-wait-' + postid);
  186. if ( toggle ) {
  187. wait.fadeIn( 'fast' );
  188. } else {
  189. wait.fadeOut( 'fast', function() {
  190. if ( triggerUIReady ) {
  191. $( document ).trigger( 'image-editor-ui-ready' );
  192. }
  193. } );
  194. }
  195. },
  196. /**
  197. * Shows or hides image menu popup.
  198. *
  199. * @since 6.3.0
  200. *
  201. * @memberof imageEdit
  202. *
  203. * @param {HTMLElement} el The activated control element.
  204. *
  205. * @return {boolean} Always returns false.
  206. */
  207. togglePopup : function(el) {
  208. var $el = $( el );
  209. var $targetEl = $( el ).attr( 'aria-controls' );
  210. var $target = $( '#' + $targetEl );
  211. $el
  212. .attr( 'aria-expanded', 'false' === $el.attr( 'aria-expanded' ) ? 'true' : 'false' );
  213. // Open menu and set z-index to appear above image crop area if it is enabled.
  214. $target
  215. .toggleClass( 'imgedit-popup-menu-open' ).slideToggle( 'fast' ).css( { 'z-index' : 200000 } );
  216. // Move focus to first item in menu when opening menu.
  217. if ( 'true' === $el.attr( 'aria-expanded' ) ) {
  218. $target.find( 'button' ).first().trigger( 'focus' );
  219. }
  220. return false;
  221. },
  222. /**
  223. * Observes whether the popup should remain open based on focus position.
  224. *
  225. * @since 6.4.0
  226. *
  227. * @memberof imageEdit
  228. *
  229. * @param {HTMLElement} el The activated control element.
  230. *
  231. * @return {boolean} Always returns false.
  232. */
  233. monitorPopup : function() {
  234. var $parent = document.querySelector( '.imgedit-rotate-menu-container' );
  235. var $toggle = document.querySelector( '.imgedit-rotate-menu-container .imgedit-rotate' );
  236. setTimeout( function() {
  237. var $focused = document.activeElement;
  238. var $contains = $parent.contains( $focused );
  239. // If $focused is defined and not inside the menu container, close the popup.
  240. if ( $focused && ! $contains ) {
  241. if ( 'true' === $toggle.getAttribute( 'aria-expanded' ) ) {
  242. imageEdit.togglePopup( $toggle );
  243. }
  244. }
  245. }, 100 );
  246. return false;
  247. },
  248. /**
  249. * Navigate popup menu by arrow keys.
  250. *
  251. * @since 6.3.0
  252. *
  253. * @memberof imageEdit
  254. *
  255. * @param {HTMLElement} el The current element.
  256. *
  257. * @return {boolean} Always returns false.
  258. */
  259. browsePopup : function(el) {
  260. var $el = $( el );
  261. var $collection = $( el ).parent( '.imgedit-popup-menu' ).find( 'button' );
  262. var $index = $collection.index( $el );
  263. var $prev = $index - 1;
  264. var $next = $index + 1;
  265. var $last = $collection.length;
  266. if ( $prev < 0 ) {
  267. $prev = $last - 1;
  268. }
  269. if ( $next === $last ) {
  270. $next = 0;
  271. }
  272. var $target = false;
  273. if ( event.keyCode === 40 ) {
  274. $target = $collection.get( $next );
  275. } else if ( event.keyCode === 38 ) {
  276. $target = $collection.get( $prev );
  277. }
  278. if ( $target ) {
  279. $target.focus();
  280. event.preventDefault();
  281. }
  282. return false;
  283. },
  284. /**
  285. * Close popup menu and reset focus on feature activation.
  286. *
  287. * @since 6.3.0
  288. *
  289. * @memberof imageEdit
  290. *
  291. * @param {HTMLElement} el The current element.
  292. *
  293. * @return {boolean} Always returns false.
  294. */
  295. closePopup : function(el) {
  296. var $parent = $(el).parent( '.imgedit-popup-menu' );
  297. var $controlledID = $parent.attr( 'id' );
  298. var $target = $( 'button[aria-controls="' + $controlledID + '"]' );
  299. $target
  300. .attr( 'aria-expanded', 'false' ).trigger( 'focus' );
  301. $parent
  302. .toggleClass( 'imgedit-popup-menu-open' ).slideToggle( 'fast' );
  303. return false;
  304. },
  305. /**
  306. * Shows or hides the image edit help box.
  307. *
  308. * @since 2.9.0
  309. *
  310. * @memberof imageEdit
  311. *
  312. * @param {HTMLElement} el The element to create the help window in.
  313. *
  314. * @return {boolean} Always returns false.
  315. */
  316. toggleHelp : function(el) {
  317. var $el = $( el );
  318. $el
  319. .attr( 'aria-expanded', 'false' === $el.attr( 'aria-expanded' ) ? 'true' : 'false' )
  320. .parents( '.imgedit-group-top' ).toggleClass( 'imgedit-help-toggled' ).find( '.imgedit-help' ).slideToggle( 'fast' );
  321. return false;
  322. },
  323. /**
  324. * Shows or hides image edit input fields when enabled.
  325. *
  326. * @since 6.3.0
  327. *
  328. * @memberof imageEdit
  329. *
  330. * @param {HTMLElement} el The element to trigger the edit panel.
  331. *
  332. * @return {boolean} Always returns false.
  333. */
  334. toggleControls : function(el) {
  335. var $el = $( el );
  336. var $target = $( '#' + $el.attr( 'aria-controls' ) );
  337. $el
  338. .attr( 'aria-expanded', 'false' === $el.attr( 'aria-expanded' ) ? 'true' : 'false' );
  339. $target
  340. .parent( '.imgedit-group' ).toggleClass( 'imgedit-panel-active' );
  341. return false;
  342. },
  343. /**
  344. * Gets the value from the image edit target.
  345. *
  346. * The image edit target contains the image sizes where the (possible) changes
  347. * have to be applied to.
  348. *
  349. * @since 2.9.0
  350. *
  351. * @memberof imageEdit
  352. *
  353. * @param {number} postid The post ID.
  354. *
  355. * @return {string} The value from the imagedit-save-target input field when available,
  356. * 'full' when not selected, or 'all' if it doesn't exist.
  357. */
  358. getTarget : function( postid ) {
  359. var element = $( '#imgedit-save-target-' + postid );
  360. if ( element.length ) {
  361. return element.find( 'input[name="imgedit-target-' + postid + '"]:checked' ).val() || 'full';
  362. }
  363. return 'all';
  364. },
  365. /**
  366. * Recalculates the height or width and keeps the original aspect ratio.
  367. *
  368. * If the original image size is exceeded a red exclamation mark is shown.
  369. *
  370. * @since 2.9.0
  371. *
  372. * @memberof imageEdit
  373. *
  374. * @param {number} postid The current post ID.
  375. * @param {number} x Is 0 when it applies the y-axis
  376. * and 1 when applicable for the x-axis.
  377. * @param {jQuery} el Element.
  378. *
  379. * @return {void}
  380. */
  381. scaleChanged : function( postid, x, el ) {
  382. var w = $('#imgedit-scale-width-' + postid), h = $('#imgedit-scale-height-' + postid),
  383. warn = $('#imgedit-scale-warn-' + postid), w1 = '', h1 = '',
  384. scaleBtn = $('#imgedit-scale-button');
  385. if ( false === this.validateNumeric( el ) ) {
  386. return;
  387. }
  388. if ( x ) {
  389. h1 = ( w.val() !== '' ) ? Math.round( w.val() / this.hold.xy_ratio ) : '';
  390. h.val( h1 );
  391. } else {
  392. w1 = ( h.val() !== '' ) ? Math.round( h.val() * this.hold.xy_ratio ) : '';
  393. w.val( w1 );
  394. }
  395. if ( ( h1 && h1 > this.hold.oh ) || ( w1 && w1 > this.hold.ow ) ) {
  396. warn.css('visibility', 'visible');
  397. scaleBtn.prop('disabled', true);
  398. } else {
  399. warn.css('visibility', 'hidden');
  400. scaleBtn.prop('disabled', false);
  401. }
  402. },
  403. /**
  404. * Gets the selected aspect ratio.
  405. *
  406. * @since 2.9.0
  407. *
  408. * @memberof imageEdit
  409. *
  410. * @param {number} postid The post ID.
  411. *
  412. * @return {string} The aspect ratio.
  413. */
  414. getSelRatio : function(postid) {
  415. var x = this.hold.w, y = this.hold.h,
  416. X = this.intval( $('#imgedit-crop-width-' + postid).val() ),
  417. Y = this.intval( $('#imgedit-crop-height-' + postid).val() );
  418. if ( X && Y ) {
  419. return X + ':' + Y;
  420. }
  421. if ( x && y ) {
  422. return x + ':' + y;
  423. }
  424. return '1:1';
  425. },
  426. /**
  427. * Removes the last action from the image edit history.
  428. * The history consist of (edit) actions performed on the image.
  429. *
  430. * @since 2.9.0
  431. *
  432. * @memberof imageEdit
  433. *
  434. * @param {number} postid The post ID.
  435. * @param {number} setSize 0 or 1, when 1 the image resets to its original size.
  436. *
  437. * @return {string} JSON string containing the history or an empty string if no history exists.
  438. */
  439. filterHistory : function(postid, setSize) {
  440. // Apply undo state to history.
  441. var history = $('#imgedit-history-' + postid).val(), pop, n, o, i, op = [];
  442. if ( history !== '' ) {
  443. // Read the JSON string with the image edit history.
  444. history = JSON.parse(history);
  445. pop = this.intval( $('#imgedit-undone-' + postid).val() );
  446. if ( pop > 0 ) {
  447. while ( pop > 0 ) {
  448. history.pop();
  449. pop--;
  450. }
  451. }
  452. // Reset size to its original state.
  453. if ( setSize ) {
  454. if ( !history.length ) {
  455. this.hold.w = this.hold.ow;
  456. this.hold.h = this.hold.oh;
  457. return '';
  458. }
  459. // Restore original 'o'.
  460. o = history[history.length - 1];
  461. // c = 'crop', r = 'rotate', f = 'flip'.
  462. o = o.c || o.r || o.f || false;
  463. if ( o ) {
  464. // fw = Full image width.
  465. this.hold.w = o.fw;
  466. // fh = Full image height.
  467. this.hold.h = o.fh;
  468. }
  469. }
  470. // Filter the last step/action from the history.
  471. for ( n in history ) {
  472. i = history[n];
  473. if ( i.hasOwnProperty('c') ) {
  474. op[n] = { 'c': { 'x': i.c.x, 'y': i.c.y, 'w': i.c.w, 'h': i.c.h } };
  475. } else if ( i.hasOwnProperty('r') ) {
  476. op[n] = { 'r': i.r.r };
  477. } else if ( i.hasOwnProperty('f') ) {
  478. op[n] = { 'f': i.f.f };
  479. }
  480. }
  481. return JSON.stringify(op);
  482. }
  483. return '';
  484. },
  485. /**
  486. * Binds the necessary events to the image.
  487. *
  488. * When the image source is reloaded the image will be reloaded.
  489. *
  490. * @since 2.9.0
  491. *
  492. * @memberof imageEdit
  493. *
  494. * @param {number} postid The post ID.
  495. * @param {string} nonce The nonce to verify the request.
  496. * @param {function} callback Function to execute when the image is loaded.
  497. *
  498. * @return {void}
  499. */
  500. refreshEditor : function(postid, nonce, callback) {
  501. var t = this, data, img;
  502. t.toggleEditor(postid, 1);
  503. data = {
  504. 'action': 'imgedit-preview',
  505. '_ajax_nonce': nonce,
  506. 'postid': postid,
  507. 'history': t.filterHistory(postid, 1),
  508. 'rand': t.intval(Math.random() * 1000000)
  509. };
  510. img = $( '<img id="image-preview-' + postid + '" alt="" />' )
  511. .on( 'load', { history: data.history }, function( event ) {
  512. var max1, max2,
  513. parent = $( '#imgedit-crop-' + postid ),
  514. t = imageEdit,
  515. historyObj;
  516. // Checks if there already is some image-edit history.
  517. if ( '' !== event.data.history ) {
  518. historyObj = JSON.parse( event.data.history );
  519. // If last executed action in history is a crop action.
  520. if ( historyObj[historyObj.length - 1].hasOwnProperty( 'c' ) ) {
  521. /*
  522. * A crop action has completed and the crop button gets disabled
  523. * ensure the undo button is enabled.
  524. */
  525. t.setDisabled( $( '#image-undo-' + postid) , true );
  526. // Move focus to the undo button to avoid a focus loss.
  527. $( '#image-undo-' + postid ).trigger( 'focus' );
  528. }
  529. }
  530. parent.empty().append(img);
  531. // w, h are the new full size dimensions.
  532. max1 = Math.max( t.hold.w, t.hold.h );
  533. max2 = Math.max( $(img).width(), $(img).height() );
  534. t.hold.sizer = max1 > max2 ? max2 / max1 : 1;
  535. t.initCrop(postid, img, parent);
  536. if ( (typeof callback !== 'undefined') && callback !== null ) {
  537. callback();
  538. }
  539. if ( $('#imgedit-history-' + postid).val() && $('#imgedit-undone-' + postid).val() === '0' ) {
  540. $('button.imgedit-submit-btn', '#imgedit-panel-' + postid).prop('disabled', false);
  541. } else {
  542. $('button.imgedit-submit-btn', '#imgedit-panel-' + postid).prop('disabled', true);
  543. }
  544. var successMessage = __( 'Image updated.' );
  545. t.toggleEditor(postid, 0);
  546. wp.a11y.speak( successMessage, 'assertive' );
  547. })
  548. .on( 'error', function() {
  549. var errorMessage = __( 'Could not load the preview image. Please reload the page and try again.' );
  550. $( '#imgedit-crop-' + postid )
  551. .empty()
  552. .append( '<div class="notice notice-error" tabindex="-1" role="alert"><p>' + errorMessage + '</p></div>' );
  553. t.toggleEditor( postid, 0, true );
  554. wp.a11y.speak( errorMessage, 'assertive' );
  555. } )
  556. .attr('src', ajaxurl + '?' + $.param(data));
  557. },
  558. /**
  559. * Performs an image edit action.
  560. *
  561. * @since 2.9.0
  562. *
  563. * @memberof imageEdit
  564. *
  565. * @param {number} postid The post ID.
  566. * @param {string} nonce The nonce to verify the request.
  567. * @param {string} action The action to perform on the image.
  568. * The possible actions are: "scale" and "restore".
  569. *
  570. * @return {boolean|void} Executes a post request that refreshes the page
  571. * when the action is performed.
  572. * Returns false if an invalid action is given,
  573. * or when the action cannot be performed.
  574. */
  575. action : function(postid, nonce, action) {
  576. var t = this, data, w, h, fw, fh;
  577. if ( t.notsaved(postid) ) {
  578. return false;
  579. }
  580. data = {
  581. 'action': 'image-editor',
  582. '_ajax_nonce': nonce,
  583. 'postid': postid
  584. };
  585. if ( 'scale' === action ) {
  586. w = $('#imgedit-scale-width-' + postid),
  587. h = $('#imgedit-scale-height-' + postid),
  588. fw = t.intval(w.val()),
  589. fh = t.intval(h.val());
  590. if ( fw < 1 ) {
  591. w.trigger( 'focus' );
  592. return false;
  593. } else if ( fh < 1 ) {
  594. h.trigger( 'focus' );
  595. return false;
  596. }
  597. if ( fw === t.hold.ow || fh === t.hold.oh ) {
  598. return false;
  599. }
  600. data['do'] = 'scale';
  601. data.fwidth = fw;
  602. data.fheight = fh;
  603. } else if ( 'restore' === action ) {
  604. data['do'] = 'restore';
  605. } else {
  606. return false;
  607. }
  608. t.toggleEditor(postid, 1);
  609. $.post( ajaxurl, data, function( response ) {
  610. $( '#image-editor-' + postid ).empty().append( response.data.html );
  611. t.toggleEditor( postid, 0, true );
  612. // Refresh the attachment model so that changes propagate.
  613. if ( t._view ) {
  614. t._view.refresh();
  615. }
  616. } ).done( function( response ) {
  617. // Whether the executed action was `scale` or `restore`, the response does have a message.
  618. if ( response && response.data.message.msg ) {
  619. wp.a11y.speak( response.data.message.msg );
  620. return;
  621. }
  622. if ( response && response.data.message.error ) {
  623. wp.a11y.speak( response.data.message.error );
  624. }
  625. } );
  626. },
  627. /**
  628. * Stores the changes that are made to the image.
  629. *
  630. * @since 2.9.0
  631. *
  632. * @memberof imageEdit
  633. *
  634. * @param {number} postid The post ID to get the image from the database.
  635. * @param {string} nonce The nonce to verify the request.
  636. *
  637. * @return {boolean|void} If the actions are successfully saved a response message is shown.
  638. * Returns false if there is no image editing history,
  639. * thus there are not edit-actions performed on the image.
  640. */
  641. save : function(postid, nonce) {
  642. var data,
  643. target = this.getTarget(postid),
  644. history = this.filterHistory(postid, 0),
  645. self = this;
  646. if ( '' === history ) {
  647. return false;
  648. }
  649. this.toggleEditor(postid, 1);
  650. data = {
  651. 'action': 'image-editor',
  652. '_ajax_nonce': nonce,
  653. 'postid': postid,
  654. 'history': history,
  655. 'target': target,
  656. 'context': $('#image-edit-context').length ? $('#image-edit-context').val() : null,
  657. 'do': 'save'
  658. };
  659. // Post the image edit data to the backend.
  660. $.post( ajaxurl, data, function( response ) {
  661. // If a response is returned, close the editor and show an error.
  662. if ( response.data.error ) {
  663. $( '#imgedit-response-' + postid )
  664. .html( '<div class="notice notice-error" tabindex="-1" role="alert"><p>' + response.data.error + '</p></div>' );
  665. imageEdit.close(postid);
  666. wp.a11y.speak( response.data.error );
  667. return;
  668. }
  669. if ( response.data.fw && response.data.fh ) {
  670. $( '#media-dims-' + postid ).html( response.data.fw + ' &times; ' + response.data.fh );
  671. }
  672. if ( response.data.thumbnail ) {
  673. $( '.thumbnail', '#thumbnail-head-' + postid ).attr( 'src', '' + response.data.thumbnail );
  674. }
  675. if ( response.data.msg ) {
  676. $( '#imgedit-response-' + postid )
  677. .html( '<div class="notice notice-success" tabindex="-1" role="alert"><p>' + response.data.msg + '</p></div>' );
  678. wp.a11y.speak( response.data.msg );
  679. }
  680. if ( self._view ) {
  681. self._view.save();
  682. } else {
  683. imageEdit.close(postid);
  684. }
  685. });
  686. },
  687. /**
  688. * Creates the image edit window.
  689. *
  690. * @since 2.9.0
  691. *
  692. * @memberof imageEdit
  693. *
  694. * @param {number} postid The post ID for the image.
  695. * @param {string} nonce The nonce to verify the request.
  696. * @param {Object} view The image editor view to be used for the editing.
  697. *
  698. * @return {void|promise} Either returns void if the button was already activated
  699. * or returns an instance of the image editor, wrapped in a promise.
  700. */
  701. open : function( postid, nonce, view ) {
  702. this._view = view;
  703. var dfd, data,
  704. elem = $( '#image-editor-' + postid ),
  705. head = $( '#media-head-' + postid ),
  706. btn = $( '#imgedit-open-btn-' + postid ),
  707. spin = btn.siblings( '.spinner' );
  708. /*
  709. * Instead of disabling the button, which causes a focus loss and makes screen
  710. * readers announce "unavailable", return if the button was already clicked.
  711. */
  712. if ( btn.hasClass( 'button-activated' ) ) {
  713. return;
  714. }
  715. spin.addClass( 'is-active' );
  716. data = {
  717. 'action': 'image-editor',
  718. '_ajax_nonce': nonce,
  719. 'postid': postid,
  720. 'do': 'open'
  721. };
  722. dfd = $.ajax( {
  723. url: ajaxurl,
  724. type: 'post',
  725. data: data,
  726. beforeSend: function() {
  727. btn.addClass( 'button-activated' );
  728. }
  729. } ).done( function( response ) {
  730. var errorMessage;
  731. if ( '-1' === response ) {
  732. errorMessage = __( 'Could not load the preview image.' );
  733. elem.html( '<div class="notice notice-error" tabindex="-1" role="alert"><p>' + errorMessage + '</p></div>' );
  734. }
  735. if ( response.data && response.data.html ) {
  736. elem.html( response.data.html );
  737. }
  738. head.fadeOut( 'fast', function() {
  739. elem.fadeIn( 'fast', function() {
  740. if ( errorMessage ) {
  741. $( document ).trigger( 'image-editor-ui-ready' );
  742. }
  743. } );
  744. btn.removeClass( 'button-activated' );
  745. spin.removeClass( 'is-active' );
  746. } );
  747. // Initialize the Image Editor now that everything is ready.
  748. imageEdit.init( postid );
  749. } );
  750. return dfd;
  751. },
  752. /**
  753. * Initializes the cropping tool and sets a default cropping selection.
  754. *
  755. * @since 2.9.0
  756. *
  757. * @memberof imageEdit
  758. *
  759. * @param {number} postid The post ID.
  760. *
  761. * @return {void}
  762. */
  763. imgLoaded : function(postid) {
  764. var img = $('#image-preview-' + postid), parent = $('#imgedit-crop-' + postid);
  765. // Ensure init has run even when directly loaded.
  766. if ( 'undefined' === typeof this.hold.sizer ) {
  767. this.init( postid );
  768. }
  769. this.initCrop(postid, img, parent);
  770. this.setCropSelection( postid, { 'x1': 0, 'y1': 0, 'x2': 0, 'y2': 0, 'width': img.innerWidth(), 'height': img.innerHeight() } );
  771. this.toggleEditor( postid, 0, true );
  772. },
  773. /**
  774. * Manages keyboard focus in the Image Editor user interface.
  775. *
  776. * @since 5.5.0
  777. *
  778. * @return {void}
  779. */
  780. focusManager: function() {
  781. /*
  782. * Editor is ready. Move focus to one of the admin alert notices displayed
  783. * after a user action or to the first focusable element. Since the DOM
  784. * update is pretty large, the timeout helps browsers update their
  785. * accessibility tree to better support assistive technologies.
  786. */
  787. setTimeout( function() {
  788. var elementToSetFocusTo = $( '.notice[role="alert"]' );
  789. if ( ! elementToSetFocusTo.length ) {
  790. elementToSetFocusTo = $( '.imgedit-wrap' ).find( ':tabbable:first' );
  791. }
  792. elementToSetFocusTo.attr( 'tabindex', '-1' ).trigger( 'focus' );
  793. }, 100 );
  794. },
  795. /**
  796. * Initializes the cropping tool.
  797. *
  798. * @since 2.9.0
  799. *
  800. * @memberof imageEdit
  801. *
  802. * @param {number} postid The post ID.
  803. * @param {HTMLElement} image The preview image.
  804. * @param {HTMLElement} parent The preview image container.
  805. *
  806. * @return {void}
  807. */
  808. initCrop : function(postid, image, parent) {
  809. var t = this,
  810. selW = $('#imgedit-sel-width-' + postid),
  811. selH = $('#imgedit-sel-height-' + postid),
  812. selX = $('#imgedit-start-x-' + postid),
  813. selY = $('#imgedit-start-y-' + postid),
  814. $image = $( image ),
  815. $img;
  816. // Already initialized?
  817. if ( $image.data( 'imgAreaSelect' ) ) {
  818. return;
  819. }
  820. t.iasapi = $image.imgAreaSelect({
  821. parent: parent,
  822. instance: true,
  823. handles: true,
  824. keys: true,
  825. minWidth: 3,
  826. minHeight: 3,
  827. /**
  828. * Sets the CSS styles and binds events for locking the aspect ratio.
  829. *
  830. * @ignore
  831. *
  832. * @param {jQuery} img The preview image.
  833. */
  834. onInit: function( img ) {
  835. // Ensure that the imgAreaSelect wrapper elements are position:absolute
  836. // (even if we're in a position:fixed modal).
  837. $img = $( img );
  838. $img.next().css( 'position', 'absolute' )
  839. .nextAll( '.imgareaselect-outer' ).css( 'position', 'absolute' );
  840. /**
  841. * Binds mouse down event to the cropping container.
  842. *
  843. * @return {void}
  844. */
  845. parent.children().on( 'mousedown, touchstart', function(e){
  846. var ratio = false, sel, defRatio;
  847. if ( e.shiftKey ) {
  848. sel = t.iasapi.getSelection();
  849. defRatio = t.getSelRatio(postid);
  850. ratio = ( sel && sel.width && sel.height ) ? sel.width + ':' + sel.height : defRatio;
  851. }
  852. t.iasapi.setOptions({
  853. aspectRatio: ratio
  854. });
  855. });
  856. },
  857. /**
  858. * Event triggered when starting a selection.
  859. *
  860. * @ignore
  861. *
  862. * @return {void}
  863. */
  864. onSelectStart: function() {
  865. imageEdit.setDisabled($('#imgedit-crop-sel-' + postid), 1);
  866. imageEdit.setDisabled($('.imgedit-crop-clear'), 1);
  867. imageEdit.setDisabled($('.imgedit-crop-apply'), 1);
  868. },
  869. /**
  870. * Event triggered when the selection is ended.
  871. *
  872. * @ignore
  873. *
  874. * @param {Object} img jQuery object representing the image.
  875. * @param {Object} c The selection.
  876. *
  877. * @return {Object}
  878. */
  879. onSelectEnd: function(img, c) {
  880. imageEdit.setCropSelection(postid, c);
  881. if ( ! $('#imgedit-crop > *').is(':visible') ) {
  882. imageEdit.toggleControls($('.imgedit-crop.button'));
  883. }
  884. },
  885. /**
  886. * Event triggered when the selection changes.
  887. *
  888. * @ignore
  889. *
  890. * @param {Object} img jQuery object representing the image.
  891. * @param {Object} c The selection.
  892. *
  893. * @return {void}
  894. */
  895. onSelectChange: function(img, c) {
  896. var sizer = imageEdit.hold.sizer;
  897. selW.val( imageEdit.round(c.width / sizer) );
  898. selH.val( imageEdit.round(c.height / sizer) );
  899. selX.val( imageEdit.round(c.x1 / sizer) );
  900. selY.val( imageEdit.round(c.y1 / sizer) );
  901. }
  902. });
  903. },
  904. /**
  905. * Stores the current crop selection.
  906. *
  907. * @since 2.9.0
  908. *
  909. * @memberof imageEdit
  910. *
  911. * @param {number} postid The post ID.
  912. * @param {Object} c The selection.
  913. *
  914. * @return {boolean}
  915. */
  916. setCropSelection : function(postid, c) {
  917. var sel;
  918. c = c || 0;
  919. if ( !c || ( c.width < 3 && c.height < 3 ) ) {
  920. this.setDisabled( $( '.imgedit-crop', '#imgedit-panel-' + postid ), 1 );
  921. this.setDisabled( $( '#imgedit-crop-sel-' + postid ), 1 );
  922. $('#imgedit-sel-width-' + postid).val('');
  923. $('#imgedit-sel-height-' + postid).val('');
  924. $('#imgedit-start-x-' + postid).val('0');
  925. $('#imgedit-start-y-' + postid).val('0');
  926. $('#imgedit-selection-' + postid).val('');
  927. return false;
  928. }
  929. sel = { 'x': c.x1, 'y': c.y1, 'w': c.width, 'h': c.height };
  930. this.setDisabled($('.imgedit-crop', '#imgedit-panel-' + postid), 1);
  931. $('#imgedit-selection-' + postid).val( JSON.stringify(sel) );
  932. },
  933. /**
  934. * Closes the image editor.
  935. *
  936. * @since 2.9.0
  937. *
  938. * @memberof imageEdit
  939. *
  940. * @param {number} postid The post ID.
  941. * @param {boolean} warn Warning message.
  942. *
  943. * @return {void|boolean} Returns false if there is a warning.
  944. */
  945. close : function(postid, warn) {
  946. warn = warn || false;
  947. if ( warn && this.notsaved(postid) ) {
  948. return false;
  949. }
  950. this.iasapi = {};
  951. this.hold = {};
  952. // If we've loaded the editor in the context of a Media Modal,
  953. // then switch to the previous view, whatever that might have been.
  954. if ( this._view ){
  955. this._view.back();
  956. }
  957. // In case we are not accessing the image editor in the context of a View,
  958. // close the editor the old-school way.
  959. else {
  960. $('#image-editor-' + postid).fadeOut('fast', function() {
  961. $( '#media-head-' + postid ).fadeIn( 'fast', function() {
  962. // Move focus back to the Edit Image button. Runs also when saving.
  963. $( '#imgedit-open-btn-' + postid ).trigger( 'focus' );
  964. });
  965. $(this).empty();
  966. });
  967. }
  968. },
  969. /**
  970. * Checks if the image edit history is saved.
  971. *
  972. * @since 2.9.0
  973. *
  974. * @memberof imageEdit
  975. *
  976. * @param {number} postid The post ID.
  977. *
  978. * @return {boolean} Returns true if the history is not saved.
  979. */
  980. notsaved : function(postid) {
  981. var h = $('#imgedit-history-' + postid).val(),
  982. history = ( h !== '' ) ? JSON.parse(h) : [],
  983. pop = this.intval( $('#imgedit-undone-' + postid).val() );
  984. if ( pop < history.length ) {
  985. if ( confirm( $('#imgedit-leaving-' + postid).text() ) ) {
  986. return false;
  987. }
  988. return true;
  989. }
  990. return false;
  991. },
  992. /**
  993. * Adds an image edit action to the history.
  994. *
  995. * @since 2.9.0
  996. *
  997. * @memberof imageEdit
  998. *
  999. * @param {Object} op The original position.
  1000. * @param {number} postid The post ID.
  1001. * @param {string} nonce The nonce.
  1002. *
  1003. * @return {void}
  1004. */
  1005. addStep : function(op, postid, nonce) {
  1006. var t = this, elem = $('#imgedit-history-' + postid),
  1007. history = ( elem.val() !== '' ) ? JSON.parse( elem.val() ) : [],
  1008. undone = $( '#imgedit-undone-' + postid ),
  1009. pop = t.intval( undone.val() );
  1010. while ( pop > 0 ) {
  1011. history.pop();
  1012. pop--;
  1013. }
  1014. undone.val(0); // Reset.
  1015. history.push(op);
  1016. elem.val( JSON.stringify(history) );
  1017. t.refreshEditor(postid, nonce, function() {
  1018. t.setDisabled($('#image-undo-' + postid), true);
  1019. t.setDisabled($('#image-redo-' + postid), false);
  1020. });
  1021. },
  1022. /**
  1023. * Rotates the image.
  1024. *
  1025. * @since 2.9.0
  1026. *
  1027. * @memberof imageEdit
  1028. *
  1029. * @param {string} angle The angle the image is rotated with.
  1030. * @param {number} postid The post ID.
  1031. * @param {string} nonce The nonce.
  1032. * @param {Object} t The target element.
  1033. *
  1034. * @return {boolean}
  1035. */
  1036. rotate : function(angle, postid, nonce, t) {
  1037. if ( $(t).hasClass('disabled') ) {
  1038. return false;
  1039. }
  1040. this.closePopup(t);
  1041. this.addStep({ 'r': { 'r': angle, 'fw': this.hold.h, 'fh': this.hold.w }}, postid, nonce);
  1042. },
  1043. /**
  1044. * Flips the image.
  1045. *
  1046. * @since 2.9.0
  1047. *
  1048. * @memberof imageEdit
  1049. *
  1050. * @param {number} axis The axle the image is flipped on.
  1051. * @param {number} postid The post ID.
  1052. * @param {string} nonce The nonce.
  1053. * @param {Object} t The target element.
  1054. *
  1055. * @return {boolean}
  1056. */
  1057. flip : function (axis, postid, nonce, t) {
  1058. if ( $(t).hasClass('disabled') ) {
  1059. return false;
  1060. }
  1061. this.closePopup(t);
  1062. this.addStep({ 'f': { 'f': axis, 'fw': this.hold.w, 'fh': this.hold.h }}, postid, nonce);
  1063. },
  1064. /**
  1065. * Crops the image.
  1066. *
  1067. * @since 2.9.0
  1068. *
  1069. * @memberof imageEdit
  1070. *
  1071. * @param {number} postid The post ID.
  1072. * @param {string} nonce The nonce.
  1073. * @param {Object} t The target object.
  1074. *
  1075. * @return {void|boolean} Returns false if the crop button is disabled.
  1076. */
  1077. crop : function (postid, nonce, t) {
  1078. var sel = $('#imgedit-selection-' + postid).val(),
  1079. w = this.intval( $('#imgedit-sel-width-' + postid).val() ),
  1080. h = this.intval( $('#imgedit-sel-height-' + postid).val() );
  1081. if ( $(t).hasClass('disabled') || sel === '' ) {
  1082. return false;
  1083. }
  1084. sel = JSON.parse(sel);
  1085. if ( sel.w > 0 && sel.h > 0 && w > 0 && h > 0 ) {
  1086. sel.fw = w;
  1087. sel.fh = h;
  1088. this.addStep({ 'c': sel }, postid, nonce);
  1089. }
  1090. // Clear the selection fields after cropping.
  1091. $('#imgedit-sel-width-' + postid).val('');
  1092. $('#imgedit-sel-height-' + postid).val('');
  1093. $('#imgedit-start-x-' + postid).val('0');
  1094. $('#imgedit-start-y-' + postid).val('0');
  1095. },
  1096. /**
  1097. * Undoes an image edit action.
  1098. *
  1099. * @since 2.9.0
  1100. *
  1101. * @memberof imageEdit
  1102. *
  1103. * @param {number} postid The post ID.
  1104. * @param {string} nonce The nonce.
  1105. *
  1106. * @return {void|false} Returns false if the undo button is disabled.
  1107. */
  1108. undo : function (postid, nonce) {
  1109. var t = this, button = $('#image-undo-' + postid), elem = $('#imgedit-undone-' + postid),
  1110. pop = t.intval( elem.val() ) + 1;
  1111. if ( button.hasClass('disabled') ) {
  1112. return;
  1113. }
  1114. elem.val(pop);
  1115. t.refreshEditor(postid, nonce, function() {
  1116. var elem = $('#imgedit-history-' + postid),
  1117. history = ( elem.val() !== '' ) ? JSON.parse( elem.val() ) : [];
  1118. t.setDisabled($('#image-redo-' + postid), true);
  1119. t.setDisabled(button, pop < history.length);
  1120. // When undo gets disabled, move focus to the redo button to avoid a focus loss.
  1121. if ( history.length === pop ) {
  1122. $( '#image-redo-' + postid ).trigger( 'focus' );
  1123. }
  1124. });
  1125. },
  1126. /**
  1127. * Reverts a undo action.
  1128. *
  1129. * @since 2.9.0
  1130. *
  1131. * @memberof imageEdit
  1132. *
  1133. * @param {number} postid The post ID.
  1134. * @param {string} nonce The nonce.
  1135. *
  1136. * @return {void}
  1137. */
  1138. redo : function(postid, nonce) {
  1139. var t = this, button = $('#image-redo-' + postid), elem = $('#imgedit-undone-' + postid),
  1140. pop = t.intval( elem.val() ) - 1;
  1141. if ( button.hasClass('disabled') ) {
  1142. return;
  1143. }
  1144. elem.val(pop);
  1145. t.refreshEditor(postid, nonce, function() {
  1146. t.setDisabled($('#image-undo-' + postid), true);
  1147. t.setDisabled(button, pop > 0);
  1148. // When redo gets disabled, move focus to the undo button to avoid a focus loss.
  1149. if ( 0 === pop ) {
  1150. $( '#image-undo-' + postid ).trigger( 'focus' );
  1151. }
  1152. });
  1153. },
  1154. /**
  1155. * Sets the selection for the height and width in pixels.
  1156. *
  1157. * @since 2.9.0
  1158. *
  1159. * @memberof imageEdit
  1160. *
  1161. * @param {number} postid The post ID.
  1162. * @param {jQuery} el The element containing the values.
  1163. *
  1164. * @return {void|boolean} Returns false when the x or y value is lower than 1,
  1165. * void when the value is not numeric or when the operation
  1166. * is successful.
  1167. */
  1168. setNumSelection : function( postid, el ) {
  1169. var sel, elX = $('#imgedit-sel-width-' + postid), elY = $('#imgedit-sel-height-' + postid),
  1170. elX1 = $('#imgedit-start-x-' + postid), elY1 = $('#imgedit-start-y-' + postid),
  1171. xS = this.intval( elX1.val() ), yS = this.intval( elY1.val() ),
  1172. x = this.intval( elX.val() ), y = this.intval( elY.val() ),
  1173. img = $('#image-preview-' + postid), imgh = img.height(), imgw = img.width(),
  1174. sizer = this.hold.sizer, x1, y1, x2, y2, ias = this.iasapi;
  1175. if ( false === this.validateNumeric( el ) ) {
  1176. return;
  1177. }
  1178. if ( x < 1 ) {
  1179. elX.val('');
  1180. return false;
  1181. }
  1182. if ( y < 1 ) {
  1183. elY.val('');
  1184. return false;
  1185. }
  1186. if ( ( ( x && y ) || ( xS && yS ) ) && ( sel = ias.getSelection() ) ) {
  1187. x2 = sel.x1 + Math.round( x * sizer );
  1188. y2 = sel.y1 + Math.round( y * sizer );
  1189. x1 = ( xS === sel.x1 ) ? sel.x1 : Math.round( xS * sizer );
  1190. y1 = ( yS === sel.y1 ) ? sel.y1 : Math.round( yS * sizer );
  1191. if ( x2 > imgw ) {
  1192. x1 = 0;
  1193. x2 = imgw;
  1194. elX.val( Math.round( x2 / sizer ) );
  1195. }
  1196. if ( y2 > imgh ) {
  1197. y1 = 0;
  1198. y2 = imgh;
  1199. elY.val( Math.round( y2 / sizer ) );
  1200. }
  1201. ias.setSelection( x1, y1, x2, y2 );
  1202. ias.update();
  1203. this.setCropSelection(postid, ias.getSelection());
  1204. }
  1205. },
  1206. /**
  1207. * Rounds a number to a whole.
  1208. *
  1209. * @since 2.9.0
  1210. *
  1211. * @memberof imageEdit
  1212. *
  1213. * @param {number} num The number.
  1214. *
  1215. * @return {number} The number rounded to a whole number.
  1216. */
  1217. round : function(num) {
  1218. var s;
  1219. num = Math.round(num);
  1220. if ( this.hold.sizer > 0.6 ) {
  1221. return num;
  1222. }
  1223. s = num.toString().slice(-1);
  1224. if ( '1' === s ) {
  1225. return num - 1;
  1226. } else if ( '9' === s ) {
  1227. return num + 1;
  1228. }
  1229. return num;
  1230. },
  1231. /**
  1232. * Sets a locked aspect ratio for the selection.
  1233. *
  1234. * @since 2.9.0
  1235. *
  1236. * @memberof imageEdit
  1237. *
  1238. * @param {number} postid The post ID.
  1239. * @param {number} n The ratio to set.
  1240. * @param {jQuery} el The element containing the values.
  1241. *
  1242. * @return {void}
  1243. */
  1244. setRatioSelection : function(postid, n, el) {
  1245. var sel, r, x = this.intval( $('#imgedit-crop-width-' + postid).val() ),
  1246. y = this.intval( $('#imgedit-crop-height-' + postid).val() ),
  1247. h = $('#image-preview-' + postid).height();
  1248. if ( false === this.validateNumeric( el ) ) {
  1249. this.iasapi.setOptions({
  1250. aspectRatio: null
  1251. });
  1252. return;
  1253. }
  1254. if ( x && y ) {
  1255. this.iasapi.setOptions({
  1256. aspectRatio: x + ':' + y
  1257. });
  1258. if ( sel = this.iasapi.getSelection(true) ) {
  1259. r = Math.ceil( sel.y1 + ( ( sel.x2 - sel.x1 ) / ( x / y ) ) );
  1260. if ( r > h ) {
  1261. r = h;
  1262. var errorMessage = __( 'Selected crop ratio exceeds the boundaries of the image. Try a different ratio.' );
  1263. $( '#imgedit-crop-' + postid )
  1264. .prepend( '<div class="notice notice-error" tabindex="-1" role="alert"><p>' + errorMessage + '</p></div>' );
  1265. wp.a11y.speak( errorMessage, 'assertive' );
  1266. if ( n ) {
  1267. $('#imgedit-crop-height-' + postid).val( '' );
  1268. } else {
  1269. $('#imgedit-crop-width-' + postid).val( '');
  1270. }
  1271. } else {
  1272. var error = $( '#imgedit-crop-' + postid ).find( '.notice-error' );
  1273. if ( 'undefined' !== typeof( error ) ) {
  1274. error.remove();
  1275. }
  1276. }
  1277. this.iasapi.setSelection( sel.x1, sel.y1, sel.x2, r );
  1278. this.iasapi.update();
  1279. }
  1280. }
  1281. },
  1282. /**
  1283. * Validates if a value in a jQuery.HTMLElement is numeric.
  1284. *
  1285. * @since 4.6.0
  1286. *
  1287. * @memberof imageEdit
  1288. *
  1289. * @param {jQuery} el The html element.
  1290. *
  1291. * @return {void|boolean} Returns false if the value is not numeric,
  1292. * void when it is.
  1293. */
  1294. validateNumeric: function( el ) {
  1295. if ( false === this.intval( $( el ).val() ) ) {
  1296. $( el ).val( '' );
  1297. return false;
  1298. }
  1299. }
  1300. };
  1301. })(jQuery);