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