diff --git a/.changeset/olive-rivers-eat.md b/.changeset/olive-rivers-eat.md new file mode 100644 index 0000000000..3ee427d0c2 --- /dev/null +++ b/.changeset/olive-rivers-eat.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: copy Mafs' implementation of useMovable into our own useDraggable hook. diff --git a/packages/perseus/package.json b/packages/perseus/package.json index 6c5e1590fc..8d9aba3641 100644 --- a/packages/perseus/package.json +++ b/packages/perseus/package.json @@ -46,6 +46,7 @@ "@khanacademy/perseus-linter": "^0.4.0", "@khanacademy/pure-markdown": "^0.3.5", "@khanacademy/simple-markdown": "^0.12.0", + "@use-gesture/react": "^10.2.27", "mafs": "0.18.7" }, "devDependencies": { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.test.tsx new file mode 100644 index 0000000000..a37ff0117f --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.test.tsx @@ -0,0 +1,249 @@ +import {render, screen, fireEvent} from "@testing-library/react"; +import {userEvent as userEventLib} from "@testing-library/user-event"; +import {Mafs, Transform} from "mafs"; +import * as React from "react"; +import {useRef} from "react"; + +import {useDraggable} from "./use-draggable"; + +import type {vec, Interval} from "mafs"; + +function TestDraggable(props: { + point: vec.Vector2; + constrain?: (point: vec.Vector2) => vec.Vector2; + onMove?: (point: vec.Vector2) => unknown; +}) { + const {onMove = () => {}, constrain = (p) => p} = props; + const gestureTarget = useRef(null); + const {dragging} = useDraggable({ + ...props, + onMove, + constrain, + gestureTarget, + }); + return ( + + ); +} + +describe("useDraggable", () => { + let userEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + }); + + it("initially returns {dragging: false}", () => { + render( + + + , + ); + + expect(screen.getByText("dragging: false")).toBeInTheDocument(); + }); + + it("returns {dragging: true} when the mouse button is held down", async () => { + render( + + + , + ); + const dragHandle = screen.getByRole("button"); + + // Act + await userEvent.pointer({keys: "[MouseLeft>]", target: dragHandle}); + + // Assert + expect(screen.getByText("dragging: true")).toBeInTheDocument(); + }); + + it("returns {dragging: false} when the mouse button is released", async () => { + render( + + + , + ); + const dragHandle = screen.getByRole("button"); + + // Act + await userEvent.pointer([ + {keys: "[MouseLeft>]", target: dragHandle}, + {keys: "[/MouseLeft]"}, + ]); + + // Assert + expect(screen.getByText("dragging: false")).toBeInTheDocument(); + }); + + it("calls onMove with the destination point when the user drags", async () => { + // Arrange: a 200x200px graph with a 20-unit range in each dimension. + // One graph unit = 10px. + const mafsProps = { + width: 200, + height: 200, + viewBox: { + x: [-10, 10] as Interval, + y: [-10, 10] as Interval, + padding: 0, + }, + }; + const onMoveSpy = jest.fn(); + render( + + + , + ); + const dragHandle = screen.getByRole("button"); + + // Act: click and hold the drag handle... + mouseDownAt(dragHandle, 0, 0); + // ...and then drag 10px right and 10px down + moveMouseTo(dragHandle, 10, 10); + + // Assert: the draggable element was moved to (1, -1) + expect(onMoveSpy).toHaveBeenCalledWith([1, -1]); + }); + + it("constrains the destination point using the given constrain function", async () => { + // Arrange: a 200x200px graph with a 20-unit range in each dimension. + // One graph unit = 10px. + const mafsProps = { + width: 200, + height: 200, + viewBox: { + x: [-10, 10] as Interval, + y: [-10, 10] as Interval, + padding: 0, + }, + }; + const onMoveSpy = jest.fn(); + render( + + [Math.round(p[0]), Math.round(p[1])]} + /> + , + ); + const dragHandle = screen.getByRole("button"); + + // Act: click and hold the drag handle... + mouseDownAt(dragHandle, 0, 0); + // ...and then drag 12px right and 13px down + moveMouseTo(dragHandle, 12, 13); + + // Assert: the draggable element was moved to (1, -1) due to the + // constrain function. If you see (1.2, -1.3) instead, that means the + // constraint is not being applied. + expect(onMoveSpy).toHaveBeenCalledWith([1, -1]); + }); + + it("accounts for the user transform when measuring drag distance", async () => { + // See: https://mafs.dev/guides/custom-components/contexts + + // Arrange: a 200x200px graph with a 20-unit range in each dimension. + // One graph unit = 10px. + const mafsProps = { + width: 200, + height: 200, + viewBox: { + x: [-10, 10] as Interval, + y: [-10, 10] as Interval, + padding: 0, + }, + }; + const onMoveSpy = jest.fn(); + render( + + + + + , + ); + const dragHandle = screen.getByRole("button"); + + // Act: click and hold the drag handle... + mouseDownAt(dragHandle, 0, 0); + // ...and then drag 10px right and 10px down. Because of the + // , this movement actually represents the vector + // [0.5, -0.5] in graph coordinates. + moveMouseTo(dragHandle, 10, 10); + + // Assert: the draggable element moved to (10.5, 9.5). + // If you see... + // - (5.5, 4.5), the userTransform was not applied to the pickupPoint. + // - (21, 19), the inverse user transform was not applied to the move. + // - (11, 9), neither userTransform nor the inverse was applied. + expect(onMoveSpy).toHaveBeenCalledWith([10.5, 9.5]); + }); + + it("moves a draggable element with the keyboard", async () => { + // Arrange: a 200x200px graph with a 20-unit range in each dimension. + // One graph unit = 10px. + const mafsProps = { + width: 200, + height: 200, + viewBox: { + x: [-10, 10] as Interval, + y: [-10, 10] as Interval, + padding: 0, + }, + }; + const onMoveSpy = jest.fn(); + render( + + [ + Math.round(point[0]), + Math.round(point[1]), + ]} + /> + , + ); + // focus the draggable element + await userEvent.tab(); + await userEvent.tab(); + + // Pre-assert: the draggable element is actually focused + expect(screen.getByRole("button")).toHaveFocus(); + + // Act: + await userEvent.keyboard("{arrowright>1}{arrowup>1}"); + + // Assert: the element moved one step to the right and then one step up + expect(onMoveSpy.mock.calls).toEqual([[[1, 0]], [[0, 1]]]); + }); +}); + +function mouseDownAt(element: Element, clientX: number, clientY: number) { + // NOTE(benchristel): I could not figure out how to write these tests in + // terms of userEvent. The tests for @use-gesture/react use fireEvent, so + // I went with that approach. + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.mouseDown(element, { + pointerId: 1, + buttons: 1, + clientX, + clientY, + }); +} + +function moveMouseTo(element: Element, clientX: number, clientY: number) { + // NOTE(benchristel): I could not figure out how to write these tests in + // terms of userEvent. The tests for @use-gesture/react use fireEvent, so + // I went with that approach. + // eslint-disable-next-line testing-library/prefer-user-event + fireEvent.mouseMove(element, { + pointerId: 1, + buttons: 1, + clientX, + clientY, + }); +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts index e466fa1c7b..10c05d7f2f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts @@ -1,9 +1,31 @@ -import {useMovable} from "mafs"; +import {useDrag} from "@use-gesture/react"; +import {useTransformContext, vec} from "mafs"; +import * as React from "react"; +import invariant from "tiny-invariant"; + +import useGraphConfig from "../reducer/use-graph-config"; -import type {vec} from "mafs"; import type {RefObject} from "react"; -type Params = { +/** + * Code in this file is derived from + * https://github.com/stevenpetryk/mafs/blob/4520319379a2cc2df8148d8baaef1f85db117103/src/interaction/useMovable.tsx#L20-L83 + * and copied here under the terms of the MIT license. + * + * Copyright 2021 Steven Petryk + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + */ + +export type Params = { gestureTarget: RefObject; onMove: (point: vec.Vector2) => unknown; point: vec.Vector2; @@ -14,6 +36,137 @@ type DragState = { dragging: boolean; }; -export function useDraggable(params: Params): DragState { - return useMovable(params); +export function useDraggable(args: Params): DragState { + const {gestureTarget: target, onMove, point, constrain} = args; + const [dragging, setDragging] = React.useState(false); + const {xSpan, ySpan} = useSpanContext(); + const {viewTransform, userTransform} = useTransformContext(); + + const inverseViewTransform = vec.matrixInvert(viewTransform); + invariant(inverseViewTransform, "The view transform must be invertible."); + + const inverseTransform = React.useMemo( + () => getInverseTransform(userTransform), + [userTransform], + ); + + const pickup = React.useRef([0, 0]); + + useDrag( + (state) => { + const {type, event} = state; + event?.stopPropagation(); + + const isKeyboard = type.includes("key"); + if (isKeyboard) { + event?.preventDefault(); + const { + direction: yDownDirection, + altKey, + metaKey, + shiftKey, + } = state; + + const direction = [ + yDownDirection[0], + -yDownDirection[1], + ] as vec.Vector2; + const span = Math.abs(direction[0]) ? xSpan : ySpan; + + let divisions = 50; + if (altKey || metaKey) { + divisions = 200; + } + if (shiftKey) { + divisions = 10; + } + + const min = span / (divisions * 2); + const tests = range( + span / divisions, + span / 2, + span / divisions, + ); + + for (const dx of tests) { + // Transform the test back into the point's coordinate system + const testMovement = vec.scale(direction, dx); + const testPoint = constrain( + vec.transform( + vec.add( + vec.transform(point, userTransform), + testMovement, + ), + inverseTransform, + ), + ); + + if (vec.dist(testPoint, point) > min) { + onMove(testPoint); + break; + } + } + } else { + const {last, movement: pixelMovement, first} = state; + + setDragging(!last); + + if (first) { + pickup.current = vec.transform(point, userTransform); + } + if (vec.mag(pixelMovement) === 0) { + return; + } + + const movement = vec.transform( + pixelMovement, + inverseViewTransform, + ); + onMove( + constrain( + vec.transform( + vec.add(pickup.current, movement), + inverseTransform, + ), + ), + ); + } + }, + {target, eventOptions: {passive: false}}, + ); + return {dragging}; +} + +function getInverseTransform(transform: vec.Matrix) { + const invert = vec.matrixInvert(transform); + invariant( + invert !== null, + "Could not invert transform matrix. A parent transformation matrix might be degenerative (mapping 2D space to a line).", + ); + return invert; +} + +function useSpanContext() { + const { + range: [[xMin, xMax], [yMin, yMax]], + } = useGraphConfig(); + const xSpan = xMax - xMin; + const ySpan = yMax - yMin; + return {xSpan, ySpan}; +} + +function range(min: number, max: number, step = 1): number[] { + const result: number[] = []; + for (let i = min; i < max - step / 2; i += step) { + result.push(i); + } + + const computedMax = result[result.length - 1] + step; + if (Math.abs(max - computedMax) < step / 1e-6) { + result.push(max); + } else { + result.push(computedMax); + } + + return result; }