7
common/resources/client/ui/interactions/active-interaction.ts
Executable file
7
common/resources/client/ui/interactions/active-interaction.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
type InteractionName = null | 'resize' | 'rotate' | 'drag' | 'move';
|
||||
|
||||
export let activeInteraction: InteractionName = null;
|
||||
|
||||
export function setActiveInteraction(name: InteractionName) {
|
||||
activeInteraction = name;
|
||||
}
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
45
common/resources/client/ui/interactions/interactable-event.ts
Executable file
45
common/resources/client/ui/interactions/interactable-event.ts
Executable 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,
|
||||
};
|
||||
}
|
||||
15
common/resources/client/ui/interactions/interactable-pointer-event.ts
Executable file
15
common/resources/client/ui/interactions/interactable-pointer-event.ts
Executable 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;
|
||||
}
|
||||
99
common/resources/client/ui/interactions/use-move.ts
Executable file
99
common/resources/client/ui/interactions/use-move.ts
Executable 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};
|
||||
}
|
||||
169
common/resources/client/ui/interactions/use-pointer-events.ts
Executable file
169
common/resources/client/ui/interactions/use-pointer-events.ts
Executable 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
224
common/resources/client/ui/interactions/use-resize.ts
Executable file
224
common/resources/client/ui/interactions/use-resize.ts
Executable 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;
|
||||
}
|
||||
93
common/resources/client/ui/interactions/use-rotate.ts
Executable file
93
common/resources/client/ui/interactions/use-rotate.ts
Executable 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;
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
30
common/resources/client/ui/interactions/utils/center-within-boundary.ts
Executable file
30
common/resources/client/ui/interactions/utils/center-within-boundary.ts
Executable 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;
|
||||
}
|
||||
10
common/resources/client/ui/interactions/utils/dom-rect-to-obj.ts
Executable file
10
common/resources/client/ui/interactions/utils/dom-rect-to-obj.ts
Executable 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,
|
||||
};
|
||||
}
|
||||
19
common/resources/client/ui/interactions/utils/interactable-rect-from-el.ts
Executable file
19
common/resources/client/ui/interactions/utils/interactable-rect-from-el.ts
Executable 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};
|
||||
}
|
||||
14
common/resources/client/ui/interactions/utils/rects-intersect.ts
Executable file
14
common/resources/client/ui/interactions/utils/rects-intersect.ts
Executable 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user