packages/core/Interactable.ts

import * as arr from '@interactjs/utils/arr'
import browser from '@interactjs/utils/browser'
import clone from '@interactjs/utils/clone'
import { getElementRect, matchesUpTo, nodeContains, trySelector } from '@interactjs/utils/domUtils'
import events from '@interactjs/utils/events'
import extend from '@interactjs/utils/extend'
import * as is from '@interactjs/utils/is'
import normalizeListeners from '@interactjs/utils/normalizeListeners'
import { getWindow } from '@interactjs/utils/window'
import { ActionDefaults, Defaults, Options } from './defaultOptions'
import Eventable from './Eventable'
import { Actions } from './scope'

type IgnoreValue = string | Element | boolean

/** */
export class Interactable implements Partial<Eventable> {
  protected get _defaults (): Defaults {
    return {
      base: {},
      perAction: {},
      actions: {} as ActionDefaults,
    }
  }

  readonly options!: Required<Options>
  readonly _actions: Actions
  readonly target: Interact.Target
  readonly events = new Eventable()
  readonly _context: Document | Element
  readonly _win: Window
  readonly _doc: Document

  /** */
  constructor (target: Interact.Target, options: any, defaultContext: Document | Element) {
    this._actions = options.actions
    this.target   = target
    this._context = options.context || defaultContext
    this._win     = getWindow(trySelector(target) ? this._context : target)
    this._doc     = this._win.document

    this.set(options)
  }

  setOnEvents (actionName: string, phases: NonNullable<any>) {
    if (is.func(phases.onstart)) { this.on(`${actionName}start`, phases.onstart) }
    if (is.func(phases.onmove)) { this.on(`${actionName}move`, phases.onmove) }
    if (is.func(phases.onend)) { this.on(`${actionName}end`, phases.onend) }
    if (is.func(phases.oninertiastart)) { this.on(`${actionName}inertiastart`, phases.oninertiastart) }

    return this
  }

  updatePerActionListeners (actionName, prev, cur) {
    if (is.array(prev) || is.object(prev)) {
      this.off(actionName, prev)
    }

    if (is.array(cur) || is.object(cur)) {
      this.on(actionName, cur)
    }
  }

  setPerAction (actionName, options: Interact.OrBoolean<Options>) {
    const defaults = this._defaults

    // for all the default per-action options
    for (const optionName in options) {
      const actionOptions = this.options[actionName]
      const optionValue = options[optionName]
      const isArray = is.array(optionValue)

      // remove old event listeners and add new ones
      if (optionName === 'listeners') {
        this.updatePerActionListeners(actionName, actionOptions.listeners, optionValue)
      }

      // if the option value is an array
      if (isArray) {
        actionOptions[optionName] = arr.from(optionValue)
      }
      // if the option value is an object
      else if (!isArray && is.plainObject(optionValue)) {
        // copy the object
        actionOptions[optionName] = extend(
          actionOptions[optionName] || {},
          clone(optionValue))

        // set anabled field to true if it exists in the defaults
        if (is.object(defaults.perAction[optionName]) && 'enabled' in defaults.perAction[optionName]) {
          actionOptions[optionName].enabled = optionValue.enabled !== false
        }
      }
      // if the option value is a boolean and the default is an object
      else if (is.bool(optionValue) && is.object(defaults.perAction[optionName])) {
        actionOptions[optionName].enabled = optionValue
      }
      // if it's anything else, do a plain assignment
      else {
        actionOptions[optionName] = optionValue
      }
    }
  }

  /**
   * The default function to get an Interactables bounding rect. Can be
   * overridden using {@link Interactable.rectChecker}.
   *
   * @param {Element} [element] The element to measure.
   * @return {object} The object's bounding rectangle.
   */
  getRect (element: Element) {
    element = element || (is.element(this.target)
      ? this.target
      : null)

    if (is.string(this.target)) {
      element = element || this._context.querySelector(this.target)
    }

    return getElementRect(element)
  }

  /**
   * Returns or sets the function used to calculate the interactable's
   * element's rectangle
   *
   * @param {function} [checker] A function which returns this Interactable's
   * bounding rectangle. See {@link Interactable.getRect}
   * @return {function | object} The checker function or this Interactable
   */
  rectChecker (checker: (element: Element) => any) {
    if (is.func(checker)) {
      this.getRect = checker

      return this
    }

    if (checker === null) {
      delete this.getRect

      return this
    }

    return this.getRect
  }

  _backCompatOption (optionName, newValue) {
    if (trySelector(newValue) || is.object(newValue)) {
      this.options[optionName] = newValue

      for (const action of this._actions.names) {
        this.options[action][optionName] = newValue
      }

      return this
    }

    return this.options[optionName]
  }

  /**
   * Gets or sets the origin of the Interactable's element.  The x and y
   * of the origin will be subtracted from action event coordinates.
   *
   * @param {Element | object | string} [origin] An HTML or SVG Element whose
   * rect will be used, an object eg. { x: 0, y: 0 } or string 'parent', 'self'
   * or any CSS selector
   *
   * @return {object} The current origin or this Interactable
   */
  origin (newValue) {
    return this._backCompatOption('origin', newValue)
  }

  /**
   * Returns or sets the mouse coordinate types used to calculate the
   * movement of the pointer.
   *
   * @param {string} [newValue] Use 'client' if you will be scrolling while
   * interacting; Use 'page' if you want autoScroll to work
   * @return {string | object} The current deltaSource or this Interactable
   */
  deltaSource (newValue) {
    if (newValue === 'page' || newValue === 'client') {
      this.options.deltaSource = newValue

      return this
    }

    return this.options.deltaSource
  }

  /**
   * Gets the selector context Node of the Interactable. The default is
   * `window.document`.
   *
   * @return {Node} The context Node of this Interactable
   */
  context () {
    return this._context
  }

  inContext (element) {
    return (this._context === element.ownerDocument ||
            nodeContains(this._context, element))
  }

  testIgnoreAllow (this: Interactable, options: { ignoreFrom: IgnoreValue, allowFrom: IgnoreValue }, targetNode: Node, eventTarget: Element) {
    return (!this.testIgnore(options.ignoreFrom, targetNode, eventTarget) &&
            this.testAllow(options.allowFrom, targetNode, eventTarget))
  }

  testAllow (this: Interactable, allowFrom: IgnoreValue, targetNode: Node, element: Element) {
    if (!allowFrom) { return true }

    if (!is.element(element)) { return false }

    if (is.string(allowFrom)) {
      return matchesUpTo(element, allowFrom, targetNode)
    }
    else if (is.element(allowFrom)) {
      return nodeContains(allowFrom, element)
    }

    return false
  }

  testIgnore (this: Interactable, ignoreFrom: IgnoreValue, targetNode: Node, element: Element) {
    if (!ignoreFrom || !is.element(element)) { return false }

    if (is.string(ignoreFrom)) {
      return matchesUpTo(element, ignoreFrom, targetNode)
    }
    else if (is.element(ignoreFrom)) {
      return nodeContains(ignoreFrom, element)
    }

    return false
  }

  /**
   * Calls listeners for the given InteractEvent type bound globally
   * and directly to this Interactable
   *
   * @param {InteractEvent} iEvent The InteractEvent object to be fired on this
   * Interactable
   * @return {Interactable} this Interactable
   */
  fire (iEvent) {
    this.events.fire(iEvent)

    return this
  }

  _onOff (method: 'on' | 'off', typeArg: Interact.EventTypes, listenerArg?: Interact.ListenersArg | null, options?: any) {
    if (is.object(typeArg) && !is.array(typeArg)) {
      options = listenerArg
      listenerArg = null
    }

    const addRemove = method === 'on' ? 'add' : 'remove'
    const listeners = normalizeListeners(typeArg, listenerArg)

    for (let type in listeners) {
      if (type === 'wheel') { type = browser.wheelEvent }

      for (const listener of listeners[type]) {
        // if it is an action event type
        if (arr.contains(this._actions.eventTypes, type)) {
          this.events[method](type, listener)
        }
        // delegated event
        else if (is.string(this.target)) {
          events[`${addRemove}Delegate`](this.target, this._context, type, listener, options)
        }
        // remove listener from this Interatable's element
        else {
          (events[addRemove] as typeof events.remove)(this.target, type, listener, options)
        }
      }
    }

    return this
  }

  /**
   * Binds a listener for an InteractEvent, pointerEvent or DOM event.
   *
   * @param {string | array | object} types The types of events to listen
   * for
   * @param {function | array | object} [listener] The event listener function(s)
   * @param {object | boolean} [options] options object or useCapture flag for
   * addEventListener
   * @return {Interactable} This Interactable
   */
  on (types: Interact.EventTypes, listener?: Interact.ListenersArg, options?: any) {
    return this._onOff('on', types, listener, options)
  }

  /**
   * Removes an InteractEvent, pointerEvent or DOM event listener.
   *
   * @param {string | array | object} types The types of events that were
   * listened for
   * @param {function | array | object} [listener] The event listener function(s)
   * @param {object | boolean} [options] options object or useCapture flag for
   * removeEventListener
   * @return {Interactable} This Interactable
   */
  off (types: string | string[] | Interact.EventTypes, listener?: Interact.ListenersArg, options?: any) {
    return this._onOff('off', types, listener, options)
  }

  /**
   * Reset the options of this Interactable
   *
   * @param {object} options The new settings to apply
   * @return {object} This Interactable
   */
  set (options: Interact.OptionsArg) {
    const defaults = this._defaults

    if (!is.object(options)) {
      options = {}
    }

    (this.options as Required<Options>) = clone(defaults.base) as Required<Options>

    for (const actionName in this._actions.methodDict) {
      const methodName = this._actions.methodDict[actionName]

      this.options[actionName] = {}
      this.setPerAction(actionName, extend(extend({}, defaults.perAction), defaults.actions[actionName]))

      this[methodName](options[actionName])
    }

    for (const setting in options) {
      if (is.func(this[setting])) {
        this[setting](options[setting])
      }
    }

    return this
  }

  /**
   * Remove this interactable from the list of interactables and remove it's
   * action capabilities and event listeners
   *
   * @return {interact}
   */
  unset () {
    events.remove(this.target as Node, 'all')

    if (is.string(this.target)) {
      // remove delegated events
      for (const type in events.delegatedEvents) {
        const delegated = events.delegatedEvents[type]

        if (delegated.selectors[0] === this.target &&
            delegated.contexts[0] === this._context) {
          delegated.selectors.splice(0, 1)
          delegated.contexts.splice(0, 1)
          delegated.listeners.splice(0, 1)

          // remove the arrays if they are empty
          if (!delegated.selectors.length) {
            delegated[type] = null
          }
        }

        events.remove(this._context, type, events.delegateListener)
        events.remove(this._context, type, events.delegateUseCapture, true)
      }
    }
    else {
      events.remove(this.target as Node, 'all')
    }
  }
}

export default Interactable