mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 13:05:08 +09:00
Merge pull request #149 from webstudio-is/drag-drop-prototype
Drag & drop prototype
This commit is contained in:
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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+182
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in new issue
Block a user