UNPKG

react-dnd-html5-backend

Version:

HTML5 backend for React DnD

759 lines (654 loc) 21.7 kB
import type { Backend, DragDropActions, DragDropManager, DragDropMonitor, HandlerRegistry, Identifier, Unsubscribe, XYCoord, } from 'dnd-core' import { EnterLeaveCounter } from './EnterLeaveCounter.js' import { createNativeDragSource, matchNativeItemType, } from './NativeDragSources/index.js' import type { NativeDragSource } from './NativeDragSources/NativeDragSource.js' import * as NativeTypes from './NativeTypes.js' import { getDragPreviewOffset, getEventClientOffset, getNodeClientOffset, } from './OffsetUtils.js' import { OptionsReader } from './OptionsReader.js' import type { HTML5BackendContext, HTML5BackendOptions } from './types.js' type RootNode = Node & { __isReactDndBackendSetUp: boolean | undefined } export class HTML5BackendImpl implements Backend { private options: OptionsReader // React-Dnd Components private actions: DragDropActions private monitor: DragDropMonitor private registry: HandlerRegistry // Internal State private enterLeaveCounter: EnterLeaveCounter private sourcePreviewNodes: Map<string, Element> = new Map() private sourcePreviewNodeOptions: Map<string, any> = new Map() private sourceNodes: Map<string, Element> = new Map() private sourceNodeOptions: Map<string, any> = new Map() private dragStartSourceIds: string[] | null = null private dropTargetIds: string[] = [] private dragEnterTargetIds: string[] = [] private currentNativeSource: NativeDragSource | null = null private currentNativeHandle: Identifier | null = null private currentDragSourceNode: Element | null = null private altKeyPressed = false private mouseMoveTimeoutTimer: number | null = null private asyncEndDragFrameId: number | null = null private dragOverTargetIds: string[] | null = null private lastClientOffset: XYCoord | null = null private hoverRafId: number | null = null public constructor( manager: DragDropManager, globalContext?: HTML5BackendContext, options?: HTML5BackendOptions, ) { this.options = new OptionsReader(globalContext, options) this.actions = manager.getActions() this.monitor = manager.getMonitor() this.registry = manager.getRegistry() this.enterLeaveCounter = new EnterLeaveCounter(this.isNodeInDocument) } /** * Generate profiling statistics for the HTML5Backend. */ public profile(): Record<string, number> { return { sourcePreviewNodes: this.sourcePreviewNodes.size, sourcePreviewNodeOptions: this.sourcePreviewNodeOptions.size, sourceNodeOptions: this.sourceNodeOptions.size, sourceNodes: this.sourceNodes.size, dragStartSourceIds: this.dragStartSourceIds?.length || 0, dropTargetIds: this.dropTargetIds.length, dragEnterTargetIds: this.dragEnterTargetIds.length, dragOverTargetIds: this.dragOverTargetIds?.length || 0, } } // public for test public get window(): Window | undefined { return this.options.window } public get document(): Document | undefined { return this.options.document } /** * Get the root element to use for event subscriptions */ private get rootElement(): Node | undefined { return this.options.rootElement as Node } public setup(): void { const root = this.rootElement as RootNode | undefined if (root === undefined) { return } if (root.__isReactDndBackendSetUp) { throw new Error('Cannot have two HTML5 backends at the same time.') } root.__isReactDndBackendSetUp = true this.addEventListeners(root) } public teardown(): void { const root = this.rootElement as RootNode if (root === undefined) { return } root.__isReactDndBackendSetUp = false this.removeEventListeners(this.rootElement as Element) this.clearCurrentDragSourceNode() if (this.asyncEndDragFrameId) { this.window?.cancelAnimationFrame(this.asyncEndDragFrameId) } } public connectDragPreview( sourceId: string, node: Element, options: any, ): Unsubscribe { this.sourcePreviewNodeOptions.set(sourceId, options) this.sourcePreviewNodes.set(sourceId, node) return (): void => { this.sourcePreviewNodes.delete(sourceId) this.sourcePreviewNodeOptions.delete(sourceId) } } public connectDragSource( sourceId: string, node: Element, options: any, ): Unsubscribe { this.sourceNodes.set(sourceId, node) this.sourceNodeOptions.set(sourceId, options) const handleDragStart = (e: any) => this.handleDragStart(e, sourceId) const handleSelectStart = (e: any) => this.handleSelectStart(e) node.setAttribute('draggable', 'true') node.addEventListener('dragstart', handleDragStart) node.addEventListener('selectstart', handleSelectStart) return (): void => { this.sourceNodes.delete(sourceId) this.sourceNodeOptions.delete(sourceId) node.removeEventListener('dragstart', handleDragStart) node.removeEventListener('selectstart', handleSelectStart) node.setAttribute('draggable', 'false') } } public connectDropTarget(targetId: string, node: HTMLElement): Unsubscribe { const handleDragEnter = (e: DragEvent) => this.handleDragEnter(e, targetId) const handleDragOver = (e: DragEvent) => this.handleDragOver(e, targetId) const handleDrop = (e: DragEvent) => this.handleDrop(e, targetId) node.addEventListener('dragenter', handleDragEnter) node.addEventListener('dragover', handleDragOver) node.addEventListener('drop', handleDrop) return (): void => { node.removeEventListener('dragenter', handleDragEnter) node.removeEventListener('dragover', handleDragOver) node.removeEventListener('drop', handleDrop) } } private addEventListeners(target: Node) { // SSR Fix (https://github.com/react-dnd/react-dnd/pull/813 if (!target.addEventListener) { return } target.addEventListener( 'dragstart', this.handleTopDragStart as EventListener, ) target.addEventListener('dragstart', this.handleTopDragStartCapture, true) target.addEventListener('dragend', this.handleTopDragEndCapture, true) target.addEventListener( 'dragenter', this.handleTopDragEnter as EventListener, ) target.addEventListener( 'dragenter', this.handleTopDragEnterCapture as EventListener, true, ) target.addEventListener( 'dragleave', this.handleTopDragLeaveCapture as EventListener, true, ) target.addEventListener('dragover', this.handleTopDragOver as EventListener) target.addEventListener( 'dragover', this.handleTopDragOverCapture as EventListener, true, ) target.addEventListener('drop', this.handleTopDrop as EventListener) target.addEventListener( 'drop', this.handleTopDropCapture as EventListener, true, ) } private removeEventListeners(target: Node) { // SSR Fix (https://github.com/react-dnd/react-dnd/pull/813 if (!target.removeEventListener) { return } target.removeEventListener('dragstart', this.handleTopDragStart as any) target.removeEventListener( 'dragstart', this.handleTopDragStartCapture, true, ) target.removeEventListener('dragend', this.handleTopDragEndCapture, true) target.removeEventListener( 'dragenter', this.handleTopDragEnter as EventListener, ) target.removeEventListener( 'dragenter', this.handleTopDragEnterCapture as EventListener, true, ) target.removeEventListener( 'dragleave', this.handleTopDragLeaveCapture as EventListener, true, ) target.removeEventListener( 'dragover', this.handleTopDragOver as EventListener, ) target.removeEventListener( 'dragover', this.handleTopDragOverCapture as EventListener, true, ) target.removeEventListener('drop', this.handleTopDrop as EventListener) target.removeEventListener( 'drop', this.handleTopDropCapture as EventListener, true, ) } private getCurrentSourceNodeOptions() { const sourceId = this.monitor.getSourceId() as string const sourceNodeOptions = this.sourceNodeOptions.get(sourceId) return { dropEffect: this.altKeyPressed ? 'copy' : 'move', ...(sourceNodeOptions || {}), } } private getCurrentDropEffect() { if (this.isDraggingNativeItem()) { // It makes more sense to default to 'copy' for native resources return 'copy' } return this.getCurrentSourceNodeOptions().dropEffect } private getCurrentSourcePreviewNodeOptions() { const sourceId = this.monitor.getSourceId() as string const sourcePreviewNodeOptions = this.sourcePreviewNodeOptions.get(sourceId) return { anchorX: 0.5, anchorY: 0.5, captureDraggingState: false, ...(sourcePreviewNodeOptions || {}), } } private getSourceClientOffset = (sourceId: string): XYCoord | null => { const source = this.sourceNodes.get(sourceId) return (source && getNodeClientOffset(source as HTMLElement)) || null } private isDraggingNativeItem() { const itemType = this.monitor.getItemType() return Object.keys(NativeTypes).some( (key: string) => (NativeTypes as any)[key] === itemType, ) } private beginDragNativeItem(type: string, dataTransfer?: DataTransfer) { this.clearCurrentDragSourceNode() this.currentNativeSource = createNativeDragSource(type, dataTransfer) this.currentNativeHandle = this.registry.addSource( type, this.currentNativeSource, ) this.actions.beginDrag([this.currentNativeHandle]) } private endDragNativeItem = (): void => { if (!this.isDraggingNativeItem()) { return } this.actions.endDrag() if (this.currentNativeHandle) { this.registry.removeSource(this.currentNativeHandle) } this.currentNativeHandle = null this.currentNativeSource = null } private isNodeInDocument = (node: Node | null | undefined): boolean => { // Check the node either in the main document or in the current context return Boolean( node && this.document && this.document.body && this.document.body.contains(node), ) } private endDragIfSourceWasRemovedFromDOM = (): void => { const node = this.currentDragSourceNode if (node == null || this.isNodeInDocument(node)) { return } if (this.clearCurrentDragSourceNode() && this.monitor.isDragging()) { this.actions.endDrag() } this.cancelHover() } private setCurrentDragSourceNode(node: Element | null) { this.clearCurrentDragSourceNode() this.currentDragSourceNode = node // 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(() => { return this.rootElement?.addEventListener( 'mousemove', this.endDragIfSourceWasRemovedFromDOM, true, ) }, MOUSE_MOVE_TIMEOUT) as any as number } private clearCurrentDragSourceNode() { if (this.currentDragSourceNode) { this.currentDragSourceNode = null if (this.rootElement) { this.window?.clearTimeout(this.mouseMoveTimeoutTimer || undefined) this.rootElement.removeEventListener( 'mousemove', this.endDragIfSourceWasRemovedFromDOM, true, ) } this.mouseMoveTimeoutTimer = null return true } return false } private scheduleHover = (dragOverTargetIds: string[] | null) => { if ( this.hoverRafId === null && typeof requestAnimationFrame !== 'undefined' ) { this.hoverRafId = requestAnimationFrame(() => { if (this.monitor.isDragging()) { this.actions.hover(dragOverTargetIds || [], { clientOffset: this.lastClientOffset, }) } this.hoverRafId = null }) } } private cancelHover = () => { if ( this.hoverRafId !== null && typeof cancelAnimationFrame !== 'undefined' ) { cancelAnimationFrame(this.hoverRafId) this.hoverRafId = null } } public handleTopDragStartCapture = (): void => { this.clearCurrentDragSourceNode() this.dragStartSourceIds = [] } public handleDragStart(e: DragEvent, sourceId: string): void { if (e.defaultPrevented) { return } if (!this.dragStartSourceIds) { this.dragStartSourceIds = [] } this.dragStartSourceIds.unshift(sourceId) } public handleTopDragStart = (e: DragEvent): void => { if (e.defaultPrevented) { return } 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() this.cancelHover() } // 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 (dataTransfer && 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: string = this.monitor.getSourceId() as string const sourceNode = this.sourceNodes.get(sourceId) const dragPreview = this.sourcePreviewNodes.get(sourceId) || sourceNode if (dragPreview) { const { anchorX, anchorY, offsetX, offsetY } = this.getCurrentSourcePreviewNodeOptions() const anchorPoint = { anchorX, anchorY } const offsetPoint = { offsetX, offsetY } const dragPreviewOffset = getDragPreviewOffset( sourceNode as HTMLElement, dragPreview as HTMLElement, clientOffset, anchorPoint, offsetPoint, ) dataTransfer.setDragImage( dragPreview, dragPreviewOffset.x, dragPreviewOffset.y, ) } } try { // Firefox won't drag without setting data dataTransfer?.setData('application/json', {} as any) } 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 as Element) // 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(), 0) } 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 && !dataTransfer.types && ((e.target && !(e.target as Element).hasAttribute) || !(e.target as Element).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 } else { // If by this time no drag source reacted, tell browser not to drag. e.preventDefault() } } public handleTopDragEndCapture = (): void => { if (this.clearCurrentDragSourceNode() && this.monitor.isDragging()) { // 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() } this.cancelHover() } public handleTopDragEnterCapture = (e: DragEvent): void => { this.dragEnterTargetIds = [] if (this.isDraggingNativeItem()) { this.currentNativeSource?.loadDataTransfer(e.dataTransfer) } 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, dataTransfer as DataTransfer) } } public handleDragEnter(_e: DragEvent, targetId: string): void { this.dragEnterTargetIds.unshift(targetId) } public handleTopDragEnter = (e: DragEvent): void => { 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 the target changes position as the result of `dragenter`, `dragover` might still // get dispatched despite target being no longer there. The easy solution is to check // whether there actually is a target before firing `hover`. if (dragEnterTargetIds.length > 0) { 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() if (e.dataTransfer) { e.dataTransfer.dropEffect = this.getCurrentDropEffect() } } } public handleTopDragOverCapture = (e: DragEvent): void => { this.dragOverTargetIds = [] if (this.isDraggingNativeItem()) { this.currentNativeSource?.loadDataTransfer(e.dataTransfer) } } public handleDragOver(_e: DragEvent, targetId: string): void { if (this.dragOverTargetIds === null) { this.dragOverTargetIds = [] } this.dragOverTargetIds.unshift(targetId) } public handleTopDragOver = (e: DragEvent): void => { 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() if (e.dataTransfer) { e.dataTransfer.dropEffect = 'none' } return } this.altKeyPressed = e.altKey this.lastClientOffset = getEventClientOffset(e) this.scheduleHover(dragOverTargetIds) const canDrop = (dragOverTargetIds || []).some((targetId) => this.monitor.canDropOnTarget(targetId), ) if (canDrop) { // Show user-specified drop effect. e.preventDefault() if (e.dataTransfer) { 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() } else { e.preventDefault() if (e.dataTransfer) { e.dataTransfer.dropEffect = 'none' } } } public handleTopDragLeaveCapture = (e: DragEvent): void => { if (this.isDraggingNativeItem()) { e.preventDefault() } const isLastLeave = this.enterLeaveCounter.leave(e.target) if (!isLastLeave) { return } if (this.isDraggingNativeItem()) { setTimeout(() => this.endDragNativeItem(), 0) } this.cancelHover() } public handleTopDropCapture = (e: DragEvent): void => { this.dropTargetIds = [] if (this.isDraggingNativeItem()) { e.preventDefault() this.currentNativeSource?.loadDataTransfer(e.dataTransfer) } else if (matchNativeItemType(e.dataTransfer)) { // Dragging some elements, like <a> and <img> may still behave like a native drag event, // even if the current drag event matches a user-defined type. // Stop the default behavior when we're not expecting a native item to be dropped. e.preventDefault() } this.enterLeaveCounter.reset() } public handleDrop(_e: DragEvent, targetId: string): void { this.dropTargetIds.unshift(targetId) } public handleTopDrop = (e: DragEvent): void => { const { dropTargetIds } = this this.dropTargetIds = [] this.actions.hover(dropTargetIds, { clientOffset: getEventClientOffset(e), }) this.actions.drop({ dropEffect: this.getCurrentDropEffect() }) if (this.isDraggingNativeItem()) { this.endDragNativeItem() } else if (this.monitor.isDragging()) { this.actions.endDrag() } this.cancelHover() } public handleSelectStart = (e: DragEvent): void => { const target = e.target as HTMLElement & { dragDrop: () => void } // 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() } }