packages/@interactjs/actions/drop/plugin.ts
import type { Interactable } from '@interactjs/core/Interactable'
import type { EventPhase, InteractEvent } from '@interactjs/core/InteractEvent'
import type { Interaction, DoPhaseArg } from '@interactjs/core/Interaction'
import type { PerActionDefaults } from '@interactjs/core/options'
import type { Scope, Plugin } from '@interactjs/core/scope'
import type { Element, PointerEventType, Rect, ListenersArg } from '@interactjs/core/types'
import * as domUtils from '@interactjs/utils/domUtils'
import extend from '@interactjs/utils/extend'
import getOriginXY from '@interactjs/utils/getOriginXY'
import is from '@interactjs/utils/is'
import normalizeListeners from '@interactjs/utils/normalizeListeners'
import * as pointerUtils from '@interactjs/utils/pointerUtils'
/* eslint-disable import/no-duplicates -- for typescript module augmentations */
import '../drag/plugin'
import type { DragEvent } from '../drag/plugin'
import drag from '../drag/plugin'
/* eslint-enable import/no-duplicates */
import { DropEvent } from './DropEvent'
export type DropFunctionChecker = (
dragEvent: any, // related drag operation
event: any, // touch or mouse EventEmitter
dropped: boolean, // default checker result
dropzone: Interactable, // dropzone interactable
dropElement: Element, // drop zone element
draggable: Interactable, // draggable's Interactable
draggableElement: Element, // dragged element
) => boolean
export interface DropzoneOptions extends PerActionDefaults {
accept?:
| string
| Element
| (({ dropzone, draggableElement }: { dropzone: Interactable; draggableElement: Element }) => boolean)
// How the overlap is checked on the drop zone
overlap?: 'pointer' | 'center' | number
checker?: DropFunctionChecker
ondropactivate?: ListenersArg
ondropdeactivate?: ListenersArg
ondragenter?: ListenersArg
ondragleave?: ListenersArg
ondropmove?: ListenersArg
ondrop?: ListenersArg
}
export interface DropzoneMethod {
(this: Interactable, options: DropzoneOptions | boolean): Interactable
(): DropzoneOptions
}
declare module '@interactjs/core/Interactable' {
interface Interactable {
/**
*
* ```js
* interact('.drop').dropzone({
* accept: '.can-drop' || document.getElementById('single-drop'),
* overlap: 'pointer' || 'center' || zeroToOne
* }
* ```
*
* Returns or sets whether draggables can be dropped onto this target to
* trigger drop events
*
* Dropzones can receive the following events:
* - `dropactivate` and `dropdeactivate` when an acceptable drag starts and ends
* - `dragenter` and `dragleave` when a draggable enters and leaves the dropzone
* - `dragmove` when a draggable that has entered the dropzone is moved
* - `drop` when a draggable is dropped into this dropzone
*
* Use the `accept` option to allow only elements that match the given CSS
* selector or element. The value can be:
*
* - **an Element** - only that element can be dropped into this dropzone.
* - **a string**, - the element being dragged must match it as a CSS selector.
* - **`null`** - accept options is cleared - it accepts any element.
*
* Use the `overlap` option to set how drops are checked for. The allowed
* values are:
*
* - `'pointer'`, the pointer must be over the dropzone (default)
* - `'center'`, the draggable element's center must be over the dropzone
* - a number from 0-1 which is the `(intersection area) / (draggable area)`.
* e.g. `0.5` for drop to happen when half of the area of the draggable is
* over the dropzone
*
* Use the `checker` option to specify a function to check if a dragged element
* is over this Interactable.
*
* @param options - The new options to be set
*/
dropzone(options: DropzoneOptions | boolean): Interactable
/** @returns The current setting */
dropzone(): DropzoneOptions
/**
* ```js
* interact(target)
* .dropChecker(function(dragEvent, // related dragmove or dragend event
* event, // TouchEvent/PointerEvent/MouseEvent
* dropped, // bool result of the default checker
* dropzone, // dropzone Interactable
* dropElement, // dropzone elemnt
* draggable, // draggable Interactable
* draggableElement) {// draggable element
*
* return dropped && event.target.hasAttribute('allow-drop')
* }
* ```
*/
dropCheck(
dragEvent: InteractEvent,
event: PointerEventType,
draggable: Interactable,
draggableElement: Element,
dropElemen: Element,
rect: any,
): boolean
}
}
declare module '@interactjs/core/Interaction' {
interface Interaction {
dropState?: DropState
}
}
declare module '@interactjs/core/InteractEvent' {
interface InteractEvent {
/** @internal */
prevDropzone?: Interactable
dropzone?: Interactable
dragEnter?: Element
dragLeave?: Element
}
}
declare module '@interactjs/core/options' {
interface ActionDefaults {
drop: DropzoneOptions
}
}
declare module '@interactjs/core/scope' {
interface Scope {
dynamicDrop?: boolean
}
interface SignalArgs {
'actions/drop:start': DropSignalArg
'actions/drop:move': DropSignalArg
'actions/drop:end': DropSignalArg
}
}
declare module '@interactjs/core/types' {
interface ActionMap {
drop?: typeof drop
}
}
declare module '@interactjs/core/InteractStatic' {
interface InteractStatic {
/**
* Returns or sets whether the dimensions of dropzone elements are calculated
* on every dragmove or only on dragstart for the default dropChecker
*
* @param {boolean} [newValue] True to check on each move. False to check only
* before start
* @return {boolean | interact} The current setting or interact
*/
dynamicDrop: (newValue?: boolean) => boolean | this
}
}
interface DropSignalArg {
interaction: Interaction<'drag'>
dragEvent: DragEvent
}
export interface ActiveDrop {
dropzone: Interactable
element: Element
rect: Rect
}
export interface DropState {
cur: {
// the dropzone a drag target might be dropped into
dropzone: Interactable
// the element at the time of checking
element: Element
}
prev: {
// the dropzone that was recently dragged away from
dropzone: Interactable
// the element at the time of checking
element: Element
}
// wheather the potential drop was rejected from a listener
rejected: boolean
// the drop events related to the current drag event
events: FiredDropEvents
activeDrops: ActiveDrop[]
}
function install(scope: Scope) {
const { actions, interactStatic: interact, Interactable, defaults } = scope
scope.usePlugin(drag)
Interactable.prototype.dropzone = function (this: Interactable, options) {
return dropzoneMethod(this, options)
} as Interactable['dropzone']
Interactable.prototype.dropCheck = function (
this: Interactable,
dragEvent,
event,
draggable,
draggableElement,
dropElement,
rect,
) {
return dropCheckMethod(this, dragEvent, event, draggable, draggableElement, dropElement, rect)
}
interact.dynamicDrop = function (newValue?: boolean) {
if (is.bool(newValue)) {
// if (dragging && scope.dynamicDrop !== newValue && !newValue) {
// calcRects(dropzones)
// }
scope.dynamicDrop = newValue
return interact
}
return scope.dynamicDrop!
}
extend(actions.phaselessTypes, {
dragenter: true,
dragleave: true,
dropactivate: true,
dropdeactivate: true,
dropmove: true,
drop: true,
})
actions.methodDict.drop = 'dropzone'
scope.dynamicDrop = false
defaults.actions.drop = drop.defaults
}
function collectDropzones({ interactables }: Scope, draggableElement: Element) {
const drops: ActiveDrop[] = []
// collect all dropzones and their elements which qualify for a drop
for (const dropzone of interactables.list) {
if (!dropzone.options.drop.enabled) {
continue
}
const accept = dropzone.options.drop.accept
// test the draggable draggableElement against the dropzone's accept setting
if (
(is.element(accept) && accept !== draggableElement) ||
(is.string(accept) && !domUtils.matchesSelector(draggableElement, accept)) ||
(is.func(accept) && !accept({ dropzone, draggableElement }))
) {
continue
}
for (const dropzoneElement of dropzone.getAllElements()) {
if (dropzoneElement !== draggableElement) {
drops.push({
dropzone,
element: dropzoneElement,
rect: dropzone.getRect(dropzoneElement),
})
}
}
}
return drops
}
function fireActivationEvents(activeDrops: ActiveDrop[], event: DropEvent) {
// loop through all active dropzones and trigger event
for (const { dropzone, element } of activeDrops.slice()) {
event.dropzone = dropzone
// set current element as event target
event.target = element
dropzone.fire(event)
event.propagationStopped = event.immediatePropagationStopped = false
}
}
// return a new array of possible drops. getActiveDrops should always be
// called when a drag has just started or a drag event happens while
// dynamicDrop is true
function getActiveDrops(scope: Scope, dragElement: Element) {
// get dropzones and their elements that could receive the draggable
const activeDrops = collectDropzones(scope, dragElement)
for (const activeDrop of activeDrops) {
activeDrop.rect = activeDrop.dropzone.getRect(activeDrop.element)
}
return activeDrops
}
function getDrop(
{ dropState, interactable: draggable, element: dragElement }: Interaction,
dragEvent,
pointerEvent,
) {
const validDrops: Element[] = []
// collect all dropzones and their elements which qualify for a drop
for (const { dropzone, element: dropzoneElement, rect } of dropState.activeDrops) {
const isValid = dropzone.dropCheck(
dragEvent,
pointerEvent,
draggable!,
dragElement!,
dropzoneElement,
rect,
)
validDrops.push(isValid ? dropzoneElement : null)
}
// get the most appropriate dropzone based on DOM depth and order
const dropIndex = domUtils.indexOfDeepestElement(validDrops)
return dropState!.activeDrops[dropIndex] || null
}
function getDropEvents(interaction: Interaction, _pointerEvent, dragEvent: DragEvent) {
const dropState = interaction.dropState!
const dropEvents: Record<string, DropEvent | null> = {
enter: null,
leave: null,
activate: null,
deactivate: null,
move: null,
drop: null,
}
if (dragEvent.type === 'dragstart') {
dropEvents.activate = new DropEvent(dropState, dragEvent, 'dropactivate')
dropEvents.activate.target = null as never
dropEvents.activate.dropzone = null as never
}
if (dragEvent.type === 'dragend') {
dropEvents.deactivate = new DropEvent(dropState, dragEvent, 'dropdeactivate')
dropEvents.deactivate.target = null as never
dropEvents.deactivate.dropzone = null as never
}
if (dropState.rejected) {
return dropEvents
}
if (dropState.cur.element !== dropState.prev.element) {
// if there was a previous dropzone, create a dragleave event
if (dropState.prev.dropzone) {
dropEvents.leave = new DropEvent(dropState, dragEvent, 'dragleave')
dragEvent.dragLeave = dropEvents.leave.target = dropState.prev.element
dragEvent.prevDropzone = dropEvents.leave.dropzone = dropState.prev.dropzone
}
// if dropzone is not null, create a dragenter event
if (dropState.cur.dropzone) {
dropEvents.enter = new DropEvent(dropState, dragEvent, 'dragenter')
dragEvent.dragEnter = dropState.cur.element
dragEvent.dropzone = dropState.cur.dropzone
}
}
if (dragEvent.type === 'dragend' && dropState.cur.dropzone) {
dropEvents.drop = new DropEvent(dropState, dragEvent, 'drop')
dragEvent.dropzone = dropState.cur.dropzone
dragEvent.relatedTarget = dropState.cur.element
}
if (dragEvent.type === 'dragmove' && dropState.cur.dropzone) {
dropEvents.move = new DropEvent(dropState, dragEvent, 'dropmove')
dragEvent.dropzone = dropState.cur.dropzone
}
return dropEvents
}
type FiredDropEvents = Partial<
Record<'leave' | 'enter' | 'move' | 'drop' | 'activate' | 'deactivate', DropEvent>
>
function fireDropEvents(interaction: Interaction, events: FiredDropEvents) {
const dropState = interaction.dropState!
const { activeDrops, cur, prev } = dropState
if (events.leave) {
prev.dropzone.fire(events.leave)
}
if (events.enter) {
cur.dropzone.fire(events.enter)
}
if (events.move) {
cur.dropzone.fire(events.move)
}
if (events.drop) {
cur.dropzone.fire(events.drop)
}
if (events.deactivate) {
fireActivationEvents(activeDrops, events.deactivate)
}
dropState.prev.dropzone = cur.dropzone
dropState.prev.element = cur.element
}
function onEventCreated({ interaction, iEvent, event }: DoPhaseArg<'drag', EventPhase>, scope: Scope) {
if (iEvent.type !== 'dragmove' && iEvent.type !== 'dragend') {
return
}
const dropState = interaction.dropState!
if (scope.dynamicDrop) {
dropState.activeDrops = getActiveDrops(scope, interaction.element!)
}
const dragEvent = iEvent
const dropResult = getDrop(interaction, dragEvent, event)
// update rejected status
dropState.rejected =
dropState.rejected &&
!!dropResult &&
dropResult.dropzone === dropState.cur.dropzone &&
dropResult.element === dropState.cur.element
dropState.cur.dropzone = dropResult && dropResult.dropzone
dropState.cur.element = dropResult && dropResult.element
dropState.events = getDropEvents(interaction, event, dragEvent)
}
function dropzoneMethod(interactable: Interactable): DropzoneOptions
function dropzoneMethod(interactable: Interactable, options: DropzoneOptions | boolean): Interactable
function dropzoneMethod(interactable: Interactable, options?: DropzoneOptions | boolean) {
if (is.object(options)) {
interactable.options.drop.enabled = options.enabled !== false
if (options.listeners) {
const normalized = normalizeListeners(options.listeners)
// rename 'drop' to '' as it will be prefixed with 'drop'
const corrected = Object.keys(normalized).reduce((acc, type) => {
const correctedType = /^(enter|leave)/.test(type)
? `drag${type}`
: /^(activate|deactivate|move)/.test(type)
? `drop${type}`
: type
acc[correctedType] = normalized[type]
return acc
}, {})
const prevListeners = interactable.options.drop.listeners
prevListeners && interactable.off(prevListeners)
interactable.on(corrected)
interactable.options.drop.listeners = corrected
}
if (is.func(options.ondrop)) {
interactable.on('drop', options.ondrop)
}
if (is.func(options.ondropactivate)) {
interactable.on('dropactivate', options.ondropactivate)
}
if (is.func(options.ondropdeactivate)) {
interactable.on('dropdeactivate', options.ondropdeactivate)
}
if (is.func(options.ondragenter)) {
interactable.on('dragenter', options.ondragenter)
}
if (is.func(options.ondragleave)) {
interactable.on('dragleave', options.ondragleave)
}
if (is.func(options.ondropmove)) {
interactable.on('dropmove', options.ondropmove)
}
if (/^(pointer|center)$/.test(options.overlap as string)) {
interactable.options.drop.overlap = options.overlap
} else if (is.number(options.overlap)) {
interactable.options.drop.overlap = Math.max(Math.min(1, options.overlap), 0)
}
if ('accept' in options) {
interactable.options.drop.accept = options.accept
}
if ('checker' in options) {
interactable.options.drop.checker = options.checker
}
return interactable
}
if (is.bool(options)) {
interactable.options.drop.enabled = options
return interactable
}
return interactable.options.drop
}
function dropCheckMethod(
interactable: Interactable,
dragEvent: InteractEvent,
event: PointerEventType,
draggable: Interactable,
draggableElement: Element,
dropElement: Element,
rect: any,
) {
let dropped = false
// if the dropzone has no rect (eg. display: none)
// call the custom dropChecker or just return false
if (!(rect = rect || interactable.getRect(dropElement))) {
return interactable.options.drop.checker
? interactable.options.drop.checker(
dragEvent,
event,
dropped,
interactable,
dropElement,
draggable,
draggableElement,
)
: false
}
const dropOverlap = interactable.options.drop.overlap
if (dropOverlap === 'pointer') {
const origin = getOriginXY(draggable, draggableElement, 'drag')
const page = pointerUtils.getPageXY(dragEvent)
page.x += origin.x
page.y += origin.y
const horizontal = page.x > rect.left && page.x < rect.right
const vertical = page.y > rect.top && page.y < rect.bottom
dropped = horizontal && vertical
}
const dragRect = draggable.getRect(draggableElement)
if (dragRect && dropOverlap === 'center') {
const cx = dragRect.left + dragRect.width / 2
const cy = dragRect.top + dragRect.height / 2
dropped = cx >= rect.left && cx <= rect.right && cy >= rect.top && cy <= rect.bottom
}
if (dragRect && is.number(dropOverlap)) {
const overlapArea =
Math.max(0, Math.min(rect.right, dragRect.right) - Math.max(rect.left, dragRect.left)) *
Math.max(0, Math.min(rect.bottom, dragRect.bottom) - Math.max(rect.top, dragRect.top))
const overlapRatio = overlapArea / (dragRect.width * dragRect.height)
dropped = overlapRatio >= dropOverlap
}
if (interactable.options.drop.checker) {
dropped = interactable.options.drop.checker(
dragEvent,
event,
dropped,
interactable,
dropElement,
draggable,
draggableElement,
)
}
return dropped
}
const drop: Plugin = {
id: 'actions/drop',
install,
listeners: {
'interactions:before-action-start': ({ interaction }) => {
if (interaction.prepared.name !== 'drag') {
return
}
interaction.dropState = {
cur: {
dropzone: null,
element: null,
},
prev: {
dropzone: null,
element: null,
},
rejected: null,
events: null,
activeDrops: [],
}
},
'interactions:after-action-start': (
{ interaction, event, iEvent: dragEvent }: DoPhaseArg<'drag', EventPhase>,
scope,
) => {
if (interaction.prepared.name !== 'drag') {
return
}
const dropState = interaction.dropState!
// reset active dropzones
dropState.activeDrops = []
dropState.events = {}
dropState.activeDrops = getActiveDrops(scope, interaction.element!)
dropState.events = getDropEvents(interaction, event, dragEvent)
if (dropState.events.activate) {
fireActivationEvents(dropState.activeDrops, dropState.events.activate)
scope.fire('actions/drop:start', { interaction, dragEvent })
}
},
'interactions:action-move': onEventCreated,
'interactions:after-action-move': (
{ interaction, iEvent: dragEvent }: DoPhaseArg<'drag', EventPhase>,
scope,
) => {
if (interaction.prepared.name !== 'drag') {
return
}
const dropState = interaction.dropState!
fireDropEvents(interaction, dropState.events)
scope.fire('actions/drop:move', { interaction, dragEvent })
dropState.events = {}
},
'interactions:action-end': (arg: DoPhaseArg<'drag', EventPhase>, scope) => {
if (arg.interaction.prepared.name !== 'drag') {
return
}
const { interaction, iEvent: dragEvent } = arg
onEventCreated(arg, scope)
fireDropEvents(interaction, interaction.dropState!.events)
scope.fire('actions/drop:end', { interaction, dragEvent })
},
'interactions:stop': ({ interaction }) => {
if (interaction.prepared.name !== 'drag') {
return
}
const { dropState } = interaction
if (dropState) {
dropState.activeDrops = null as never
dropState.events = null as never
dropState.cur.dropzone = null as never
dropState.cur.element = null as never
dropState.prev.dropzone = null as never
dropState.prev.element = null as never
dropState.rejected = false
}
},
},
getActiveDrops,
getDrop,
getDropEvents,
fireDropEvents,
filterEventType: (type: string) => type.search('drag') === 0 || type.search('drop') === 0,
defaults: {
enabled: false,
accept: null as never,
overlap: 'pointer',
} as DropzoneOptions,
}
export default drop