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