packages/actions/resize.ts

import { ActionProps, Interaction } from '@interactjs/core/Interaction'
import { ActionName, Scope } from '@interactjs/core/scope'
import * as arr from '@interactjs/utils/arr'
import * as dom from '@interactjs/utils/domUtils'
import extend from '@interactjs/utils/extend'
import * as is from '@interactjs/utils/is'

export type EdgeName = 'top' | 'left' | 'bottom' | 'right'

export type ResizableMethod = Interact.ActionMethod<Interact.ResizableOptions>

declare module '@interactjs/core/Interactable' {
  interface Interactable {
    resizable: ResizableMethod
  }
}

declare module '@interactjs/core/Interaction' {
  interface Interaction {
    resizeAxes: 'x' | 'y' | 'xy'
    resizeRects: {
      start: Interact.FullRect
      current: Interact.Rect
      inverted: Interact.FullRect
      previous: Interact.FullRect
      delta: Interact.FullRect
    }
    resizeStartAspectRatio: number
  }

  interface ActionProps {
    edges?: { [edge in 'top' | 'left' | 'bottom' | 'right']?: boolean }
    _linkedEdges?: { [edge in 'top' | 'left' | 'bottom' | 'right']?: boolean }
  }
}

declare module '@interactjs/core/defaultOptions' {
  interface ActionDefaults {
    resize: Interact.ResizableOptions
  }
}

declare module '@interactjs/core/scope' {
  interface Actions {
    [ActionName.Resize]?: typeof resize
  }

  // eslint-disable-next-line no-shadow
  enum ActionName {
    Resize = 'resize'
  }
}

(ActionName as any).Resize = 'resize'

export interface ResizeEvent extends Interact.InteractEvent<ActionName.Resize> {
  deltaRect?: Interact.FullRect
  edges?: Interact.ActionProps['edges']
}

function install (scope: Scope) {
  const {
    actions,
    browser,
    /** @lends Interactable */
    Interactable, // tslint:disable-line no-shadowed-variable
    interactions,
    defaults,
  } = scope

  // Less Precision with touch input

  interactions.signals.on('new', interaction => {
    interaction.resizeAxes = 'xy'
  })

  interactions.signals.on('action-start', start)
  interactions.signals.on('action-move', move)
  interactions.signals.on('action-end', end)

  interactions.signals.on('action-start', updateEventAxes)
  interactions.signals.on('action-move', updateEventAxes)

  resize.cursors = initCursors(browser)
  resize.defaultMargin = browser.supportsTouch || browser.supportsPointerEvent ? 20 : 10

  /**
   * ```js
   * interact(element).resizable({
   *   onstart: function (event) {},
   *   onmove : function (event) {},
   *   onend  : function (event) {},
   *
   *   edges: {
   *     top   : true,       // Use pointer coords to check for resize.
   *     left  : false,      // Disable resizing from left edge.
   *     bottom: '.resize-s',// Resize if pointer target matches selector
   *     right : handleEl    // Resize if pointer target is the given Element
   *   },
   *
   *     // Width and height can be adjusted independently. When `true`, width and
   *     // height are adjusted at a 1:1 ratio.
   *     square: false,
   *
   *     // Width and height can be adjusted independently. When `true`, width and
   *     // height maintain the aspect ratio they had when resizing started.
   *     preserveAspectRatio: false,
   *
   *   // a value of 'none' will limit the resize rect to a minimum of 0x0
   *   // 'negate' will allow the rect to have negative width/height
   *   // 'reposition' will keep the width/height positive by swapping
   *   // the top and bottom edges and/or swapping the left and right edges
   *   invert: 'none' || 'negate' || 'reposition'
   *
   *   // limit multiple resizes.
   *   // See the explanation in the {@link Interactable.draggable} example
   *   max: Infinity,
   *   maxPerElement: 1,
   * })
   *
   * var isResizeable = interact(element).resizable()
   * ```
   *
   * Gets or sets whether resize actions can be performed on the target
   *
   * @param {boolean | object} [options] true/false or An object with event
   * listeners to be fired on resize events (object makes the Interactable
   * resizable)
   * @return {boolean | Interactable} A boolean indicating if this can be the
   * target of resize elements, or this Interactable
   */
  Interactable.prototype.resizable = function (this: Interact.Interactable, options: Interact.ResizableOptions | boolean) {
    return resizable(this, options, scope)
  } as ResizableMethod

  actions[ActionName.Resize] = resize
  actions.names.push(ActionName.Resize)
  arr.merge(actions.eventTypes, [
    'resizestart',
    'resizemove',
    'resizeinertiastart',
    'resizeresume',
    'resizeend',
  ])
  actions.methodDict.resize = 'resizable'

  defaults.actions.resize = resize.defaults
}

const resize = {
  id: 'actions/resize',
  install,
  defaults: {
    square: false,
    preserveAspectRatio: false,
    axis: 'xy',

    // use default margin
    margin: NaN,

    // object with props left, right, top, bottom which are
    // true/false values to resize when the pointer is over that edge,
    // CSS selectors to match the handles for each direction
    // or the Elements for each handle
    edges: null,

    // a value of 'none' will limit the resize rect to a minimum of 0x0
    // 'negate' will alow the rect to have negative width/height
    // 'reposition' will keep the width/height positive by swapping
    // the top and bottom edges and/or swapping the left and right edges
    invert: 'none',
  } as Interact.ResizableOptions,

  checker (
    _pointer: Interact.PointerType,
    _event: Interact.PointerEventType,
    interactable: Interact.Interactable,
    element: Interact.Element,
    interaction: Interaction,
    rect: Interact.Rect
  ) {
    if (!rect) { return null }

    const page = extend({}, interaction.coords.cur.page)
    const options = interactable.options

    if (options.resize.enabled) {
      const resizeOptions = options.resize
      const resizeEdges: { [edge: string]: boolean } = { left: false, right: false, top: false, bottom: false }

      // if using resize.edges
      if (is.object(resizeOptions.edges)) {
        for (const edge in resizeEdges) {
          resizeEdges[edge] = checkResizeEdge(edge,
            resizeOptions.edges[edge],
            page,
            interaction._latestPointer.eventTarget,
            element,
            rect,
            resizeOptions.margin || this.defaultMargin)
        }

        resizeEdges.left = resizeEdges.left && !resizeEdges.right
        resizeEdges.top  = resizeEdges.top  && !resizeEdges.bottom

        if (resizeEdges.left || resizeEdges.right || resizeEdges.top || resizeEdges.bottom) {
          return {
            name: 'resize',
            edges: resizeEdges,
          }
        }
      }
      else {
        const right  = options.resize.axis !== 'y' && page.x > (rect.right  - this.defaultMargin)
        const bottom = options.resize.axis !== 'x' && page.y > (rect.bottom - this.defaultMargin)

        if (right || bottom) {
          return {
            name: 'resize',
            axes: (right ? 'x' : '') + (bottom ? 'y' : ''),
          }
        }
      }
    }

    return null
  },

  cursors: null as ReturnType<typeof initCursors>,

  getCursor ({ edges, axis, name }: ActionProps) {
    const cursors = resize.cursors
    let result: string = null

    if (axis) {
      result = cursors[name + axis]
    }
    else if (edges) {
      let cursorKey = ''

      for (const edge of ['top', 'bottom', 'left', 'right']) {
        if (edges[edge]) {
          cursorKey += edge
        }
      }

      result = cursors[cursorKey]
    }

    return result
  },

  defaultMargin: null as number,
}

function resizable (interactable: Interact.Interactable, options: Interact.OrBoolean<Interact.ResizableOptions> | boolean, scope: Scope) {
  if (is.object(options)) {
    interactable.options.resize.enabled = options.enabled !== false
    interactable.setPerAction('resize', options)
    interactable.setOnEvents('resize', options)

    if (is.string(options.axis) && /^x$|^y$|^xy$/.test(options.axis)) {
      interactable.options.resize.axis = options.axis
    }
    else if (options.axis === null) {
      interactable.options.resize.axis = scope.defaults.actions.resize.axis
    }

    if (is.bool(options.preserveAspectRatio)) {
      interactable.options.resize.preserveAspectRatio = options.preserveAspectRatio
    }
    else if (is.bool(options.square)) {
      interactable.options.resize.square = options.square
    }

    return interactable
  }
  if (is.bool(options)) {
    interactable.options.resize.enabled = options

    return interactable
  }
  return interactable.options.resize
}

function checkResizeEdge (
  name: string,
  value: any,
  page: Interact.Point,
  element: Node,
  interactableElement: Interact.Element,
  rect: Interact.Rect,
  margin: number,
) {
  // false, '', undefined, null
  if (!value) { return false }

  // true value, use pointer coords and element rect
  if (value === true) {
    // if dimensions are negative, "switch" edges
    const width  = is.number(rect.width) ? rect.width  : rect.right  - rect.left
    const height = is.number(rect.height) ? rect.height : rect.bottom - rect.top

    // don't use margin greater than half the relevent dimension
    margin = Math.min(margin, (name === 'left' || name === 'right' ? width : height) / 2)

    if (width < 0) {
      if      (name === 'left')  { name = 'right' }
      else if (name === 'right') { name = 'left'  }
    }
    if (height < 0) {
      if      (name === 'top')    { name = 'bottom' }
      else if (name === 'bottom') { name = 'top'    }
    }

    if (name === 'left') { return page.x < ((width  >= 0 ? rect.left : rect.right) + margin) }
    if (name === 'top') { return page.y < ((height >= 0 ? rect.top : rect.bottom) + margin) }

    if (name === 'right') { return page.x > ((width  >= 0 ? rect.right : rect.left) - margin) }
    if (name === 'bottom') { return page.y > ((height >= 0 ? rect.bottom : rect.top) - margin) }
  }

  // the remaining checks require an element
  if (!is.element(element)) { return false }

  return is.element(value)
  // the value is an element to use as a resize handle
    ? value === element
    // otherwise check if element matches value as selector
    : dom.matchesUpTo(element, value, interactableElement)
}

function initCursors (browser: typeof import ('@interactjs/utils/browser').default) {
  return (browser.isIe9 ? {
    x : 'e-resize',
    y : 's-resize',
    xy: 'se-resize',

    top        : 'n-resize',
    left       : 'w-resize',
    bottom     : 's-resize',
    right      : 'e-resize',
    topleft    : 'se-resize',
    bottomright: 'se-resize',
    topright   : 'ne-resize',
    bottomleft : 'ne-resize',
  } : {
    x : 'ew-resize',
    y : 'ns-resize',
    xy: 'nwse-resize',

    top        : 'ns-resize',
    left       : 'ew-resize',
    bottom     : 'ns-resize',
    right      : 'ew-resize',
    topleft    : 'nwse-resize',
    bottomright: 'nwse-resize',
    topright   : 'nesw-resize',
    bottomleft : 'nesw-resize',
  })
}

function start ({ iEvent, interaction }: { iEvent: ResizeEvent, interaction: Interaction }) {
  if (interaction.prepared.name !== 'resize' || !interaction.prepared.edges) {
    return
  }

  const startRect = extend({}, interaction.rect)
  const resizeOptions = interaction.interactable.options.resize

  /*
   * When using the `resizable.square` or `resizable.preserveAspectRatio` options, resizing from one edge
   * will affect another. E.g. with `resizable.square`, resizing to make the right edge larger will make
   * the bottom edge larger by the same amount. We call these 'linked' edges. Any linked edges will depend
   * on the active edges and the edge being interacted with.
   */
  if (resizeOptions.square || resizeOptions.preserveAspectRatio) {
    const linkedEdges = extend({}, interaction.prepared.edges)

    linkedEdges.top    = linkedEdges.top    || (linkedEdges.left   && !linkedEdges.bottom)
    linkedEdges.left   = linkedEdges.left   || (linkedEdges.top    && !linkedEdges.right)
    linkedEdges.bottom = linkedEdges.bottom || (linkedEdges.right  && !linkedEdges.top)
    linkedEdges.right  = linkedEdges.right  || (linkedEdges.bottom && !linkedEdges.left)

    interaction.prepared._linkedEdges = linkedEdges
  }
  else {
    interaction.prepared._linkedEdges = null
  }

  // if using `resizable.preserveAspectRatio` option, record aspect ratio at the start of the resize
  if (resizeOptions.preserveAspectRatio) {
    interaction.resizeStartAspectRatio = startRect.width / startRect.height
  }

  interaction.resizeRects = {
    start     : startRect,
    current   : {
      left: startRect.left,
      right: startRect.right,
      top: startRect.top,
      bottom: startRect.bottom,
    },
    inverted  : extend({}, startRect),
    previous  : extend({}, startRect),
    delta     : {
      left: 0,
      right : 0,
      width : 0,
      top : 0,
      bottom: 0,
      height: 0,
    },
  }

  iEvent.edges = interaction.prepared.edges
  iEvent.rect = interaction.resizeRects.inverted
  iEvent.deltaRect = interaction.resizeRects.delta
}

function move ({ iEvent, interaction }: { iEvent: ResizeEvent, interaction: Interaction }) {
  if (interaction.prepared.name !== 'resize' || !interaction.prepared.edges) { return }

  const resizeOptions = interaction.interactable.options.resize
  const invert = resizeOptions.invert
  const invertible = invert === 'reposition' || invert === 'negate'

  let edges = interaction.prepared.edges

  // eslint-disable-next-line no-shadow
  const start      = interaction.resizeRects.start
  const current    = interaction.resizeRects.current
  const inverted   = interaction.resizeRects.inverted
  const deltaRect  = interaction.resizeRects.delta
  const previous   = extend(interaction.resizeRects.previous, inverted)
  const originalEdges = edges

  const eventDelta = extend({}, iEvent.delta)

  if (resizeOptions.preserveAspectRatio || resizeOptions.square) {
    // `resize.preserveAspectRatio` takes precedence over `resize.square`
    const startAspectRatio = resizeOptions.preserveAspectRatio
      ? interaction.resizeStartAspectRatio
      : 1

    edges = interaction.prepared._linkedEdges

    if ((originalEdges.left && originalEdges.bottom) ||
        (originalEdges.right && originalEdges.top)) {
      eventDelta.y = -eventDelta.x / startAspectRatio
    }
    else if (originalEdges.left || originalEdges.right) { eventDelta.y = eventDelta.x / startAspectRatio }
    else if (originalEdges.top  || originalEdges.bottom) { eventDelta.x = eventDelta.y * startAspectRatio }
  }

  // update the 'current' rect without modifications
  if (edges.top) { current.top    += eventDelta.y }
  if (edges.bottom) { current.bottom += eventDelta.y }
  if (edges.left) { current.left   += eventDelta.x }
  if (edges.right) { current.right  += eventDelta.x }

  if (invertible) {
    // if invertible, copy the current rect
    extend(inverted, current)

    if (invert === 'reposition') {
      // swap edge values if necessary to keep width/height positive
      let swap

      if (inverted.top > inverted.bottom) {
        swap = inverted.top

        inverted.top = inverted.bottom
        inverted.bottom = swap
      }
      if (inverted.left > inverted.right) {
        swap = inverted.left

        inverted.left = inverted.right
        inverted.right = swap
      }
    }
  }
  else {
    // if not invertible, restrict to minimum of 0x0 rect
    inverted.top    = Math.min(current.top, start.bottom)
    inverted.bottom = Math.max(current.bottom, start.top)
    inverted.left   = Math.min(current.left, start.right)
    inverted.right  = Math.max(current.right, start.left)
  }

  inverted.width  = inverted.right  - inverted.left
  inverted.height = inverted.bottom - inverted.top

  for (const edge in inverted) {
    deltaRect[edge] = inverted[edge] - previous[edge]
  }

  iEvent.edges = interaction.prepared.edges
  iEvent.rect = inverted
  iEvent.deltaRect = deltaRect
}

function end ({ iEvent, interaction }: { iEvent: ResizeEvent, interaction: Interaction }) {
  if (interaction.prepared.name !== 'resize' || !interaction.prepared.edges) { return }

  iEvent.edges = interaction.prepared.edges
  iEvent.rect = interaction.resizeRects.inverted
  iEvent.deltaRect = interaction.resizeRects.delta
}

function updateEventAxes ({ iEvent, interaction, action }: { iEvent: ResizeEvent, interaction: Interaction, action: ActionName }) {
  if (action !== ActionName.Resize || !interaction.resizeAxes) { return }

  const options = interaction.interactable.options

  if (options.resize.square) {
    if (interaction.resizeAxes === 'y') {
      iEvent.delta.x = iEvent.delta.y
    }
    else {
      iEvent.delta.y = iEvent.delta.x
    }
    iEvent.axes = 'xy'
  }
  else {
    iEvent.axes = interaction.resizeAxes

    if (interaction.resizeAxes === 'x') {
      iEvent.delta.y = 0
    }
    else if (interaction.resizeAxes === 'y') {
      iEvent.delta.x = 0
    }
  }
}

export default resize