diff --git a/.api-report/mafs.api.md b/.api-report/mafs.api.md index ce6bef0f..6e59058f 100644 --- a/.api-report/mafs.api.md +++ b/.api-report/mafs.api.md @@ -279,7 +279,7 @@ export interface UseMovablePoint { } // @public (undocumented) -export function useMovablePoint([initialX, initialY]: Vector2, { constrain, color, transform }?: UseMovablePointArguments): UseMovablePoint; +export function useMovablePoint(initialPoint: Vector2, { constrain, color, transform }?: UseMovablePointArguments): UseMovablePoint; // @public (undocumented) export interface UseMovablePointArguments { diff --git a/src/index.css b/src/index.css index 82ecf883..daeae82a 100644 --- a/src/index.css +++ b/src/index.css @@ -2,6 +2,7 @@ display: block; background: var(--mafs-bg); overflow: hidden; + -webkit-user-select: none; user-select: none; font-family: inherit; font-variant-numeric: tabular-nums; @@ -32,11 +33,17 @@ stroke: var(--mafs-fg); } +.MafsWrapper, +.MafsView .draggable-hitbox { + touch-action: none; +} + .MafsView .draggable { transition: r 0.2s ease, stroke-width 0.2s ease; stroke-width: 18px; cursor: grab; outline: 0 !important; + touch-action: none; color: hsl(0, 100%, 47%); } diff --git a/src/interaction/MovablePoint.tsx b/src/interaction/MovablePoint.tsx index 23bc28ae..9f199098 100644 --- a/src/interaction/MovablePoint.tsx +++ b/src/interaction/MovablePoint.tsx @@ -1,67 +1,104 @@ -import React, { useState } from "react" -import * as vec from "vec-la" import { useDrag } from "@use-gesture/react" +import React, { useMemo, useRef, useState } from "react" +import invariant from "tiny-invariant" +import * as vec from "vec-la" +import { matrixInvert, range, Vector2 } from "../math" import { useScaleContext } from "../view/ScaleContext" -import { Vector2 } from "../math" + +export type ConstraintFunction = (position: Vector2) => Vector2 interface MovablePointProps { - x: number - y: number - onMovement: (props: { movement: Vector2; first: boolean }) => void - color?: string + point: Vector2 + onMove: (point: Vector2) => void + transform: vec.Matrix + constrain: ConstraintFunction + color: string } +/** @private */ const MovablePoint: React.VFC = ({ - x, - y, - onMovement, - color = "var(--mafs-blue)", + point, + onMove, + transform, + constrain, + color, }) => { - const { scaleX, scaleY, xSpan, ySpan, inversePixelMatrix } = useScaleContext() + const { xSpan, ySpan, pixelMatrix, inversePixelMatrix } = useScaleContext() + const inverseTransform = useMemo(() => getInverseTransform(transform), [transform]) + const [dragging, setDragging] = useState(false) + const [displayX, displayY] = vec.transform(vec.transform(point, transform), pixelMatrix) - const bind = useDrag( - ({ event, down, movement, first }) => { - event?.stopPropagation() - setDragging(down) - onMovement({ movement: vec.transform(movement, inversePixelMatrix), first }) - }, - { eventOptions: { passive: false } } - ) + const pickup = useRef([0, 0]) + + const bind = useDrag(({ event, down, movement: pixelMovement, first }) => { + event?.stopPropagation() + setDragging(down) + + if (first) pickup.current = vec.transform(point, transform) + if (vec.mag(pixelMovement) === 0) return + + const movement = vec.transform(pixelMovement, inversePixelMatrix) + const newPoint = constrain(vec.transform(vec.add(pickup.current, movement), inverseTransform)) + + onMove(newPoint) + }) function handleKeyDown(event: React.KeyboardEvent) { - const scale = event.altKey || event.metaKey || event.shiftKey ? 400 : 40 + const small = event.altKey || event.metaKey || event.shiftKey + + let span: number + let testDir: Vector2 switch (event.key) { case "ArrowLeft": + span = xSpan + testDir = [-1, 0] event.preventDefault() - onMovement({ movement: [0, 0], first: true }) - onMovement({ movement: [-xSpan / scale, 0], first: false }) break case "ArrowRight": - onMovement({ movement: [0, 0], first: true }) - onMovement({ movement: [xSpan / scale, 0], first: false }) + span = xSpan + testDir = [1, 0] event.preventDefault() break case "ArrowUp": - onMovement({ movement: [0, 0], first: true }) - onMovement({ movement: [0, ySpan / scale], first: false }) + span = ySpan + testDir = [0, 1] event.preventDefault() break case "ArrowDown": - onMovement({ movement: [0, 0], first: true }) - onMovement({ movement: [0, -ySpan / scale], first: false }) + span = ySpan + testDir = [0, -1] event.preventDefault() break + default: + return + } + + const divisions = small ? 200 : 50 + 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(testDir, dx) + const testPoint = constrain( + vec.transform(vec.add(vec.transform(point, transform), testMovement), inverseTransform) + ) + + if (vec.dist(testPoint, point) > min) { + onMove(testPoint) + break + } } } return ( - - + + = ({ ) } +function getInverseTransform(transform: vec.Matrix) { + const invert = matrixInvert(transform) + invariant( + invert !== null, + "Could not invert transform matrix. Your movable point's constraint function might be degenerative (mapping 2D space to a line)." + ) + return invert +} + export default MovablePoint diff --git a/src/interaction/useMovablePoint.tsx b/src/interaction/useMovablePoint.tsx index a47c205e..41b05796 100644 --- a/src/interaction/useMovablePoint.tsx +++ b/src/interaction/useMovablePoint.tsx @@ -1,10 +1,8 @@ -import React, { useRef } from "react" -import { useMemo, useState } from "react" -import MovablePoint from "./MovablePoint" +import React, { useMemo, useState } from "react" import * as vec from "vec-la" import { theme } from "../display/Theme" -import { matrixInvert, Vector2 } from "../math" -import invariant from "tiny-invariant" +import { Vector2 } from "../math" +import MovablePoint from "./MovablePoint" const identity = vec.matrixBuilder().get() @@ -36,59 +34,42 @@ export interface UseMovablePoint { } function useMovablePoint( - [initialX, initialY]: Vector2, + initialPoint: Vector2, { constrain, color = theme.pink, transform = identity }: UseMovablePointArguments = {} ): UseMovablePoint { - let constraintFunction: ConstraintFunction = ([x, y]) => [x, y] - if (constrain === "horizontal") { - constraintFunction = ([x]) => [x, initialY] - } else if (constrain === "vertical") { - constraintFunction = ([, y]) => [initialX, y] - } else if (typeof constrain === "function") { - constraintFunction = constrain - } - - const [point, setPoint] = useState(() => constraintFunction([initialX, initialY])) + const [initialX, initialY] = initialPoint + const [point, setPoint] = useState(initialPoint) const [x, y] = point - const [displayX, displayY] = vec.transform(point, transform) - const pickup = useRef([0, 0]) + const constraintFunction: ConstraintFunction = React.useMemo(() => { + if (constrain === "horizontal") { + return ([x]) => [x, initialY] + } else if (constrain === "vertical") { + return ([, y]) => [initialX, y] + } else if (typeof constrain === "function") { + return constrain + } + + return ([x, y]) => [x, y] + }, [constrain, initialX, initialY]) const element = useMemo(() => { return ( { - if (first) { - pickup.current = vec.transform([x, y], transform) - } - setPoint( - constraintFunction( - vec.transform(vec.add(pickup.current, movement), getInverseTransform(transform)) - ) - ) - }} + {...{ point, transform, color }} + constrain={constraintFunction} + point={point} + onMove={setPoint} /> ) - }, [x, y, displayX, displayY, color, transform]) + }, [point, transform, color, constraintFunction]) return { x, y, - point, + point: [x, y], element, } } -function getInverseTransform(transform: vec.Matrix) { - const invert = matrixInvert(transform) - invariant( - invert !== null, - "Could not invert transform matrix. Your movable point's constraint function might be degenerative (mapping 2D space to a line)." - ) - return invert -} - export default useMovablePoint diff --git a/src/view/MafsView.tsx b/src/view/MafsView.tsx index a4540e0e..98569092 100644 --- a/src/view/MafsView.tsx +++ b/src/view/MafsView.tsx @@ -112,7 +112,7 @@ const MafsView: React.FC = ({ return (