215 lines
6.4 KiB
TypeScript
Executable File
215 lines
6.4 KiB
TypeScript
Executable File
import React, {RefObject, useLayoutEffect, useRef} from 'react';
|
|
import {draggables, dragSession, droppables} from './drag-state';
|
|
import {readFilesFromDataTransfer} from './read-files-from-data-transfer';
|
|
import {asyncIterableToArray} from '@common/utils/array/async-iterable-to-array';
|
|
import {InteractableRect} from '../interactable-event';
|
|
import {DraggableId, MixedDraggable} from './use-draggable';
|
|
import {UploadedFile} from '@common/uploads/uploaded-file';
|
|
|
|
export interface ConnectedDroppable {
|
|
id: DraggableId;
|
|
rect?: InteractableRect;
|
|
disabled?: boolean;
|
|
ref: RefObject<HTMLElement>;
|
|
}
|
|
|
|
// File dragged in from desktop
|
|
export interface NativeFileDraggable {
|
|
type: 'nativeFile';
|
|
el: null;
|
|
ref: null;
|
|
getData: () => Promise<UploadedFile[]>;
|
|
}
|
|
|
|
interface UseDroppableProps<T extends HTMLElement> {
|
|
id: DraggableId;
|
|
disabled?: boolean;
|
|
types: ('nativeFile' | string)[];
|
|
ref: RefObject<T>;
|
|
// this will fire dragEnter/dragLeave/dragOver events when same element is both draggable and drop target and dragging target over itself. Used for showing line previews before/after element during sort.
|
|
allowDragEventsFromItself?: boolean;
|
|
onDragEnter?: (target: MixedDraggable) => void;
|
|
onDragLeave?: (target: MixedDraggable) => void;
|
|
onDragOver?: (
|
|
target: MixedDraggable,
|
|
e: React.DragEvent<HTMLElement>
|
|
) => void;
|
|
// Handler that is called after draggable is held over droppable for a period of time.
|
|
// This typically opens the item so that the user can drop within it.
|
|
onDropActivate?: (e: MixedDraggable) => void;
|
|
onDrop?: (target: MixedDraggable) => void | Promise<void> | false;
|
|
acceptsDrop?: (target: MixedDraggable) => boolean;
|
|
}
|
|
|
|
interface DroppableState {
|
|
dragOverElements: Set<Element>;
|
|
dropActivateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
}
|
|
|
|
const DROP_ACTIVATE_TIMEOUT = 400;
|
|
|
|
export function useDroppable<T extends HTMLElement>({
|
|
id,
|
|
disabled,
|
|
ref,
|
|
...options
|
|
}: UseDroppableProps<T>) {
|
|
const state = useRef<DroppableState>({
|
|
dragOverElements: new Set<Element>(),
|
|
dropActivateTimer: undefined,
|
|
}).current;
|
|
|
|
const optionsRef = useRef(options);
|
|
optionsRef.current = options;
|
|
|
|
useLayoutEffect(() => {
|
|
droppables.set(id, {
|
|
...droppables.get(id),
|
|
disabled,
|
|
id,
|
|
ref,
|
|
});
|
|
return () => {
|
|
droppables.delete(id);
|
|
};
|
|
}, [id, optionsRef, disabled, ref]);
|
|
|
|
// check if drop target accepts drag target
|
|
const canDrop = (draggable: MixedDraggable): boolean => {
|
|
const options = optionsRef.current;
|
|
|
|
const allowEventsOnSelf =
|
|
options.allowDragEventsFromItself ||
|
|
ref.current !== draggable.ref?.current;
|
|
|
|
return !!(
|
|
draggable?.type &&
|
|
allowEventsOnSelf &&
|
|
options.types.includes(draggable.type) &&
|
|
(!options.acceptsDrop || options.acceptsDrop(draggable))
|
|
);
|
|
};
|
|
|
|
const fireDragLeave = (e: React.DragEvent<HTMLElement>) => {
|
|
const draggable = getDraggable(e);
|
|
if (draggable) {
|
|
optionsRef.current.onDragLeave?.(draggable);
|
|
}
|
|
};
|
|
|
|
const onDragEnter = (e: React.DragEvent<HTMLElement>) => {
|
|
e.stopPropagation();
|
|
|
|
state.dragOverElements.add(e.target as Element);
|
|
if (state.dragOverElements.size > 1) {
|
|
return;
|
|
}
|
|
|
|
const draggable = getDraggable(e);
|
|
if (draggable && canDrop(draggable)) {
|
|
optionsRef.current.onDragEnter?.(draggable);
|
|
|
|
clearTimeout(state.dropActivateTimer);
|
|
if (typeof optionsRef.current.onDropActivate === 'function') {
|
|
state.dropActivateTimer = setTimeout(() => {
|
|
if (draggable) {
|
|
optionsRef.current.onDropActivate?.(draggable);
|
|
}
|
|
}, DROP_ACTIVATE_TIMEOUT);
|
|
}
|
|
}
|
|
};
|
|
|
|
const onDragLeave = (e: React.DragEvent<HTMLElement>) => {
|
|
e.stopPropagation();
|
|
|
|
// Track all the targets of dragenter events in a set, and remove them
|
|
// in dragleave. When the set becomes empty, we've left the drop target completely.
|
|
// We must also remove any elements that are no longer in the DOM, because dragleave
|
|
// events will never be fired for these. This can happen, for example, with drop
|
|
// indicators between items, which disappear when the drop target changes.
|
|
state.dragOverElements.delete(e.target as Element);
|
|
for (const element of state.dragOverElements) {
|
|
if (!e.currentTarget.contains(element)) {
|
|
state.dragOverElements.delete(element);
|
|
}
|
|
}
|
|
|
|
if (state.dragOverElements.size > 0) {
|
|
return;
|
|
}
|
|
|
|
const draggable = getDraggable(e);
|
|
if (draggable && canDrop(draggable)) {
|
|
fireDragLeave(e);
|
|
clearTimeout(state.dropActivateTimer);
|
|
}
|
|
};
|
|
|
|
const onDrop = async (e: React.DragEvent<HTMLElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
state.dragOverElements.clear();
|
|
|
|
fireDragLeave(e);
|
|
clearTimeout(state.dropActivateTimer);
|
|
|
|
const draggable = getDraggable(e);
|
|
if (draggable) {
|
|
optionsRef.current.onDragLeave?.(draggable);
|
|
|
|
// drop target does not accept this type of droppable
|
|
if (!canDrop(draggable)) {
|
|
if (dragSession.status !== 'inactive') {
|
|
dragSession.status = 'dropFail';
|
|
}
|
|
// drop target accepts this type, but it might still reject the drop in callback
|
|
} else {
|
|
// allow callback to mark drop as failed
|
|
const dropResult = optionsRef.current.onDrop?.(draggable);
|
|
|
|
// drag session will only be active for draggables within the app, never for files dragged in from desktop
|
|
if (dragSession.status !== 'inactive') {
|
|
dragSession.status =
|
|
dropResult === false ? 'dropFail' : 'dropSuccess';
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const droppableProps = {
|
|
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const draggable = getDraggable(e);
|
|
if (draggable && canDrop(draggable)) {
|
|
optionsRef.current.onDragOver?.(draggable, e);
|
|
}
|
|
},
|
|
onDragEnter,
|
|
onDragLeave,
|
|
onDrop,
|
|
};
|
|
|
|
return {
|
|
droppableProps: disabled ? {} : droppableProps,
|
|
};
|
|
}
|
|
|
|
function getDraggable(
|
|
e: React.DragEvent<HTMLElement>
|
|
): MixedDraggable | undefined {
|
|
if (dragSession.dragTargetId != null) {
|
|
return draggables.get(dragSession.dragTargetId);
|
|
} else if (e.dataTransfer.types.includes('Files')) {
|
|
return {
|
|
type: 'nativeFile',
|
|
el: null,
|
|
ref: null,
|
|
getData: () => {
|
|
return asyncIterableToArray(readFilesFromDataTransfer(e.dataTransfer));
|
|
},
|
|
};
|
|
}
|
|
}
|