214
common/resources/client/ui/interactions/dnd/use-droppable.ts
Executable file
214
common/resources/client/ui/interactions/dnd/use-droppable.ts
Executable file
@@ -0,0 +1,214 @@
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user