/** * @file * A Backbone View that provides an entity level toolbar. */ (function ($, _, Backbone, Drupal, debounce) { Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{ /** * @type {jQuery} */ _fieldToolbarRoot: null, /** * @return {object} * A map of events. */ events() { const map = { 'click button.action-save': 'onClickSave', 'click button.action-cancel': 'onClickCancel', mouseenter: 'onMouseenter', }; return map; }, /** * @constructs * * @augments Backbone.View * * @param {object} options * Options to construct the view. * @param {Drupal.quickedit.AppModel} options.appModel * A quickedit `AppModel` to use in the view. */ initialize(options) { const that = this; this.appModel = options.appModel; this.$entity = $(this.model.get('el')); // Rerender whenever the entity state changes. this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render); // Also rerender whenever a different field is highlighted or activated. this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render); // Rerender when a field of the entity changes state. this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange); // Reposition the entity toolbar as the viewport and the position within // the viewport changes. $(window).on('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150)); // Adjust the fence placement within which the entity toolbar may be // positioned. $(document).on('drupalViewportOffsetChange.quickedit', (event, offsets) => { if (that.$fence) { that.$fence.css(offsets); } }); // Set the entity toolbar DOM element as the el for this view. const $toolbar = this.buildToolbarEl(); this.setElement($toolbar); this._fieldToolbarRoot = $toolbar.find('.quickedit-toolbar-field').get(0); // Initial render. this.render(); }, /** * @inheritdoc * * @return {Drupal.quickedit.EntityToolbarView} * The entity toolbar view. */ render() { if (this.model.get('isActive')) { // If the toolbar container doesn't exist, create it. const $body = $('body'); if ($body.children('#quickedit-entity-toolbar').length === 0) { $body.append(this.$el); } // The fence will define a area on the screen that the entity toolbar // will be position within. if ($body.children('#quickedit-toolbar-fence').length === 0) { this.$fence = $(Drupal.theme('quickeditEntityToolbarFence')) .css(Drupal.displace()) .appendTo($body); } // Adds the entity title to the toolbar. this.label(); // Show the save and cancel buttons. this.show('ops'); // If render is being called and the toolbar is already visible, just // reposition it. this.position(); } // The save button text and state varies with the state of the entity // model. const $button = this.$el.find('.quickedit-button.action-save'); const isDirty = this.model.get('isDirty'); // Adjust the save button according to the state of the model. switch (this.model.get('state')) { // Quick editing is active, but no field is being edited. case 'opened': // The saving throbber is not managed by AJAX system. The // EntityToolbarView manages this visual element. $button .removeClass('action-saving icon-throbber icon-end') .text(Drupal.t('Save')) .removeAttr('disabled') .attr('aria-hidden', !isDirty); break; // The changes to the fields of the entity are being committed. case 'committing': $button .addClass('action-saving icon-throbber icon-end') .text(Drupal.t('Saving')) .attr('disabled', 'disabled'); break; default: $button.attr('aria-hidden', true); break; } return this; }, /** * @inheritdoc */ remove() { // Remove additional DOM elements controlled by this View. this.$fence.remove(); // Stop listening to additional events. $(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit'); $(document).off('drupalViewportOffsetChange.quickedit'); Backbone.View.prototype.remove.call(this); }, /** * Repositions the entity toolbar on window scroll and resize. * * @param {jQuery.Event} event * The scroll or resize event. */ windowChangeHandler(event) { this.position(); }, /** * Determines the actions to take given a change of state. * * @param {Drupal.quickedit.FieldModel} model * The `FieldModel` model. * @param {string} state * The state of the associated field. One of * {@link Drupal.quickedit.FieldModel.states}. */ fieldStateChange(model, state) { switch (state) { case 'active': this.render(); break; case 'invalid': this.render(); break; } }, /** * Uses the jQuery.ui.position() method to position the entity toolbar. * * @param {HTMLElement} [element] * The element against which the entity toolbar is positioned. */ position(element) { clearTimeout(this.timer); const that = this; // Vary the edge of the positioning according to the direction of language // in the document. const edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left'; // A time unit to wait until the entity toolbar is repositioned. let delay = 0; // Determines what check in the series of checks below should be // evaluated. let check = 0; // When positioned against an active field that has padding, we should // ignore that padding when positioning the toolbar, to not unnecessarily // move the toolbar horizontally, which feels annoying. let horizontalPadding = 0; let of; let activeField; let highlightedField; // There are several elements in the page that the entity toolbar might be // positioned against. They are considered below in a priority order. do { switch (check) { case 0: // Position against a specific element. of = element; break; case 1: // Position against a form container. activeField = Drupal.quickedit.app.model.get('activeField'); of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form'); break; case 2: // Position against an active field. of = activeField && activeField.editorView && activeField.editorView.getEditedElement(); if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) { horizontalPadding = 5; } break; case 3: // Position against a highlighted field. highlightedField = Drupal.quickedit.app.model.get('highlightedField'); of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement(); delay = 250; break; default: { const fieldModels = this.model.get('fields').models; let topMostPosition = 1000000; let topMostField = null; // Position against the topmost field. for (let i = 0; i < fieldModels.length; i++) { const pos = fieldModels[i].get('el').getBoundingClientRect().top; if (pos < topMostPosition) { topMostPosition = pos; topMostField = fieldModels[i]; } } of = topMostField.get('el'); delay = 50; break; } } // Prepare to check the next possible element to position against. check++; } while (!of); /** * Refines the positioning algorithm of jquery.ui.position(). * * Invoked as the 'using' callback of jquery.ui.position() in * positionToolbar(). * * @param {*} view * The view the positions will be calculated from. * @param {object} suggested * A hash of top and left values for the position that should be set. It * can be forwarded to .css() or .animate(). * @param {object} info * The position and dimensions of both the 'my' element and the 'of' * elements, as well as calculations to their relative position. This * object contains the following properties: * @param {object} info.element * A hash that contains information about the HTML element that will be * positioned. Also known as the 'my' element. * @param {object} info.target * A hash that contains information about the HTML element that the * 'my' element will be positioned against. Also known as the 'of' * element. */ function refinePosition(view, suggested, info) { // Determine if the pointer should be on the top or bottom. const isBelow = suggested.top > info.target.top; info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow); // Don't position the toolbar past the first or last editable field if // the entity is the target. if (view.$entity[0] === info.target.element[0]) { // Get the first or last field according to whether the toolbar is // above or below the entity. const $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0); if ($field.length > 0) { suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true); } } // Don't let the toolbar go outside the fence. const fenceTop = view.$fence.offset().top; const fenceHeight = view.$fence.height(); const toolbarHeight = info.element.element.outerHeight(true); if (suggested.top < fenceTop) { suggested.top = fenceTop; } else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) { suggested.top = (fenceTop + fenceHeight) - toolbarHeight; } // Position the toolbar. info.element.element.css({ left: Math.floor(suggested.left), top: Math.floor(suggested.top), }); } /** * Calls the jquery.ui.position() method on the $el of this view. */ function positionToolbar() { that.$el .position({ my: `${edge} bottom`, // Move the toolbar 1px towards the start edge of the 'of' element, // plus any horizontal padding that may have been added to the // element that is being added, to prevent unwanted horizontal // movement. at: `${edge}+${1 + horizontalPadding} top`, of, collision: 'flipfit', using: refinePosition.bind(null, that), within: that.$fence, }) // Resize the toolbar to match the dimensions of the field, up to a // maximum width that is equal to 90% of the field's width. .css({ 'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450, // Set a minimum width of 240px for the entity toolbar, or the width // of the client if it is less than 240px, so that the toolbar // never folds up into a squashed and jumbled mess. 'min-width': (document.documentElement.clientWidth < 240) ? document.documentElement.clientWidth : 240, width: '100%', }); } // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar // only after the user has focused on an editable for 250ms. This prevents // the toolbar from jumping around the screen. this.timer = setTimeout(() => { // Render the position in the next execution cycle, so that animations // on the field have time to process. This is not strictly speaking, a // guarantee that all animations will be finished, but it's a simple // way to get better positioning without too much additional code. _.defer(positionToolbar); }, delay); }, /** * Set the model state to 'saving' when the save button is clicked. * * @param {jQuery.Event} event * The click event. */ onClickSave(event) { event.stopPropagation(); event.preventDefault(); // Save the model. this.model.set('state', 'committing'); }, /** * Sets the model state to candidate when the cancel button is clicked. * * @param {jQuery.Event} event * The click event. */ onClickCancel(event) { event.preventDefault(); this.model.set('state', 'deactivating'); }, /** * Clears the timeout that will eventually reposition the entity toolbar. * * Without this, it may reposition itself, away from the user's cursor! * * @param {jQuery.Event} event * The mouse event. */ onMouseenter(event) { clearTimeout(this.timer); }, /** * Builds the entity toolbar HTML; attaches to DOM; sets starting position. * * @return {jQuery} * The toolbar element. */ buildToolbarEl() { const $toolbar = $(Drupal.theme('quickeditEntityToolbar', { id: 'quickedit-entity-toolbar', })); $toolbar .find('.quickedit-toolbar-entity') // Append the "ops" toolgroup into the toolbar. .prepend(Drupal.theme('quickeditToolgroup', { classes: ['ops'], buttons: [ { label: Drupal.t('Save'), type: 'submit', classes: 'action-save quickedit-button icon', attributes: { 'aria-hidden': true, }, }, { label: Drupal.t('Close'), classes: 'action-cancel quickedit-button icon icon-close icon-only', }, ], })); // Give the toolbar a sensible starting position so that it doesn't // animate on to the screen from a far off corner. $toolbar .css({ left: this.$entity.offset().left, top: this.$entity.offset().top, }); return $toolbar; }, /** * Returns the DOM element that fields will attach their toolbars to. * * @return {jQuery} * The DOM element that fields will attach their toolbars to. */ getToolbarRoot() { return this._fieldToolbarRoot; }, /** * Generates a state-dependent label for the entity toolbar. */ label() { // The entity label. let label = ''; const entityLabel = this.model.get('label'); // Label of an active field, if it exists. const activeField = Drupal.quickedit.app.model.get('activeField'); const activeFieldLabel = activeField && activeField.get('metadata').label; // Label of a highlighted field, if it exists. const highlightedField = Drupal.quickedit.app.model.get('highlightedField'); const highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label; // The label is constructed in a priority order. if (activeFieldLabel) { label = Drupal.theme('quickeditEntityToolbarLabel', { entityLabel, fieldLabel: activeFieldLabel, }); } else if (highlightedFieldLabel) { label = Drupal.theme('quickeditEntityToolbarLabel', { entityLabel, fieldLabel: highlightedFieldLabel, }); } else { // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437 label = Drupal.checkPlain(entityLabel); } this.$el .find('.quickedit-toolbar-label') .html(label); }, /** * Adds classes to a toolgroup. * * @param {string} toolgroup * A toolgroup name. * @param {string} classes * A string of space-delimited class names that will be applied to the * wrapping element of the toolbar group. */ addClass(toolgroup, classes) { this._find(toolgroup).addClass(classes); }, /** * Removes classes from a toolgroup. * * @param {string} toolgroup * A toolgroup name. * @param {string} classes * A string of space-delimited class names that will be removed from the * wrapping element of the toolbar group. */ removeClass(toolgroup, classes) { this._find(toolgroup).removeClass(classes); }, /** * Finds a toolgroup. * * @param {string} toolgroup * A toolgroup name. * * @return {jQuery} * The toolgroup DOM element. */ _find(toolgroup) { return this.$el.find(`.quickedit-toolbar .quickedit-toolgroup.${toolgroup}`); }, /** * Shows a toolgroup. * * @param {string} toolgroup * A toolgroup name. */ show(toolgroup) { this.$el.removeClass('quickedit-animate-invisible'); }, }); }(jQuery, _, Backbone, Drupal, Drupal.debounce));