3 * A Backbone View that provides an entity level toolbar.
6 (function($, _, Backbone, Drupal, debounce) {
7 Drupal.quickedit.EntityToolbarView = Backbone.View.extend(
8 /** @lends Drupal.quickedit.EntityToolbarView# */ {
12 _fieldToolbarRoot: null,
20 'click button.action-save': 'onClickSave',
21 'click button.action-cancel': 'onClickCancel',
22 mouseenter: 'onMouseenter',
30 * @augments Backbone.View
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.
39 this.appModel = options.appModel;
40 this.$entity = $(this.model.get('el'));
42 // Rerender whenever the entity state changes.
45 'change:isActive change:isDirty change:state',
48 // Also rerender whenever a different field is highlighted or activated.
51 'change:highlightedField change:activeField',
54 // Rerender when a field of the entity changes state.
56 this.model.get('fields'),
58 this.fieldStateChange,
61 // Reposition the entity toolbar as the viewport and the position within
62 // the viewport changes.
64 'resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit',
65 debounce($.proxy(this.windowChangeHandler, this), 150),
68 // Adjust the fence placement within which the entity toolbar may be
71 'drupalViewportOffsetChange.quickedit',
74 that.$fence.css(offsets);
79 // Set the entity toolbar DOM element as the el for this view.
80 const $toolbar = this.buildToolbarEl();
81 this.setElement($toolbar);
82 this._fieldToolbarRoot = $toolbar
83 .find('.quickedit-toolbar-field')
93 * @return {Drupal.quickedit.EntityToolbarView}
94 * The entity toolbar view.
97 if (this.model.get('isActive')) {
98 // If the toolbar container doesn't exist, create it.
99 const $body = $('body');
100 if ($body.children('#quickedit-entity-toolbar').length === 0) {
101 $body.append(this.$el);
103 // The fence will define a area on the screen that the entity toolbar
104 // will be position within.
105 if ($body.children('#quickedit-toolbar-fence').length === 0) {
106 this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
107 .css(Drupal.displace())
110 // Adds the entity title to the toolbar.
113 // Show the save and cancel buttons.
115 // If render is being called and the toolbar is already visible, just
120 // The save button text and state varies with the state of the entity
122 const $button = this.$el.find('.quickedit-button.action-save');
123 const isDirty = this.model.get('isDirty');
124 // Adjust the save button according to the state of the model.
125 switch (this.model.get('state')) {
126 // Quick editing is active, but no field is being edited.
128 // The saving throbber is not managed by AJAX system. The
129 // EntityToolbarView manages this visual element.
131 .removeClass('action-saving icon-throbber icon-end')
132 .text(Drupal.t('Save'))
133 .removeAttr('disabled')
134 .attr('aria-hidden', !isDirty);
137 // The changes to the fields of the entity are being committed.
140 .addClass('action-saving icon-throbber icon-end')
141 .text(Drupal.t('Saving'))
142 .attr('disabled', 'disabled');
146 $button.attr('aria-hidden', true);
157 // Remove additional DOM elements controlled by this View.
158 this.$fence.remove();
160 // Stop listening to additional events.
162 'resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit',
164 $(document).off('drupalViewportOffsetChange.quickedit');
166 Backbone.View.prototype.remove.call(this);
170 * Repositions the entity toolbar on window scroll and resize.
172 * @param {jQuery.Event} event
173 * The scroll or resize event.
175 windowChangeHandler(event) {
180 * Determines the actions to take given a change of state.
182 * @param {Drupal.quickedit.FieldModel} model
183 * The `FieldModel` model.
184 * @param {string} state
185 * The state of the associated field. One of
186 * {@link Drupal.quickedit.FieldModel.states}.
188 fieldStateChange(model, state) {
201 * Uses the jQuery.ui.position() method to position the entity toolbar.
203 * @param {HTMLElement} [element]
204 * The element against which the entity toolbar is positioned.
207 clearTimeout(this.timer);
210 // Vary the edge of the positioning according to the direction of language
212 const edge = document.documentElement.dir === 'rtl' ? 'right' : 'left';
213 // A time unit to wait until the entity toolbar is repositioned.
215 // Determines what check in the series of checks below should be
218 // When positioned against an active field that has padding, we should
219 // ignore that padding when positioning the toolbar, to not unnecessarily
220 // move the toolbar horizontally, which feels annoying.
221 let horizontalPadding = 0;
224 let highlightedField;
225 // There are several elements in the page that the entity toolbar might be
226 // positioned against. They are considered below in a priority order.
230 // Position against a specific element.
235 // Position against a form container.
236 activeField = Drupal.quickedit.app.model.get('activeField');
239 activeField.editorView &&
240 activeField.editorView.$formContainer &&
241 activeField.editorView.$formContainer.find('.quickedit-form');
245 // Position against an active field.
248 activeField.editorView &&
249 activeField.editorView.getEditedElement();
252 activeField.editorView &&
253 activeField.editorView.getQuickEditUISettings().padding
255 horizontalPadding = 5;
260 // Position against a highlighted field.
261 highlightedField = Drupal.quickedit.app.model.get(
266 highlightedField.editorView &&
267 highlightedField.editorView.getEditedElement();
272 const fieldModels = this.model.get('fields').models;
273 let topMostPosition = 1000000;
274 let topMostField = null;
275 // Position against the topmost field.
276 for (let i = 0; i < fieldModels.length; i++) {
277 const pos = fieldModels[i].get('el').getBoundingClientRect()
279 if (pos < topMostPosition) {
280 topMostPosition = pos;
281 topMostField = fieldModels[i];
284 of = topMostField.get('el');
289 // Prepare to check the next possible element to position against.
294 * Refines the positioning algorithm of jquery.ui.position().
296 * Invoked as the 'using' callback of jquery.ui.position() in
300 * The view the positions will be calculated from.
301 * @param {object} suggested
302 * A hash of top and left values for the position that should be set. It
303 * can be forwarded to .css() or .animate().
304 * @param {object} info
305 * The position and dimensions of both the 'my' element and the 'of'
306 * elements, as well as calculations to their relative position. This
307 * object contains the following properties:
308 * @param {object} info.element
309 * A hash that contains information about the HTML element that will be
310 * positioned. Also known as the 'my' element.
311 * @param {object} info.target
312 * A hash that contains information about the HTML element that the
313 * 'my' element will be positioned against. Also known as the 'of'
316 function refinePosition(view, suggested, info) {
317 // Determine if the pointer should be on the top or bottom.
318 const isBelow = suggested.top > info.target.top;
319 info.element.element.toggleClass(
320 'quickedit-toolbar-pointer-top',
323 // Don't position the toolbar past the first or last editable field if
324 // the entity is the target.
325 if (view.$entity[0] === info.target.element[0]) {
326 // Get the first or last field according to whether the toolbar is
327 // above or below the entity.
328 const $field = view.$entity
329 .find('.quickedit-editable')
330 .eq(isBelow ? -1 : 0);
331 if ($field.length > 0) {
332 suggested.top = isBelow
333 ? $field.offset().top + $field.outerHeight(true)
334 : $field.offset().top - info.element.element.outerHeight(true);
337 // Don't let the toolbar go outside the fence.
338 const fenceTop = view.$fence.offset().top;
339 const fenceHeight = view.$fence.height();
340 const toolbarHeight = info.element.element.outerHeight(true);
341 if (suggested.top < fenceTop) {
342 suggested.top = fenceTop;
343 } else if (suggested.top + toolbarHeight > fenceTop + fenceHeight) {
344 suggested.top = fenceTop + fenceHeight - toolbarHeight;
346 // Position the toolbar.
347 info.element.element.css({
348 left: Math.floor(suggested.left),
349 top: Math.floor(suggested.top),
354 * Calls the jquery.ui.position() method on the $el of this view.
356 function positionToolbar() {
359 my: `${edge} bottom`,
360 // Move the toolbar 1px towards the start edge of the 'of' element,
361 // plus any horizontal padding that may have been added to the
362 // element that is being added, to prevent unwanted horizontal
364 at: `${edge}+${1 + horizontalPadding} top`,
366 collision: 'flipfit',
367 using: refinePosition.bind(null, that),
370 // Resize the toolbar to match the dimensions of the field, up to a
371 // maximum width that is equal to 90% of the field's width.
374 document.documentElement.clientWidth < 450
375 ? document.documentElement.clientWidth
377 // Set a minimum width of 240px for the entity toolbar, or the width
378 // of the client if it is less than 240px, so that the toolbar
379 // never folds up into a squashed and jumbled mess.
381 document.documentElement.clientWidth < 240
382 ? document.documentElement.clientWidth
388 // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
389 // only after the user has focused on an editable for 250ms. This prevents
390 // the toolbar from jumping around the screen.
391 this.timer = setTimeout(() => {
392 // Render the position in the next execution cycle, so that animations
393 // on the field have time to process. This is not strictly speaking, a
394 // guarantee that all animations will be finished, but it's a simple
395 // way to get better positioning without too much additional code.
396 _.defer(positionToolbar);
401 * Set the model state to 'saving' when the save button is clicked.
403 * @param {jQuery.Event} event
407 event.stopPropagation();
408 event.preventDefault();
410 this.model.set('state', 'committing');
414 * Sets the model state to candidate when the cancel button is clicked.
416 * @param {jQuery.Event} event
419 onClickCancel(event) {
420 event.preventDefault();
421 this.model.set('state', 'deactivating');
425 * Clears the timeout that will eventually reposition the entity toolbar.
427 * Without this, it may reposition itself, away from the user's cursor!
429 * @param {jQuery.Event} event
432 onMouseenter(event) {
433 clearTimeout(this.timer);
437 * Builds the entity toolbar HTML; attaches to DOM; sets starting position.
440 * The toolbar element.
444 Drupal.theme('quickeditEntityToolbar', {
445 id: 'quickedit-entity-toolbar',
450 .find('.quickedit-toolbar-entity')
451 // Append the "ops" toolgroup into the toolbar.
453 Drupal.theme('quickeditToolgroup', {
457 label: Drupal.t('Save'),
459 classes: 'action-save quickedit-button icon',
465 label: Drupal.t('Close'),
467 'action-cancel quickedit-button icon icon-close icon-only',
473 // Give the toolbar a sensible starting position so that it doesn't
474 // animate on to the screen from a far off corner.
476 left: this.$entity.offset().left,
477 top: this.$entity.offset().top,
484 * Returns the DOM element that fields will attach their toolbars to.
487 * The DOM element that fields will attach their toolbars to.
490 return this._fieldToolbarRoot;
494 * Generates a state-dependent label for the entity toolbar.
499 const entityLabel = this.model.get('label');
501 // Label of an active field, if it exists.
502 const activeField = Drupal.quickedit.app.model.get('activeField');
503 const activeFieldLabel =
504 activeField && activeField.get('metadata').label;
505 // Label of a highlighted field, if it exists.
506 const highlightedField = Drupal.quickedit.app.model.get(
509 const highlightedFieldLabel =
510 highlightedField && highlightedField.get('metadata').label;
511 // The label is constructed in a priority order.
512 if (activeFieldLabel) {
513 label = Drupal.theme('quickeditEntityToolbarLabel', {
515 fieldLabel: activeFieldLabel,
517 } else if (highlightedFieldLabel) {
518 label = Drupal.theme('quickeditEntityToolbarLabel', {
520 fieldLabel: highlightedFieldLabel,
523 // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
524 label = Drupal.checkPlain(entityLabel);
527 this.$el.find('.quickedit-toolbar-label').html(label);
531 * Adds classes to a toolgroup.
533 * @param {string} toolgroup
535 * @param {string} classes
536 * A string of space-delimited class names that will be applied to the
537 * wrapping element of the toolbar group.
539 addClass(toolgroup, classes) {
540 this._find(toolgroup).addClass(classes);
544 * Removes classes from a toolgroup.
546 * @param {string} toolgroup
548 * @param {string} classes
549 * A string of space-delimited class names that will be removed from the
550 * wrapping element of the toolbar group.
552 removeClass(toolgroup, classes) {
553 this._find(toolgroup).removeClass(classes);
559 * @param {string} toolgroup
563 * The toolgroup DOM element.
566 return this.$el.find(
567 `.quickedit-toolbar .quickedit-toolgroup.${toolgroup}`,
574 * @param {string} toolgroup
578 this.$el.removeClass('quickedit-animate-invisible');
582 })(jQuery, _, Backbone, Drupal, Drupal.debounce);