EntityToolbarView.js 18 KB

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