From 3ac3f5e9985ac37390b651b9227fb69d69bda48c Mon Sep 17 00:00:00 2001 From: Dan Wood Date: Fri, 21 Jan 2022 06:39:34 +1100 Subject: [PATCH] new(drag): Add restrictToPath as a Drag parameter (#1379) * Add restrictToPath as a Drag parameter Allow the user to pass an SVGGeometryElement to restrict the drag area to following the path of an SVG. Useful for constrining the drag of an object to a curved line. * Performance Improvements Calculate and cache sample points along restrictToPath. This turns out to be fairly tricky as the path is a ref and we need to use the getTotalLength function as an estimate to when the path has changed. Remove getParentSvg. Turns out we can just use the DOMMatrix from the path element itself, saving the need to traverse the DOM. Fix bug with summing points for offset, rather than finding the difference. * Update packages/visx-drag/src/useDrag.ts Complete sentence for docs Co-authored-by: Chris Williams Co-authored-by: Chris Williams --- packages/visx-drag/package.json | 1 + packages/visx-drag/src/Drag.tsx | 2 + packages/visx-drag/src/useDrag.ts | 90 ++++++++++--------- .../visx-drag/src/util/getClosestPoint.ts | 13 +++ packages/visx-drag/src/util/restrictPoint.ts | 18 ++++ .../visx-drag/src/util/useSamplesAlongPath.ts | 26 ++++++ packages/visx-point/src/index.ts | 2 + packages/visx-point/src/subtractPoints.ts | 8 ++ packages/visx-point/src/sumPoints.ts | 8 ++ 9 files changed, 124 insertions(+), 44 deletions(-) create mode 100644 packages/visx-drag/src/util/getClosestPoint.ts create mode 100644 packages/visx-drag/src/util/restrictPoint.ts create mode 100644 packages/visx-drag/src/util/useSamplesAlongPath.ts create mode 100644 packages/visx-point/src/subtractPoints.ts create mode 100644 packages/visx-point/src/sumPoints.ts 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, + }); +}