first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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,
);
});

View 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',
};

View File

@@ -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]);
}

View File

@@ -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},
};
}

View 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));
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View 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;
}

View File

@@ -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 = '';
}
});
},
};

View 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,
};
}

View 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);
}
});
}

View 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]);
}

View 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);
}

View 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));
},
};
}
}