refreshless-8.x-1.x-dev/components/page-transition/page-transition.js

components/page-transition/page-transition.js
/**
 * @file
 * RefreshLess page transitions.
 */
(function(html, Drupal, $) {

  'use strict';

  // @todo Remove when SDC additive aggregation is fixed.
  //
  // @see https://www.drupal.org/project/refreshless/issues/3543409
  if (Drupal?.RefreshLess?.pageTransition) {
    return;
  }

  /**
   * Property name on the overlay element where class names are stored.
   *
   * @type {String}
   */
  const classesPropName = 'refreshlessPageTransitionOverlayClasses';

  /**
   * Our event namespace.
   *
   * @type {String}
   *
   * @see https://learn.jquery.com/events/event-basics/#namespacing-events
   */
  const eventNamespace = 'refreshless-page-transition-overlay';

  Drupal.theme.refreshlessPageTransitionOverlay = (
    elementType   = 'div',
    elementClass  = 'refreshless-page-transition-overlay',
  ) => {

    const $element = $(
      `<${elementType} class="${elementClass}"></${elementType}`,
    );

    // Save the classes to a known property for the transition class to use.
    //
    // @todo What about just creating a method on the element to set active or
    //  inactive so knowledge of these classes isn't required?
    $element.prop(classesPropName, {
      base:   elementClass,
      active: `${elementClass}--active`,
    });

    return $element[0];

  }

  /**
   * The maximum amount of time in milliseconds the transition may take.
   *
   * This is the failsafe timeout to ensure rendering continues if this time
   * has passed without the transition finishing. Defensive programming and all
   * that.
   *
   * @type {Number}
   *
   * @todo Expose this as a configurable value.
   */
  const failsafeTimeout = 500;

  /**
   * Name of the root element attribute that we write the current state to.
   *
   * This is primarily intended for styling and is only one way; changing it
   * doesn't affect the behaviour of the overlay itself.
   *
   * @type {String}
   */
  const transitionStateAttrName = 'data-refreshless-page-transition-state';

  /**
   * Represents a RefreshLess page transition sequence.
   */
  class TransitionSequence {

    /**
     * The sequence order for transition states.
     *
     * @type {Set}
     */
    #sequence = new Set([
      'revealed',
      'hiding',
      'hidden',
      'revealing',
    ]);

    /**
     * The current state.
     *
     * @type {String}
     */
    #current;

    /**
     * The current iterable from this.#sequence.
     *
     * @type {Iterator}
     */
    #iterable;

    /**
     * A function to be called if this.assertCurrent() fails.
     *
     * @type {Function}
     */
    #logError;

    constructor(logError) {

      this.#logError = logError;

      this.restart();

    }

    /**
     * Advance the sequence to the next state, restarting if necessary.
     *
     * @return {this}
     *   The current instance for chaining.
     */
    advance() {

      const value = this.#iterable.next().value;

      if (typeof value !== 'undefined') {

        this.#current = value;

        return this;

      }

      this.#restart();

      this.#current = this.#iterable.next().value;

      return this;

    }

    /**
     * Restart the sequence.
     *
     * This is separate from the public this.restart() method to avoid infinite
     * recursion when we call it in this.advance().
     *
     * @return {this}
     *   The current instance for chaining.
     */
    #restart() {

      // Note that built-in iterators don't allow for restarting once consumed,
      // so we have to create a new one.
      this.#iterable = this.#sequence[Symbol.iterator]();

      return this;

    }

    /**
     * Restart the sequence.
     *
     * This is a wrapper around and this.#restart() and this.advance().
     *
     * @return {this}
     *   The current instance for chaining.
     */
    restart() {
      return this.#restart().advance();
    }

    /**
     * Get the current sequence state.
     *
     * @return {String}
     *
     * @see this.#sequence
     *   Lists available state values.
     */
    get current() {
      return this.#current;
    }

    /**
     * Assert that the current state matches an expected state.
     *
     * @param {String} expected
     *   The expected state to assert against.
     *
     * @return {this}
     *   The current instance for chaining.
     *
     * @see this.#sequence
     *   Lists available state values.
     */
    assertCurrent(expected) {

      if (this.#current === expected) {
        return this;
      }

      this.#logError({
          message:  'Expected state "%s" but found "%s"!',
          expected: expected,
          current:  this.#current,
        }
      );

      return this;

    }

    /**
     * Determine if the current state is in the process of revealing.
     *
     * This means that the page is currently transitioning into view but has not
     * finished transitioning in.
     *
     * @return {Boolean}
     */
    isRevealing() {
      return this.#current === 'revealing';
    }

    /**
     * Determione if the current state is fully revealed.
     *
     * This means that the page is visible and that it's not in the process of
     * transitioning in nor out.
     *
     * @return {Boolean}
     */
    isRevealed() {
      return this.#current === 'revealed';
    }

    /**
     * Determine if the current state is revealing or revealed.
     *
     * @return {Boolean}
     */
    isRevealingOrRevealed() {
      return (this.isRevealing() || this.isRevealed());
    }

    /**
     * Determine if the current state is in the process of hiding.
     *
     * This means that the page is currently transitioning out of to view but
     * has not finished transitioning out.
     *
     * @return {Boolean}
     */
    isHiding() {
      return this.#current === 'hiding';
    }

    /**
     * Determione if the current state is fully hidden.
     *
     * This means that the page is not visible visible and that it's not in
     * the process of transitioning in nor out.
     *
     * @return {Boolean}
     */
    isHidden() {
      return this.#current === 'hidden';
    }

    /**
     * Determine if the current state is hiding or hidden.
     *
     * @return {Boolean}
     */
    isHidingOrHidden() {
      return (this.isHiding() || this.isHidden());
    }

  }

  /**
   * Represents the RefreshLess page transition overlay.
   */
  class TransitionOverlay {

    /**
     * The overlay element wrapped in a jQuery collection.
     *
     * @type {jQuery}
     */
    #$overlay;

    /**
     * The root (<html>) element wrapped in a jQuery collection.
     *
     * @type {jQuery}
     */
    #$root;

    /**
     * RefreshLess transition sequence instance.
     *
     * @type {TransitionSequence}
     */
    #sequence;

    /**
     * A resolve function from 'refreshless:before-render' event.detail.delay().
     *
     * This is set to undefined when the transition out is finished or the
     * failsafe is triggered.
     *
     * @type {undefined|Function}
     */
    #resolveTransitionOut;

    constructor(root) {

      this.#$root = $(root);

      this.#$overlay = $(Drupal.theme('refreshlessPageTransitionOverlay'));

      this.#bindEventHandlers();

      this.#sequence = new TransitionSequence((detail) => {
        this.#assertError(detail);
      });

      // Explicitly set the state to 'initial' so that any CSS page reveal
      // animations not interrupted, allowing style rules to differentiate
      // between a full page load and a RefreshLess transition.
      this.#updateRootAttribute('initial');

    }

    #assertError(detail) {

      const event = new CustomEvent(
        'refreshless:page-transition-state-error', {detail: detail},
      );

      this.#$root[0].dispatchEvent(event);

    }

    /**
     * Destroy this instance.
     */
    destroy() {

      this.#unbindEventHandlers();

      this.#$overlay.remove();

      this.#$root.removeAttr(transitionStateAttrName);

    }

    /**
     * Bind all of our event handlers.
     *
     * @see this~#unbindEventHandlers()
     */
    #bindEventHandlers() {

      this.#$root.on({
        [`refreshless:before-render.${eventNamespace}`]: async (event) => {
          await this.#beforeRenderHandler(event);
        },
        [`refreshless:render.${eventNamespace}`]: async (event) => {
          await this.#renderHandler(event);
        },
        [`refreshless:load.${eventNamespace}`]: async (event) => {
          await this.#loadHandler(event);
        },
      });

      this.#$overlay.on({
        [`transitionend.${eventNamespace}`]: async (event) => {
          await this.#transitionEndHandler(event);
        },
        [`transitioncancel.${eventNamespace}`]: async (event) => {
          await this.#transitionCancelHandler(event);
        },
      });

    }

    /**
     * Unbind all of our event handlers.
     *
     * @see this~#bindEventHandlers()
     */
    #unbindEventHandlers() {

      this.#$root.add(this.#$overlay).off(`.${eventNamespace}`);

    }

    /**
     * Update the root attribute with the current state.
     *
     * @param {String} state
     *   The state string to use as the attribute value. If not provided, will
     *   use the current sequence state. This is only used to set the 'initial'
     *   state on construct.
     */
    #updateRootAttribute(state) {
      this.#$root.attr(
        transitionStateAttrName, state ?? this.#sequence.current,
      );
    }

    /**
     * 'refreshless:before-render' event handler.
     *
     * @param {jQuery.Event} event
     */
    async #beforeRenderHandler(event) {

      // If this is a fresh page that replaced a cached preview, do nothing
      // because the page will have already been transitioned in when the
      // preview was rendered.
      if (event.detail.isFreshReplacingPreview === true) {
        return;
      }

      await event.detail.delay(async (resolve, reject) => {

        this.#resolveTransitionOut = resolve;

        // This acts as a failsafe to resolve the delay if too much time has
        // passed if our transitionend event handler does not resolve in a
        // reasonable amount of time (or at all), the next page still renders,
        // even if a bit less smoothly.
        setTimeout(async () => {

          if (this.#sequence.isRevealingOrRevealed() === true) {
            return;
          }

          // Resolve here explicitly using the function instead of relying on
          // this.#endTransitionOut() in case the reference to resolve() got
          // out of sync or there's some other error.
          resolve();

          // Also ensure that the stored reference is resolved in case it's out
          // of sync.
          if (typeof this.#resolveTransitionOut === 'function') {

            this.#resolveTransitionOut();

            this.#resolveTransitionOut = undefined;

          }

          const event = new CustomEvent(
            'refreshless:page-transition-failsafe', {},
          );

          this.#$root[0].dispatchEvent(event);

          await this.#endTransitionOut();

          await this.#startTransitionIn();

        }, failsafeTimeout);

        // Insert the overlay and indicate it's active only at this point, so
        // that we don't do it too early and risk it removing a CSS page reveal
        // animation.
        this.#$overlay.insertBefore(this.#$root.find('body'));

        await this.#startTransitionOut();

      });

    }

    /**
     * 'refreshless:render' event handler.
     *
     * @param {jQuery.Event} event
     */
    async #renderHandler(event) {

      if (event.detail.isPreview === false) {
        return;
      }

      await this.#loadHandler();

    }

    /**
     * 'refreshless:load' event handler.
     *
     * @param {jQuery.Event} event
     */
    async #loadHandler(event) {

      if (this.#sequence.isRevealingOrRevealed() === true) {
        return;
      }

      await this.#startTransitionIn();

    }

    /**
     * Overlay 'transitionend' event handler.
     *
     * @param {jQuery.Event} event
     */
    async #transitionEndHandler(event) {

      if (event.originalEvent.propertyName !== 'opacity') {
        return;
      }

      const opacity = this.$overlay.css('opacity');

      if (opacity === '1') {
        await this.#endTransitionOut();
      } else {
        await this.#endTransitionIn();
      }

    }

    /**
     * Overlay 'transitioncancel' event handler.
     *
     * @param {jQuery.Event} event
     */
    async #transitionCancelHandler(event) {

      if (event.originalEvent.propertyName !== 'opacity') {
        return;
      }

      if (this.#sequence.isRevealing() === true) {
        await this.#endTransitionIn();
      }

      if (this.#sequence.isHiding() === true) {
        await this.#endTransitionOut();
      }

    }

    /**
     * Start revealing.
     */
    async #startTransitionIn() {

      if (this.#sequence.isRevealing() === true) {
        await this.#endTransitionIn();
      }

      // We want to advance the sequence and assert the expected value as soon
      // as possible without delaying it. This ensures that any checks for the
      // current state are accurate if we get a race condition as can sometimes
      // occur with preloading or rapid clicking.
      this.#sequence.advance().assertCurrent('revealing');

      // Let any rendering/layout/etc. settle for a frame before proceeding.
      await new Promise(requestAnimationFrame);
      await new Promise(requestAnimationFrame);

      this.#$overlay.removeClass(this.#$overlay.prop(classesPropName).active);

      await this.#updateRootAttribute();

      const event = new CustomEvent(
        'refreshless:page-revealing', {},
      );

      this.#$root[0].dispatchEvent(event);

    }

    /**
     * Finish revealing in if currently in the process of revealing.
     */
    async #endTransitionIn() {

      if (this.#sequence.isRevealing() !== true) {
        return;
      }

      this.#sequence.advance().assertCurrent('revealed');

      await this.#updateRootAttribute();

      const event = new CustomEvent(
        'refreshless:page-revealed', {},
      );

      this.#$root[0].dispatchEvent(event);

    }

    /**
     * Start hiding if not already hiding or hidden.
     */
    async #startTransitionOut() {

      if (this.#sequence.isHidingOrHidden() === true) {
        return;
      }

      if (this.#sequence.isRevealing() === true) {
        await this.#endTransitionIn();
      }

      // Let any rendering/layout/etc. settle for a frame before proceeding.
      await new Promise(requestAnimationFrame);
      await new Promise(requestAnimationFrame);

      this.#$overlay.addClass(this.#$overlay.prop(classesPropName).active);

      this.#sequence.restart().advance().assertCurrent('hiding');

      await this.#updateRootAttribute();

      const event = new CustomEvent(
        'refreshless:page-hiding', {},
      );

      this.#$root[0].dispatchEvent(event);

    }

    /**
     * Finishing hiding.
     */
    async #endTransitionOut() {

      if (this.#sequence.isHiding() !== true) {
        return;
      }

      this.#sequence.advance().assertCurrent('hidden');

      await this.#updateRootAttribute();

      const event = new CustomEvent(
        'refreshless:page-hidden', {},
      );

      this.#$root[0].dispatchEvent(event);

      if (typeof this.#resolveTransitionOut !== 'function') {
        return;
      }

      this.#resolveTransitionOut();

      this.#resolveTransitionOut = undefined;

    }

    /**
     * Get the overlay element jQuery collection.
     *
     * @return {jQuery}
     */
    get $overlay() {
      return this.#$overlay;
    }

    /**
     * Get the sequence class instance.
     *
     * @return {TransitionSequence}
     */
    get sequence() {
      return this.#sequence;
    }

  }

  // Merge both the class and an instance into the existing Drupal global.
  $.extend(true, Drupal, {RefreshLess: {
    classes: {
      PageTransitionOverlay:  TransitionOverlay,
      PageTransitionSequence: TransitionSequence,
    },
    pageTransition: new TransitionOverlay(html),
  }});

})(document.documentElement, Drupal, jQuery);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc