55
common/resources/client/ui/interactions/dnd/drag-preview.tsx
Executable file
55
common/resources/client/ui/interactions/dnd/drag-preview.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
import {createPortal, flushSync} from 'react-dom';
|
||||
import React, {useImperativeHandle, useRef, useState} from 'react';
|
||||
import {ConnectedDraggable, DragPreviewRenderer} from './use-draggable';
|
||||
import {rootEl} from '@common/core/root-el';
|
||||
|
||||
export interface DragPreviewProps {
|
||||
children: (draggable: ConnectedDraggable) => JSX.Element;
|
||||
}
|
||||
export const DragPreview = React.forwardRef<
|
||||
DragPreviewRenderer,
|
||||
DragPreviewProps
|
||||
>((props, ref) => {
|
||||
const render = props.children;
|
||||
const [children, setChildren] = useState<JSX.Element | null>(null);
|
||||
const domRef = useRef<HTMLDivElement>(null!);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() =>
|
||||
(
|
||||
draggable: ConnectedDraggable,
|
||||
callback: (node: HTMLElement) => void,
|
||||
) => {
|
||||
// This will be called during the onDragStart event by useDrag. We need to render the
|
||||
// preview synchronously before this event returns, so we can call event.dataTransfer.setDragImage.
|
||||
flushSync(() => {
|
||||
setChildren(render(draggable));
|
||||
});
|
||||
|
||||
// Yield back to useDrag to set the drag image.
|
||||
callback(domRef.current);
|
||||
|
||||
// Remove the preview from the DOM after a frame so the browser has time to paint.
|
||||
requestAnimationFrame(() => {
|
||||
setChildren(null);
|
||||
});
|
||||
},
|
||||
[render],
|
||||
);
|
||||
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// portal preview, in case in needs to be used in <tr> or another element that does not accept div as child
|
||||
return createPortal(
|
||||
<div
|
||||
style={{zIndex: -100, position: 'absolute', top: 0, left: -100000}}
|
||||
ref={domRef}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
rootEl,
|
||||
);
|
||||
});
|
||||
21
common/resources/client/ui/interactions/dnd/drag-state.ts
Executable file
21
common/resources/client/ui/interactions/dnd/drag-state.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import {DragMonitor} from './use-drag-monitor';
|
||||
import {ConnectedDraggable, DraggableId} from './use-draggable';
|
||||
import {ConnectedDroppable} from './use-droppable';
|
||||
|
||||
export type DragSessionStatus =
|
||||
| 'dropSuccess'
|
||||
| 'dropFail'
|
||||
| 'dragging'
|
||||
| 'inactive';
|
||||
|
||||
export interface DragSession {
|
||||
dragTargetId?: DraggableId;
|
||||
status: DragSessionStatus;
|
||||
}
|
||||
|
||||
export const draggables = new Map<DraggableId, ConnectedDraggable>();
|
||||
export const droppables = new Map<DraggableId, ConnectedDroppable>();
|
||||
export const dragMonitors = new Map<DraggableId, DragMonitor>();
|
||||
export const dragSession: DragSession = {
|
||||
status: 'inactive',
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import {RefObject, useLayoutEffect, useRef} from 'react';
|
||||
import {droppables} from '../drag-state';
|
||||
import {InteractableRect} from '../../interactable-event';
|
||||
import {DraggableId} from '../use-draggable';
|
||||
|
||||
export interface ConnectedMouseSelectable {
|
||||
id: DraggableId;
|
||||
onSelected?: () => void;
|
||||
onDeselected?: () => void;
|
||||
ref: RefObject<HTMLElement>;
|
||||
rect?: InteractableRect;
|
||||
}
|
||||
|
||||
export const mouseSelectables = new Map<
|
||||
DraggableId,
|
||||
ConnectedMouseSelectable
|
||||
>();
|
||||
|
||||
export function useMouseSelectable(options: ConnectedMouseSelectable) {
|
||||
const {id, ref} = options;
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) return;
|
||||
// register droppable regardless if it's enabled or not, it might be used by mouse selection box
|
||||
mouseSelectables.set(id, {
|
||||
...mouseSelectables.get(id),
|
||||
id,
|
||||
ref,
|
||||
// avoid stale closures
|
||||
onSelected: () => {
|
||||
optionsRef.current.onSelected?.();
|
||||
},
|
||||
onDeselected: () => optionsRef.current.onDeselected?.(),
|
||||
});
|
||||
return () => {
|
||||
droppables.delete(id);
|
||||
};
|
||||
}, [id, optionsRef, ref]);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, {RefObject, useRef} from 'react';
|
||||
import {usePointerEvents} from '../../use-pointer-events';
|
||||
import {InteractableRect} from '../../interactable-event';
|
||||
import {restrictResizableWithinBoundary} from '../../utils/restrict-resizable-within-boundary';
|
||||
import {activeInteraction} from '../../active-interaction';
|
||||
import {updateRects} from '../update-rects';
|
||||
import {mouseSelectables} from './use-mouse-selectable';
|
||||
import {rectsIntersect} from '../../utils/rects-intersect';
|
||||
import {DraggableId} from '../use-draggable';
|
||||
|
||||
interface SelectionState {
|
||||
startPoint?: {x: number; y: number; scrollTop: number};
|
||||
endPoint?: {x: number; y: number};
|
||||
boundaryRect?: InteractableRect & {heightWithoutScroll: number};
|
||||
scrollListener?: EventListener;
|
||||
rafId?: number;
|
||||
selectedIds?: Set<DraggableId>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onPointerDown?: (e: React.PointerEvent) => void;
|
||||
containerRef?: RefObject<HTMLDivElement>;
|
||||
}
|
||||
export function useMouseSelectionBox({onPointerDown, ...props}: Props = {}) {
|
||||
const defaultRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = props.containerRef || defaultRef;
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
let state = useRef<SelectionState>({}).current;
|
||||
|
||||
const drawSelectionBox = () => {
|
||||
if (state.rafId) {
|
||||
cancelAnimationFrame(state.rafId);
|
||||
}
|
||||
|
||||
if (!state.startPoint || !state.endPoint || !state.boundaryRect) return;
|
||||
|
||||
const startPoint = state.startPoint;
|
||||
const endPoint = state.endPoint;
|
||||
const initialScrollTop = startPoint.scrollTop || 0;
|
||||
const currentScrollTop = containerRef.current?.scrollTop || 0;
|
||||
|
||||
const newRect = {
|
||||
left: Math.min(startPoint.x, endPoint.x),
|
||||
top: Math.min(startPoint.y, endPoint.y),
|
||||
width: Math.abs(startPoint.x - endPoint.x),
|
||||
height: Math.abs(startPoint.y - endPoint.y),
|
||||
};
|
||||
|
||||
// convert box coords to be relative to container and not viewport
|
||||
newRect.left -= state.boundaryRect.left;
|
||||
newRect.top -= state.boundaryRect.top;
|
||||
|
||||
// take initial scroll of container into account
|
||||
newRect.top += initialScrollTop;
|
||||
|
||||
// scroll diff between drag start and now (auto scroll or mouse wheel)
|
||||
const scrollDiff = currentScrollTop - initialScrollTop;
|
||||
const scrollValue = Math.abs(scrollDiff);
|
||||
|
||||
// top needs to be changed only if scroll direction is top
|
||||
if (scrollDiff < 0) {
|
||||
newRect.top -= scrollValue;
|
||||
}
|
||||
|
||||
// height needs to be changed regardless of direction and method
|
||||
newRect.height += scrollValue;
|
||||
|
||||
const boundedRect = state.boundaryRect
|
||||
? restrictResizableWithinBoundary(newRect, state.boundaryRect)
|
||||
: {...newRect};
|
||||
|
||||
if (boxRef.current) {
|
||||
state.rafId = requestAnimationFrame(() => {
|
||||
if (boxRef.current) {
|
||||
boxRef.current.style.display = `block`;
|
||||
boxRef.current.style.transform = `translate(${boundedRect.left}px, ${boundedRect.top}px)`;
|
||||
boxRef.current.style.width = `${boundedRect.width}px`;
|
||||
boxRef.current.style.height = `${boundedRect.height}px`;
|
||||
}
|
||||
state.rafId = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
// convert rect back to absolute for intersection testing
|
||||
const absoluteRect = {
|
||||
...boundedRect,
|
||||
left: boundedRect.left + state.boundaryRect.left,
|
||||
top: boundedRect.top + state.boundaryRect.top - currentScrollTop,
|
||||
};
|
||||
|
||||
for (const [, selectable] of mouseSelectables) {
|
||||
const intersect = rectsIntersect(selectable.rect, absoluteRect);
|
||||
if (intersect && !state.selectedIds?.has(selectable.id)) {
|
||||
state.selectedIds?.add(selectable.id);
|
||||
selectable.onSelected?.();
|
||||
} else if (!intersect && state.selectedIds?.has(selectable.id)) {
|
||||
state.selectedIds?.delete(selectable.id);
|
||||
selectable.onDeselected?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pointerEvents = usePointerEvents({
|
||||
minimumMovement: 4,
|
||||
onPointerDown,
|
||||
onMoveStart: e => {
|
||||
if (activeInteraction) {
|
||||
return false;
|
||||
}
|
||||
updateRects(mouseSelectables);
|
||||
state = {
|
||||
selectedIds: new Set(),
|
||||
};
|
||||
const el = containerRef.current;
|
||||
state.startPoint = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
scrollTop: el?.scrollTop || 0,
|
||||
};
|
||||
|
||||
state.scrollListener = e => {
|
||||
if (!state.startPoint) return;
|
||||
// update rects on scroll, because we are using relative position
|
||||
updateRects(mouseSelectables);
|
||||
if (state.boundaryRect?.height) {
|
||||
state.boundaryRect.height = (e.target as HTMLElement).scrollHeight;
|
||||
}
|
||||
// draw selection box (for autoscroll and mousewheel)
|
||||
drawSelectionBox();
|
||||
};
|
||||
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.addEventListener('scroll', state.scrollListener);
|
||||
state.boundaryRect = {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
height: el.scrollHeight,
|
||||
heightWithoutScroll: rect.height,
|
||||
width: el.scrollWidth,
|
||||
};
|
||||
}
|
||||
},
|
||||
onMove: e => {
|
||||
state.endPoint = {x: e.clientX, y: e.clientY};
|
||||
|
||||
if (state.boundaryRect && containerRef.current) {
|
||||
const reachedBottomEdge =
|
||||
e.clientY + 20 >
|
||||
state.boundaryRect.heightWithoutScroll + state.boundaryRect.top;
|
||||
const reachedTopEdge = e.clientY - 20 < state.boundaryRect.top;
|
||||
|
||||
if (reachedBottomEdge) {
|
||||
containerRef.current.scrollBy({top: 10});
|
||||
} else if (reachedTopEdge) {
|
||||
containerRef.current.scrollBy({top: -10});
|
||||
}
|
||||
}
|
||||
|
||||
drawSelectionBox();
|
||||
},
|
||||
onMoveEnd: () => {
|
||||
if (state.rafId) {
|
||||
cancelAnimationFrame(state.rafId);
|
||||
}
|
||||
if (containerRef.current && state.scrollListener) {
|
||||
containerRef.current.removeEventListener(
|
||||
'scroll',
|
||||
state.scrollListener
|
||||
);
|
||||
}
|
||||
if (boxRef.current) {
|
||||
boxRef.current.style.display = `none`;
|
||||
boxRef.current.style.transform = '';
|
||||
boxRef.current.style.width = '';
|
||||
boxRef.current.style.height = '';
|
||||
}
|
||||
state = {};
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
containerProps: {
|
||||
...pointerEvents.domProps,
|
||||
ref: containerRef,
|
||||
},
|
||||
boxProps: {ref: boxRef},
|
||||
};
|
||||
}
|
||||
55
common/resources/client/ui/interactions/dnd/read-files-from-data-transfer.ts
Executable file
55
common/resources/client/ui/interactions/dnd/read-files-from-data-transfer.ts
Executable file
@@ -0,0 +1,55 @@
|
||||
import {UploadedFile} from '@common/uploads/uploaded-file';
|
||||
|
||||
export async function* readFilesFromDataTransfer(dataTransfer: DataTransfer) {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
|
||||
// Pull out all entries before reading them, otherwise
|
||||
// some entries will be lost due to recursion with promises
|
||||
for (const item of dataTransfer.items) {
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile) {
|
||||
if (entry.name === '.DS_Store') continue;
|
||||
const file = await getEntryFile(entry as FileSystemFileEntry);
|
||||
yield new UploadedFile(file, entry.fullPath);
|
||||
} else if (entry.isDirectory) {
|
||||
yield* getEntriesFromDirectory(entry as FileSystemDirectoryEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* getEntriesFromDirectory(
|
||||
item: FileSystemDirectoryEntry
|
||||
): AsyncIterable<any> {
|
||||
const reader = item.createReader();
|
||||
|
||||
// We must call readEntries repeatedly because there may be a limit to the
|
||||
// number of entries that are returned at once.
|
||||
let entries: FileSystemEntry[];
|
||||
do {
|
||||
entries = await new Promise((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile) {
|
||||
if (entry.name === '.DS_Store') continue;
|
||||
const file = await getEntryFile(entry as FileSystemFileEntry);
|
||||
yield new UploadedFile(file, entry.fullPath);
|
||||
} else if (entry.isDirectory) {
|
||||
yield* getEntriesFromDirectory(entry as FileSystemDirectoryEntry);
|
||||
}
|
||||
}
|
||||
} while (entries.length > 0);
|
||||
}
|
||||
|
||||
function getEntryFile(entry: FileSystemFileEntry): Promise<File> {
|
||||
return new Promise((resolve, reject) => entry.file(resolve, reject));
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
DropPosition,
|
||||
SortSession,
|
||||
} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
import {SortableStrategy} from '@common/ui/interactions/dnd/sortable/sortable-strategy';
|
||||
import {droppables} from '@common/ui/interactions/dnd/drag-state';
|
||||
|
||||
export const sortableLineStrategy: SortableStrategy = {
|
||||
onDragStart: () => {},
|
||||
onDragEnter: () => {},
|
||||
onDragOver: ({e, ref, item, sortSession, onDropPositionChange}) => {
|
||||
const previousPosition = sortSession.dropPosition;
|
||||
let newPosition: DropPosition = null;
|
||||
|
||||
const rect = droppables.get(item)?.rect;
|
||||
if (rect) {
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY <= midY) {
|
||||
newPosition = 'before';
|
||||
} else if (e.clientY >= midY) {
|
||||
newPosition = 'after';
|
||||
}
|
||||
}
|
||||
|
||||
if (newPosition !== previousPosition) {
|
||||
const overIndex = sortSession.sortables.indexOf(item);
|
||||
sortSession.dropPosition = newPosition;
|
||||
onDropPositionChange?.(sortSession.dropPosition);
|
||||
|
||||
clearLinePreview(sortSession);
|
||||
if (ref.current) {
|
||||
if (sortSession.dropPosition === 'after') {
|
||||
addLinePreview(ref.current, 'bottom', sortSession);
|
||||
} else {
|
||||
// if it's the first row, add preview to the top border, as there's no previous element
|
||||
if (overIndex === 0) {
|
||||
addLinePreview(ref.current, 'top', sortSession);
|
||||
// otherwise add preview to the bottom border of the previous row
|
||||
} else {
|
||||
const droppableId = sortSession.sortables[overIndex - 1];
|
||||
const droppable = droppables.get(droppableId);
|
||||
if (droppable?.ref.current) {
|
||||
addLinePreview(droppable.ref.current, 'bottom', sortSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemIndex = sortSession.sortables.indexOf(item);
|
||||
|
||||
// don't move item at all if hovering over itself
|
||||
if (sortSession.activeIndex === itemIndex) {
|
||||
sortSession.finalIndex = sortSession.activeIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
// adjust final drop index based on whether we're dropping drag target after or before it's original index
|
||||
// this is needed, so we get the same index if target is dropped after current item or before next item
|
||||
const dragDirection =
|
||||
overIndex > sortSession.activeIndex ? 'after' : 'before';
|
||||
if (dragDirection === 'after') {
|
||||
sortSession.finalIndex =
|
||||
sortSession.dropPosition === 'before' ? itemIndex - 1 : itemIndex;
|
||||
} else {
|
||||
sortSession.finalIndex =
|
||||
sortSession.dropPosition === 'after' ? itemIndex + 1 : itemIndex;
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd: sortSession => {
|
||||
clearLinePreview(sortSession);
|
||||
},
|
||||
};
|
||||
|
||||
function clearLinePreview(sortSession: SortSession) {
|
||||
if (sortSession?.linePreviewEl) {
|
||||
sortSession.linePreviewEl.style.borderBottomColor = '';
|
||||
sortSession.linePreviewEl.style.borderTopColor = '';
|
||||
sortSession.linePreviewEl = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function addLinePreview(
|
||||
el: HTMLElement,
|
||||
side: 'top' | 'bottom',
|
||||
sortSession: SortSession,
|
||||
) {
|
||||
const color = 'rgb(var(--be-primary))';
|
||||
if (side === 'top') {
|
||||
el.style.borderTopColor = color;
|
||||
} else {
|
||||
el.style.borderBottomColor = color;
|
||||
}
|
||||
if (sortSession) {
|
||||
sortSession.linePreviewEl = el;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {moveItemInArray} from '@common/utils/array/move-item-in-array';
|
||||
import {droppables} from '@common/ui/interactions/dnd/drag-state';
|
||||
import {SortableStrategy} from '@common/ui/interactions/dnd/sortable/sortable-strategy';
|
||||
|
||||
export const sortableMoveNodeStrategy: SortableStrategy = {
|
||||
onDragStart: () => {},
|
||||
onDragOver: () => {},
|
||||
onDragEnter: (sortSession, overIndex: number, currentIndex: number) => {
|
||||
const node = droppables.get(sortSession.sortables[currentIndex])?.ref
|
||||
.current;
|
||||
if (node) {
|
||||
moveNode(node, currentIndex, overIndex);
|
||||
moveItemInArray(sortSession.sortables, currentIndex, overIndex);
|
||||
sortSession.finalIndex = overIndex;
|
||||
}
|
||||
},
|
||||
onDragEnd: () => {},
|
||||
};
|
||||
|
||||
function moveNode(el: HTMLElement, currentIndex: number, newIndex: number) {
|
||||
const parentEl = el.parentElement!;
|
||||
if (newIndex < 0) {
|
||||
parentEl.prepend(el);
|
||||
} else {
|
||||
// if parent already contains this node, and we're changing
|
||||
// node's index within parent, need to adjust index by one
|
||||
if (currentIndex > -1 && currentIndex <= newIndex) {
|
||||
newIndex++;
|
||||
}
|
||||
const ref = parentEl.children.item(newIndex);
|
||||
if (ref) {
|
||||
ref.before(el);
|
||||
} else {
|
||||
parentEl.append(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
common/resources/client/ui/interactions/dnd/sortable/sortable-strategy.ts
Executable file
25
common/resources/client/ui/interactions/dnd/sortable/sortable-strategy.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
DropPosition,
|
||||
SortSession,
|
||||
} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
import {DraggableId} from '@common/ui/interactions/dnd/use-draggable';
|
||||
import {DragEvent, RefObject} from 'react';
|
||||
|
||||
interface OnDragOverProps {
|
||||
e: DragEvent<HTMLElement>;
|
||||
ref: RefObject<HTMLElement>;
|
||||
item: DraggableId;
|
||||
sortSession: SortSession;
|
||||
onDropPositionChange?: (dropPosition: DropPosition) => void;
|
||||
}
|
||||
|
||||
export interface SortableStrategy {
|
||||
onDragStart: (sortSession: SortSession) => void;
|
||||
onDragEnter: (
|
||||
sortSession: SortSession,
|
||||
overIndex: number,
|
||||
currentIndex: number,
|
||||
) => void;
|
||||
onDragOver: (props: OnDragOverProps) => void;
|
||||
onDragEnd: (sortSession: SortSession) => void;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {moveItemInArray} from '@common/utils/array/move-item-in-array';
|
||||
import {droppables} from '@common/ui/interactions/dnd/drag-state';
|
||||
import {moveItemInNewArray} from '@common/utils/array/move-item-in-new-array';
|
||||
import type {SortSession} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
import type {SortableStrategy} from '@common/ui/interactions/dnd/sortable/sortable-strategy';
|
||||
|
||||
const transition = 'transform 0.2s cubic-bezier(0.2, 0, 0, 1)';
|
||||
|
||||
export const sortableTransformStrategy: SortableStrategy = {
|
||||
onDragStart: sortSession => {
|
||||
sortSession.sortables.forEach((sortable, index) => {
|
||||
const droppable = droppables.get(sortable);
|
||||
if (!droppable?.ref.current) return;
|
||||
|
||||
droppable.ref.current.style.transition = transition;
|
||||
|
||||
if (sortSession?.activeIndex === index) {
|
||||
droppable.ref.current.style.opacity = '0.4';
|
||||
}
|
||||
});
|
||||
},
|
||||
onDragEnter: (
|
||||
sortSession: SortSession,
|
||||
overIndex: number,
|
||||
currentIndex: number,
|
||||
) => {
|
||||
moveItemInArray(sortSession.sortables, currentIndex, overIndex);
|
||||
const rects = sortSession.sortables.map(s => droppables.get(s)?.rect);
|
||||
|
||||
sortSession.sortables.forEach((sortable, index) => {
|
||||
if (!sortSession) return;
|
||||
|
||||
const newRects = moveItemInNewArray(
|
||||
rects,
|
||||
overIndex,
|
||||
sortSession.activeIndex,
|
||||
);
|
||||
const oldRect = rects[index];
|
||||
const newRect = newRects[index];
|
||||
const sortableTarget = droppables.get(sortable);
|
||||
|
||||
if (sortableTarget?.ref.current && newRect && oldRect) {
|
||||
const x = newRect.left - oldRect.left;
|
||||
const y = newRect.top - oldRect.top;
|
||||
sortableTarget.ref.current.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
}
|
||||
});
|
||||
|
||||
sortSession.finalIndex = overIndex;
|
||||
},
|
||||
onDragOver: () => {},
|
||||
onDragEnd: sortSession => {
|
||||
// clear any styles and transforms applied to sortables during sorting
|
||||
sortSession.sortables.forEach(sortable => {
|
||||
const droppable = droppables.get(sortable);
|
||||
if (droppable?.ref.current) {
|
||||
droppable.ref.current.style.transform = '';
|
||||
droppable.ref.current.style.transition = '';
|
||||
droppable.ref.current.style.opacity = '';
|
||||
droppable.ref.current.style.zIndex = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
162
common/resources/client/ui/interactions/dnd/sortable/use-sortable.ts
Executable file
162
common/resources/client/ui/interactions/dnd/sortable/use-sortable.ts
Executable file
@@ -0,0 +1,162 @@
|
||||
import {DraggableId, DragPreviewRenderer, useDraggable} from '../use-draggable';
|
||||
import {useDroppable} from '../use-droppable';
|
||||
import {RefObject, useEffect} from 'react';
|
||||
import {getScrollParent, mergeProps} from '@react-aria/utils';
|
||||
import {droppables} from '../drag-state';
|
||||
import {updateRects} from '@common/ui/interactions/dnd/update-rects';
|
||||
import {sortableLineStrategy} from '@common/ui/interactions/dnd/sortable/sortable-line-strategy';
|
||||
import {sortableTransformStrategy} from '@common/ui/interactions/dnd/sortable/sortable-transform-strategy';
|
||||
import {sortableMoveNodeStrategy} from '@common/ui/interactions/dnd/sortable/sortable-move-node-strategy';
|
||||
import {SortableStrategy} from '@common/ui/interactions/dnd/sortable/sortable-strategy';
|
||||
|
||||
export interface SortSession {
|
||||
// items in this list will be moved when user is sorting
|
||||
sortables: DraggableId[];
|
||||
|
||||
// sortable user started dragging to start this session
|
||||
activeSortable: DraggableId;
|
||||
activeIndex: number;
|
||||
|
||||
// final index sortable was dropped in and should be moved to
|
||||
finalIndex: number;
|
||||
|
||||
// drop position for displaying line preview
|
||||
dropPosition: DropPosition;
|
||||
// element that currently has a line preview at the top or bottom
|
||||
linePreviewEl?: HTMLElement;
|
||||
scrollParent?: Element;
|
||||
scrollListener: () => void;
|
||||
ref: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
let sortSession: null | SortSession = null;
|
||||
|
||||
export type DropPosition = 'before' | 'after' | null;
|
||||
|
||||
type StrategyName = 'line' | 'liveSort' | 'moveNode';
|
||||
|
||||
const strategies: Record<StrategyName, SortableStrategy> = {
|
||||
line: sortableLineStrategy,
|
||||
liveSort: sortableTransformStrategy,
|
||||
moveNode: sortableMoveNodeStrategy,
|
||||
};
|
||||
|
||||
export interface UseSortableProps {
|
||||
item: DraggableId;
|
||||
items: DraggableId[];
|
||||
onSortStart?: () => void;
|
||||
onSortEnd?: (oldIndex: number, newIndex: number) => void;
|
||||
onDragEnd?: () => void;
|
||||
onDropPositionChange?: (dropPosition: DropPosition) => void;
|
||||
ref: RefObject<HTMLElement>;
|
||||
type: string;
|
||||
preview?: RefObject<DragPreviewRenderer>;
|
||||
strategy?: StrategyName;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function useSortable({
|
||||
item,
|
||||
items,
|
||||
type,
|
||||
ref,
|
||||
onSortEnd,
|
||||
onSortStart,
|
||||
onDragEnd,
|
||||
preview,
|
||||
disabled,
|
||||
onDropPositionChange,
|
||||
strategy = 'liveSort',
|
||||
}: UseSortableProps) {
|
||||
// todo: issue with sorting after scrolling menu editor item list
|
||||
|
||||
// update sortables and active index, in case we lazy load more items while sorting
|
||||
useEffect(() => {
|
||||
if (sortSession && sortSession.sortables.length !== items.length) {
|
||||
sortSession.sortables = [...items];
|
||||
sortSession.activeIndex = items.indexOf(item);
|
||||
}
|
||||
}, [items, item]);
|
||||
|
||||
const {draggableProps, dragHandleRef} = useDraggable({
|
||||
id: item,
|
||||
ref,
|
||||
type,
|
||||
preview,
|
||||
disabled,
|
||||
onDragStart: () => {
|
||||
sortSession = {
|
||||
sortables: [...items],
|
||||
activeSortable: item,
|
||||
activeIndex: items.indexOf(item),
|
||||
finalIndex: items.indexOf(item),
|
||||
dropPosition: null,
|
||||
ref,
|
||||
scrollParent: ref.current ? getScrollParent(ref.current) : undefined,
|
||||
scrollListener: () => {
|
||||
updateRects(droppables);
|
||||
},
|
||||
};
|
||||
strategies[strategy].onDragStart(sortSession);
|
||||
|
||||
onSortStart?.();
|
||||
sortSession.scrollParent?.addEventListener(
|
||||
'scroll',
|
||||
sortSession.scrollListener,
|
||||
);
|
||||
},
|
||||
onDragEnd: () => {
|
||||
if (!sortSession) return;
|
||||
|
||||
sortSession.dropPosition = null;
|
||||
onDropPositionChange?.(sortSession.dropPosition);
|
||||
if (sortSession.activeIndex !== sortSession.finalIndex) {
|
||||
onSortEnd?.(sortSession.activeIndex, sortSession.finalIndex);
|
||||
}
|
||||
sortSession.scrollParent?.removeEventListener(
|
||||
'scroll',
|
||||
sortSession.scrollListener,
|
||||
);
|
||||
strategies[strategy].onDragEnd(sortSession);
|
||||
// call "onDragEnd" after "onSortEnd", so listener has a chance to use sort session data
|
||||
onDragEnd?.();
|
||||
sortSession = null;
|
||||
},
|
||||
getData: () => {},
|
||||
});
|
||||
|
||||
const {droppableProps} = useDroppable({
|
||||
id: item,
|
||||
ref,
|
||||
types: [type],
|
||||
disabled,
|
||||
allowDragEventsFromItself: true,
|
||||
onDragOver: (target, e) => {
|
||||
if (!sortSession) return;
|
||||
strategies[strategy].onDragOver({
|
||||
e,
|
||||
ref,
|
||||
item,
|
||||
sortSession,
|
||||
onDropPositionChange,
|
||||
});
|
||||
},
|
||||
onDragEnter: () => {
|
||||
if (!sortSession) return;
|
||||
const overIndex = sortSession.sortables.indexOf(item);
|
||||
const oldIndex = sortSession.sortables.indexOf(
|
||||
sortSession.activeSortable,
|
||||
);
|
||||
strategies[strategy].onDragEnter(sortSession, overIndex, oldIndex);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
if (!sortSession) return;
|
||||
sortSession.dropPosition = null;
|
||||
onDropPositionChange?.(sortSession.dropPosition);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sortableProps: {...mergeProps(draggableProps, droppableProps)},
|
||||
dragHandleRef,
|
||||
};
|
||||
}
|
||||
35
common/resources/client/ui/interactions/dnd/update-rects.ts
Executable file
35
common/resources/client/ui/interactions/dnd/update-rects.ts
Executable file
@@ -0,0 +1,35 @@
|
||||
// use intersection observer instead of getBoundingClientRect for better performance as this will be called in onPointerMove event
|
||||
import {InteractableRect} from '../interactable-event';
|
||||
import {ConnectedMouseSelectable} from './mouse-selection/use-mouse-selectable';
|
||||
import {DraggableId} from './use-draggable';
|
||||
import {ConnectedDroppable} from './use-droppable';
|
||||
|
||||
export function updateRects(
|
||||
targets: Map<DraggableId, ConnectedDroppable | ConnectedMouseSelectable>
|
||||
) {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
const {width, height, left, top} = entry.boundingClientRect;
|
||||
const [id, target] =
|
||||
[...targets].find(
|
||||
([, target]) => target.ref.current === entry.target
|
||||
) || [];
|
||||
if (id == null || target == null) return;
|
||||
|
||||
const rect: InteractableRect = {
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
};
|
||||
targets.set(id, {...target, rect});
|
||||
});
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
[...targets.values()].forEach(target => {
|
||||
if (target.ref.current) {
|
||||
observer.observe(target.ref.current);
|
||||
}
|
||||
});
|
||||
}
|
||||
27
common/resources/client/ui/interactions/dnd/use-drag-monitor.ts
Executable file
27
common/resources/client/ui/interactions/dnd/use-drag-monitor.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import {InteractableEvent} from '../interactable-event';
|
||||
import {useEffect, useId, useRef} from 'react';
|
||||
import {dragMonitors, DragSessionStatus} from './drag-state';
|
||||
import {ConnectedDraggable} from './use-draggable';
|
||||
|
||||
export interface DragMonitor {
|
||||
type: string;
|
||||
onDragStart?: (e: InteractableEvent, dragTarget: ConnectedDraggable) => void;
|
||||
onDragMove?: (e: InteractableEvent, dragTarget: ConnectedDraggable) => void;
|
||||
onDragEnd?: (
|
||||
e: InteractableEvent,
|
||||
dragTarget: ConnectedDraggable,
|
||||
status: DragSessionStatus
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useDragMonitor(monitor: DragMonitor) {
|
||||
const monitorRef = useRef(monitor);
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
dragMonitors.set(id, monitorRef.current);
|
||||
return () => {
|
||||
dragMonitors.delete(id);
|
||||
};
|
||||
}, [id]);
|
||||
}
|
||||
220
common/resources/client/ui/interactions/dnd/use-draggable.ts
Executable file
220
common/resources/client/ui/interactions/dnd/use-draggable.ts
Executable file
@@ -0,0 +1,220 @@
|
||||
import React, {RefObject, useLayoutEffect, useRef} from 'react';
|
||||
import {draggables, dragMonitors, dragSession, droppables} from './drag-state';
|
||||
import {
|
||||
InteractableEvent,
|
||||
interactableEvent,
|
||||
InteractableRect,
|
||||
} from '../interactable-event';
|
||||
import {activeInteraction, setActiveInteraction} from '../active-interaction';
|
||||
import {domRectToObj} from '../utils/dom-rect-to-obj';
|
||||
import {updateRects} from './update-rects';
|
||||
import {useGlobalListeners} from '@react-aria/utils';
|
||||
import {DragMonitor} from './use-drag-monitor';
|
||||
import {NativeFileDraggable} from './use-droppable';
|
||||
|
||||
interface DragState {
|
||||
currentRect?: InteractableRect;
|
||||
lastPosition: {x: number; y: number};
|
||||
clickedEl?: HTMLElement;
|
||||
}
|
||||
|
||||
export type DragPreviewRenderer = (
|
||||
draggable: ConnectedDraggable,
|
||||
callback: (node: HTMLElement) => void
|
||||
) => void;
|
||||
|
||||
export type DraggableId = string | number | object;
|
||||
|
||||
export interface ConnectedDraggable<T = any> {
|
||||
type: string;
|
||||
id: DraggableId;
|
||||
getData: () => T;
|
||||
ref: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
// Either draggable from within the app, or file dragged in from the desktop
|
||||
export type MixedDraggable = ConnectedDraggable | NativeFileDraggable;
|
||||
|
||||
interface UseDragProps extends ConnectedDraggable {
|
||||
disabled?: boolean;
|
||||
onDragStart?: (e: InteractableEvent, target: ConnectedDraggable) => void;
|
||||
onDragMove?: (e: InteractableEvent, target: ConnectedDraggable) => void;
|
||||
onDragEnd?: (e: InteractableEvent, target: ConnectedDraggable) => void;
|
||||
preview?: RefObject<DragPreviewRenderer>;
|
||||
hidePreview?: boolean;
|
||||
}
|
||||
export function useDraggable({
|
||||
id,
|
||||
disabled,
|
||||
ref,
|
||||
preview,
|
||||
hidePreview,
|
||||
...options
|
||||
}: UseDragProps) {
|
||||
const dragHandleRef = useRef<HTMLButtonElement>(null);
|
||||
const {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
|
||||
|
||||
const state = useRef<DragState>({
|
||||
lastPosition: {x: 0, y: 0},
|
||||
}).current;
|
||||
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!disabled) {
|
||||
draggables.set(id, {
|
||||
...draggables.get(id),
|
||||
id,
|
||||
ref,
|
||||
type: optionsRef.current.type,
|
||||
getData: optionsRef.current.getData,
|
||||
});
|
||||
} else {
|
||||
draggables.delete(id);
|
||||
}
|
||||
return () => {
|
||||
draggables.delete(id);
|
||||
};
|
||||
}, [id, disabled, optionsRef, ref]);
|
||||
|
||||
// notify monitors connected to the same drag type as this draggable
|
||||
const notifyMonitors = (callback: (m: DragMonitor) => void) => {
|
||||
dragMonitors.forEach(monitor => {
|
||||
if (monitor.type === draggables.get(id)?.type) {
|
||||
callback(monitor);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onDragStart = (e: React.DragEvent<HTMLElement>) => {
|
||||
const draggable = draggables.get(id);
|
||||
const el = ref.current;
|
||||
const clickedOnHandle =
|
||||
!dragHandleRef.current ||
|
||||
!state.clickedEl ||
|
||||
dragHandleRef.current.contains(state.clickedEl);
|
||||
|
||||
// if another interaction is in progress (rotate, resize, drag etc.), bail
|
||||
if (activeInteraction || !el || !draggable || !clickedOnHandle) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
updateRects(droppables);
|
||||
setActiveInteraction('drag');
|
||||
|
||||
// hide default browser ghost image
|
||||
if (hidePreview) {
|
||||
hideNativeGhostImage(e);
|
||||
}
|
||||
// this will hide default browser cursor icon, if "dropEffect" is not set in dragOver/dragEnter
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
state.lastPosition = {x: e.clientX, y: e.clientY};
|
||||
state.currentRect = domRectToObj(el.getBoundingClientRect());
|
||||
const ie = interactableEvent({rect: state.currentRect!, e});
|
||||
|
||||
// If there is a preview option, use it to render a custom preview image that will
|
||||
// appear under the pointer while dragging. If not, the element itself is dragged by the browser.
|
||||
if (preview?.current) {
|
||||
preview.current(draggable, node => {
|
||||
e.dataTransfer.setDragImage(node, 0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
dragSession.status = 'dragging';
|
||||
dragSession.dragTargetId = id;
|
||||
if (ref.current) {
|
||||
ref.current.dataset.dragging = 'true';
|
||||
}
|
||||
|
||||
optionsRef.current.onDragStart?.(ie, draggable);
|
||||
|
||||
// wait until next frame so changes made in "onDragStart" are reflected in drag monitors
|
||||
requestAnimationFrame(() => {
|
||||
notifyMonitors(m => m.onDragStart?.(ie, draggable));
|
||||
});
|
||||
|
||||
// firefox does not provide clientX/clientY in "onDrag", need to listen for dragOver on window instead
|
||||
addGlobalListener(window, 'dragover', onDragOver, true);
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent<HTMLElement> | DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!state.currentRect) return;
|
||||
|
||||
const deltaX = e.clientX - state.lastPosition.x;
|
||||
const deltaY = e.clientY - state.lastPosition.y;
|
||||
|
||||
const newRect = {
|
||||
...state.currentRect,
|
||||
left: state.currentRect.left + deltaX,
|
||||
top: state.currentRect.top + deltaY,
|
||||
};
|
||||
|
||||
const ie = interactableEvent({rect: newRect, e, deltaX, deltaY});
|
||||
|
||||
const target = draggables.get(id);
|
||||
if (target) {
|
||||
optionsRef.current.onDragMove?.(ie, target);
|
||||
notifyMonitors(m => m.onDragMove?.(ie, target));
|
||||
}
|
||||
|
||||
state.lastPosition = {x: e.clientX, y: e.clientY};
|
||||
state.currentRect = newRect;
|
||||
};
|
||||
|
||||
const onDragEnd = (e: React.DragEvent<HTMLElement>) => {
|
||||
removeAllGlobalListeners();
|
||||
if (!state.currentRect) return;
|
||||
|
||||
setActiveInteraction(null);
|
||||
if (emptyImage) {
|
||||
emptyImage.remove();
|
||||
}
|
||||
|
||||
const ie = interactableEvent({rect: state.currentRect, e});
|
||||
|
||||
const draggable = draggables.get(id);
|
||||
if (draggable) {
|
||||
optionsRef.current.onDragEnd?.(ie, draggable);
|
||||
notifyMonitors(m => m.onDragEnd?.(ie, draggable, dragSession!.status));
|
||||
}
|
||||
|
||||
// wait a frame before clearing so monitors have a chance to use drag session status
|
||||
requestAnimationFrame(() => {
|
||||
dragSession.dragTargetId = undefined;
|
||||
dragSession.status = 'inactive';
|
||||
if (ref.current) {
|
||||
delete ref.current.dataset.dragging;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const draggableProps = {
|
||||
draggable: !disabled,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onPointerDown: (e: React.PointerEvent) => {
|
||||
state.clickedEl = e.target as HTMLElement;
|
||||
},
|
||||
};
|
||||
|
||||
return {draggableProps, dragHandleRef};
|
||||
}
|
||||
|
||||
let emptyImage: HTMLImageElement | undefined;
|
||||
function hideNativeGhostImage(e: React.DragEvent) {
|
||||
if (!emptyImage) {
|
||||
emptyImage = new Image();
|
||||
// image needs to be in the dom to prevent "globe" icon in chrome
|
||||
document.body.append(emptyImage);
|
||||
emptyImage.src =
|
||||
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
}
|
||||
|
||||
e.dataTransfer.setDragImage(emptyImage, 0, 0);
|
||||
}
|
||||
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