Source: modules/quickedit/js/views/EntityToolbarView.es6.js

  1. /**
  2. * @file
  3. * A Backbone View that provides an entity level toolbar.
  4. */
  5. (function ($, _, Backbone, Drupal, debounce, Popper) {
  6. Drupal.quickedit.EntityToolbarView = Backbone.View.extend(
  7. /** @lends Drupal.quickedit.EntityToolbarView# */ {
  8. /**
  9. * @type {jQuery}
  10. */
  11. _fieldToolbarRoot: null,
  12. /**
  13. * @return {object}
  14. * A map of events.
  15. */
  16. events() {
  17. const map = {
  18. 'click button.action-save': 'onClickSave',
  19. 'click button.action-cancel': 'onClickCancel',
  20. mouseenter: 'onMouseenter',
  21. };
  22. return map;
  23. },
  24. /**
  25. * @constructs
  26. *
  27. * @augments Backbone.View
  28. *
  29. * @param {object} options
  30. * Options to construct the view.
  31. * @param {Drupal.quickedit.AppModel} options.appModel
  32. * A quickedit `AppModel` to use in the view.
  33. */
  34. initialize(options) {
  35. const that = this;
  36. this.appModel = options.appModel;
  37. this.$entity = $(this.model.get('el'));
  38. // Rerender whenever the entity state changes.
  39. this.listenTo(
  40. this.model,
  41. 'change:isActive change:isDirty change:state',
  42. this.render,
  43. );
  44. // Also rerender whenever a different field is highlighted or activated.
  45. this.listenTo(
  46. this.appModel,
  47. 'change:highlightedField change:activeField',
  48. this.render,
  49. );
  50. // Rerender when a field of the entity changes state.
  51. this.listenTo(
  52. this.model.get('fields'),
  53. 'change:state',
  54. this.fieldStateChange,
  55. );
  56. // Reposition the entity toolbar as the viewport and the position within
  57. // the viewport changes.
  58. $(window).on(
  59. 'resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit',
  60. debounce($.proxy(this.windowChangeHandler, this), 150),
  61. );
  62. // Adjust the fence placement within which the entity toolbar may be
  63. // positioned.
  64. $(document).on(
  65. 'drupalViewportOffsetChange.quickedit',
  66. (event, offsets) => {
  67. if (that.$fence) {
  68. that.$fence.css(offsets);
  69. }
  70. },
  71. );
  72. // Set the entity toolbar DOM element as the el for this view.
  73. const $toolbar = this.buildToolbarEl();
  74. this.setElement($toolbar);
  75. this._fieldToolbarRoot = $toolbar
  76. .find('.quickedit-toolbar-field')
  77. .get(0);
  78. // Initial render.
  79. this.render();
  80. },
  81. /**
  82. * {@inheritdoc}
  83. *
  84. * @return {Drupal.quickedit.EntityToolbarView}
  85. * The entity toolbar view.
  86. */
  87. render() {
  88. if (this.model.get('isActive')) {
  89. // If the toolbar container doesn't exist, create it.
  90. const $body = $('body');
  91. if ($body.children('#quickedit-entity-toolbar').length === 0) {
  92. $body.append(this.$el);
  93. }
  94. // The fence will define a area on the screen that the entity toolbar
  95. // will be position within.
  96. if ($body.children('#quickedit-toolbar-fence').length === 0) {
  97. this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
  98. .css(Drupal.displace())
  99. .appendTo($body);
  100. }
  101. // Adds the entity title to the toolbar.
  102. this.label();
  103. // Show the save and cancel buttons.
  104. this.show('ops');
  105. // If render is being called and the toolbar is already visible, just
  106. // reposition it.
  107. this.position();
  108. }
  109. // The save button text and state varies with the state of the entity
  110. // model.
  111. const $button = this.$el.find('.quickedit-button.action-save');
  112. const isDirty = this.model.get('isDirty');
  113. // Adjust the save button according to the state of the model.
  114. switch (this.model.get('state')) {
  115. // Quick editing is active, but no field is being edited.
  116. case 'opened':
  117. // The saving throbber is not managed by AJAX system. The
  118. // EntityToolbarView manages this visual element.
  119. $button
  120. .removeClass('action-saving icon-throbber icon-end')
  121. .text(Drupal.t('Save'))
  122. .removeAttr('disabled')
  123. .attr('aria-hidden', !isDirty);
  124. break;
  125. // The changes to the fields of the entity are being committed.
  126. case 'committing':
  127. $button
  128. .addClass('action-saving icon-throbber icon-end')
  129. .text(Drupal.t('Saving'))
  130. .attr('disabled', 'disabled');
  131. break;
  132. default:
  133. $button.attr('aria-hidden', true);
  134. break;
  135. }
  136. return this;
  137. },
  138. /**
  139. * {@inheritdoc}
  140. */
  141. remove() {
  142. // Remove additional DOM elements controlled by this View.
  143. this.$fence.remove();
  144. // Stop listening to additional events.
  145. $(window).off(
  146. 'resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit',
  147. );
  148. $(document).off('drupalViewportOffsetChange.quickedit');
  149. Backbone.View.prototype.remove.call(this);
  150. },
  151. /**
  152. * Repositions the entity toolbar on window scroll and resize.
  153. *
  154. * @param {jQuery.Event} event
  155. * The scroll or resize event.
  156. */
  157. windowChangeHandler(event) {
  158. this.position();
  159. },
  160. /**
  161. * Determines the actions to take given a change of state.
  162. *
  163. * @param {Drupal.quickedit.FieldModel} model
  164. * The `FieldModel` model.
  165. * @param {string} state
  166. * The state of the associated field. One of
  167. * {@link Drupal.quickedit.FieldModel.states}.
  168. */
  169. fieldStateChange(model, state) {
  170. switch (state) {
  171. case 'active':
  172. this.render();
  173. break;
  174. case 'invalid':
  175. this.render();
  176. break;
  177. }
  178. },
  179. /**
  180. * Uses the Popper() method to position the entity toolbar.
  181. *
  182. * @param {HTMLElement} [element]
  183. * The element against which the entity toolbar is positioned.
  184. */
  185. position(element) {
  186. clearTimeout(this.timer);
  187. const that = this;
  188. // Vary the edge of the positioning according to the direction of language
  189. // in the document.
  190. const edge = document.documentElement.dir === 'rtl' ? 'right' : 'left';
  191. // A time unit to wait until the entity toolbar is repositioned.
  192. let delay = 0;
  193. // Determines what check in the series of checks below should be
  194. // evaluated.
  195. let check = 0;
  196. // When positioned against an active field that has padding, we should
  197. // ignore that padding when positioning the toolbar, to not unnecessarily
  198. // move the toolbar horizontally, which feels annoying.
  199. let horizontalPadding = 0;
  200. let of;
  201. let activeField;
  202. let highlightedField;
  203. // There are several elements in the page that the entity toolbar might be
  204. // positioned against. They are considered below in a priority order.
  205. do {
  206. switch (check) {
  207. case 0:
  208. // Position against a specific element.
  209. of = element;
  210. break;
  211. case 1:
  212. // Position against a form container.
  213. activeField = Drupal.quickedit.app.model.get('activeField');
  214. of =
  215. activeField &&
  216. activeField.editorView &&
  217. activeField.editorView.$formContainer &&
  218. activeField.editorView.$formContainer.find('.quickedit-form');
  219. break;
  220. case 2:
  221. // Position against an active field.
  222. of =
  223. activeField &&
  224. activeField.editorView &&
  225. activeField.editorView.getEditedElement();
  226. if (
  227. activeField &&
  228. activeField.editorView &&
  229. activeField.editorView.getQuickEditUISettings().padding
  230. ) {
  231. horizontalPadding = 5;
  232. }
  233. break;
  234. case 3:
  235. // Position against a highlighted field.
  236. highlightedField = Drupal.quickedit.app.model.get(
  237. 'highlightedField',
  238. );
  239. of =
  240. highlightedField &&
  241. highlightedField.editorView &&
  242. highlightedField.editorView.getEditedElement();
  243. delay = 250;
  244. break;
  245. default: {
  246. const fieldModels = this.model.get('fields').models;
  247. let topMostPosition = 1000000;
  248. let topMostField = null;
  249. // Position against the topmost field.
  250. for (let i = 0; i < fieldModels.length; i++) {
  251. const pos = fieldModels[i].get('el').getBoundingClientRect()
  252. .top;
  253. if (pos < topMostPosition) {
  254. topMostPosition = pos;
  255. topMostField = fieldModels[i];
  256. }
  257. }
  258. of = topMostField.get('el');
  259. delay = 50;
  260. break;
  261. }
  262. }
  263. // Prepare to check the next possible element to position against.
  264. check++;
  265. } while (!of);
  266. /**
  267. * Refines popper positioning.
  268. *
  269. * @param {object} data
  270. * Data object containing popper and target data.
  271. */
  272. function refinePopper({ state }) {
  273. // Determine if the pointer should be on the top or bottom.
  274. const isBelow = state.placement.split('-')[0] === 'bottom';
  275. const classListMethod = isBelow ? 'add' : 'remove';
  276. state.elements.popper.classList[classListMethod](
  277. 'quickedit-toolbar-pointer-top',
  278. );
  279. }
  280. /**
  281. * Calls the Popper() method on the $el of this view.
  282. */
  283. function positionToolbar() {
  284. const popperElement = that.el;
  285. const referenceElement = of;
  286. const boundariesElement = that.$fence[0];
  287. const popperedge = edge === 'left' ? 'start' : 'end';
  288. if (referenceElement !== undefined) {
  289. if (!popperElement.classList.contains('js-popper-processed')) {
  290. that.popper = Popper.createPopper(
  291. referenceElement,
  292. popperElement,
  293. {
  294. placement: `top-${popperedge}`,
  295. modifiers: [
  296. {
  297. name: 'flip',
  298. options: {
  299. boundary: boundariesElement,
  300. },
  301. },
  302. {
  303. name: 'preventOverflow',
  304. options: {
  305. boundary: boundariesElement,
  306. tether: false,
  307. altAxis: true,
  308. padding: { top: 5, bottom: 5 },
  309. },
  310. },
  311. {
  312. name: 'computeStyles',
  313. options: {
  314. adaptive: false,
  315. },
  316. },
  317. {
  318. name: 'refinePopper',
  319. phase: 'write',
  320. enabled: true,
  321. fn: refinePopper,
  322. },
  323. ],
  324. },
  325. );
  326. popperElement.classList.add('js-popper-processed');
  327. } else {
  328. that.popper.state.elements.reference = referenceElement[0]
  329. ? referenceElement[0]
  330. : referenceElement;
  331. that.popper.forceUpdate();
  332. }
  333. }
  334. that.$el
  335. // Resize the toolbar to match the dimensions of the field, up to a
  336. // maximum width that is equal to 90% of the field's width.
  337. .css({
  338. 'max-width':
  339. document.documentElement.clientWidth < 450
  340. ? document.documentElement.clientWidth
  341. : 450,
  342. // Set a minimum width of 240px for the entity toolbar, or the width
  343. // of the client if it is less than 240px, so that the toolbar
  344. // never folds up into a squashed and jumbled mess.
  345. 'min-width':
  346. document.documentElement.clientWidth < 240
  347. ? document.documentElement.clientWidth
  348. : 240,
  349. width: '100%',
  350. });
  351. }
  352. // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
  353. // only after the user has focused on an editable for 250ms. This prevents
  354. // the toolbar from jumping around the screen.
  355. this.timer = setTimeout(() => {
  356. // Render the position in the next execution cycle, so that animations
  357. // on the field have time to process. This is not strictly speaking, a
  358. // guarantee that all animations will be finished, but it's a simple
  359. // way to get better positioning without too much additional code.
  360. _.defer(positionToolbar);
  361. }, delay);
  362. },
  363. /**
  364. * Set the model state to 'saving' when the save button is clicked.
  365. *
  366. * @param {jQuery.Event} event
  367. * The click event.
  368. */
  369. onClickSave(event) {
  370. event.stopPropagation();
  371. event.preventDefault();
  372. // Save the model.
  373. this.model.set('state', 'committing');
  374. },
  375. /**
  376. * Sets the model state to candidate when the cancel button is clicked.
  377. *
  378. * @param {jQuery.Event} event
  379. * The click event.
  380. */
  381. onClickCancel(event) {
  382. event.preventDefault();
  383. this.model.set('state', 'deactivating');
  384. },
  385. /**
  386. * Clears the timeout that will eventually reposition the entity toolbar.
  387. *
  388. * Without this, it may reposition itself, away from the user's cursor!
  389. *
  390. * @param {jQuery.Event} event
  391. * The mouse event.
  392. */
  393. onMouseenter(event) {
  394. clearTimeout(this.timer);
  395. },
  396. /**
  397. * Builds the entity toolbar HTML; attaches to DOM; sets starting position.
  398. *
  399. * @return {jQuery}
  400. * The toolbar element.
  401. */
  402. buildToolbarEl() {
  403. const $toolbar = $(
  404. Drupal.theme('quickeditEntityToolbar', {
  405. id: 'quickedit-entity-toolbar',
  406. }),
  407. );
  408. $toolbar
  409. .find('.quickedit-toolbar-entity')
  410. // Append the "ops" toolgroup into the toolbar.
  411. .prepend(
  412. Drupal.theme('quickeditToolgroup', {
  413. classes: ['ops'],
  414. buttons: [
  415. {
  416. label: Drupal.t('Save'),
  417. type: 'submit',
  418. classes: 'action-save quickedit-button icon',
  419. attributes: {
  420. 'aria-hidden': true,
  421. },
  422. },
  423. {
  424. label: Drupal.t('Close'),
  425. classes:
  426. 'action-cancel quickedit-button icon icon-close icon-only',
  427. },
  428. ],
  429. }),
  430. );
  431. // Give the toolbar a sensible starting position so that it doesn't
  432. // animate on to the screen from a far off corner.
  433. $toolbar.css({
  434. left: this.$entity.offset().left,
  435. top: this.$entity.offset().top,
  436. });
  437. return $toolbar;
  438. },
  439. /**
  440. * Returns the DOM element that fields will attach their toolbars to.
  441. *
  442. * @return {jQuery}
  443. * The DOM element that fields will attach their toolbars to.
  444. */
  445. getToolbarRoot() {
  446. return this._fieldToolbarRoot;
  447. },
  448. /**
  449. * Generates a state-dependent label for the entity toolbar.
  450. */
  451. label() {
  452. // The entity label.
  453. let label = '';
  454. const entityLabel = this.model.get('label');
  455. // Label of an active field, if it exists.
  456. const activeField = Drupal.quickedit.app.model.get('activeField');
  457. const activeFieldLabel =
  458. activeField && activeField.get('metadata').label;
  459. // Label of a highlighted field, if it exists.
  460. const highlightedField = Drupal.quickedit.app.model.get(
  461. 'highlightedField',
  462. );
  463. const highlightedFieldLabel =
  464. highlightedField && highlightedField.get('metadata').label;
  465. // The label is constructed in a priority order.
  466. if (activeFieldLabel) {
  467. label = Drupal.theme('quickeditEntityToolbarLabel', {
  468. entityLabel,
  469. fieldLabel: activeFieldLabel,
  470. });
  471. } else if (highlightedFieldLabel) {
  472. label = Drupal.theme('quickeditEntityToolbarLabel', {
  473. entityLabel,
  474. fieldLabel: highlightedFieldLabel,
  475. });
  476. } else {
  477. // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
  478. label = Drupal.checkPlain(entityLabel);
  479. }
  480. this.$el.find('.quickedit-toolbar-label').html(label);
  481. },
  482. /**
  483. * Adds classes to a toolgroup.
  484. *
  485. * @param {string} toolgroup
  486. * A toolgroup name.
  487. * @param {string} classes
  488. * A string of space-delimited class names that will be applied to the
  489. * wrapping element of the toolbar group.
  490. */
  491. addClass(toolgroup, classes) {
  492. this._find(toolgroup).addClass(classes);
  493. },
  494. /**
  495. * Removes classes from a toolgroup.
  496. *
  497. * @param {string} toolgroup
  498. * A toolgroup name.
  499. * @param {string} classes
  500. * A string of space-delimited class names that will be removed from the
  501. * wrapping element of the toolbar group.
  502. */
  503. removeClass(toolgroup, classes) {
  504. this._find(toolgroup).removeClass(classes);
  505. },
  506. /**
  507. * Finds a toolgroup.
  508. *
  509. * @param {string} toolgroup
  510. * A toolgroup name.
  511. *
  512. * @return {jQuery}
  513. * The toolgroup DOM element.
  514. */
  515. _find(toolgroup) {
  516. return this.$el.find(
  517. `.quickedit-toolbar .quickedit-toolgroup.${toolgroup}`,
  518. );
  519. },
  520. /**
  521. * Shows a toolgroup.
  522. *
  523. * @param {string} toolgroup
  524. * A toolgroup name.
  525. */
  526. show(toolgroup) {
  527. this.$el.removeClass('quickedit-animate-invisible');
  528. },
  529. },
  530. );
  531. })(jQuery, _, Backbone, Drupal, Drupal.debounce, Popper);