Merge pull request #149 from webstudio-is/drag-drop-prototype

Drag & drop prototype
This commit is contained in:
Oleg Isonen authored and GitHub committed 2022-07-21 21:54:06 +02:00
commit 893fc33e9c
9 files changed
+1533 -2

No files matched your search

@@ -0,0 +1,309 @@
import { ComponentMeta } from "@storybook/react";
import React, { useState, useRef } from "react";
import { Box } from "../../box";
import { useDropTarget } from "./use-drop-target";
import { useDrag } from "./use-drag";
import { usePlacement, PlacementIndicator, type Rect } from "./placement";
import { useAutoScroll } from "./use-auto-scroll";
const ROOT_ID = "root";
type ItemData = {
id: string;
style: React.CSSProperties;
acceptsChildren: boolean;
children: ItemData[];
};
const Item = ({
data,
dragItemId,
}: {
data: ItemData;
dragItemId: string | undefined;
}) => {
return (
<Box
css={{
opacity: dragItemId === data.id ? 0.3 : 1,
minWidth: 100,
minHeight: 100,
margin: 10,
padding: 10,
background: "$mint12",
border: "1px solid $mint9",
}}
style={data.style}
data-id={data.id}
title={JSON.stringify({ id: data.id, style: data.style }, null, 2)}
>
<Items data={data.children} dragItemId={dragItemId} />
</Box>
);
};
const Items = ({
data,
dragItemId,
}: {
data: ItemData[];
dragItemId: string | undefined;
}) => {
return (
<>
{data.map((child) => (
<Item data={child} dragItemId={dragItemId} key={child.id} />
))}
</>
);
};
const elementToId = (element: HTMLElement) => element.dataset.id;
const idToElement = (
root: HTMLElement,
id: string
): HTMLElement | undefined => {
if (elementToId(root) === id) {
return root;
}
for (const child of root.children) {
if (child instanceof HTMLElement) {
const found = idToElement(child, id);
if (found) {
return found;
}
}
}
return undefined;
};
const mapItems = (
data: ItemData[],
fn: (item: ItemData) => ItemData
): ItemData[] => {
return fn({
id: ROOT_ID,
style: {},
acceptsChildren: true,
children: data.map((item) => {
return fn({
...item,
children: mapItems(item.children, fn),
});
}),
}).children;
};
const findItem = (data: ItemData[], id: string): ItemData | undefined => {
for (const item of data) {
if (item.id === id) {
return item;
}
const found = findItem(item.children, id);
if (found) {
return found;
}
}
return undefined;
};
const findItemPath = (data: ItemData[], id: string): ItemData[] | undefined => {
for (const item of data) {
if (item.id === id) {
return [item];
}
const found = findItemPath(item.children, id);
if (found) {
return [...found, item];
}
}
return undefined;
};
export const Canvas = () => {
const [data, setData] = useState<ItemData[]>([
{
id: "0",
style: {},
children: [
{ id: "1", style: { margin: 0 }, children: [], acceptsChildren: true },
{ id: "2", style: { margin: 0 }, children: [], acceptsChildren: true },
{ id: "3", style: { margin: 0 }, children: [], acceptsChildren: true },
],
acceptsChildren: true,
},
{ id: "4", style: {}, children: [], acceptsChildren: true },
{ id: "5", style: {}, children: [], acceptsChildren: true },
{ id: "6", style: {}, children: [], acceptsChildren: false },
]);
const [placement, setPalcement] = useState<{
index: number;
placementRect: Rect;
}>();
const [dragItemId, setDragItemId] = useState<string>();
const dropTargetId = useRef<string>();
const rootRef = useRef<HTMLElement | null>(null);
const setDropTarget = (id: string, element: HTMLElement) => {
dropTargetId.current = id;
placementHandlers.handleTargetChange(element);
};
const dropTargetHandlers = useDropTarget({
edgeDistanceThreshold: 10,
isDropTarget(element: HTMLElement) {
return elementToId(element) !== undefined;
},
onDropTargetChange(event) {
const id = elementToId(event.target);
const rootElement = rootRef.current;
if (id === undefined || rootElement === null) {
return;
}
if (id !== ROOT_ID) {
let path = findItemPath(data, id) ?? [];
// NOTE: not sure we should always do this.
// Might depend on whether there's a parent with acceptsChildren, or something.
if (event.area !== "center") {
path = path.slice(1);
}
// to make sure we are not dropping on ourself
const dragItemIndex = path.findIndex((item) => item.id === dragItemId);
if (dragItemIndex !== -1) {
path = path.slice(dragItemIndex + 1);
}
for (const item of path) {
if (item.acceptsChildren) {
const element = idToElement(rootElement, item.id);
if (element) {
setDropTarget(item.id, element);
return;
}
}
}
}
setDropTarget(ROOT_ID, rootElement);
},
});
const autoScrollHandlers = useAutoScroll();
const dragProps = useDrag({
onStart(event) {
const id = event.target.dataset.id;
if (id == null) {
event.cancel();
return;
}
setDragItemId(id);
autoScrollHandlers.setEnabled(true);
},
onMove: (poiterCoordinate) => {
dropTargetHandlers.handleMove(poiterCoordinate);
autoScrollHandlers.handleMove(poiterCoordinate);
placementHandlers.handleMove(poiterCoordinate);
},
onEnd() {
const currentDropTargetId = dropTargetId.current;
if (
placement !== undefined &&
dragItemId !== undefined &&
currentDropTargetId !== undefined
) {
setData((current) => {
const dragItem = findItem(current, dragItemId);
// shouldn't happen, for TypeScript
if (dragItem === undefined) {
return current;
}
return mapItems(current, (item) => {
let children = item.children;
const oldIndex = children.findIndex(
(child) => child.id === dragItemId
);
if (oldIndex !== -1) {
children = children.slice();
children.splice(oldIndex, 1);
}
if (item.id === currentDropTargetId) {
// placement.index does not take into account the fact that the drag item will be removed.
// we need to do this to account for it.
const newIndex =
oldIndex !== -1 && oldIndex < placement.index
? placement.index - 1
: placement.index;
children = children.slice();
children.splice(newIndex, 0, dragItem);
}
return children === item.children ? item : { ...item, children };
});
});
}
dropTargetHandlers.handleEnd();
autoScrollHandlers.setEnabled(false);
placementHandlers.handleEnd();
setDragItemId(undefined);
setPalcement(undefined);
dropTargetId.current = undefined;
},
});
const placementHandlers = usePlacement({
onPlacementChange: (event) => {
setPalcement(event);
},
});
return (
<>
<Box
css={{
background: "white",
padding: 10,
width: 500,
height: 500,
overflow: "auto",
// to make DnD work we have to disable scrolling using touch
touchAction: "none",
"[data-id]": {
cursor: dragItemId === undefined ? "grab" : "default",
},
}}
ref={(element) => {
dropTargetHandlers.rootRef(element);
autoScrollHandlers.targetRef(element);
rootRef.current = element;
}}
onScroll={() => {
dropTargetHandlers.handleScroll();
placementHandlers.handleScroll();
}}
data-id={ROOT_ID}
{...dragProps}
>
<Items data={data} dragItemId={dragItemId} />
</Box>
{placement && <PlacementIndicator rect={placement.placementRect} />}
</>
);
};
export default {} as ComponentMeta<typeof Canvas>;
@@ -0,0 +1,234 @@
import { useRef } from "react";
import { Box } from "../../box";
export type Rect = Pick<DOMRect, "top" | "left" | "width" | "height">;
type Point = { x: number; y: number };
const probe = document.createElement("div");
/**
* We place a div at the position where future drop will happen.
* We learn if the indicator will render vertically or horizontally as well as its width/heigh.
*/
const getPlacement = ({
target,
index = 0,
}: {
target: HTMLElement;
index?: number;
}) => {
const { children } = target;
if (index > children.length - 1) {
target.appendChild(probe);
} else {
target.insertBefore(probe, children[index]);
}
const rect = probe.getBoundingClientRect();
target.removeChild(probe);
return rect;
};
// https://stackoverflow.com/a/18157551/478603
const getDistance = (rect: Rect, { x, y }: Point) => {
const dx = Math.max(rect.left - x, 0, x - (rect.left + rect.width));
const dy = Math.max(rect.top - y, 0, y - (rect.top + rect.height));
return Math.sqrt(dx * dx + dy * dy);
};
const getClosestRectIndex = (rects: Rect[], point: Point) => {
if (rects.length === 0) {
return -1;
}
const sorted = rects
.map((rect, index) => ({
index,
distance: getDistance(rect, point),
}))
.sort((a, b) => a.distance - b.distance);
return sorted[0].index;
};
const isEqualRect = (a: Rect | undefined, b: Rect) => {
return (
a !== undefined &&
a.top === b.top &&
a.left === b.left &&
a.width === b.width &&
a.height === b.height
);
};
type UsePlacementProps = {
onPlacementChange: (event: { index: number; placementRect: Rect }) => void;
};
type Handlers = {
handleEnd: () => void;
handleMove: (pointerPoint: Point) => void;
handleScroll: () => void;
handleTargetChange: (target: HTMLElement) => void;
};
export const usePlacement = ({
onPlacementChange,
}: UsePlacementProps): Handlers => {
const state = useRef({
childrenRectsCache: new WeakMap<HTMLElement, Rect[]>(),
target: undefined as HTMLElement | undefined,
targetRect: undefined as DOMRect | undefined,
pointerPoint: undefined as Point | undefined,
index: undefined as number | undefined,
placementRext: undefined as Rect | undefined,
});
const getChildrenRects = (parent: HTMLElement, parentRect: DOMRect) => {
const fromCache = state.current.childrenRectsCache.get(parent);
if (fromCache !== undefined) {
return fromCache;
}
const result: Rect[] = [];
for (const child of parent.children) {
if (child instanceof HTMLElement) {
const rect = child.getBoundingClientRect();
result.push({
top: rect.top - parentRect.top,
left: rect.left - parentRect.left,
width: rect.width,
height: rect.height,
});
}
}
state.current.childrenRectsCache.set(parent, result);
return result;
};
const getIndex = (
parent: HTMLElement,
parentRect: DOMRect,
pointerPoint: Point
) => {
if (parent.children.length === 0) {
return 0;
}
const childrenRects = getChildrenRects(parent, parentRect);
const pointerAdjusted = {
x: pointerPoint.x - parentRect.left,
y: pointerPoint.y - parentRect.top,
};
const closestRectIndex = getClosestRectIndex(
childrenRects,
pointerAdjusted
);
const closesRect = childrenRects[closestRectIndex];
const isOrientationVertical =
childrenRects.length < 2 ||
childrenRects[0].left === childrenRects[1].left;
if (isOrientationVertical) {
const middleY = closesRect.top + closesRect.height / 2;
return pointerAdjusted.y < middleY
? closestRectIndex
: closestRectIndex + 1;
} else {
const middleX = closesRect.left + closesRect.width / 2;
return pointerAdjusted.x < middleX
? closestRectIndex
: closestRectIndex + 1;
}
};
const detectChange = (
reason: "pointer-move" | "scroll" | "target-change"
) => {
const { target, pointerPoint } = state.current;
if (target === undefined || pointerPoint === undefined) {
return;
}
const nextTargetRect = target.getBoundingClientRect();
const targetRectChanged = !isEqualRect(
state.current.targetRect,
nextTargetRect
);
state.current.targetRect = nextTargetRect;
const nextIndex = getIndex(target, nextTargetRect, pointerPoint);
const indexChanged = nextIndex !== state.current.index;
state.current.index = nextIndex;
if (reason !== "target-change" && !indexChanged && !targetRectChanged) {
return;
}
const nextPlacementRect = getPlacement({ target, index: nextIndex });
const placementRectChanged = !isEqualRect(
state.current.placementRext,
nextPlacementRect
);
state.current.placementRext = nextPlacementRect;
if (placementRectChanged || indexChanged) {
onPlacementChange({ index: nextIndex, placementRect: nextPlacementRect });
}
};
return {
handleEnd() {
state.current = {
childrenRectsCache: new WeakMap(),
target: undefined,
targetRect: undefined,
pointerPoint: undefined,
index: undefined,
placementRext: undefined,
};
},
handleMove(pointerPoint) {
state.current.pointerPoint = pointerPoint;
detectChange("pointer-move");
},
handleScroll() {
detectChange("scroll");
},
handleTargetChange: (target) => {
state.current.target = target;
detectChange("target-change");
},
};
};
export const PlacementIndicator = ({ rect }: { rect?: Rect }) => {
if (rect === undefined) {
return null;
}
const style = {
top: rect.top - 1,
left: rect.left - 1,
width: rect.width,
height: rect.height,
};
return (
<Box
style={style}
css={{
boxSizing: "content-box",
position: "absolute",
border: "1px solid $grass9",
pointerEvents: "none",
}}
/>
);
};
@@ -0,0 +1,182 @@
import { ComponentMeta } from "@storybook/react";
import { useState } from "react";
import { Box } from "../../box";
import { styled } from "../../../stitches.config";
import { useDropTarget } from "./use-drop-target";
import { useDrag } from "./use-drag";
import { usePlacement, PlacementIndicator, type Rect } from "./placement";
import { useAutoScroll } from "./use-auto-scroll";
type ItemData = { id: string; text: string };
const ListItem = styled("li", {
display: "block",
margin: 10,
background: "$mint12",
padding: 10,
userSelect: "none",
});
const List = styled("ul", {
display: "block",
margin: 0,
padding: 10,
});
const Item = ({
data,
isDragging,
}: {
data: ItemData;
isDragging: boolean;
}) => {
return (
<ListItem css={{ opacity: isDragging ? 0.3 : 1 }} data-id={data.id}>
{data.text}
</ListItem>
);
};
export const SortableList = ({
direction = "vertical",
}: {
direction?: "horizontal" | "vertical" | "wrap";
}) => {
const [data, setData] = useState([
{ id: "0", text: "First" },
{ id: "1", text: "Second" },
{ id: "2", text: "Third" },
{ id: "3", text: "Fourth" },
{ id: "4", text: "Fifth" },
{ id: "5", text: "Sixth" },
{ id: "6", text: "Seventh" },
{ id: "7", text: "Eighth" },
{ id: "8", text: "Ninth" },
{ id: "9", text: "Tenth" },
{ id: "10", text: "Eleventh" },
{ id: "11", text: "Twelfth" },
{ id: "12", text: "Thirteenth" },
{ id: "13", text: "Fourteenth" },
{ id: "14", text: "Fifteenth" },
{ id: "15", text: "Sixteenth" },
{ id: "16", text: "Seventeenth" },
{ id: "17", text: "Eighteenth" },
] as ItemData[]);
const [placement, setPalcement] = useState<{
index: number;
placementRect: Rect;
}>();
const [dragItemId, setDragItemId] = useState<string>();
const dropTargetHandlers = useDropTarget({
isDropTarget(element: HTMLElement) {
return element instanceof HTMLUListElement;
},
onDropTargetChange(event) {
placementHandlers.handleTargetChange(event.target);
},
});
const autoScrollHandlers = useAutoScroll();
const dragProps = useDrag({
onStart(event) {
if (!(event.target instanceof HTMLLIElement)) {
event.cancel();
return;
}
setDragItemId(event.target.dataset.id);
autoScrollHandlers.setEnabled(true);
},
onMove: (poiterCoordinate) => {
dropTargetHandlers.handleMove(poiterCoordinate);
autoScrollHandlers.handleMove(poiterCoordinate);
placementHandlers.handleMove(poiterCoordinate);
},
onEnd() {
if (placement !== undefined && dragItemId !== undefined) {
const oldIndex = data.findIndex((item) => item.id === dragItemId);
if (oldIndex !== -1) {
let newIndex = placement.index;
// placement.index does not take into account the fact that the drag item will be removed.
// we need to do this to account for it.
if (oldIndex < newIndex) {
newIndex = Math.max(0, newIndex - 1);
}
if (oldIndex !== newIndex) {
const newData = [...data];
newData.splice(oldIndex, 1);
newData.splice(newIndex, 0, data[oldIndex]);
setData(newData);
}
}
}
dropTargetHandlers.handleEnd();
autoScrollHandlers.setEnabled(false);
placementHandlers.handleEnd();
setDragItemId(undefined);
setPalcement(undefined);
},
});
const placementHandlers = usePlacement({
onPlacementChange: (event) => {
setPalcement(event);
},
});
return (
<>
<Box
css={{
height: direction === "horizontal" ? "auto" : 500,
width: direction === "wrap" ? 200 : "auto",
overflow: "auto",
background: "white",
color: "black",
// to make DnD work we have to disable scrolling using touch
touchAction: "none",
}}
ref={autoScrollHandlers.targetRef}
onScroll={() => {
dropTargetHandlers.handleScroll();
placementHandlers.handleScroll();
}}
{...dragProps}
>
<List
ref={dropTargetHandlers.rootRef}
css={{
li: { cursor: dragItemId === undefined ? "grab" : "default" },
display: direction === "vertical" ? "block" : "flex",
flexDirection: direction === "vertical" ? "none" : "row",
flexWrap: direction === "wrap" ? "wrap" : "none",
}}
>
{data.map((item) => (
<Item
key={item.id}
data={item}
isDragging={item.id === dragItemId}
/>
))}
</List>
</Box>
{placement && <PlacementIndicator rect={placement.placementRect} />}
</>
);
};
export const SortableListHorizontal = () => (
<SortableList direction="horizontal" />
);
export const SortableListWrap = () => <SortableList direction="wrap" />;
export default {} as ComponentMeta<typeof SortableList>;
@@ -0,0 +1,152 @@
import { useRef } from "react";
// Time between frames of scroll animation in milliseconds
const FRAME_PERIOD = 30;
const getSpeed = (
pointerPosition: number,
containerStart: number,
containerEnd: number,
edgeDistanceThreshold: number,
minSpeed: number,
maxSpeed: number
) => {
const thresholdSafe = Math.min(
(containerEnd - containerStart) / 2,
edgeDistanceThreshold
);
const startAdjusted = containerStart + thresholdSafe;
const endAdjusted = containerEnd - thresholdSafe;
const distanceFromEdgeToSpeed = (distance: number) => {
// between 0 and 1
const normalized = Math.min(distance, thresholdSafe) / thresholdSafe;
// speed in pixels per second
return minSpeed + normalized * (maxSpeed - minSpeed);
};
if (pointerPosition < startAdjusted) {
return -1 * distanceFromEdgeToSpeed(startAdjusted - pointerPosition);
}
if (pointerPosition > endAdjusted) {
return distanceFromEdgeToSpeed(pointerPosition - endAdjusted);
}
return 0;
};
export type Parameters = {
edgeDistanceThreshold?: number;
// min/max speed of the scroll animation in pixels per second
minSpeed?: number;
maxSpeed?: number;
};
export type Handlers = {
handleMove: (pointerCoordinate: { x: number; y: number }) => void;
setEnabled: (enabled: boolean) => void;
targetRef: (element: HTMLElement | null) => void;
};
export const useAutoScroll = ({
edgeDistanceThreshold = 100,
minSpeed = 1,
maxSpeed = 500,
}: Parameters = {}): Handlers => {
const state = useRef({
target: null as HTMLElement | null,
enabled: false,
prevTimestamp: 0,
speedX: 0,
speedY: 0,
stepScheduled: false,
});
const step = (timestamp: number) => {
state.current.stepScheduled = false;
if (
!state.current.enabled ||
(Math.round((state.current.speedX / 1000) * FRAME_PERIOD) === 0 &&
Math.round((state.current.speedY / 1000) * FRAME_PERIOD) === 0)
) {
return;
}
const elapsed = timestamp - state.current.prevTimestamp;
// to avoid a big jump when auto-scroll becomes enabled
if (elapsed > 3000) {
state.current.prevTimestamp = timestamp;
scheduleStep();
return;
}
if (elapsed < FRAME_PERIOD) {
scheduleStep();
return;
}
state.current.prevTimestamp = timestamp;
// for TypeScript
if (state.current.target === null) {
return;
}
state.current.target.scrollBy(
(state.current.speedX / 1000) * elapsed,
(state.current.speedY / 1000) * elapsed
);
scheduleStep();
};
const scheduleStep = () => {
if (!state.current.stepScheduled) {
state.current.stepScheduled = true;
window.requestAnimationFrame(step);
}
};
return {
handleMove({ x, y }) {
if (!state.current.enabled || state.current.target === null) {
return;
}
const rect = state.current.target.getBoundingClientRect();
state.current.speedY = getSpeed(
y,
rect.top,
rect.bottom,
edgeDistanceThreshold,
minSpeed,
maxSpeed
);
state.current.speedX = getSpeed(
x,
rect.left,
rect.right,
edgeDistanceThreshold,
minSpeed,
maxSpeed
);
scheduleStep();
},
setEnabled(newEnabled) {
state.current.enabled = newEnabled;
scheduleStep();
},
targetRef: (element) => {
state.current.target = element;
},
};
};
@@ -0,0 +1,120 @@
import { useMove } from "./use-move";
import { useRef } from "react";
type State =
| {
status: "idle" | "canceled";
}
| {
status: "pending" | "dragging";
x: number;
y: number;
initialX: number;
initialY: number;
shifts: number;
};
const initialState = {
status: "idle",
} as const;
export type Parameters = {
startDistanceThreashold?: number;
shiftDistanceThreshold?: number;
onStart: (event: { target: HTMLElement; cancel: () => void }) => void;
onMove: (event: { x: number; y: number }) => void;
onShiftChange?: (event: { shifts: number }) => void;
onEnd: () => void;
};
export const useDrag = ({
startDistanceThreashold = 3,
shiftDistanceThreshold = 20,
onStart,
onMove,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onShiftChange = () => {},
onEnd,
}: Parameters): React.HTMLAttributes<HTMLElement> => {
const state = useRef<State>(initialState);
const cancel = () => {
state.current = { status: "canceled" };
};
const detectShift = () => {
if (state.current.status !== "dragging") {
return;
}
const deltaX = state.current.x - state.current.initialX;
const shifts =
deltaX > 0
? Math.floor(deltaX / shiftDistanceThreshold)
: Math.ceil(deltaX / shiftDistanceThreshold);
if (shifts !== state.current.shifts) {
state.current.shifts = shifts;
onShiftChange({ shifts });
}
};
const props = useMove({
onMoveStart({
pageX: x,
pageY: y,
target,
}: {
pageX: number;
pageY: number;
target: HTMLElement;
}) {
state.current = {
status: "pending",
x,
y,
initialX: x,
initialY: y,
shifts: 0,
};
onStart({ target, cancel });
},
onMove({ pageX: x, pageY: y }: { pageX: number; pageY: number }) {
if (state.current.status === "canceled") {
return;
}
// We want to start dragging only when the user has moved more than startDistanceThreashold.
if (
state.current.status === "pending" &&
Math.abs(x - state.current.x) < startDistanceThreashold &&
Math.abs(y - state.current.y) < startDistanceThreashold
) {
return;
}
// We shouldn't be in non-pending state when we get here, unles user has ended the drag and yet somehow we ended up with onMove() call.
if (
state.current.status === "pending" ||
state.current.status === "dragging"
) {
state.current = {
status: "dragging",
x,
y,
initialX: state.current.initialX,
initialY: state.current.initialY,
shifts: state.current.shifts,
};
}
onMove({ x, y });
detectShift();
},
onMoveEnd() {
state.current = initialState;
onEnd();
},
});
return props.moveProps;
};
@@ -0,0 +1,171 @@
import { useRef } from "react";
export type Area = "top" | "right" | "bottom" | "left" | "center";
export type Parameters = {
isDropTarget: (element: HTMLElement) => boolean;
edgeDistanceThreshold?: number;
holdTimeThreshold?: number;
onDropTargetChange: (event: {
rect: DOMRect;
area: Area;
target: HTMLElement;
}) => void;
onHold?: (event: { target: HTMLElement }) => void;
};
export type Handlers = {
handleMove: (pointerCoordinate: Coordinate) => void;
handleScroll: () => void;
handleEnd: () => void;
rootRef: (element: HTMLElement | null) => void;
};
export const useDropTarget = ({
isDropTarget,
edgeDistanceThreshold = 3,
holdTimeThreshold = 500,
onDropTargetChange,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onHold = () => {},
}: Parameters): Handlers => {
const state = useRef({
root: null as HTMLElement | null,
target: undefined as HTMLElement | undefined,
area: "center" as Area,
rect: undefined as DOMRect | undefined,
holdTimeout: undefined as NodeJS.Timeout | undefined,
pointerCoordinate: undefined as Coordinate | undefined,
dropIndex: undefined as number | undefined,
});
const clearHoldTimeout = () => {
if (state.current.holdTimeout) {
clearTimeout(state.current.holdTimeout);
state.current.holdTimeout = undefined;
}
};
const detectHold = (target: HTMLElement) => {
clearHoldTimeout();
state.current.holdTimeout = setTimeout(() => {
state.current.holdTimeout = undefined;
if (target === state.current.target) {
onHold({ target });
}
}, holdTimeThreshold);
};
const detectTarget = () => {
const pointerCoordinate = state.current.pointerCoordinate;
if (pointerCoordinate === undefined) {
return;
}
const target = elementFromPoint(pointerCoordinate);
if (target === undefined || state.current.root === null) {
return;
}
const nextTarget = findClosestDropTarget({
root: state.current.root,
target,
isDropTarget,
});
const hasTargetChanged = nextTarget !== state.current.target;
state.current.target = nextTarget;
if (hasTargetChanged) {
detectHold(nextTarget);
}
const nextRect = nextTarget.getBoundingClientRect();
const hasRectChanged = nextRect !== state.current.rect;
state.current.rect = nextRect;
const nextArea = getArea(
pointerCoordinate,
edgeDistanceThreshold,
nextRect
);
const hasAreaChanged = nextArea !== state.current.area;
state.current.area = nextArea;
if (hasTargetChanged || hasAreaChanged || hasRectChanged) {
onDropTargetChange({
rect: nextRect,
area: nextArea,
target: nextTarget,
});
}
};
return {
handleMove(pointerCoordinate: Coordinate) {
state.current.pointerCoordinate = pointerCoordinate;
detectTarget();
},
handleScroll() {
detectTarget();
},
handleEnd() {
// Clear onHold timeout if we ended interaction before it fired
clearHoldTimeout();
},
rootRef(rootElement: HTMLElement | null) {
state.current.root = rootElement;
},
};
};
type Coordinate = { x: number; y: number };
const elementFromPoint = (coordinate: Coordinate): HTMLElement | undefined => {
const element = document.elementFromPoint(coordinate.x, coordinate.y);
if (element instanceof HTMLElement) return element;
};
const getArea = (
pointerCoordinate: Coordinate,
edgeDistanceThreshold: number,
targetRect?: DOMRect
) => {
let area: Area = "center";
if (targetRect === undefined) return area;
// We are at the edge and this means user wants to insert after that element into its parent
if (pointerCoordinate.y - targetRect.top <= edgeDistanceThreshold)
area = "top";
if (targetRect.bottom - pointerCoordinate.y <= edgeDistanceThreshold)
area = "bottom";
if (pointerCoordinate.x - targetRect.left <= edgeDistanceThreshold)
area = "left";
if (targetRect.right - pointerCoordinate.x <= edgeDistanceThreshold)
area = "right";
return area;
};
const findClosestDropTarget = ({
root,
target,
isDropTarget,
}: {
root: HTMLElement;
target: HTMLElement;
isDropTarget: (target: HTMLElement) => boolean;
}): HTMLElement => {
let currentTarget: HTMLElement | null = target;
while (currentTarget !== null && currentTarget !== root) {
const isValid = isDropTarget(currentTarget);
if (isValid) {
return currentTarget;
}
currentTarget = currentTarget.parentElement;
}
return root;
};
@@ -0,0 +1,332 @@
/* eslint-disable prefer-const */
/* eslint-disable func-style */
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/**
* We had to copy this file from react-aria because we need pageX and pageY.
*/
import React, { HTMLAttributes, useMemo, useRef } from "react";
import { useGlobalListeners } from "@react-aria/utils";
interface MoveResult {
/** Props to spread on the target element. */
moveProps: HTMLAttributes<HTMLElement>;
}
interface EventBase {
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
}
export type PointerType = "mouse" | "pen" | "touch" | "keyboard" | "virtual";
interface BaseMoveEvent {
/** The pointer type that triggered the move event. */
pointerType: PointerType;
/** Whether the shift keyboard modifier was held during the move event. */
shiftKey: boolean;
/** Whether the ctrl keyboard modifier was held during the move event. */
ctrlKey: boolean;
/** Whether the meta keyboard modifier was held during the move event. */
metaKey: boolean;
/** Whether the alt keyboard modifier was held during the move event. */
altKey: boolean;
}
interface MoveStartEvent extends BaseMoveEvent {
/** The type of move event being fired. */
type: "movestart";
target: HTMLElement;
pageX: number;
pageY: number;
}
interface MoveMoveEvent extends BaseMoveEvent {
/** The type of move event being fired. */
type: "move";
/** The amount moved in the X direction since the last event. */
deltaX: number;
/** The amount moved in the Y direction since the last event. */
deltaY: number;
pageX: number;
pageY: number;
}
interface MoveEndEvent extends BaseMoveEvent {
/** The type of move event being fired. */
type: "moveend";
}
interface MoveEvents {
/** Handler that is called when a move interaction starts. */
onMoveStart?: (e: MoveStartEvent) => void;
/** Handler that is called when the element is moved. */
onMove?: (e: MoveMoveEvent) => void;
/** Handler that is called when a move interaction ends. */
onMoveEnd?: (e: MoveEndEvent) => void;
}
/**
* Handles move interactions across mouse, touch, and keyboard, including dragging with
* the mouse or touch, and using the arrow keys. Normalizes behavior across browsers and
* platforms, and ignores emulated mouse events on touch devices.
*/
export function useMove(props: MoveEvents): MoveResult {
let state = useRef<{
didMove: boolean;
lastPosition: { pageX: number; pageY: number } | null;
id: number | null;
}>({ didMove: false, lastPosition: null, id: null });
let { addGlobalListener, removeGlobalListener } = useGlobalListeners();
// Because addGlobalListener is used to set callbacks,
// noramlly it will "see" the version of "props" at the time of addGlobalListener call.
// This in turn means that user's callbakcs will see old state variables etc.
// To workaround this, we use a ref that always points to the latest props.
let latestProps = useRef<MoveEvents>(props);
latestProps.current = props;
let moveProps = useMemo(() => {
let moveProps: HTMLAttributes<HTMLElement> = {};
let start = () => {
//disableTextSelection();
state.current.didMove = false;
};
let move = (
originalEvent: MouseEvent | TouchEvent | React.KeyboardEvent,
pointerType: PointerType,
deltaX: number,
deltaY: number
) => {
if (deltaX === 0 && deltaY === 0) {
return;
}
// FIXME: this is a temporary fix
// we need to figure out how to properly support keyboard and touch
if (
!(originalEvent.target instanceof HTMLElement) ||
!("pageX" in originalEvent)
) {
return;
}
if (!state.current.didMove) {
state.current.didMove = true;
latestProps.current.onMoveStart?.({
type: "movestart",
pointerType,
shiftKey: originalEvent.shiftKey,
metaKey: originalEvent.metaKey,
ctrlKey: originalEvent.ctrlKey,
altKey: originalEvent.altKey,
target: originalEvent.target,
pageX: originalEvent.pageX,
pageY: originalEvent.pageY,
});
}
latestProps.current.onMove?.({
type: "move",
pointerType,
deltaX: deltaX,
deltaY: deltaY,
shiftKey: originalEvent.shiftKey,
metaKey: originalEvent.metaKey,
ctrlKey: originalEvent.ctrlKey,
altKey: originalEvent.altKey,
pageX: originalEvent.pageX,
pageY: originalEvent.pageY,
});
};
let end = (originalEvent: EventBase, pointerType: PointerType) => {
//restoreTextSelection();
if (state.current.didMove) {
latestProps.current.onMoveEnd?.({
type: "moveend",
pointerType,
shiftKey: originalEvent.shiftKey,
metaKey: originalEvent.metaKey,
ctrlKey: originalEvent.ctrlKey,
altKey: originalEvent.altKey,
});
}
};
if (typeof PointerEvent === "undefined") {
let onMouseMove = (e: MouseEvent) => {
if (e.button === 0 && state.current.lastPosition !== null) {
move(
e,
"mouse",
e.pageX - state.current.lastPosition.pageX,
e.pageY - state.current.lastPosition.pageY
);
state.current.lastPosition = { pageX: e.pageX, pageY: e.pageY };
}
};
let onMouseUp = (e: MouseEvent) => {
if (e.button === 0) {
end(e, "mouse");
removeGlobalListener(window, "mousemove", onMouseMove, false);
removeGlobalListener(window, "mouseup", onMouseUp, false);
}
};
moveProps.onMouseDown = (e: React.MouseEvent) => {
if (e.button === 0) {
start();
e.stopPropagation();
e.preventDefault();
state.current.lastPosition = { pageX: e.pageX, pageY: e.pageY };
addGlobalListener(window, "mousemove", onMouseMove, false);
addGlobalListener(window, "mouseup", onMouseUp, false);
}
};
let onTouchMove = (e: TouchEvent) => {
let touch = [...e.changedTouches].findIndex(
({ identifier }) => identifier === state.current.id
);
if (touch >= 0 && state.current.lastPosition !== null) {
let { pageX, pageY } = e.changedTouches[touch];
move(
e,
"touch",
pageX - state.current.lastPosition.pageX,
pageY - state.current.lastPosition.pageY
);
state.current.lastPosition = { pageX, pageY };
}
};
let onTouchEnd = (e: TouchEvent) => {
let touch = [...e.changedTouches].findIndex(
({ identifier }) => identifier === state.current.id
);
if (touch >= 0) {
end(e, "touch");
state.current.id = null;
removeGlobalListener(window, "touchmove", onTouchMove);
removeGlobalListener(window, "touchend", onTouchEnd);
removeGlobalListener(window, "touchcancel", onTouchEnd);
}
};
moveProps.onTouchStart = (e: React.TouchEvent) => {
if (e.changedTouches.length === 0 || state.current.id != null) {
return;
}
let { pageX, pageY, identifier } = e.changedTouches[0];
start();
e.stopPropagation();
e.preventDefault();
state.current.lastPosition = { pageX, pageY };
state.current.id = identifier;
addGlobalListener(window, "touchmove", onTouchMove, false);
addGlobalListener(window, "touchend", onTouchEnd, false);
addGlobalListener(window, "touchcancel", onTouchEnd, false);
};
} else {
let onPointerMove = (e: PointerEvent) => {
if (
e.pointerId === state.current.id &&
state.current.lastPosition !== null
) {
let pointerType = (e.pointerType || "mouse") as PointerType;
// Problems with PointerEvent#movementX/movementY:
// 1. it is always 0 on macOS Safari.
// 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS
move(
e,
pointerType,
e.pageX - state.current.lastPosition.pageX,
e.pageY - state.current.lastPosition.pageY
);
state.current.lastPosition = { pageX: e.pageX, pageY: e.pageY };
}
};
let onPointerUp = (e: PointerEvent) => {
if (e.pointerId === state.current.id) {
let pointerType = (e.pointerType || "mouse") as PointerType;
end(e, pointerType);
state.current.id = null;
removeGlobalListener(window, "pointermove", onPointerMove, false);
removeGlobalListener(window, "pointerup", onPointerUp, false);
removeGlobalListener(window, "pointercancel", onPointerUp, false);
}
};
moveProps.onPointerDown = (e: React.PointerEvent) => {
if (e.button === 0 && state.current.id == null) {
start();
e.stopPropagation();
e.preventDefault();
state.current.lastPosition = { pageX: e.pageX, pageY: e.pageY };
state.current.id = e.pointerId;
addGlobalListener(window, "pointermove", onPointerMove, false);
addGlobalListener(window, "pointerup", onPointerUp, false);
addGlobalListener(window, "pointercancel", onPointerUp, false);
}
};
}
let triggerKeyboardMove = (
e: React.KeyboardEvent,
deltaX: number,
deltaY: number
) => {
start();
move(e, "keyboard", deltaX, deltaY);
end(e, "keyboard");
};
moveProps.onKeyDown = (e) => {
switch (e.key) {
case "Left":
case "ArrowLeft":
e.preventDefault();
e.stopPropagation();
triggerKeyboardMove(e, -1, 0);
break;
case "Right":
case "ArrowRight":
e.preventDefault();
e.stopPropagation();
triggerKeyboardMove(e, 1, 0);
break;
case "Up":
case "ArrowUp":
e.preventDefault();
e.stopPropagation();
triggerKeyboardMove(e, 0, -1);
break;
case "Down":
case "ArrowDown":
e.preventDefault();
e.stopPropagation();
triggerKeyboardMove(e, 0, 1);
break;
}
};
return moveProps;
}, [addGlobalListener, removeGlobalListener]);
return { moveProps };
}
+1
View File
@@ -48,6 +48,7 @@
"@radix-ui/react-toggle-group": "^0.1.5",
"@radix-ui/react-tooltip": "^0.1.7",
"@radix-ui/react-use-layout-effect": "^0.1.0",
"@react-aria/utils": "^3.13.1",
"@remix-run/node": "^1.6.4",
"@remix-run/react": "^1.6.4",
"@remix-run/serve": "^1.6.4",
+32 -2
View File
@@ -1975,7 +1975,7 @@
dependencies:
regenerator-runtime "^0.13.2"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.17.8", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.0", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.17.8", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.6.tgz#6a1ef59f838debd670421f8c7f2cbb8da9751580"
integrity sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==
@@ -3277,6 +3277,24 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@react-aria/ssr@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.2.0.tgz#88460384b43204f91c972d5b0de24ee44d6a2984"
integrity sha512-wwJFdkl+Q8NU5yJ4NvdAOqx5LM3QtUVoSjuK7Ey8jZ4WS4bB0EqT3Kr3IInBs257HzZ5nXCiKXKE4NGXXuIRWA==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-aria/utils@^3.13.1":
version "3.13.1"
resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.13.1.tgz#45557fdc7ae9de057a83014013bf09e54d074c96"
integrity sha512-usW6RoLKil4ylgDbRcaQ5YblNGv5ZihI4I9NB8pdazhw53cSRyLaygLdmHO33xgpPnAhb6Nb/tv8d5p6cAde+A==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-aria/ssr" "^3.2.0"
"@react-stately/utils" "^3.5.0"
"@react-types/shared" "^3.13.1"
clsx "^1.1.1"
"@react-dnd/asap@4.0.1", "@react-dnd/asap@^4.0.0":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab"
@@ -3297,6 +3315,18 @@
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
"@react-stately/utils@^3.5.0":
version "3.5.0"
resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.5.0.tgz#e76231cfc93042814dcc5d96436e50c807c1d905"
integrity sha512-WzzwlQtJrf7egaN+lt02/f/wkFKbcscsbinmXs9pL7QyYm+BCQ9xXj01w0juzt93piZgB/kfIYJqInEdpudh1A==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-types/shared@^3.13.1":
version "3.13.1"
resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.13.1.tgz#eda5e3744971606f753baf7879136bf8e3f707ab"
integrity sha512-EHQqILDJeDvnloy5VV9lnnEjpCMwH1ghptCfa/lz9Ht9nwco3qGCvUABkWyND7yU1Adt3A/1oJxhpRUu3eTlyg==
"@remix-run/dev@^1.6.4":
version "1.6.4"
resolved "https://registry.yarnpkg.com/@remix-run/dev/-/dev-1.6.4.tgz#7a2a7fc24e66434c5cf64d6e79a62d35d09b73de"
@@ -7002,7 +7032,7 @@ clsx@1.1.0:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702"
integrity sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA==
clsx@^1.0.4:
clsx@^1.0.4, clsx@^1.1.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==