--- /dev/null
+/**
+ * @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:
+ var fieldModels = this.model.get('fields').models;
+ var topMostPosition = 1000000;
+ var 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));