Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copy the implementation of useMovable into useDraggable #1370

Merged
merged 7 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-rivers-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Internal: copy Mafs' implementation of useMovable into our own useDraggable hook.
1 change: 1 addition & 0 deletions packages/perseus/package.json
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@use-gesture/react is a dependency of Mafs that we are now importing directly. I verified that adding it here doesn't cause multiple copies/versions of @use-gesture/react to get installed.

Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests are unfortunately incomplete. I couldn't figure out how to get @use-gesture/react to register a drag gesture, either with userEvent or fireEvent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Is this Moving a Pointer section of the Testing Library docs useful? It seems like it is showing how to user userEvent.pointer to drag, though I could be reading it wrong.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I tried what those docs suggest. @use-gesture/react doesn't see it as a drag, though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: I looked at the tests for @use-gesture/react itself and found that they're using fireEvent, so I came up with a testing solution based on that.

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {render, screen} from "@testing-library/react";
import {userEvent as userEventLib} from "@testing-library/user-event";
import {Mafs} from "mafs";
import * as React from "react";
import {useRef} from "react";

import {useDraggable} from "./use-draggable";

import type {vec} from "mafs";

function TestDraggable(props: {
point: vec.Vector2;
constrain: (point: vec.Vector2) => vec.Vector2;
}) {
const gestureTarget = useRef<HTMLParagraphElement>(null);
const {dragging} = useDraggable({
...props,
gestureTarget,
onMove: () => {},
});
return (
<p role="button" ref={gestureTarget}>
dragging: {String(dragging)}
</p>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is it okay to set the role of a p element to "button" like this? I know this is only a test, so maybe it's not super important.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's valid, but potentially confusing. I should probably just make this a button element.

);
}

describe("useDraggable", () => {
let userEvent;
beforeEach(() => {
userEvent = userEventLib.setup({
advanceTimers: jest.advanceTimersByTime,
});
});

it("initially returns {dragging: false}", () => {
render(
<Mafs width={200} height={200}>
<TestDraggable point={[0, 0]} constrain={(p) => p} />
</Mafs>,
);

expect(screen.getByText("dragging: false")).toBeInTheDocument();
});

it("returns {dragging: true} when the mouse button is held down", async () => {
render(
<Mafs width={200} height={200}>
<TestDraggable point={[0, 0]} constrain={(p) => p} />
</Mafs>,
);
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(
<Mafs width={200} height={200}>
<TestDraggable point={[0, 0]} constrain={(p) => p} />
</Mafs>,
);
const dragHandle = screen.getByRole("button");

// Act
await userEvent.pointer([
{keys: "[MouseLeft>]", target: dragHandle},
{keys: "[/MouseLeft]"},
]);

// Assert
expect(screen.getByText("dragging: false")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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<Element>;
onMove: (point: vec.Vector2) => unknown;
point: vec.Vector2;
Expand All @@ -14,6 +36,137 @@
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<vec.Vector2>([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;
}

Check warning on line 79 in packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts#L78-L79

Added lines #L78 - L79 were not covered by tests
if (shiftKey) {
divisions = 10;
}

Check warning on line 82 in packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts#L81-L82

Added lines #L81 - L82 were not covered by tests

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,
),
),
);
}

Check warning on line 133 in packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts#L120-L133

Added lines #L120 - L133 were not covered by tests
},
{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);
}

Check warning on line 169 in packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/interactive-graphs/graphs/use-draggable.ts#L168-L169

Added lines #L168 - L169 were not covered by tests

return result;
}
Loading