538 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			538 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/**
 | 
						|
 * @file
 | 
						|
 * Attaches behaviors for the Contextual module.
 | 
						|
 */
 | 
						|
 | 
						|
(function ($, Drupal, drupalSettings, JSON, storage) {
 | 
						|
  const options = $.extend(
 | 
						|
    drupalSettings.contextual,
 | 
						|
    // Merge strings on top of drupalSettings so that they are not mutable.
 | 
						|
    {
 | 
						|
      strings: {
 | 
						|
        open: Drupal.t('Open'),
 | 
						|
        close: Drupal.t('Close'),
 | 
						|
      },
 | 
						|
    },
 | 
						|
  );
 | 
						|
  // Clear the cached contextual links whenever the current user's set of
 | 
						|
  // permissions changes.
 | 
						|
  const cachedPermissionsHash = storage.getItem(
 | 
						|
    'Drupal.contextual.permissionsHash',
 | 
						|
  );
 | 
						|
  const { permissionsHash } = drupalSettings.user;
 | 
						|
  if (cachedPermissionsHash !== permissionsHash) {
 | 
						|
    if (typeof permissionsHash === 'string') {
 | 
						|
      Object.keys(storage).forEach((key) => {
 | 
						|
        if (key.startsWith('Drupal.contextual.')) {
 | 
						|
          storage.removeItem(key);
 | 
						|
        }
 | 
						|
      });
 | 
						|
    }
 | 
						|
    storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Determines if a contextual link is nested & overlapping, if so: adjusts it.
 | 
						|
   *
 | 
						|
   * This only deals with two levels of nesting; deeper levels are not touched.
 | 
						|
   *
 | 
						|
   * @param {jQuery} $contextual
 | 
						|
   *   A contextual links placeholder DOM element, containing the actual
 | 
						|
   *   contextual links as rendered by the server.
 | 
						|
   */
 | 
						|
  function adjustIfNestedAndOverlapping($contextual) {
 | 
						|
    const $contextuals = $contextual
 | 
						|
      // @todo confirm that .closest() is not sufficient
 | 
						|
      .parents('.contextual-region')
 | 
						|
      .eq(-1)
 | 
						|
      .find('.contextual');
 | 
						|
 | 
						|
    // Early-return when there's no nesting.
 | 
						|
    if ($contextuals.length <= 1) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // If the two contextual links overlap, then we move the second one.
 | 
						|
    const firstTop = $contextuals.eq(0).offset().top;
 | 
						|
    const secondTop = $contextuals.eq(1).offset().top;
 | 
						|
    if (firstTop === secondTop) {
 | 
						|
      const $nestedContextual = $contextuals.eq(1);
 | 
						|
 | 
						|
      // Retrieve height of nested contextual link.
 | 
						|
      let height = 0;
 | 
						|
      const $trigger = $nestedContextual.find('.trigger');
 | 
						|
      // Elements with the .visually-hidden class have no dimensions, so this
 | 
						|
      // class must be temporarily removed to the calculate the height.
 | 
						|
      $trigger.removeClass('visually-hidden');
 | 
						|
      height = $nestedContextual.height();
 | 
						|
      $trigger.addClass('visually-hidden');
 | 
						|
 | 
						|
      // Adjust nested contextual link's position.
 | 
						|
      $nestedContextual[0].style.top =
 | 
						|
        $nestedContextual.position().top + height;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Initializes a contextual link: updates its DOM, sets up model and views.
 | 
						|
   *
 | 
						|
   * @param {jQuery} $contextual
 | 
						|
   *   A contextual links placeholder DOM element, containing the actual
 | 
						|
   *   contextual links as rendered by the server.
 | 
						|
   * @param {string} html
 | 
						|
   *   The server-side rendered HTML for this contextual link.
 | 
						|
   */
 | 
						|
  function initContextual($contextual, html) {
 | 
						|
    const $region = $contextual.closest('.contextual-region');
 | 
						|
    const { contextual } = Drupal;
 | 
						|
 | 
						|
    $contextual
 | 
						|
      // Update the placeholder to contain its rendered contextual links.
 | 
						|
      .html(html)
 | 
						|
      // Use the placeholder as a wrapper with a specific class to provide
 | 
						|
      // positioning and behavior attachment context.
 | 
						|
      .addClass('contextual')
 | 
						|
      // Ensure a trigger element exists before the actual contextual links.
 | 
						|
      .prepend(Drupal.theme('contextualTrigger'));
 | 
						|
 | 
						|
    // Set the destination parameter on each of the contextual links.
 | 
						|
    const destination = `destination=${Drupal.encodePath(
 | 
						|
      Drupal.url(drupalSettings.path.currentPath + window.location.search),
 | 
						|
    )}`;
 | 
						|
    $contextual.find('.contextual-links a').each(function () {
 | 
						|
      const url = this.getAttribute('href');
 | 
						|
      const glue = url.includes('?') ? '&' : '?';
 | 
						|
      this.setAttribute('href', url + glue + destination);
 | 
						|
    });
 | 
						|
    let title = '';
 | 
						|
    const $regionHeading = $region.find('h2');
 | 
						|
    if ($regionHeading.length) {
 | 
						|
      title = $regionHeading[0].textContent.trim();
 | 
						|
    }
 | 
						|
    options.title = title;
 | 
						|
    const contextualModelView = new Drupal.contextual.ContextualModelView(
 | 
						|
      $contextual,
 | 
						|
      $region,
 | 
						|
      options,
 | 
						|
    );
 | 
						|
    contextual.instances.push(contextualModelView);
 | 
						|
    // Fix visual collisions between contextual link triggers.
 | 
						|
    adjustIfNestedAndOverlapping($contextual);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Attaches outline behavior for regions associated with contextual links.
 | 
						|
   *
 | 
						|
   * Events
 | 
						|
   *   Contextual triggers an event that can be used by other scripts.
 | 
						|
   *   - drupalContextualLinkAdded: Triggered when a contextual link is added.
 | 
						|
   *
 | 
						|
   * @type {Drupal~behavior}
 | 
						|
   *
 | 
						|
   * @prop {Drupal~behaviorAttach} attach
 | 
						|
   *  Attaches the outline behavior to the right context.
 | 
						|
   */
 | 
						|
  Drupal.behaviors.contextual = {
 | 
						|
    attach(context) {
 | 
						|
      const $context = $(context);
 | 
						|
 | 
						|
      // Find all contextual links placeholders, if any.
 | 
						|
      let $placeholders = $(
 | 
						|
        once('contextual-render', '[data-contextual-id]', context),
 | 
						|
      );
 | 
						|
      if ($placeholders.length === 0) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      // Collect the IDs for all contextual links placeholders.
 | 
						|
      const ids = [];
 | 
						|
      $placeholders.each(function () {
 | 
						|
        ids.push({
 | 
						|
          id: $(this).attr('data-contextual-id'),
 | 
						|
          token: $(this).attr('data-contextual-token'),
 | 
						|
        });
 | 
						|
      });
 | 
						|
 | 
						|
      const uncachedIDs = [];
 | 
						|
      const uncachedTokens = [];
 | 
						|
      ids.forEach((contextualID) => {
 | 
						|
        const html = storage.getItem(`Drupal.contextual.${contextualID.id}`);
 | 
						|
        if (html?.length) {
 | 
						|
          // Initialize after the current execution cycle, to make the AJAX
 | 
						|
          // request for retrieving the uncached contextual links as soon as
 | 
						|
          // possible, but also to ensure that other Drupal behaviors have had
 | 
						|
          // the chance to set up an event listener on the collection
 | 
						|
          // Drupal.contextual.collection.
 | 
						|
          window.setTimeout(() => {
 | 
						|
            initContextual(
 | 
						|
              $context
 | 
						|
                .find(`[data-contextual-id="${contextualID.id}"]:empty`)
 | 
						|
                .eq(0),
 | 
						|
              html,
 | 
						|
            );
 | 
						|
          });
 | 
						|
          return;
 | 
						|
        }
 | 
						|
        uncachedIDs.push(contextualID.id);
 | 
						|
        uncachedTokens.push(contextualID.token);
 | 
						|
      });
 | 
						|
 | 
						|
      // Perform an AJAX request to let the server render the contextual links
 | 
						|
      // for each of the placeholders.
 | 
						|
      if (uncachedIDs.length > 0) {
 | 
						|
        $.ajax({
 | 
						|
          url: Drupal.url('contextual/render'),
 | 
						|
          type: 'POST',
 | 
						|
          data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
 | 
						|
          dataType: 'json',
 | 
						|
          success(results) {
 | 
						|
            Object.entries(results).forEach(([contextualID, html]) => {
 | 
						|
              // Store the metadata.
 | 
						|
              storage.setItem(`Drupal.contextual.${contextualID}`, html);
 | 
						|
              // If the rendered contextual links are empty, then the current
 | 
						|
              // user does not have permission to access the associated links:
 | 
						|
              // don't render anything.
 | 
						|
              if (html.length > 0) {
 | 
						|
                // Update the placeholders to contain its rendered contextual
 | 
						|
                // links. Usually there will only be one placeholder, but it's
 | 
						|
                // possible for multiple identical placeholders exist on the
 | 
						|
                // page (probably because the same content appears more than
 | 
						|
                // once).
 | 
						|
                $placeholders = $context.find(
 | 
						|
                  `[data-contextual-id="${contextualID}"]`,
 | 
						|
                );
 | 
						|
 | 
						|
                // Initialize the contextual links.
 | 
						|
                for (let i = 0; i < $placeholders.length; i++) {
 | 
						|
                  initContextual($placeholders.eq(i), html);
 | 
						|
                }
 | 
						|
              }
 | 
						|
            });
 | 
						|
          },
 | 
						|
        });
 | 
						|
      }
 | 
						|
    },
 | 
						|
  };
 | 
						|
 | 
						|
  /**
 | 
						|
   * Namespace for contextual related functionality.
 | 
						|
   *
 | 
						|
   * @namespace
 | 
						|
   *
 | 
						|
   * @private
 | 
						|
   */
 | 
						|
  Drupal.contextual = {
 | 
						|
    /**
 | 
						|
     * The {@link Drupal.contextual.View} instances associated with each list
 | 
						|
     * element of contextual links.
 | 
						|
     *
 | 
						|
     * @type {Array}
 | 
						|
     *
 | 
						|
     * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
 | 
						|
     *  replacement.
 | 
						|
     */
 | 
						|
    views: [],
 | 
						|
 | 
						|
    /**
 | 
						|
     * The {@link Drupal.contextual.RegionView} instances associated with each
 | 
						|
     * contextual region element.
 | 
						|
     *
 | 
						|
     * @type {Array}
 | 
						|
     *
 | 
						|
     * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
 | 
						|
     *  replacement.
 | 
						|
     */
 | 
						|
    regionViews: [],
 | 
						|
    instances: new Proxy([], {
 | 
						|
      set: function set(obj, prop, value) {
 | 
						|
        obj[prop] = value;
 | 
						|
        window.dispatchEvent(new Event('contextual-instances-added'));
 | 
						|
        return true;
 | 
						|
      },
 | 
						|
      deleteProperty(target, prop) {
 | 
						|
        if (prop in target) {
 | 
						|
          delete target[prop];
 | 
						|
          window.dispatchEvent(new Event('contextual-instances-removed'));
 | 
						|
        }
 | 
						|
      },
 | 
						|
    }),
 | 
						|
 | 
						|
    /**
 | 
						|
     * Models the state of a contextual link's trigger, list & region.
 | 
						|
     */
 | 
						|
    ContextualModelView: class {
 | 
						|
      constructor($contextual, $region, options) {
 | 
						|
        this.title = options.title || '';
 | 
						|
        this.regionIsHovered = false;
 | 
						|
        this._hasFocus = false;
 | 
						|
        this._isOpen = false;
 | 
						|
        this._isLocked = false;
 | 
						|
        this.strings = options.strings;
 | 
						|
        this.timer = NaN;
 | 
						|
        this.modelId = btoa(Math.random()).substring(0, 12);
 | 
						|
        this.$region = $region;
 | 
						|
        this.$contextual = $contextual;
 | 
						|
 | 
						|
        if (!document.body.classList.contains('touchevents')) {
 | 
						|
          this.$region.on({
 | 
						|
            mouseenter: () => {
 | 
						|
              this.regionIsHovered = true;
 | 
						|
            },
 | 
						|
            mouseleave: () => {
 | 
						|
              this.close().blur();
 | 
						|
              this.regionIsHovered = false;
 | 
						|
            },
 | 
						|
            'mouseleave mouseenter': () => this.render(),
 | 
						|
          });
 | 
						|
          this.$contextual.on('mouseenter', () => {
 | 
						|
            this.focus();
 | 
						|
            this.render();
 | 
						|
          });
 | 
						|
        }
 | 
						|
 | 
						|
        this.$contextual.on(
 | 
						|
          {
 | 
						|
            click: () => {
 | 
						|
              this.toggleOpen();
 | 
						|
            },
 | 
						|
            touchend: () => {
 | 
						|
              Drupal.contextual.ContextualModelView.touchEndToClick();
 | 
						|
            },
 | 
						|
            focus: () => {
 | 
						|
              this.focus();
 | 
						|
            },
 | 
						|
            blur: () => {
 | 
						|
              this.blur();
 | 
						|
            },
 | 
						|
            'click blur touchend focus': () => this.render(),
 | 
						|
          },
 | 
						|
          '.trigger',
 | 
						|
        );
 | 
						|
 | 
						|
        this.$contextual.on(
 | 
						|
          {
 | 
						|
            click: () => {
 | 
						|
              this.close().blur();
 | 
						|
            },
 | 
						|
            touchend: (event) => {
 | 
						|
              Drupal.contextual.ContextualModelView.touchEndToClick(event);
 | 
						|
            },
 | 
						|
            focus: () => {
 | 
						|
              this.focus();
 | 
						|
            },
 | 
						|
            blur: () => {
 | 
						|
              this.waitCloseThenBlur();
 | 
						|
            },
 | 
						|
            'click blur touchend focus': () => this.render(),
 | 
						|
          },
 | 
						|
          '.contextual-links a',
 | 
						|
        );
 | 
						|
 | 
						|
        this.render();
 | 
						|
 | 
						|
        // Let other JavaScript react to the adding of a new contextual link.
 | 
						|
        $(document).trigger('drupalContextualLinkAdded', {
 | 
						|
          $el: $contextual,
 | 
						|
          $region,
 | 
						|
          model: this,
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Updates the rendered representation of the current contextual links.
 | 
						|
       */
 | 
						|
      render() {
 | 
						|
        const { isOpen } = this;
 | 
						|
        const isVisible = this.isLocked || this.regionIsHovered || isOpen;
 | 
						|
        this.$region.toggleClass('focus', this.hasFocus);
 | 
						|
        this.$contextual
 | 
						|
          .toggleClass('open', isOpen)
 | 
						|
          // Update the visibility of the trigger.
 | 
						|
          .find('.trigger')
 | 
						|
          .toggleClass('visually-hidden', !isVisible);
 | 
						|
 | 
						|
        this.$contextual.find('.contextual-links').prop('hidden', !isOpen);
 | 
						|
        const trigger = this.$contextual.find('.trigger').get(0);
 | 
						|
        trigger.textContent = Drupal.t('@action @title configuration options', {
 | 
						|
          '@action': !isOpen ? this.strings.open : this.strings.close,
 | 
						|
          '@title': this.title,
 | 
						|
        });
 | 
						|
        trigger.setAttribute('aria-pressed', isOpen);
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Prevents delay and simulated mouse events.
 | 
						|
       *
 | 
						|
       * @param {jQuery.Event} event the touch end event.
 | 
						|
       */
 | 
						|
      static touchEndToClick(event) {
 | 
						|
        event.preventDefault();
 | 
						|
        event.target.click();
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Set up a timeout to allow a user to tab between the trigger and the
 | 
						|
       * contextual links without the menu dismissing.
 | 
						|
       */
 | 
						|
      waitCloseThenBlur() {
 | 
						|
        this.timer = window.setTimeout(() => {
 | 
						|
          this.isOpen = false;
 | 
						|
          this.hasFocus = false;
 | 
						|
          this.render();
 | 
						|
        }, 150);
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Opens or closes the contextual link.
 | 
						|
       *
 | 
						|
       * If it is opened, then also give focus.
 | 
						|
       *
 | 
						|
       * @return {Drupal.contextual.ContextualModelView}
 | 
						|
       *   The current contextual model view.
 | 
						|
       */
 | 
						|
      toggleOpen() {
 | 
						|
        const newIsOpen = !this.isOpen;
 | 
						|
        this.isOpen = newIsOpen;
 | 
						|
        if (newIsOpen) {
 | 
						|
          this.focus();
 | 
						|
        }
 | 
						|
        return this;
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Gives focus to this contextual link.
 | 
						|
       *
 | 
						|
       * Also closes + removes focus from every other contextual link.
 | 
						|
       *
 | 
						|
       * @return {Drupal.contextual.ContextualModelView}
 | 
						|
       *   The current contextual model view.
 | 
						|
       */
 | 
						|
      focus() {
 | 
						|
        const { modelId } = this;
 | 
						|
        Drupal.contextual.instances.forEach((model) => {
 | 
						|
          if (model.modelId !== modelId) {
 | 
						|
            model.close().blur();
 | 
						|
          }
 | 
						|
        });
 | 
						|
        window.clearTimeout(this.timer);
 | 
						|
        this.hasFocus = true;
 | 
						|
        return this;
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Removes focus from this contextual link, unless it is open.
 | 
						|
       *
 | 
						|
       * @return {Drupal.contextual.ContextualModelView}
 | 
						|
       *   The current contextual model view.
 | 
						|
       */
 | 
						|
      blur() {
 | 
						|
        if (!this.isOpen) {
 | 
						|
          this.hasFocus = false;
 | 
						|
        }
 | 
						|
        return this;
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Closes this contextual link.
 | 
						|
       *
 | 
						|
       * Does not call blur() because we want to allow a contextual link to have
 | 
						|
       * focus, yet be closed for example when hovering.
 | 
						|
       *
 | 
						|
       * @return {Drupal.contextual.ContextualModelView}
 | 
						|
       *   The current contextual model view.
 | 
						|
       */
 | 
						|
      close() {
 | 
						|
        this.isOpen = false;
 | 
						|
        return this;
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Gets the current focus state.
 | 
						|
       *
 | 
						|
       * @return {boolean} the focus state.
 | 
						|
       */
 | 
						|
      get hasFocus() {
 | 
						|
        return this._hasFocus;
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Sets the current focus state.
 | 
						|
       *
 | 
						|
       * @param {boolean} value - new focus state
 | 
						|
       */
 | 
						|
      set hasFocus(value) {
 | 
						|
        this._hasFocus = value;
 | 
						|
        this.$region.toggleClass('focus', this._hasFocus);
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Gets the current open state.
 | 
						|
       *
 | 
						|
       * @return {boolean} the open state.
 | 
						|
       */
 | 
						|
      get isOpen() {
 | 
						|
        return this._isOpen;
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Sets the current open state.
 | 
						|
       *
 | 
						|
       * @param {boolean} value - new open state
 | 
						|
       */
 | 
						|
      set isOpen(value) {
 | 
						|
        this._isOpen = value;
 | 
						|
        // Nested contextual region handling: hide any nested contextual triggers.
 | 
						|
        this.$region
 | 
						|
          .closest('.contextual-region')
 | 
						|
          .find('.contextual .trigger:not(:first)')
 | 
						|
          .toggle(!this.isOpen);
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Gets the current locked state.
 | 
						|
       *
 | 
						|
       * @return {boolean} the locked state.
 | 
						|
       */
 | 
						|
      get isLocked() {
 | 
						|
        return this._isLocked;
 | 
						|
      }
 | 
						|
 | 
						|
      /**
 | 
						|
       * Sets the current locked state.
 | 
						|
       *
 | 
						|
       * @param {boolean} value - new locked state
 | 
						|
       */
 | 
						|
      set isLocked(value) {
 | 
						|
        if (value !== this._isLocked) {
 | 
						|
          this._isLocked = value;
 | 
						|
          this.render();
 | 
						|
        }
 | 
						|
      }
 | 
						|
    },
 | 
						|
  };
 | 
						|
 | 
						|
  /**
 | 
						|
   * A trigger is an interactive element often bound to a click handler.
 | 
						|
   *
 | 
						|
   * @return {string}
 | 
						|
   *   A string representing a DOM fragment.
 | 
						|
   */
 | 
						|
  Drupal.theme.contextualTrigger = function () {
 | 
						|
    return '<button class="trigger visually-hidden focusable" type="button"></button>';
 | 
						|
  };
 | 
						|
 | 
						|
  /**
 | 
						|
   * Bind Ajax contextual links when added.
 | 
						|
   *
 | 
						|
   * @param {jQuery.Event} event
 | 
						|
   *   The `drupalContextualLinkAdded` event.
 | 
						|
   * @param {object} data
 | 
						|
   *   An object containing the data relevant to the event.
 | 
						|
   *
 | 
						|
   * @listens event:drupalContextualLinkAdded
 | 
						|
   */
 | 
						|
  $(document).on('drupalContextualLinkAdded', (event, data) => {
 | 
						|
    Drupal.ajax.bindAjaxLinks(data.$el[0]);
 | 
						|
  });
 | 
						|
})(jQuery, Drupal, drupalSettings, window.JSON, window.sessionStorage);
 |