diff --git a/packages/visx-drag/package.json b/packages/visx-drag/package.json index 1d18ae582..8dc12571d 100644 --- a/packages/visx-drag/package.json +++ b/packages/visx-drag/package.json @@ -36,6 +36,7 @@ "dependencies": { "@types/react": "*", "@visx/event": "2.1.2", + "@visx/point": "^2.1.0", "prop-types": "^15.5.10" } } diff --git a/packages/visx-drag/src/Drag.tsx b/packages/visx-drag/src/Drag.tsx index bd14f0dac..784f1b081 100644 --- a/packages/visx-drag/src/Drag.tsx +++ b/packages/visx-drag/src/Drag.tsx @@ -33,6 +33,7 @@ export default function Drag({ y, isDragging, restrict, + restrictToPath, }: DragProps) { const drag = useDrag({ resetOnStart, @@ -46,6 +47,7 @@ export default function Drag({ dy, isDragging, restrict, + restrictToPath, }); return ( diff --git a/packages/visx-drag/src/useDrag.ts b/packages/visx-drag/src/useDrag.ts index de5f624a8..b35b6011d 100644 --- a/packages/visx-drag/src/useDrag.ts +++ b/packages/visx-drag/src/useDrag.ts @@ -1,7 +1,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Point, subtractPoints, sumPoints } from '@visx/point'; import { localPoint } from '@visx/event'; import useStateWithCallback from './util/useStateWithCallback'; -import clampNumber from './util/clampNumber'; +import restrictPoint from './util/restrictPoint'; +import useSamplesAlongPath from './util/useSamplesAlongPath'; export type MouseTouchOrPointerEvent = React.MouseEvent | React.TouchEvent | React.PointerEvent; @@ -38,6 +40,8 @@ export type UseDragOptions = { yMin?: number; yMax?: number; }; + /** Limit drag to an SVG path. Overrides `restrict` constraints. */ + restrictToPath?: SVGGeometryElement | null; }; export type DragState = { @@ -75,20 +79,11 @@ export default function useDrag({ dy, isDragging, restrict = {}, + restrictToPath, }: UseDragOptions | undefined = {}): UseDrag { // use ref to detect prop changes const positionPropsRef = useRef({ x, y, dx, dy }); - const { xMin, xMax, yMin, yMax } = restrict; - const clampX = useCallback( - (num: number) => clampNumber(num, xMin ?? -Infinity, xMax ?? Infinity), - [xMax, xMin], - ); - const clampY = useCallback( - (num: number) => clampNumber(num, yMin ?? -Infinity, yMax ?? Infinity), - [yMax, yMin], - ); - const [dragState, setDragStateWithCallback] = useStateWithCallback({ x, y, @@ -98,10 +93,9 @@ export default function useDrag({ }); // Track distance between pointer on dragStart and position of element being dragged - const [dragStartPointerOffset, setDragStartPointerOffset] = useState<{ - x: number; - y: number; - }>({ x: 0, y: 0 }); + const [dragStartPointerOffset, setDragStartPointerOffset] = useState( + new Point({ x: 0, y: 0 }), + ); // if prop position changes, update state useEffect(() => { @@ -128,30 +122,32 @@ export default function useDrag({ } }, [dragState.isDragging, isDragging, setDragStateWithCallback]); + const restrictToPathSamples = useSamplesAlongPath(restrictToPath); + const handleDragStart = useCallback( (event: MouseTouchOrPointerEvent) => { event.persist(); setDragStateWithCallback( (currState) => { - const currentPoint = { - x: (currState.x || 0) + currState.dx, - y: (currState.y || 0) + currState.dy, - }; - const eventPoint = localPoint(event) || { x: 0, y: 0 }; + // eslint-disable-next-line no-shadow + const { x = 0, y = 0, dx, dy } = currState; + const currentPoint = new Point({ + x: (x || 0) + dx, + y: (y || 0) + dy, + }); + const eventPoint = localPoint(event) || new Point({ x: 0, y: 0 }); const point = snapToPointer ? eventPoint : currentPoint; + const dragPoint = restrictPoint(point, restrictToPathSamples, restrict); - setDragStartPointerOffset({ - x: currentPoint.x - eventPoint.x, - y: currentPoint.y - eventPoint.y, - }); + setDragStartPointerOffset(subtractPoints(currentPoint, eventPoint)); return { isDragging: true, dx: resetOnStart ? 0 : currState.dx, dy: resetOnStart ? 0 : currState.dy, - x: resetOnStart ? clampX(point.x) : clampX(point.x) - currState.dx, - y: resetOnStart ? clampY(point.y) : clampY(point.y) - currState.dy, + x: resetOnStart ? dragPoint.x : dragPoint.x - currState.dx, + y: resetOnStart ? dragPoint.y : dragPoint.y - currState.dy, }; }, onDragStart && @@ -160,7 +156,14 @@ export default function useDrag({ }), ); }, - [clampX, clampY, onDragStart, resetOnStart, setDragStateWithCallback, snapToPointer], + [ + onDragStart, + resetOnStart, + restrict, + restrictToPathSamples, + setDragStateWithCallback, + snapToPointer, + ], ); const handleDragMove = useCallback( @@ -169,19 +172,19 @@ export default function useDrag({ setDragStateWithCallback( (currState) => { - const point = localPoint(event) || { x: 0, y: 0 }; - return currState.isDragging - ? { - ...currState, - isDragging: true, - dx: snapToPointer - ? clampX(point.x) - (currState.x || 0) - : clampX(point.x + dragStartPointerOffset.x) - (currState.x || 0), - dy: snapToPointer - ? clampY(point.y) - (currState.y || 0) - : clampY(point.y + dragStartPointerOffset.y) - (currState.y || 0), - } - : currState; + if (!currState.isDragging) return currState; + // eslint-disable-next-line no-shadow + const { x = 0, y = 0 } = currState; + const pointerPoint = localPoint(event) || new Point({ x: 0, y: 0 }); + const point = snapToPointer + ? pointerPoint + : sumPoints(pointerPoint, dragStartPointerOffset); + const dragPoint = restrictPoint(point, restrictToPathSamples, restrict); + return { + ...currState, + dx: dragPoint.x - x, + dy: dragPoint.y - y, + }; }, onDragMove && ((currState) => { @@ -193,10 +196,9 @@ export default function useDrag({ setDragStateWithCallback, onDragMove, snapToPointer, - dragStartPointerOffset.x, - dragStartPointerOffset.y, - clampX, - clampY, + dragStartPointerOffset, + restrictToPathSamples, + restrict, ], ); diff --git a/packages/visx-drag/src/util/getClosestPoint.ts b/packages/visx-drag/src/util/getClosestPoint.ts new file mode 100644 index 000000000..29ed44dd6 --- /dev/null +++ b/packages/visx-drag/src/util/getClosestPoint.ts @@ -0,0 +1,13 @@ +/** Gets closest point from list of points */ +export default function getClosestPoint(point: { x: number; y: number }, samples: DOMPoint[]) { + let closestPoint = point; + let minDistance = Infinity; + for (const sample of samples) { + const distance = Math.sqrt((sample.x - point.x) ** 2 + (sample.y - point.y) ** 2); + if (distance < minDistance) { + minDistance = distance; + closestPoint = { x: sample.x, y: sample.y }; + } + } + return closestPoint; +} diff --git a/packages/visx-drag/src/util/restrictPoint.ts b/packages/visx-drag/src/util/restrictPoint.ts new file mode 100644 index 000000000..5e1b6d3ba --- /dev/null +++ b/packages/visx-drag/src/util/restrictPoint.ts @@ -0,0 +1,18 @@ +import { UseDragOptions } from '../useDrag'; +import clampNumber from './clampNumber'; +import getClosestPoint from './getClosestPoint'; + +/** Restrict a point to an area, or samples along a path. */ +export default function restrictPoint( + point: { x: number; y: number }, + samples: DOMPoint[], + restrict: UseDragOptions['restrict'] = {}, +) { + if (samples.length > 0) { + return getClosestPoint(point, samples); + } + return { + x: clampNumber(point.x, restrict.xMin ?? -Infinity, restrict.xMax ?? Infinity), + y: clampNumber(point.y, restrict.yMin ?? -Infinity, restrict.yMax ?? Infinity), + }; +} diff --git a/packages/visx-drag/src/util/useSamplesAlongPath.ts b/packages/visx-drag/src/util/useSamplesAlongPath.ts new file mode 100644 index 000000000..930e4ec44 --- /dev/null +++ b/packages/visx-drag/src/util/useSamplesAlongPath.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; + +function getSamples(restrictToPath: SVGGeometryElement, transform?: DOMMatrix, precision = 1) { + if (!restrictToPath) return []; + const samples = []; + const pathLength = restrictToPath.getTotalLength(); + for (let sampleLength = 0; sampleLength <= pathLength; sampleLength += precision) { + const sample = restrictToPath.getPointAtLength(sampleLength); + const transformedSample = sample.matrixTransform(transform); + samples.push(transformedSample); + } + return samples; +} + +/** Return samples along a path, relative to the parent SVG */ +export default function useSamplesAlongPath(restrictToPath?: SVGGeometryElement | null) { + const samples = useMemo(() => { + if (!restrictToPath) return []; + const transform = restrictToPath.getCTM() || new DOMMatrix(); + return getSamples(restrictToPath, transform); + // The path can transform without triggering a re-render, + // so we need to update the samples whenever the length changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [restrictToPath?.getTotalLength()]); + return samples; +} diff --git a/packages/visx-point/src/index.ts b/packages/visx-point/src/index.ts index ec3dfec7d..fcbed8f71 100644 --- a/packages/visx-point/src/index.ts +++ b/packages/visx-point/src/index.ts @@ -1 +1,3 @@ export { default as Point } from './Point'; +export { default as sumPoints } from './sumPoints'; +export { default as subtractPoints } from './subtractPoints'; diff --git a/packages/visx-point/src/subtractPoints.ts b/packages/visx-point/src/subtractPoints.ts new file mode 100644 index 000000000..53f0bcd5d --- /dev/null +++ b/packages/visx-point/src/subtractPoints.ts @@ -0,0 +1,8 @@ +import Point from './Point'; + +export default function subtractPoints(point1: Point, point2: Point) { + return new Point({ + x: point1.x - point2.x, + y: point1.y - point2.y, + }); +} diff --git a/packages/visx-point/src/sumPoints.ts b/packages/visx-point/src/sumPoints.ts new file mode 100644 index 000000000..99f531cbc --- /dev/null +++ b/packages/visx-point/src/sumPoints.ts @@ -0,0 +1,8 @@ +import Point from './Point'; + +export default function sumPoints(point1: Point, point2: Point) { + return new Point({ + x: point1.x + point2.x, + y: point1.y + point2.y, + }); +}