3 * A Backbone View that provides an entity level toolbar.
6 (function ($, _, Backbone, Drupal, debounce) {
10 Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{
15 _fieldToolbarRoot: null,
23 'click button.action-save': 'onClickSave',
24 'click button.action-cancel': 'onClickCancel',
25 'mouseenter': 'onMouseenter'
33 * @augments Backbone.View
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.
40 initialize: function (options) {
42 this.appModel = options.appModel;
43 this.$entity = $(this.model.get('el'));
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);
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));
56 // Adjust the fence placement within which the entity toolbar may be
58 $(document).on('drupalViewportOffsetChange.quickedit', function (event, offsets) {
60 that.$fence.css(offsets);
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);
76 * @return {Drupal.quickedit.EntityToolbarView}
77 * The entity toolbar view.
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);
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())
93 // Adds the entity title to the toolbar.
96 // Show the save and cancel buttons.
98 // If render is being called and the toolbar is already visible, just
103 // The save button text and state varies with the state of the entity
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.
111 // The saving throbber is not managed by AJAX system. The
112 // EntityToolbarView manages this visual element.
114 .removeClass('action-saving icon-throbber icon-end')
115 .text(Drupal.t('Save'))
116 .removeAttr('disabled')
117 .attr('aria-hidden', !isDirty);
120 // The changes to the fields of the entity are being committed.
123 .addClass('action-saving icon-throbber icon-end')
124 .text(Drupal.t('Saving'))
125 .attr('disabled', 'disabled');
129 $button.attr('aria-hidden', true);
139 remove: function () {
140 // Remove additional DOM elements controlled by this View.
141 this.$fence.remove();
143 // Stop listening to additional events.
144 $(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit');
145 $(document).off('drupalViewportOffsetChange.quickedit');
147 Backbone.View.prototype.remove.call(this);
151 * Repositions the entity toolbar on window scroll and resize.
153 * @param {jQuery.Event} event
154 * The scroll or resize event.
156 windowChangeHandler: function (event) {
161 * Determines the actions to take given a change of state.
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}.
169 fieldStateChange: function (model, state) {
182 * Uses the jQuery.ui.position() method to position the entity toolbar.
184 * @param {HTMLElement} [element]
185 * The element against which the entity toolbar is positioned.
187 position: function (element) {
188 clearTimeout(this.timer);
191 // Vary the edge of the positioning according to the direction of language
193 var edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left';
194 // A time unit to wait until the entity toolbar is repositioned.
196 // Determines what check in the series of checks below should be
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;
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.
211 // Position against a specific element.
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');
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;
230 // Position against a highlighted field.
231 highlightedField = Drupal.quickedit.app.model.get('highlightedField');
232 of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement();
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];
248 of = topMostField.get('el');
252 // Prepare to check the next possible element to position against.
257 * Refines the positioning algorithm of jquery.ui.position().
259 * Invoked as the 'using' callback of jquery.ui.position() in
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'
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);
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;
300 else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) {
301 suggested.top = fenceTop + fenceHeight - toolbarHeight;
303 // Position the toolbar.
304 info.element.element.css({
305 left: Math.floor(suggested.left),
306 top: Math.floor(suggested.top)
311 * Calls the jquery.ui.position() method on the $el of this view.
313 function positionToolbar() {
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
321 at: edge + '+' + (1 + horizontalPadding) + ' top',
323 collision: 'flipfit',
324 using: refinePosition.bind(null, that),
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.
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,
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);
352 * Set the model state to 'saving' when the save button is clicked.
354 * @param {jQuery.Event} event
357 onClickSave: function (event) {
358 event.stopPropagation();
359 event.preventDefault();
361 this.model.set('state', 'committing');
365 * Sets the model state to candidate when the cancel button is clicked.
367 * @param {jQuery.Event} event
370 onClickCancel: function (event) {
371 event.preventDefault();
372 this.model.set('state', 'deactivating');
376 * Clears the timeout that will eventually reposition the entity toolbar.
378 * Without this, it may reposition itself, away from the user's cursor!
380 * @param {jQuery.Event} event
383 onMouseenter: function (event) {
384 clearTimeout(this.timer);
388 * Builds the entity toolbar HTML; attaches to DOM; sets starting position.
391 * The toolbar element.
393 buildToolbarEl: function () {
394 var $toolbar = $(Drupal.theme('quickeditEntityToolbar', {
395 id: 'quickedit-entity-toolbar'
399 .find('.quickedit-toolbar-entity')
400 // Append the "ops" toolgroup into the toolbar.
401 .prepend(Drupal.theme('quickeditToolgroup', {
405 label: Drupal.t('Save'),
407 classes: 'action-save quickedit-button icon',
413 label: Drupal.t('Close'),
414 classes: 'action-cancel quickedit-button icon icon-close icon-only'
419 // Give the toolbar a sensible starting position so that it doesn't
420 // animate on to the screen from a far off corner.
423 left: this.$entity.offset().left,
424 top: this.$entity.offset().top
431 * Returns the DOM element that fields will attach their toolbars to.
434 * The DOM element that fields will attach their toolbars to.
436 getToolbarRoot: function () {
437 return this._fieldToolbarRoot;
441 * Generates a state-dependent label for the entity toolbar.
446 var entityLabel = this.model.get('label');
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
461 else if (highlightedFieldLabel) {
462 label = Drupal.theme('quickeditEntityToolbarLabel', {
463 entityLabel: entityLabel,
464 fieldLabel: highlightedFieldLabel
468 // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
469 label = Drupal.checkPlain(entityLabel);
473 .find('.quickedit-toolbar-label')
478 * Adds classes to a toolgroup.
480 * @param {string} toolgroup
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.
486 addClass: function (toolgroup, classes) {
487 this._find(toolgroup).addClass(classes);
491 * Removes classes from a toolgroup.
493 * @param {string} toolgroup
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.
499 removeClass: function (toolgroup, classes) {
500 this._find(toolgroup).removeClass(classes);
506 * @param {string} toolgroup
510 * The toolgroup DOM element.
512 _find: function (toolgroup) {
513 return this.$el.find('.quickedit-toolbar .quickedit-toolgroup.' + toolgroup);
519 * @param {string} toolgroup
522 show: function (toolgroup) {
523 this.$el.removeClass('quickedit-animate-invisible');
528 })(jQuery, _, Backbone, Drupal, Drupal.debounce);