diff --git a/apps/designer/app/shared/design-system/components/primitives/dnd/canvas.stories.tsx b/apps/designer/app/shared/design-system/components/primitives/dnd/canvas.stories.tsx new file mode 100644 index 0000000000..f5a9982083 --- /dev/null +++ b/apps/designer/app/shared/design-system/components/primitives/dnd/canvas.stories.tsx @@ -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 ( + + + + ); +}; + +const Items = ({ + data, + dragItemId, +}: { + data: ItemData[]; + dragItemId: string | undefined; +}) => { + return ( + <> + {data.map((child) => ( + + ))} + + ); +}; + +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([ + { + 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(); + + const dropTargetId = useRef(); + const rootRef = useRef(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 ( + <> + { + dropTargetHandlers.rootRef(element); + autoScrollHandlers.targetRef(element); + rootRef.current = element; + }} + onScroll={() => { + dropTargetHandlers.handleScroll(); + placementHandlers.handleScroll(); + }} + data-id={ROOT_ID} + {...dragProps} + > + + + {placement && } + + ); +}; + +export default {} as ComponentMeta; diff --git a/apps/designer/app/shared/design-system/components/primitives/dnd/placement.tsx b/apps/designer/app/shared/design-system/components/primitives/dnd/placement.tsx new file mode 100644 index 0000000000..a20a29d724 --- /dev/null +++ b/apps/designer/app/shared/design-system/components/primitives/dnd/placement.tsx @@ -0,0 +1,234 @@ +import { useRef } from "react"; +import { Box } from "../../box"; + +export type Rect = Pick; +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(), + 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 ( + + ); +}; diff --git a/apps/designer/app/shared/design-system/components/primitives/dnd/sortable-list.stories.tsx b/apps/designer/app/shared/design-system/components/primitives/dnd/sortable-list.stories.tsx new file mode 100644 index 0000000000..adfa20c62e --- /dev/null +++ b/apps/designer/app/shared/design-system/components/primitives/dnd/sortable-list.stories.tsx @@ -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 ( + + {data.text} + + ); +}; + +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(); + + 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 ( + <> + { + dropTargetHandlers.handleScroll(); + placementHandlers.handleScroll(); + }} + {...dragProps} + > + + {data.map((item) => ( + + ))} + + + {placement && } + + ); +}; + +export const SortableListHorizontal = () => ( + +); + +export const SortableListWrap = () => ; + +export default {} as ComponentMeta; diff --git a/apps/designer/app/shared/design-system/components/primitives/dnd/use-auto-scroll.ts b/apps/designer/app/shared/design-system/components/primitives/dnd/use-auto-scroll.ts new file mode 100644 index 0000000000..522c20247f --- /dev/null +++ b/apps/designer/app/shared/design-system/components/primitives/dnd/use-auto-scroll.ts @@ -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; + }, + }; +}; diff --git a/apps/designer/app/shared/design-system/components/primitives/dnd/use-drag.ts b/apps/designer/app/shared/design-system/components/primitives/dnd/use-drag.ts new file mode 100644 index 0000000000..e168b71eca --- /dev/null +++ b/apps/designer/app/shared/design-system/components/primitives/dnd/use-drag.ts @@ -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 => { + const state = useRef(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; +}; diff --git a/apps/designer/app/shared/design-system/components/primitives/dnd/use-drop-target.ts b/apps/designer/app/shared/design-system/components/primitives/dnd/use-drop-target.ts new file mode 100644 index 0000000000..f3c5ea501b --- /dev/null +++ b/apps/designer/app/shared/design-system/components/primitives/dnd/use-drop-target.ts @@ -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; +}; diff --git a/apps/designer/app/shared/design-system/components/primitives/dnd/use-move.ts b/apps/designer/app/shared/design-system/components/primitives/dnd/use-move.ts new file mode 100644 index 0000000000..b16447981d --- /dev/null +++ b/apps/designer/app/shared/design-system/components/primitives/dnd/use-move.ts @@ -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; +} + +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(props); + latestProps.current = props; + + let moveProps = useMemo(() => { + let moveProps: HTMLAttributes = {}; + + 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 }; +} diff --git a/apps/designer/package.json b/apps/designer/package.json index d2d0240c6f..beb1fdbabe 100644 --- a/apps/designer/package.json +++ b/apps/designer/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 62cd43092e..6de5d29112 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==