qtsd-fork
Version:
Do not use this please
634 lines (541 loc) • 18.6 kB
JavaScript
/* eslint-disable no-underscore-dangle */
import defaults from 'lodash/defaults'
import shallowEqual from './shallowEqual'
import EnterLeaveCounter from './EnterLeaveCounter'
import { isFirefox } from './BrowserDetector'
import {
getNodeClientOffset,
getEventClientOffset,
getDragPreviewOffset,
} from './OffsetUtils'
import {
createNativeDragSource,
matchNativeItemType,
} from './NativeDragSources'
import * as NativeTypes from './NativeTypes'
export default class HTML5Backend {
constructor(manager) {
this.actions = manager.getActions()
this.monitor = manager.getMonitor()
this.registry = manager.getRegistry()
this.context = manager.getContext()
this.sourcePreviewNodes = {}
this.sourcePreviewNodeOptions = {}
this.sourceNodes = {}
this.sourceNodeOptions = {}
this.enterLeaveCounter = new EnterLeaveCounter()
this.dragStartSourceIds = []
this.dropTargetIds = []
this.dragEnterTargetIds = []
this.currentNativeSource = null
this.currentNativeHandle = null
this.currentDragSourceNode = null
this.currentDragSourceNodeOffset = null
this.currentDragSourceNodeOffsetChanged = false
this.altKeyPressed = false
this.mouseMoveTimeoutTimer = null
this.getSourceClientOffset = this.getSourceClientOffset.bind(this)
this.handleTopDragStart = this.handleTopDragStart.bind(this)
this.handleTopDragStartCapture = this.handleTopDragStartCapture.bind(this)
this.handleTopDragEndCapture = this.handleTopDragEndCapture.bind(this)
this.handleTopDragEnter = this.handleTopDragEnter.bind(this)
this.handleTopDragEnterCapture = this.handleTopDragEnterCapture.bind(this)
this.handleTopDragLeaveCapture = this.handleTopDragLeaveCapture.bind(this)
this.handleTopDragOver = this.handleTopDragOver.bind(this)
this.handleTopDragOverCapture = this.handleTopDragOverCapture.bind(this)
this.handleTopDrop = this.handleTopDrop.bind(this)
this.handleTopDropCapture = this.handleTopDropCapture.bind(this)
this.handleSelectStart = this.handleSelectStart.bind(this)
this.endDragIfSourceWasRemovedFromDOM = this.endDragIfSourceWasRemovedFromDOM.bind(
this,
)
this.endDragNativeItem = this.endDragNativeItem.bind(this)
this.asyncEndDragNativeItem = this.asyncEndDragNativeItem.bind(this)
this.isNodeInDocument = this.isNodeInDocument.bind(this)
}
get window() {
if (this.context && this.context.window) {
return this.context.window
} else if (typeof window !== 'undefined') {
return window
}
return undefined
}
setup() {
if (this.window === undefined) {
return
}
if (this.window.__isReactDndBackendSetUp) {
throw new Error('Cannot have two HTML5 backends at the same time.')
}
this.window.__isReactDndBackendSetUp = true
this.addEventListeners(this.window)
}
teardown() {
if (this.window === undefined) {
return
}
this.window.__isReactDndBackendSetUp = false
this.removeEventListeners(this.window)
this.clearCurrentDragSourceNode()
if (this.asyncEndDragFrameId) {
this.window.cancelAnimationFrame(this.asyncEndDragFrameId)
}
}
addEventListeners(target) {
// SSR Fix (https://github.com/react-dnd/react-dnd/pull/813
if (!target.addEventListener) {
return
}
target.addEventListener('dragstart', this.handleTopDragStart)
target.addEventListener('dragstart', this.handleTopDragStartCapture, true)
target.addEventListener('dragend', this.handleTopDragEndCapture, true)
target.addEventListener('dragenter', this.handleTopDragEnter)
target.addEventListener('dragenter', this.handleTopDragEnterCapture, true)
target.addEventListener('dragleave', this.handleTopDragLeaveCapture, true)
target.addEventListener('dragover', this.handleTopDragOver)
target.addEventListener('dragover', this.handleTopDragOverCapture, true)
target.addEventListener('drop', this.handleTopDrop)
target.addEventListener('drop', this.handleTopDropCapture, true)
}
removeEventListeners(target) {
// SSR Fix (https://github.com/react-dnd/react-dnd/pull/813
if (!target.removeEventListener) {
return
}
target.removeEventListener('dragstart', this.handleTopDragStart)
target.removeEventListener(
'dragstart',
this.handleTopDragStartCapture,
true,
)
target.removeEventListener('dragend', this.handleTopDragEndCapture, true)
target.removeEventListener('dragenter', this.handleTopDragEnter)
target.removeEventListener(
'dragenter',
this.handleTopDragEnterCapture,
true,
)
target.removeEventListener(
'dragleave',
this.handleTopDragLeaveCapture,
true,
)
target.removeEventListener('dragover', this.handleTopDragOver)
target.removeEventListener('dragover', this.handleTopDragOverCapture, true)
target.removeEventListener('drop', this.handleTopDrop)
target.removeEventListener('drop', this.handleTopDropCapture, true)
}
connectDragPreview(sourceId, node, options) {
this.sourcePreviewNodeOptions[sourceId] = options
this.sourcePreviewNodes[sourceId] = node
return () => {
delete this.sourcePreviewNodes[sourceId]
delete this.sourcePreviewNodeOptions[sourceId]
}
}
connectDragSource(sourceId, node, options) {
this.sourceNodes[sourceId] = node
this.sourceNodeOptions[sourceId] = options
const handleDragStart = e => this.handleDragStart(e, sourceId)
const handleSelectStart = e => this.handleSelectStart(e, sourceId)
node.setAttribute('draggable', true)
node.addEventListener('dragstart', handleDragStart)
node.addEventListener('selectstart', handleSelectStart)
return () => {
delete this.sourceNodes[sourceId]
delete this.sourceNodeOptions[sourceId]
node.removeEventListener('dragstart', handleDragStart)
node.removeEventListener('selectstart', handleSelectStart)
node.setAttribute('draggable', false)
}
}
connectDropTarget(targetId, node) {
const handleDragEnter = e => this.handleDragEnter(e, targetId)
const handleDragOver = e => this.handleDragOver(e, targetId)
const handleDrop = e => this.handleDrop(e, targetId)
node.addEventListener('dragenter', handleDragEnter)
node.addEventListener('dragover', handleDragOver)
node.addEventListener('drop', handleDrop)
return () => {
node.removeEventListener('dragenter', handleDragEnter)
node.removeEventListener('dragover', handleDragOver)
node.removeEventListener('drop', handleDrop)
}
}
getCurrentSourceNodeOptions() {
const sourceId = this.monitor.getSourceId()
const sourceNodeOptions = this.sourceNodeOptions[sourceId]
return defaults(sourceNodeOptions || {}, {
dropEffect: this.altKeyPressed ? 'copy' : 'move',
})
}
getCurrentDropEffect() {
if (this.isDraggingNativeItem()) {
// It makes more sense to default to 'copy' for native resources
return 'copy'
}
return this.getCurrentSourceNodeOptions().dropEffect
}
getCurrentSourcePreviewNodeOptions() {
const sourceId = this.monitor.getSourceId()
const sourcePreviewNodeOptions = this.sourcePreviewNodeOptions[sourceId]
return defaults(sourcePreviewNodeOptions || {}, {
anchorX: 0.5,
anchorY: 0.5,
captureDraggingState: false,
})
}
getSourceClientOffset(sourceId) {
return getNodeClientOffset(this.sourceNodes[sourceId])
}
isDraggingNativeItem() {
const itemType = this.monitor.getItemType()
return Object.keys(NativeTypes).some(key => NativeTypes[key] === itemType)
}
beginDragNativeItem(type) {
this.clearCurrentDragSourceNode()
const SourceType = createNativeDragSource(type)
this.currentNativeSource = new SourceType()
this.currentNativeHandle = this.registry.addSource(
type,
this.currentNativeSource,
)
this.actions.beginDrag([this.currentNativeHandle])
}
asyncEndDragNativeItem() {
this.asyncEndDragFrameId = this.window.requestAnimationFrame(
this.endDragNativeItem,
)
}
endDragNativeItem() {
if (!this.isDraggingNativeItem()) {
return
}
this.actions.endDrag()
this.registry.removeSource(this.currentNativeHandle)
this.currentNativeHandle = null
this.currentNativeSource = null
}
isNodeInDocument(node) {
// Check the node either in the main document or in the current context
return document.body.contains(node) || this.window
? this.window.document.body.contains(node)
: false
}
endDragIfSourceWasRemovedFromDOM() {
const node = this.currentDragSourceNode
if (this.isNodeInDocument(node)) {
return
}
if (this.clearCurrentDragSourceNode()) {
this.actions.endDrag()
}
}
setCurrentDragSourceNode(node) {
this.clearCurrentDragSourceNode()
this.currentDragSourceNode = node
this.currentDragSourceNodeOffset = getNodeClientOffset(node)
this.currentDragSourceNodeOffsetChanged = false
// A timeout of > 0 is necessary to resolve Firefox issue referenced
// See:
// * https://github.com/react-dnd/react-dnd/pull/928
// * https://github.com/react-dnd/react-dnd/issues/869
const MOUSE_MOVE_TIMEOUT = 1000
// Receiving a mouse event in the middle of a dragging operation
// means it has ended and the drag source node disappeared from DOM,
// so the browser didn't dispatch the dragend event.
//
// We need to wait before we start listening for mousemove events.
// This is needed because the drag preview needs to be drawn or else it fires an 'mousemove' event
// immediately in some browsers.
//
// See:
// * https://github.com/react-dnd/react-dnd/pull/928
// * https://github.com/react-dnd/react-dnd/issues/869
//
this.mouseMoveTimeoutTimer = setTimeout(() => {
this.mouseMoveTimeoutId = null
return this.window.addEventListener(
'mousemove',
this.endDragIfSourceWasRemovedFromDOM,
true,
)
}, MOUSE_MOVE_TIMEOUT)
}
clearCurrentDragSourceNode() {
if (this.currentDragSourceNode) {
this.currentDragSourceNode = null
this.currentDragSourceNodeOffset = null
this.currentDragSourceNodeOffsetChanged = false
this.window.clearTimeout(this.mouseMoveTimeoutTimer)
this.window.removeEventListener(
'mousemove',
this.endDragIfSourceWasRemovedFromDOM,
true,
)
this.mouseMoveTimeoutTimer = null
return true
}
return false
}
checkIfCurrentDragSourceRectChanged() {
const node = this.currentDragSourceNode
if (!node) {
return false
}
if (this.currentDragSourceNodeOffsetChanged) {
return true
}
this.currentDragSourceNodeOffsetChanged = !shallowEqual(
getNodeClientOffset(node),
this.currentDragSourceNodeOffset,
)
return this.currentDragSourceNodeOffsetChanged
}
handleTopDragStartCapture() {
this.clearCurrentDragSourceNode()
this.dragStartSourceIds = []
}
handleDragStart(e, sourceId) {
this.dragStartSourceIds.unshift(sourceId)
}
handleTopDragStart(e) {
const { dragStartSourceIds } = this
this.dragStartSourceIds = null
const clientOffset = getEventClientOffset(e)
// Avoid crashing if we missed a drop event or our previous drag died
if (this.monitor.isDragging()) {
this.actions.endDrag()
}
// Don't publish the source just yet (see why below)
this.actions.beginDrag(dragStartSourceIds, {
publishSource: false,
getSourceClientOffset: this.getSourceClientOffset,
clientOffset,
})
const { dataTransfer } = e
const nativeType = matchNativeItemType(dataTransfer)
if (this.monitor.isDragging()) {
if (typeof dataTransfer.setDragImage === 'function') {
// Use custom drag image if user specifies it.
// If child drag source refuses drag but parent agrees,
// use parent's node as drag image. Neither works in IE though.
const sourceId = this.monitor.getSourceId()
const sourceNode = this.sourceNodes[sourceId]
const dragPreview = this.sourcePreviewNodes[sourceId] || sourceNode
const {
anchorX,
anchorY,
offsetX,
offsetY,
} = this.getCurrentSourcePreviewNodeOptions()
const anchorPoint = { anchorX, anchorY }
const offsetPoint = { offsetX, offsetY }
const dragPreviewOffset = getDragPreviewOffset(
sourceNode,
dragPreview,
clientOffset,
anchorPoint,
offsetPoint,
)
dataTransfer.setDragImage(
dragPreview,
dragPreviewOffset.x,
dragPreviewOffset.y,
)
}
try {
// Firefox won't drag without setting data
dataTransfer.setData('application/json', {})
} catch (err) {
// IE doesn't support MIME types in setData
}
// Store drag source node so we can check whether
// it is removed from DOM and trigger endDrag manually.
this.setCurrentDragSourceNode(e.target)
// Now we are ready to publish the drag source.. or are we not?
const { captureDraggingState } = this.getCurrentSourcePreviewNodeOptions()
if (!captureDraggingState) {
// Usually we want to publish it in the next tick so that browser
// is able to screenshot the current (not yet dragging) state.
//
// It also neatly avoids a situation where render() returns null
// in the same tick for the source element, and browser freaks out.
setTimeout(() => this.actions.publishDragSource())
} else {
// In some cases the user may want to override this behavior, e.g.
// to work around IE not supporting custom drag previews.
//
// When using a custom drag layer, the only way to prevent
// the default drag preview from drawing in IE is to screenshot
// the dragging state in which the node itself has zero opacity
// and height. In this case, though, returning null from render()
// will abruptly end the dragging, which is not obvious.
//
// This is the reason such behavior is strictly opt-in.
this.actions.publishDragSource()
}
} else if (nativeType) {
// A native item (such as URL) dragged from inside the document
this.beginDragNativeItem(nativeType)
} else if (
!dataTransfer.types &&
(!e.target.hasAttribute || !e.target.hasAttribute('draggable'))
) {
// Looks like a Safari bug: dataTransfer.types is null, but there was no draggable.
// Just let it drag. It's a native type (URL or text) and will be picked up in
// dragenter handler.
return // eslint-disable-line no-useless-return
} else {
// If by this time no drag source reacted, tell browser not to drag.
e.preventDefault()
}
}
handleTopDragEndCapture() {
if (this.clearCurrentDragSourceNode()) {
// Firefox can dispatch this event in an infinite loop
// if dragend handler does something like showing an alert.
// Only proceed if we have not handled it already.
this.actions.endDrag()
}
}
handleTopDragEnterCapture(e) {
this.dragEnterTargetIds = []
const isFirstEnter = this.enterLeaveCounter.enter(e.target)
if (!isFirstEnter || this.monitor.isDragging()) {
return
}
const { dataTransfer } = e
const nativeType = matchNativeItemType(dataTransfer)
if (nativeType) {
// A native item (such as file or URL) dragged from outside the document
this.beginDragNativeItem(nativeType)
}
}
handleDragEnter(e, targetId) {
this.dragEnterTargetIds.unshift(targetId)
}
handleTopDragEnter(e) {
const { dragEnterTargetIds } = this
this.dragEnterTargetIds = []
if (!this.monitor.isDragging()) {
// This is probably a native item type we don't understand.
return
}
this.altKeyPressed = e.altKey
if (!isFirefox()) {
// Don't emit hover in `dragenter` on Firefox due to an edge case.
// If the target changes position as the result of `dragenter`, Firefox
// will still happily dispatch `dragover` despite target being no longer
// there. The easy solution is to only fire `hover` in `dragover` on FF.
this.actions.hover(dragEnterTargetIds, {
clientOffset: getEventClientOffset(e),
})
}
const canDrop = dragEnterTargetIds.some(targetId =>
this.monitor.canDropOnTarget(targetId),
)
if (canDrop) {
// IE requires this to fire dragover events
e.preventDefault()
e.dataTransfer.dropEffect = this.getCurrentDropEffect()
}
}
handleTopDragOverCapture() {
this.dragOverTargetIds = []
}
handleDragOver(e, targetId) {
this.dragOverTargetIds.unshift(targetId)
}
handleTopDragOver(e) {
const { dragOverTargetIds } = this
this.dragOverTargetIds = []
if (!this.monitor.isDragging()) {
// This is probably a native item type we don't understand.
// Prevent default "drop and blow away the whole document" action.
e.preventDefault()
e.dataTransfer.dropEffect = 'none'
return
}
this.altKeyPressed = e.altKey
this.actions.hover(dragOverTargetIds, {
clientOffset: getEventClientOffset(e),
})
const canDrop = dragOverTargetIds.some(targetId =>
this.monitor.canDropOnTarget(targetId),
)
if (canDrop) {
// Show user-specified drop effect.
e.preventDefault()
e.dataTransfer.dropEffect = this.getCurrentDropEffect()
} else if (this.isDraggingNativeItem()) {
// Don't show a nice cursor but still prevent default
// "drop and blow away the whole document" action.
e.preventDefault()
e.dataTransfer.dropEffect = 'none'
} else if (this.checkIfCurrentDragSourceRectChanged()) {
// Prevent animating to incorrect position.
// Drop effect must be other than 'none' to prevent animation.
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
}
handleTopDragLeaveCapture(e) {
if (this.isDraggingNativeItem()) {
e.preventDefault()
}
const isLastLeave = this.enterLeaveCounter.leave(e.target)
if (!isLastLeave) {
return
}
if (this.isDraggingNativeItem()) {
this.endDragNativeItem()
}
}
handleTopDropCapture(e) {
this.dropTargetIds = []
e.preventDefault()
if (this.isDraggingNativeItem()) {
this.currentNativeSource.mutateItemByReadingDataTransfer(e.dataTransfer)
}
this.enterLeaveCounter.reset()
}
handleDrop(e, targetId) {
this.dropTargetIds.unshift(targetId)
}
handleTopDrop(e) {
const { dropTargetIds } = this
this.dropTargetIds = []
this.actions.hover(dropTargetIds, {
clientOffset: getEventClientOffset(e),
})
this.actions.drop({ dropEffect: this.getCurrentDropEffect() })
if (this.isDraggingNativeItem()) {
this.endDragNativeItem()
} else {
this.endDragIfSourceWasRemovedFromDOM()
}
}
handleSelectStart(e) {
const { target } = e
// Only IE requires us to explicitly say
// we want drag drop operation to start
if (typeof target.dragDrop !== 'function') {
return
}
// Inputs and textareas should be selectable
if (
target.tagName === 'INPUT' ||
target.tagName === 'SELECT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return
}
// For other targets, ask IE
// to enable drag and drop
e.preventDefault()
target.dragDrop()
}
}