taye/interact.js

View on GitHub
packages/@interactjs/core/Interactable.ts

Summary

Maintainability
D
2 days
Test Coverage
/* eslint-disable no-dupe-class-members */
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 extend from '@interactjs/utils/extend'
import is from '@interactjs/utils/is'
import isNonNativeEvent from '@interactjs/utils/isNonNativeEvent'
import normalizeListeners from '@interactjs/utils/normalizeListeners'
import { getWindow } from '@interactjs/utils/window'

import type { Scope } from '@interactjs/core/scope'
import type {
  ActionMap,
  ActionMethod,
  ActionName,
  Actions,
  Context,
  Element,
  EventTypes,
  Listeners,
  ListenersArg,
  OrBoolean,
  Target,
} from '@interactjs/core/types'

import { Eventable } from './Eventable'
import type { ActionDefaults, Defaults, OptionsArg, PerActionDefaults, Options } from './options'

type IgnoreValue = string | Element | boolean
type DeltaSource = 'page' | 'client'

const enum OnOffMethod {
  On,
  Off,
}

/**
 * ```ts
 * const interactable = interact('.cards')
 *   .draggable({
 *     listeners: { move: event => console.log(event.type, event.pageX, event.pageY) }
 *   })
 *   .resizable({
 *     listeners: { move: event => console.log(event.rect) },
 *     modifiers: [interact.modifiers.restrictEdges({ outer: 'parent' })]
 *   })
 * ```
 */
export class Interactable implements Partial<Eventable> {
  /** @internal */ get _defaults(): Defaults {
    return {
      base: {},
      perAction: {},
      actions: {} as ActionDefaults,
    }
  }

  readonly target: Target
  /** @internal */ readonly options!: Required<Options>
  /** @internal */ readonly _actions: Actions
  /** @internal */ readonly events = new Eventable()
  /** @internal */ readonly _context: Context
  /** @internal */ readonly _win: Window
  /** @internal */ readonly _doc: Document
  /** @internal */ readonly _scopeEvents: Scope['events']

  constructor(
    target: Target,
    options: any,
    defaultContext: Document | Element,
    scopeEvents: Scope['events'],
  ) {
    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._scopeEvents = scopeEvents

    this.set(options)
  }

  setOnEvents(actionName: ActionName, 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: ActionName, prev: Listeners | undefined, cur: Listeners | undefined) {
    const actionFilter = (this._actions.map[actionName] as { filterEventType?: (type: string) => boolean })
      ?.filterEventType
    const filter = (type: string) =>
      (actionFilter == null || actionFilter(type)) && isNonNativeEvent(type, this._actions)

    if (is.array(prev) || is.object(prev)) {
      this._onOff(OnOffMethod.Off, actionName, prev, undefined, filter)
    }

    if (is.array(cur) || is.object(cur)) {
      this._onOff(OnOffMethod.On, actionName, cur, undefined, filter)
    }
  }

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

    // for all the default per-action options
    for (const optionName_ in options) {
      const optionName = optionName_ as keyof PerActionDefaults
      const actionOptions = this.options[actionName]
      const optionValue: any = options[optionName]

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

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

        // set anabled field to true if it exists in the defaults
        if (
          is.object(defaults.perAction[optionName]) &&
          'enabled' in (defaults.perAction[optionName] as any)
        ) {
          ;(actionOptions[optionName] as any).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] as any).enabled = optionValue
      }
      // if it's anything else, do a plain assignment
      else {
        ;(actionOptions[optionName] as any) = 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 {Rect} 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(): (element: Element) => any | null
  rectChecker(checker: (element: Element) => any): this
  rectChecker(checker?: (element: Element) => any) {
    if (is.func(checker)) {
      this.getRect = (element) => {
        const rect = extend({}, checker.apply(this, element))

        if (!(('width' in rect) as unknown)) {
          rect.width = rect.right - rect.left
          rect.height = rect.bottom - rect.top
        }

        return rect
      }

      return this
    }

    if (checker === null) {
      delete (this as Partial<typeof this>).getRect

      return this
    }

    return this.getRect
  }

  /** @internal */
  _backCompatOption(optionName: keyof Options, newValue: any) {
    if (trySelector(newValue) || is.object(newValue)) {
      ;(this.options[optionName] as any) = newValue

      for (const action in this._actions.map) {
        ;(this.options[action as keyof ActionMap] as any)[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: any) {
    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(): DeltaSource
  deltaSource(newValue: DeltaSource): this
  deltaSource(newValue?: DeltaSource) {
    if (newValue === 'page' || newValue === 'client') {
      this.options.deltaSource = newValue

      return this
    }

    return this.options.deltaSource
  }

  /** @internal */
  getAllElements(): Element[] {
    const { target } = this

    if (is.string(target)) {
      return Array.from(this._context.querySelectorAll(target))
    }

    if (is.func(target) && (target as any).getAllElements) {
      return (target as any).getAllElements()
    }

    return is.element(target) ? [target] : []
  }

  /**
   * 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: Document | Node) {
    return this._context === element.ownerDocument || nodeContains(this._context, element)
  }

  /** @internal */
  testIgnoreAllow(
    this: Interactable,
    options: { ignoreFrom?: IgnoreValue; allowFrom?: IgnoreValue },
    targetNode: Node,
    eventTarget: Node,
  ) {
    return (
      !this.testIgnore(options.ignoreFrom, targetNode, eventTarget) &&
      this.testAllow(options.allowFrom, targetNode, eventTarget)
    )
  }

  /** @internal */
  testAllow(this: Interactable, allowFrom: IgnoreValue | undefined, targetNode: Node, element: Node) {
    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
  }

  /** @internal */
  testIgnore(this: Interactable, ignoreFrom: IgnoreValue | undefined, targetNode: Node, element: Node) {
    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<E extends { type: string }>(iEvent: E) {
    this.events.fire(iEvent)

    return this
  }

  /** @internal */
  _onOff(
    method: OnOffMethod,
    typeArg: EventTypes,
    listenerArg?: ListenersArg | null,
    options?: any,
    filter?: (type: string) => boolean,
  ) {
    if (is.object(typeArg) && !is.array(typeArg)) {
      options = listenerArg
      listenerArg = null
    }

    const listeners = normalizeListeners(typeArg, listenerArg, filter)

    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 (isNonNativeEvent(type, this._actions)) {
          this.events[method === OnOffMethod.On ? 'on' : 'off'](type, listener)
        }
        // delegated event
        else if (is.string(this.target)) {
          this._scopeEvents[method === OnOffMethod.On ? 'addDelegate' : 'removeDelegate'](
            this.target,
            this._context,
            type,
            listener,
            options,
          )
        }
        // remove listener from this Interactable's element
        else {
          this._scopeEvents[method === OnOffMethod.On ? 'add' : '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: EventTypes, listener?: ListenersArg, options?: any) {
    return this._onOff(OnOffMethod.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[] | EventTypes, listener?: ListenersArg, options?: any) {
    return this._onOff(OnOffMethod.Off, types, listener, options)
  }

  /**
   * Reset the options of this Interactable
   *
   * @param {object} options The new settings to apply
   * @return {object} This Interactable
   */
  set(options: 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 actionName = actionName_ as ActionName
      const methodName = this._actions.methodDict[actionName]

      this.options[actionName] = {}
      this.setPerAction(actionName, extend(extend({}, defaults.perAction), defaults.actions[actionName]))
      ;(this[methodName] as ActionMethod<unknown>)(options[actionName])
    }

    for (const setting in options) {
      if (setting === 'getRect') {
        this.rectChecker(options.getRect)
        continue
      }

      if (is.func((this as any)[setting])) {
        ;(this as any)[setting](options[setting as keyof typeof options])
      }
    }

    return this
  }

  /**
   * Remove this interactable from the list of interactables and remove it's
   * action capabilities and event listeners
   */
  unset() {
    if (is.string(this.target)) {
      // remove delegated events
      for (const type in this._scopeEvents.delegatedEvents) {
        const delegated = this._scopeEvents.delegatedEvents[type]

        for (let i = delegated.length - 1; i >= 0; i--) {
          const { selector, context, listeners } = delegated[i]

          if (selector === this.target && context === this._context) {
            delegated.splice(i, 1)
          }

          for (let l = listeners.length - 1; l >= 0; l--) {
            this._scopeEvents.removeDelegate(
              this.target,
              this._context,
              type,
              listeners[l][0],
              listeners[l][1],
            )
          }
        }
      }
    } else {
      this._scopeEvents.remove(this.target, 'all')
    }
  }
}