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