taye/interact.js

View on GitHub
packages/@interactjs/actions/resize/plugin.ts

Summary

Maintainability
F
3 days
Test Coverage
import type { Interactable } from '@interactjs/core/Interactable'
import type { EventPhase, InteractEvent } from '@interactjs/core/InteractEvent'
import type { Interaction } from '@interactjs/core/Interaction'
import type { PerActionDefaults } from '@interactjs/core/options'
import type { Scope, Plugin } from '@interactjs/core/scope'
import type {
  ActionName,
  ActionProps,
  EdgeOptions,
  FullRect,
  ListenersArg,
  OrBoolean,
  Point,
  Rect,
} from '@interactjs/core/types'
import * as dom from '@interactjs/utils/domUtils'
import extend from '@interactjs/utils/extend'
import is from '@interactjs/utils/is'

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

declare module '@interactjs/core/Interactable' {
  interface Interactable {
    resizable(): ResizableOptions
    resizable(options: Partial<OrBoolean<ResizableOptions>> | boolean): this
    /**
     * ```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 options - true/false or An object with event
     * listeners to be fired on resize events (object makes the Interactable
     * resizable)
     * @returns A boolean indicating if this can be the
     * target of resize elements, or this Interactable
     */
    resizable(options?: Partial<OrBoolean<ResizableOptions>> | boolean): this | ResizableOptions
  }
}

declare module '@interactjs/core/Interaction' {
  interface Interaction<T extends ActionName | null = ActionName> {
    resizeAxes: 'x' | 'y' | 'xy'
    styleCursor(newValue: boolean): this
    styleCursor(): boolean
    resizeStartAspectRatio: number
  }
}

declare module '@interactjs/core/options' {
  interface ActionDefaults {
    resize: ResizableOptions
  }
}

declare module '@interactjs/core/types' {
  interface ActionMap {
    resize?: typeof resize
  }
}

export interface ResizableOptions extends PerActionDefaults {
  square?: boolean
  preserveAspectRatio?: boolean
  edges?: EdgeOptions | null
  axis?: 'x' | 'y' | 'xy' // deprecated
  invert?: 'none' | 'negate' | 'reposition'
  margin?: number
  squareResize?: boolean
  oninertiastart?: ListenersArg
  onstart?: ListenersArg
  onmove?: ListenersArg
  onend?: ListenersArg
}

export interface ResizeEvent<P extends EventPhase = EventPhase> extends InteractEvent<'resize', P> {
  deltaRect?: FullRect
  edges?: ActionProps['edges']
}

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

  // Less Precision with touch input

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

  Interactable.prototype.resizable = function (this: Interactable, options: ResizableOptions | boolean) {
    return resizable(this, options, scope)
  } as Interactable['resizable']

  actions.map.resize = resize
  actions.methodDict.resize = 'resizable'

  defaults.actions.resize = resize.defaults
}

function resizeChecker(arg) {
  const { interaction, interactable, element, rect, buttons } = arg

  if (!rect) {
    return undefined
  }

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

  if (
    !(resizeOptions && resizeOptions.enabled) ||
    // check mouseButton setting if the pointer is down
    (interaction.pointerIsDown &&
      /mouse|pointer/.test(interaction.pointerType) &&
      (buttons & resizeOptions.mouseButtons) === 0)
  ) {
    return undefined
  }

  // if using resize.edges
  if (is.object(resizeOptions.edges)) {
    const resizeEdges = {
      left: false,
      right: false,
      top: false,
      bottom: false,
    }

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

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

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

    if (right || bottom) {
      arg.action = {
        name: 'resize',
        axes: (right ? 'x' : '') + (bottom ? 'y' : ''),
      }
    }
  }

  return arg.action ? false : undefined
}

function resizable(interactable: Interactable, options: OrBoolean<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: Point,
  element: Node,
  interactableElement: Element,
  rect: 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, Math.abs((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') {
      const edge = width >= 0 ? rect.left : rect.right
      return page.x < edge + margin
    }
    if (name === 'top') {
      const edge = height >= 0 ? rect.top : rect.bottom
      return page.y < edge + 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)
}

/* eslint-disable multiline-ternary */
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
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',
      }
}
/* eslint-enable multiline-ternary */

function start({ iEvent, interaction }: { iEvent: InteractEvent<any, any>; interaction: Interaction }) {
  if (interaction.prepared.name !== 'resize' || !interaction.prepared.edges) {
    return
  }

  const resizeEvent = iEvent as ResizeEvent
  const rect = interaction.rect

  interaction._rects = {
    start: extend({}, rect),
    corrected: extend({}, rect),
    previous: extend({}, rect),
    delta: {
      left: 0,
      right: 0,
      width: 0,
      top: 0,
      bottom: 0,
      height: 0,
    },
  }

  resizeEvent.edges = interaction.prepared.edges
  resizeEvent.rect = interaction._rects.corrected
  resizeEvent.deltaRect = interaction._rects.delta
}

function move({ iEvent, interaction }: { iEvent: InteractEvent<any, any>; interaction: Interaction }) {
  if (interaction.prepared.name !== 'resize' || !interaction.prepared.edges) return

  const resizeEvent = iEvent as ResizeEvent
  const resizeOptions = interaction.interactable.options.resize
  const invert = resizeOptions.invert
  const invertible = invert === 'reposition' || invert === 'negate'

  const current = interaction.rect
  const { start: startRect, corrected, delta: deltaRect, previous } = interaction._rects

  extend(previous, corrected)

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

    if (invert === 'reposition') {
      // swap edge values if necessary to keep width/height positive
      if (corrected.top > corrected.bottom) {
        const swap = corrected.top

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

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

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

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

  resizeEvent.edges = interaction.prepared.edges
  resizeEvent.rect = corrected
  resizeEvent.deltaRect = deltaRect
}

function end({ iEvent, interaction }: { iEvent: InteractEvent<any, any>; interaction: Interaction }) {
  if (interaction.prepared.name !== 'resize' || !interaction.prepared.edges) return

  const resizeEvent = iEvent as ResizeEvent

  resizeEvent.edges = interaction.prepared.edges
  resizeEvent.rect = interaction._rects.corrected
  resizeEvent.deltaRect = interaction._rects.delta
}

function updateEventAxes({
  iEvent,
  interaction,
}: {
  iEvent: InteractEvent<any, any>
  interaction: Interaction
}) {
  if (interaction.prepared.name !== 'resize' || !interaction.resizeAxes) return

  const options = interaction.interactable.options
  const resizeEvent = iEvent as ResizeEvent

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

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

const resize: Plugin = {
  id: 'actions/resize',
  before: ['actions/drag'],
  install,
  listeners: {
    'interactions:new': ({ interaction }) => {
      interaction.resizeAxes = 'xy'
    },

    'interactions:action-start': (arg) => {
      start(arg)
      updateEventAxes(arg)
    },
    'interactions:action-move': (arg) => {
      move(arg)
      updateEventAxes(arg)
    },
    'interactions:action-end': end,
    'auto-start:check': resizeChecker,
  },

  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 ResizableOptions,

  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
  },

  filterEventType: (type: string) => type.search('resize') === 0,

  defaultMargin: null as number,
}

export default resize