text: import AbstractPlugin from 'shared/AbstractPlugin';
  import {cloneNode} from 'shared/utils';
  
  import {
    MirrorCreateEvent,
    MirrorCreatedEvent,
    MirrorAttachedEvent,
    MirrorMoveEvent,
    MirrorMovedEvent,
    MirrorDestroyEvent,
  } from './MirrorEvent';
  
  export const onDragStart = Symbol('onDragStart');
  export const onDragMove = Symbol('onDragMove');
  export const onDragStop = Symbol('onDragStop');
  export const onMirrorCreated = Symbol('onMirrorCreated');
  export const onMirrorMove = Symbol('onMirrorMove');
  export const onScroll = Symbol('onScroll');
  export const getAppendableContainer = Symbol('getAppendableContainer');
  
  /**
   * Mirror default options
   * @property {Object} defaultOptions
   * @property {Boolean} defaultOptions.constrainDimensions
   * @property {Boolean} defaultOptions.xAxis
   * @property {Boolean} defaultOptions.yAxis
   * @property {null} defaultOptions.cursorOffsetX
   * @property {null} defaultOptions.cursorOffsetY
   * @type {Object}
   */
  export const defaultOptions = {
    constrainDimensions: false,
    xAxis: true,
    yAxis: true,
    cursorOffsetX: null,
    cursorOffsetY: null,
    thresholdX: null,
    thresholdY: null,
  };
  
  /**
   * Mirror plugin which controls the mirror positioning while dragging
   * @class Mirror
   * @module Mirror
   * @extends AbstractPlugin
   */
  export default class Mirror extends AbstractPlugin {
    /**
     * Mirror constructor.
     * @constructs Mirror
     * @param {Draggable} draggable - Draggable instance
     */
    constructor(draggable) {
      super(draggable);
  
      /**
       * Mirror options
       * @property {Object} options
       * @property {Boolean} options.constrainDimensions
       * @property {Boolean} options.xAxis
       * @property {Boolean} options.yAxis
       * @property {Number|null} options.cursorOffsetX
       * @property {Number|null} options.cursorOffsetY
       * @property {String|HTMLElement|Function} options.appendTo
       * @type {Object}
       */
      this.options = {
        ...defaultOptions,
        ...this.getOptions(),
      };
  
      /**
       * Scroll offset for touch devices because the mirror is positioned fixed
       * @property {Object} scrollOffset
       * @property {Number} scrollOffset.x
       * @property {Number} scrollOffset.y
       */
      this.scrollOffset = {x: 0, y: 0};
  
      /**
       * Initial scroll offset for touch devices because the mirror is positioned fixed
       * @property {Object} scrollOffset
       * @property {Number} scrollOffset.x
       * @property {Number} scrollOffset.y
       */
      this.initialScrollOffset = {
        x: window.scrollX,
        y: window.scrollY,
      };
  
      this[onDragStart] = this[onDragStart].bind(this);
      this[onDragMove] = this[onDragMove].bind(this);
      this[onDragStop] = this[onDragStop].bind(this);
      this[onMirrorCreated] = this[onMirrorCreated].bind(this);
      this[onMirrorMove] = this[onMirrorMove].bind(this);
      this[onScroll] = this[onScroll].bind(this);
    }
  
    /**
     * Attaches plugins event listeners
     */
    attach() {
      this.draggable
        .on('drag:start', this[onDragStart])
        .on('drag:move', this[onDragMove])
        .on('drag:stop', this[onDragStop])
        .on('mirror:created', this[onMirrorCreated])
        .on('mirror:move', this[onMirrorMove]);
    }
  
    /**
     * Detaches plugins event listeners
     */
    detach() {
      this.draggable
        .off('drag:start', this[onDragStart])
        .off('drag:move', this[onDragMove])
        .off('drag:stop', this[onDragStop])
        .off('mirror:created', this[onMirrorCreated])
        .off('mirror:move', this[onMirrorMove]);
    }
  
    /**
     * Returns options passed through draggable
     * @return {Object}
     */
    getOptions() {
      return this.draggable.options.mirror || {};
    }
  
    [onDragStart](dragEvent) {
      if (dragEvent.canceled()) {
        return;
      }
  
      if ('ontouchstart' in window) {
        document.addEventListener('scroll', this[onScroll], true);
      }
  
      this.initialScrollOffset = {
        x: window.scrollX,
        y: window.scrollY,
      };
  
      const {source, originalSource, sourceContainer, sensorEvent} = dragEvent;
  
      // Last sensor position of mirror move
      this.lastMirrorMovedClient = {
        x: sensorEvent.clientX,
        y: sensorEvent.clientY,
      };
  
      const mirrorCreateEvent = new MirrorCreateEvent({
        source,
        originalSource,
        sourceContainer,
        sensorEvent,
        dragEvent,
      });
  
      this.draggable.trigger(mirrorCreateEvent);
  
      if (isNativeDragEvent(sensorEvent) || mirrorCreateEvent.canceled()) {
        return;
      }
  
      const appendableContainer = this[getAppendableContainer](source) || sourceContainer;
      this.mirror = cloneNode(source, true);
  
      const mirrorCreatedEvent = new MirrorCreatedEvent({
        source,
        originalSource,
        sourceContainer,
        sensorEvent,
        dragEvent,
        mirror: this.mirror,
      });
  
      const mirrorAttachedEvent = new MirrorAttachedEvent({
        source,
        originalSource,
        sourceContainer,
        sensorEvent,
        dragEvent,
        mirror: this.mirror,
      });
  
      this.draggable.trigger(mirrorCreatedEvent);
      appendableContainer.appendChild(this.mirror);
      this.draggable.trigger(mirrorAttachedEvent);
    }
  
    [onDragMove](dragEvent) {
      if (!this.mirror || dragEvent.canceled()) {
        return;
      }
  
      const {source, originalSource, sourceContainer, sensorEvent} = dragEvent;
  
      let passedThreshX = true;
      let passedThreshY = true;
  
      if (this.options.thresholdX || this.options.thresholdY) {
        const {x: lastX, y: lastY} = this.lastMirrorMovedClient;
  
        if (Math.abs(lastX - sensorEvent.clientX) < this.options.thresholdX) {
          passedThreshX = false;
        } else {
          this.lastMirrorMovedClient.x = sensorEvent.clientX;
        }
  
        if (Math.abs(lastY - sensorEvent.clientY) < this.options.thresholdY) {
          passedThreshY = false;
        } else {
          this.lastMirrorMovedClient.y = sensorEvent.clientY;
        }
  
        if (!passedThreshX && !passedThreshY) {
          return;
        }
      }
  
      const mirrorMoveEvent = new MirrorMoveEvent({
        source,
        originalSource,
        sourceContainer,
        sensorEvent,
        dragEvent,
        mirror: this.mirror,
        passedThreshX,
        passedThreshY,
      });
  
      this.draggable.trigger(mirrorMoveEvent);
    }
  
    [onDragStop](dragEvent) {
      if ('ontouchstart' in window) {
        document.removeEventListener('scroll', this[onScroll], true);
      }
  
      this.initialScrollOffset = {x: 0, y: 0};
      this.scrollOffset = {x: 0, y: 0};
  
      if (!this.mirror) {
        return;
      }
  
      const {source, sourceContainer, sensorEvent} = dragEvent;
  
      const mirrorDestroyEvent = new MirrorDestroyEvent({
        source,
        mirror: this.mirror,
        sourceContainer,
        sensorEvent,
        dragEvent,
      });
  
      this.draggable.trigger(mirrorDestroyEvent);
  
      if (!mirrorDestroyEvent.canceled()) {
        this.mirror.parentNode.removeChild(this.mirror);
      }
    }
  
    [onScroll]() {
      this.scrollOffset = {
        x: window.scrollX - this.initialScrollOffset.x,
        y: window.scrollY - this.initialScrollOffset.y,
      };
    }
  
    /**
     * Mirror created handler
     * @param {MirrorCreatedEvent} mirrorEvent
     * @return {Promise}
     * @private
     */
    [onMirrorCreated]({mirror, source, sensorEvent}) {
      const mirrorClasses = this.draggable.getClassNamesFor('mirror');
  
      const setState = ({mirrorOffset, initialX, initialY, ...args}) => {
        this.mirrorOffset = mirrorOffset;
        this.initialX = initialX;
        this.initialY = initialY;
        this.lastMovedX = initialX;
        this.lastMovedY = initialY;
        return {mirrorOffset, initialX, initialY, ...args};
      };
  
      mirror.style.display = 'none';
  
      const initialState = {
        mirror,
        source,
        sensorEvent,
        mirrorClasses,
        scrollOffset: this.scrollOffset,
        options: this.options,
        passedThreshX: true,
        passedThreshY: true,
      };
  
      return (
        Promise.resolve(initialState)
          // Fix reflow here
          .then(computeMirrorDimensions)
          .then(calculateMirrorOffset)
          .then(resetMirror)
          .then(addMirrorClasses)
          .then(positionMirror({initial: true}))
          .then(removeMirrorID)
          .then(setState)
      );
    }
  
    /**
     * Mirror move handler
     * @param {MirrorMoveEvent} mirrorEvent
     * @return {Promise|null}
     * @private
     */
    [onMirrorMove](mirrorEvent) {
      if (mirrorEvent.canceled()) {
        return null;
      }
  
      const setState = ({lastMovedX, lastMovedY, ...args}) => {
        this.lastMovedX = lastMovedX;
        this.lastMovedY = lastMovedY;
  
        return {lastMovedX, lastMovedY, ...args};
      };
      const triggerMoved = (args) => {
        const mirrorMovedEvent = new MirrorMovedEvent({
          source: mirrorEvent.source,
          originalSource: mirrorEvent.originalSource,
          sourceContainer: mirrorEvent.sourceContainer,
          sensorEvent: mirrorEvent.sensorEvent,
          dragEvent: mirrorEvent.dragEvent,
          mirror: this.mirror,
          passedThreshX: mirrorEvent.passedThreshX,
          passedThreshY: mirrorEvent.passedThreshY,
        });
  
        this.draggable.trigger(mirrorMovedEvent);
  
        return args;
      };
  
      const initialState = {
        mirror: mirrorEvent.mirror,
        sensorEvent: mirrorEvent.sensorEvent,
        mirrorOffset: this.mirrorOffset,
        options: this.options,
        initialX: this.initialX,
        initialY: this.initialY,
        scrollOffset: this.scrollOffset,
        passedThreshX: mirrorEvent.passedThreshX,
        passedThreshY: mirrorEvent.passedThreshY,
        lastMovedX: this.lastMovedX,
        lastMovedY: this.lastMovedY,
      };
  
      return Promise.resolve(initialState)
        .then(positionMirror({raf: true}))
        .then(setState)
        .then(triggerMoved);
    }
  
    /**
     * Returns appendable container for mirror based on the appendTo option
     * @private
     * @param {Object} options
     * @param {HTMLElement} options.source - Current source
     * @return {HTMLElement}
     */
    [getAppendableContainer](source) {
      const appendTo = this.options.appendTo;
  
      if (typeof appendTo === 'string') {
        return document.querySelector(appendTo);
      } else if (appendTo instanceof HTMLElement) {
        return appendTo;
      } else if (typeof appendTo === 'function') {
        return appendTo(source);
      } else {
        return source.parentNode;
      }
    }
  }
  
  /**
   * Computes mirror dimensions based on the source element
   * Adds sourceRect to state
   * @param {Object} state
   * @param {HTMLElement} state.source
   * @return {Promise}
   * @private
   */
  function computeMirrorDimensions({source, ...args}) {
    return withPromise((resolve) => {
      const sourceRect = source.getBoundingClientRect();
      resolve({source, sourceRect, ...args});
    });
  }
  
  /**
   * Calculates mirror offset
   * Adds mirrorOffset to state
   * @param {Object} state
   * @param {SensorEvent} state.sensorEvent
   * @param {DOMRect} state.sourceRect
   * @return {Promise}
   * @private
   */
  function calculateMirrorOffset({sensorEvent, sourceRect, options, ...args}) {
    return withPromise((resolve) => {
      const top = options.cursorOffsetY === null ? sensorEvent.clientY - sourceRect.top : options.cursorOffsetY;
      const left = options.cursorOffsetX === null ? sensorEvent.clientX - sourceRect.left : options.cursorOffsetX;
  
      const mirrorOffset = {top, left};
  
      resolve({sensorEvent, sourceRect, mirrorOffset, options, ...args});
    });
  }
  
  /**
   * Applys mirror styles
   * @param {Object} state
   * @param {HTMLElement} state.mirror
   * @param {HTMLElement} state.source
   * @param {Object} state.options
   * @return {Promise}
   * @private
   */
  function resetMirror({mirror, source, options, ...args}) {
    return withPromise((resolve) => {
      let offsetHeight;
      let offsetWidth;
  
      if (options.constrainDimensions) {
        const computedSourceStyles = getComputedStyle(source);
        offsetHeight = computedSourceStyles.getPropertyValue('height');
        offsetWidth = computedSourceStyles.getPropertyValue('width');
      }
  
      mirror.style.display = null;
      mirror.style.position = 'fixed';
      mirror.style.pointerEvents = 'none';
      mirror.style.top = 0;
      mirror.style.left = 0;
      mirror.style.margin = 0;
  
      if (options.constrainDimensions) {
        mirror.style.height = offsetHeight;
        mirror.style.width = offsetWidth;
      }
  
      resolve({mirror, source, options, ...args});
    });
  }
  
  /**
   * Applys mirror class on mirror element
   * @param {Object} state
   * @param {HTMLElement} state.mirror
   * @param {String[]} state.mirrorClasses
   * @return {Promise}
   * @private
   */
  function addMirrorClasses({mirror, mirrorClasses, ...args}) {
    return withPromise((resolve) => {
      mirror.classList.add(...mirrorClasses);
      resolve({mirror, mirrorClasses, ...args});
    });
  }
  
  /**
   * Removes source ID from cloned mirror element
   * @param {Object} state
   * @param {HTMLElement} state.mirror
   * @return {Promise}
   * @private
   */
  function removeMirrorID({mirror, ...args}) {
    return withPromise((resolve) => {
      mirror.removeAttribute('id');
      delete mirror.id;
      resolve({mirror, ...args});
    });
  }
  
  /**
   * Positions mirror with translate3d
   * @param {Object} state
   * @param {HTMLElement} state.mirror
   * @param {SensorEvent} state.sensorEvent
   * @param {Object} state.mirrorOffset
   * @param {Number} state.initialY
   * @param {Number} state.initialX
   * @param {Object} state.options
   * @return {Promise}
   * @private
   */
  function positionMirror({withFrame = false, initial = false} = {}) {
    return ({
      mirror,
      sensorEvent,
      mirrorOffset,
      initialY,
      initialX,
      scrollOffset,
      options,
      passedThreshX,
      passedThreshY,
      lastMovedX,
      lastMovedY,
      ...args
    }) => {
      return withPromise(
        (resolve) => {
          const result = {
            mirror,
            sensorEvent,
            mirrorOffset,
            options,
            ...args,
          };
  
          if (mirrorOffset) {
            const x = passedThreshX
              ? Math.round((sensorEvent.clientX - mirrorOffset.left - scrollOffset.x) / (options.thresholdX || 1)) *
                (options.thresholdX || 1)
              : Math.round(lastMovedX);
            const y = passedThreshY
              ? Math.round((sensorEvent.clientY - mirrorOffset.top - scrollOffset.y) / (options.thresholdY || 1)) *
                (options.thresholdY || 1)
              : Math.round(lastMovedY);
  
            if ((options.xAxis && options.yAxis) || initial) {
              mirror.style.transform = `translate3d(${x}px, ${y}px, 0)`;
            } else if (options.xAxis && !options.yAxis) {
              mirror.style.transform = `translate3d(${x}px, ${initialY}px, 0)`;
            } else if (options.yAxis && !options.xAxis) {
              mirror.style.transform = `translate3d(${initialX}px, ${y}px, 0)`;
            }
  
            if (initial) {
              result.initialX = x;
              result.initialY = y;
            }
  
            result.lastMovedX = x;
            result.lastMovedY = y;
          }
  
          resolve(result);
        },
        {frame: withFrame},
      );
    };
  }
  
  /**
   * Wraps functions in promise with potential animation frame option
   * @param {Function} callback
   * @param {Object} options
   * @param {Boolean} options.raf
   * @return {Promise}
   * @private
   */
  function withPromise(callback, {raf = false} = {}) {
    return new Promise((resolve, reject) => {
      if (raf) {
        requestAnimationFrame(() => {
          callback(resolve, reject);
        });
      } else {
        callback(resolve, reject);
      }
    });
  }
  
  /**
   * Returns true if the sensor event was triggered by a native browser drag event
   * @param {SensorEvent} sensorEvent
   */
  function isNativeDragEvent(sensorEvent) {
    return /^drag/.test(sensorEvent.originalEvent.type);
  }
  