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,7 @@
type InteractionName = null | 'resize' | 'rotate' | 'drag' | 'move';
export let activeInteraction: InteractionName = null;
export function setActiveInteraction(name: InteractionName) {
activeInteraction = name;
}

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

View File

@@ -0,0 +1,45 @@
import React from 'react';
type NativeEvent =
| React.PointerEvent
| PointerEvent
| React.DragEvent<HTMLElement>
| DragEvent;
export interface InteractableEvent {
x: number;
y: number;
deltaX: number;
deltaY: number;
rect: InteractableRect;
nativeEvent: NativeEvent;
}
export interface InteractableRect {
left: number;
top: number;
width: number;
height: number;
angle?: number;
}
export function interactableEvent({
e,
rect,
deltaX,
deltaY,
}: {
e: NativeEvent;
rect: InteractableRect;
deltaX?: number;
deltaY?: number;
}): InteractableEvent {
return {
rect,
x: e.clientX,
y: e.clientY,
deltaX: deltaX ?? 0,
deltaY: deltaY ?? 0,
nativeEvent: e,
};
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import {InteractableRect} from './interactable-event';
export interface InteractablePointerEvent {
nativeEvent: React.PointerEvent | PointerEvent;
el: HTMLElement;
startPoint: {x: number; y: number; scrollTop: number};
currentRect: InteractableRect;
aspectRatio: number | null | undefined;
}
export interface InteractablePointerMoveEvent extends InteractablePointerEvent {
deltaX: number;
deltaY: number;
}

View File

@@ -0,0 +1,99 @@
import {RefObject} from 'react';
import {
interactableEvent,
InteractableEvent,
InteractableRect,
} from './interactable-event';
import {usePointerEvents, UsePointerEventsProps} from './use-pointer-events';
import {activeInteraction, setActiveInteraction} from './active-interaction';
import {domRectToObj} from './utils/dom-rect-to-obj';
import {clamp} from '../../utils/number/clamp';
let state: {
currentRect?: InteractableRect;
boundaryRect?: InteractableRect;
} = {};
export interface UseMoveProps {
restrictWithinBoundary?: boolean;
minimumMovement?: number;
boundaryRef?: RefObject<HTMLElement> | null;
boundaryRect?: InteractableRect;
onMoveStart?: (e: InteractableEvent) => void;
onMove?: (e: InteractableEvent) => void;
onMoveEnd?: (e: InteractableEvent) => void;
}
export function useMove({
boundaryRef,
boundaryRect,
minimumMovement,
restrictWithinBoundary = true,
...props
}: UseMoveProps) {
const pointerProps: UsePointerEventsProps = {
minimumMovement,
onMoveStart: (e, el) => {
if (activeInteraction) {
return false;
}
state = {
currentRect: domRectToObj(el.getBoundingClientRect()),
};
setActiveInteraction('move');
if (boundaryRect) {
state.boundaryRect = boundaryRect;
} else if (boundaryRef?.current) {
state.boundaryRect = domRectToObj(
boundaryRef.current.getBoundingClientRect()
);
}
// if we have a boundary, x, y will be relative to that boundary, otherwise it will be relative to window
if (state.currentRect && state.boundaryRect) {
state.currentRect.left -= state.boundaryRect.left;
state.currentRect.top -= state.boundaryRect.top;
}
props.onMoveStart?.(interactableEvent({rect: state.currentRect!, e}));
},
onMove: (e, deltaX, deltaY) => {
if (!state.currentRect) return;
const newRect = {
...state.currentRect,
left: state.currentRect.left + deltaX,
top: state.currentRect.top + deltaY,
};
const boundedRect = {...newRect};
if (state.boundaryRect && restrictWithinBoundary) {
boundedRect.left = clamp(
newRect.left,
0,
state.boundaryRect.width - newRect.width
);
boundedRect.top = clamp(
newRect.top,
0,
state.boundaryRect.height - newRect.height
);
}
props.onMove?.(interactableEvent({rect: boundedRect, e, deltaX, deltaY}));
state.currentRect = newRect;
},
onMoveEnd: e => {
if (!state.currentRect) return;
props.onMoveEnd?.(interactableEvent({rect: state.currentRect, e}));
setActiveInteraction(null);
state = {};
},
};
const {domProps} = usePointerEvents(pointerProps);
return {moveProps: domProps};
}

View File

@@ -0,0 +1,169 @@
import React, {HTMLAttributes, useRef} from 'react';
import {createEventHandler} from '../../utils/dom/create-event-handler';
import {useGlobalListeners} from '@react-aria/utils';
interface PointerState {
lastPosition: {x: number; y: number};
id?: number;
started: boolean;
el?: HTMLElement;
originalTouchAction?: string;
originalUserSelect?: string;
longPressTimer?: any;
longPressTriggered?: boolean;
}
interface UsePointerEventsReturn {
domProps: HTMLAttributes<HTMLElement>;
}
export interface UsePointerEventsProps {
onMoveStart?: (e: PointerEvent, el: HTMLElement) => false | void;
onMove?: (e: PointerEvent, deltaX: number, deltaY: number) => void;
onMoveEnd?: (e: PointerEvent) => void;
onPointerDown?: (e: React.PointerEvent) => void | false;
onPointerUp?: (e: PointerEvent, el: HTMLElement) => void;
onPress?: (e: PointerEvent, el: HTMLElement) => void;
onLongPress?: (e: PointerEvent | React.PointerEvent, el: HTMLElement) => void;
preventDefault?: boolean;
stopPropagation?: boolean;
minimumMovement?: number;
}
export function usePointerEvents({
onMoveStart,
onMove,
onMoveEnd,
minimumMovement = 0,
preventDefault,
stopPropagation = true,
onPress,
onLongPress,
...props
}: UsePointerEventsProps): UsePointerEventsReturn {
const stateRef = useRef<PointerState>({
lastPosition: {x: 0, y: 0},
started: false,
longPressTriggered: false,
});
const state = stateRef.current;
const {addGlobalListener, removeGlobalListener} = useGlobalListeners();
const start = (e: PointerEvent) => {
if (!state.el) return;
const result = onMoveStart?.(e, state.el);
// allow user to cancel interaction
if (result === false) return;
state.originalTouchAction = state.el.style.touchAction;
state.el.style.touchAction = 'none';
state.originalUserSelect = document.documentElement.style.userSelect;
document.documentElement.style.userSelect = 'none';
state.started = true;
};
const onPointerDown = (e: React.PointerEvent) => {
if (e.button === 0 && state.id == null) {
state.started = false;
const result = props.onPointerDown?.(e);
if (result === false) return;
if (stopPropagation) {
e.stopPropagation();
}
if (preventDefault) {
e.preventDefault();
}
state.id = e.pointerId;
state.el = e.currentTarget as HTMLElement;
state.lastPosition = {x: e.clientX, y: e.clientY};
// use global listeners, so we don't have to capture pointer,
// which would prevent click events on child nodes
if (onLongPress) {
state.longPressTimer = setTimeout(() => {
onLongPress(e, state.el!);
state.longPressTriggered = true;
}, 400);
}
if (onMoveStart || onMove) {
addGlobalListener(window, 'pointermove', onPointerMove, false);
}
addGlobalListener(window, 'pointerup', onPointerUp, false);
addGlobalListener(window, 'pointercancel', onPointerUp, false);
}
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerId === state.id) {
const deltaX = e.clientX - state.lastPosition.x;
const deltaY = e.clientY - state.lastPosition.y;
if (
(Math.abs(deltaX) >= minimumMovement ||
Math.abs(deltaY) >= minimumMovement) &&
!state.started
) {
start(e);
}
if (state.started) {
onMove?.(e, deltaX, deltaY);
state.lastPosition = {x: e.clientX, y: e.clientY};
}
}
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId === state.id) {
// cancel long press timer, if exists
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
}
const longPressTriggered = state.longPressTriggered;
state.longPressTriggered = false;
// only call onMoveEnd if we actually started moving
if (state.started) {
onMoveEnd?.(e);
}
if (state.el) {
// handle press only if event was not cancelled (via touch scroll on mobile for example)
if (e.type !== 'pointercancel') {
props.onPointerUp?.(e, state.el);
// only call onPress if pointer did not leave onPointerDown element
if (e.target && state.el.contains(e.target as HTMLElement)) {
// trigger either onPress or onLongPress
if (longPressTriggered) {
onLongPress?.(e, state.el);
} else {
onPress?.(e, state.el);
}
}
}
document.documentElement.style.userSelect =
state.originalUserSelect || '';
state.el.style.touchAction = state.originalTouchAction || '';
}
state.id = undefined;
state.started = false;
removeGlobalListener(window, 'pointermove', onPointerMove, false);
removeGlobalListener(window, 'pointerup', onPointerUp, false);
removeGlobalListener(window, 'pointercancel', onPointerUp, false);
}
};
return {
domProps: {
onPointerDown: createEventHandler(onPointerDown),
},
};
}

View File

@@ -0,0 +1,224 @@
import {RefObject} from 'react';
import {
interactableEvent,
InteractableEvent,
InteractableRect,
} from './interactable-event';
import {usePointerEvents, UsePointerEventsProps} from './use-pointer-events';
import {activeInteraction, setActiveInteraction} from './active-interaction';
import {calcNewSizeFromAspectRatio} from './utils/calc-new-size-from-aspect-ratio';
import {restrictResizableWithinBoundary} from './utils/restrict-resizable-within-boundary';
import {domRectToObj} from './utils/dom-rect-to-obj';
export enum resizeHandlePosition {
topLeft = 'topLeft',
topRight = 'topRight',
bottomLeft = 'bottomLeft',
bottomRight = 'bottomRight',
}
type AspectRatio = number | null | undefined;
type InitialAspectRatio = AspectRatio | 'initial';
interface ResizeState {
currentRect?: InteractableRect;
resizeDir?: resizeHandlePosition;
initialAspectRatio?: AspectRatio;
boundaryRect?: InteractableRect;
}
let state: ResizeState = {};
const resetState = (value: ResizeState = {}) => {
setActiveInteraction(null);
state = value;
};
interface UseResizeProps {
boundaryRect?: InteractableRect;
boundaryRef?: RefObject<HTMLElement> | null;
restrictWithinBoundary?: boolean;
aspectRatio?: InitialAspectRatio;
onResizeStart?: (e: InteractableEvent) => void;
onResize?: (e: InteractableEvent) => void;
onResizeEnd?: (e: InteractableEvent) => void;
minWidth?: number;
minHeight?: number;
}
export function useResize({
aspectRatio,
boundaryRef,
boundaryRect,
restrictWithinBoundary = true,
minWidth = 50,
minHeight = 50,
...props
}: UseResizeProps) {
const pointerProps: UsePointerEventsProps = {
onMoveStart: (e, resizable) => {
const target = e.target as HTMLElement;
if (!target.dataset.resizeHandle || activeInteraction) {
return false;
}
resetState({
currentRect: domRectToObj(resizable.getBoundingClientRect()),
resizeDir: target.dataset.resizeHandle as resizeHandlePosition,
});
if (!state.currentRect) {
return false;
}
setActiveInteraction('resize');
if (boundaryRect) {
state.boundaryRect = boundaryRect;
} else if (boundaryRef?.current) {
state.boundaryRect = domRectToObj(
boundaryRef.current.getBoundingClientRect(),
);
}
// if we have a boundary, x, y will be relative to that boundary, otherwise it will be relative to window
if (state.currentRect && state.boundaryRect) {
state.currentRect.left -= state.boundaryRect.left;
state.currentRect.top -= state.boundaryRect.top;
}
state.initialAspectRatio =
state.currentRect.width / state.currentRect!.height;
props.onResizeStart?.(interactableEvent({rect: state.currentRect, e}));
},
onMove: (e, deltaX, deltaY) => {
if (!state.resizeDir || !state.currentRect) return;
const ratio =
aspectRatio === 'initial' ? state.initialAspectRatio : aspectRatio;
const newRect = resizeRect(state.currentRect, deltaX, deltaY, ratio);
const boundedRect = applyBounds(newRect, minWidth, minHeight, ratio);
props.onResize?.(
interactableEvent({rect: boundedRect, e, deltaX, deltaY}),
);
state.currentRect = newRect;
},
onMoveEnd: e => {
if (state.currentRect) {
props.onResizeEnd?.(interactableEvent({rect: state.currentRect, e}));
}
resetState();
},
};
const {domProps} = usePointerEvents(pointerProps);
return {resizeProps: domProps};
}
function resizeRect(
rect: InteractableRect,
deltaX: number,
deltaY: number,
ratio: AspectRatio,
): InteractableRect {
const prevRect = {...rect};
const newRect = {...rect};
if (state.resizeDir === resizeHandlePosition.topRight) {
newRect.width = Math.floor(newRect.width + deltaX);
if (ratio) {
newRect.height = Math.floor(newRect.width / ratio);
} else {
newRect.height = Math.floor(newRect.height - deltaY);
}
newRect.top = Math.floor(newRect.top + (prevRect.height - newRect.height));
} else if (state.resizeDir === resizeHandlePosition.bottomRight) {
newRect.width = Math.floor(newRect.width + deltaX);
if (ratio) {
newRect.height = Math.floor(newRect.width / ratio);
} else {
newRect.height = Math.floor(newRect.height + deltaY);
}
} else if (state.resizeDir === resizeHandlePosition.topLeft) {
newRect.width = Math.floor(newRect.width - deltaX);
if (ratio) {
newRect.height = Math.floor(newRect.width / ratio);
} else {
newRect.height = Math.floor(newRect.height - deltaY);
}
newRect.left = Math.floor(newRect.left + (prevRect.width - newRect.width));
newRect.top = Math.floor(newRect.top + (prevRect.height - newRect.height));
} else if (state.resizeDir === resizeHandlePosition.bottomLeft) {
newRect.width = Math.floor(newRect.width - deltaX);
if (ratio) {
newRect.height = Math.floor(newRect.width / ratio);
} else {
newRect.height = Math.floor(newRect.height + deltaY);
}
newRect.left = Math.floor(newRect.left + deltaX);
}
return newRect;
}
function applyBounds(
rect: InteractableRect,
minWidth: number,
minHeight: number,
ratio: AspectRatio,
): InteractableRect {
const isLeftSideHandle =
state.resizeDir === resizeHandlePosition.bottomLeft ||
state.resizeDir === resizeHandlePosition.topLeft;
const isTopSideHandle =
state.resizeDir === resizeHandlePosition.topRight ||
state.resizeDir === resizeHandlePosition.topLeft;
// bound width and height
let boundedRect = {...rect};
boundedRect.width = Math.max(minWidth, rect.width);
boundedRect.height = Math.max(minHeight, rect.height);
// compensate left when width is bounded
const widthRestriction = boundedRect.width - rect.width;
if (isLeftSideHandle && widthRestriction > 0) {
boundedRect.left -= widthRestriction;
}
// compensate top when height is bounded
const heightRestriction = boundedRect.height - rect.height;
if (isTopSideHandle && heightRestriction > 0) {
boundedRect.top -= heightRestriction;
}
if (state.boundaryRect) {
boundedRect = restrictResizableWithinBoundary(
boundedRect,
state.boundaryRect,
);
}
if (ratio) {
// adjust width/height based on specified aspect ratio
const oldWidth = boundedRect.width;
const oldHeight = boundedRect.height;
const size = calcNewSizeFromAspectRatio(
ratio,
boundedRect.width,
boundedRect.height,
);
boundedRect.width = size.width;
boundedRect.height = size.height;
// compensate top/left that was bound previously
if (isTopSideHandle) {
boundedRect.top += oldHeight - boundedRect.height;
}
if (isLeftSideHandle) {
boundedRect.left += oldWidth - boundedRect.width;
}
}
return boundedRect;
}

View File

@@ -0,0 +1,93 @@
import {RefObject} from 'react';
import {
interactableEvent,
InteractableEvent,
InteractableRect,
} from './interactable-event';
import {usePointerEvents, UsePointerEventsProps} from './use-pointer-events';
import {activeInteraction, setActiveInteraction} from './active-interaction';
import {domRectToObj} from './utils/dom-rect-to-obj';
interface RotateState {
currentRect?: InteractableRect;
centerX?: number;
centerY?: number;
startAngle?: number;
}
let state: RotateState = {};
interface UseRotateProps {
boundaryRect?: InteractableRect;
boundaryRef?: RefObject<HTMLElement> | null;
onRotateStart?: (e: InteractableEvent) => void;
onRotate?: (e: InteractableEvent) => void;
onRotateEnd?: (e: InteractableEvent) => void;
}
export function useRotate(props: UseRotateProps) {
const pointerProps: UsePointerEventsProps = {
onMoveStart: (e, rotatable) => {
const target = e.target as HTMLElement;
if (!target.dataset.rotateHandle || activeInteraction) {
return false;
}
const rect = domRectToObj(rotatable.getBoundingClientRect());
if (!rect) return false;
const rotateVal = rotatable.style.transform.match(/rotate\((.+?)\)/)?.[1];
const [rotation = '0'] = rotateVal ? rotateVal.split(',') : [];
resetState({
currentRect: rect,
// store the center because the element has css `transform-origin: center center`
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
startAngle: parseFloat(rotation),
});
setActiveInteraction('rotate');
// get the angle of the element when the drag starts
state.startAngle = getDragAngle(e);
props.onRotateStart?.(interactableEvent({rect, e}));
},
onMove: (e, deltaX, deltaY) => {
if (!state.currentRect) return;
const newRect = {...state.currentRect};
newRect.angle = getDragAngle(e);
newRect.left += deltaX;
newRect.top += deltaY;
props.onRotate?.(interactableEvent({rect: newRect, e, deltaX, deltaY}));
state.currentRect = newRect;
},
onMoveEnd: e => {
if (state.currentRect) {
props.onRotateEnd?.(interactableEvent({rect: state.currentRect, e}));
}
resetState();
},
};
const {domProps} = usePointerEvents(pointerProps);
return {rotateProps: domProps};
}
function getDragAngle(e: {pageX: number; pageY: number}) {
const center = {
x: state.centerX || 0,
y: state.centerY || 0,
};
const angle = Math.atan2(center.y - e.pageY, center.x - e.pageX);
return angle - (state.startAngle || 0);
}
const resetState = (value: RotateState = {}) => {
setActiveInteraction(null);
state = value;
};

View File

@@ -0,0 +1,24 @@
export function calcNewSizeFromAspectRatio(
aspectRatio: number | null,
oldWidth: number,
oldHeight: number
) {
let newWidth = oldWidth;
let newHeight = oldHeight;
if (aspectRatio) {
if (oldHeight * aspectRatio > oldWidth) {
newHeight = oldWidth / aspectRatio;
} else {
newWidth = oldHeight * aspectRatio;
}
}
return {width: Math.floor(newWidth), height: Math.floor(newHeight)};
}
export function aspectRatioFromStr(ratio: string | null): number | null {
if (!ratio) return null;
const parts = ratio.split(':');
return parseInt(parts[0]) / parseInt(parts[1]);
}

View File

@@ -0,0 +1,30 @@
import {InteractableRect} from '../interactable-event';
import {calcNewSizeFromAspectRatio} from './calc-new-size-from-aspect-ratio';
export function centerWithinBoundary(
boundary: Omit<InteractableRect, 'angle'>,
aspectRatio: number | null = null
): InteractableRect {
// set rect to the size of specified boundary
const rect: InteractableRect = {
width: boundary.width,
height: boundary.height,
top: 0,
left: 0,
angle: 0,
};
// maybe resize rect based on aspect ratio
if (aspectRatio) {
const newSize = calcNewSizeFromAspectRatio(
aspectRatio,
rect.width,
rect.height
);
rect.width = newSize.width;
rect.height = newSize.height;
}
// center the rect
rect.left = (boundary.width - rect.width) / 2;
rect.top = (boundary.height - rect.height) / 2;
return rect;
}

View File

@@ -0,0 +1,10 @@
import {InteractableRect} from '../interactable-event';
export function domRectToObj(rect: DOMRect): InteractableRect {
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}

View File

@@ -0,0 +1,19 @@
import {InteractableRect} from '../interactable-event';
export function interactableRectFromEl(el: HTMLElement) {
const translateStr = el.style.transform.match(/translate\((.+?)\)/)?.[1];
const translateValues = (translateStr || '').split(',');
const top = translateValues[1] || '0';
const left = translateValues[0] || '0';
const rect: InteractableRect = {
width: el.offsetWidth,
height: el.offsetHeight,
left: parseInt(left),
top: parseInt(top),
angle: 0,
};
const initialAspectRatio = rect.width / rect.height;
return {rect, initialAspectRatio};
}

View File

@@ -0,0 +1,14 @@
import {InteractableRect} from '../interactable-event';
export function rectsIntersect(
rectA?: InteractableRect,
rectB?: InteractableRect
) {
if (!rectA || !rectB) return false;
return (
rectA.left <= rectB.left + rectB.width &&
rectA.left + rectA.width >= rectB.left &&
rectA.top <= rectB.top + rectB.height &&
rectA.top + rectA.height >= rectB.top
);
}

View File

@@ -0,0 +1,38 @@
import {InteractableRect} from '../interactable-event';
export function restrictResizableWithinBoundary(
rect: InteractableRect,
boundaryRect: InteractableRect
) {
const boundedRect = {...rect};
// restrict to left edge of boundary
boundedRect.left = Math.max(0, boundedRect.left);
// compensate width when left is bounded
const leftRestriction = boundedRect.left - rect.left;
if (leftRestriction > 0) {
boundedRect.width -= leftRestriction;
}
// restrict to top edge of boundary
boundedRect.top = Math.max(0, boundedRect.top);
// compensate height when top is bounded
const topRestriction = boundedRect.top - rect.top;
if (topRestriction > 0) {
boundedRect.height -= topRestriction;
}
// restrict to right edge of boundary
boundedRect.width = Math.min(
boundedRect.width,
boundaryRect.width - boundedRect.left
);
// restrict to bottom edge of boundary
boundedRect.height = Math.min(
boundedRect.height,
boundaryRect.height - boundedRect.top
);
return boundedRect;
}