diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx index ba7635dbfc..422994722b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx @@ -12,7 +12,9 @@ import {TextLabel} from "./components/text-label"; import {useDraggable} from "./use-draggable"; import {pixelsToVectors, useTransformVectorsToPixels} from "./use-transform"; +import type {Coord} from "../../../interactive2/types"; import type {CollinearTuple} from "../../../perseus-types"; +import type {GraphConfig} from "../reducer/use-graph-config"; import type { Dispatch, InteractiveGraphElementSuite, @@ -31,35 +33,52 @@ export function renderPolygonGraph( } type Props = MafsGraphProps; +type StatefulProps = MafsGraphProps & { + graphConfig: GraphConfig; + polygonRef: React.RefObject; + pointsRef: React.MutableRefObject<(SVGElement | null)[]>; + lastMoveTimeRef: React.MutableRefObject; + left: number; + top: number; + dragging: boolean; + points: Coord[]; + constrain: (p: any) => any; + hovered: boolean; + setHovered: React.Dispatch>; + focusVisible: boolean; + setFocusVisible: React.Dispatch>; +}; -const LimitedPolygonGraph = (props: Props) => { - const [hovered, setHovered] = React.useState(false); - // This is more so required for the re-rendering that occurs when state - // updates; specifically with regard to line weighting and polygon focus. - const [focusVisible, setFocusVisible] = React.useState(false); - +const PolygonGraph = (props: Props) => { const {dispatch} = props; + const {numSides, coords, snapStep, snapTo = "grid"} = props.graphState; + const graphConfig = useGraphConfig(); + + // Ref to implement the dragging behavior on a Limited/Closed Polygon. + const polygonRef = React.useRef(null); + // Ref to manage dynamic focus for the points in Unlimited Polygon + const pointsRef = React.useRef>([]); + // Ref to manage the last move tim for a Limited Polygon. + const lastMoveTimeRef = React.useRef(0); + + // Dimensions to build the graph overlay for Unlimited Polgyon. const { - coords, - showAngles, - showSides, - range, - snapStep, - snapTo = "grid", - } = props.graphState; - const {disableKeyboardInteraction} = useGraphConfig(); + range: [x, y], + } = graphConfig; + const [[left, top]] = useTransformVectorsToPixels([x[0], y[1]]); // TODO(benchristel): can the default set of points be removed here? I don't // think coords can be null. const points = coords ?? [[0, 0]]; - const ref = React.useRef(null); + // Logic to build the dragging experience. Primarily used by Limited Polygon. const dragReferencePoint = points[0]; const constrain = ["angles", "sides"].includes(snapTo) ? (p) => p : (p) => snap(snapStep, p); + const {dragging} = useDraggable({ - gestureTarget: ref, + gestureTarget: polygonRef, point: dragReferencePoint, onMove: (newPoint) => { const delta = vec.sub(newPoint, dragReferencePoint); @@ -68,7 +87,63 @@ const LimitedPolygonGraph = (props: Props) => { constrainKeyboardMovement: constrain, }); - const lastMoveTime = React.useRef(0); + // Hover/focus state for Limited Polgyon effects. + const [hovered, setHovered] = React.useState(false); + // This is more so required for the re-rendering that occurs when state + // updates; specifically with regard to line weighting and polygon focus. + const [focusVisible, setFocusVisible] = React.useState(false); + + // This useEffect is to handle the focus snapping for Unlimited Polygon. + React.useEffect(() => { + const focusedIndex = props.graphState.focusedPointIndex; + if (focusedIndex != null) { + pointsRef.current[focusedIndex]?.focus(); + } + }, [props.graphState.focusedPointIndex, pointsRef]); + + const statefulProps: StatefulProps = { + ...props, + graphConfig, + polygonRef, + pointsRef, + lastMoveTimeRef, + left, + top, + dragging, + points, + constrain, + hovered, + setHovered, + focusVisible, + setFocusVisible, + }; + + return numSides === "unlimited" + ? UnlimitedPolygonGraph(statefulProps) + : LimitedPolygonGraph(statefulProps); +}; + +const LimitedPolygonGraph = (statefulProps: StatefulProps) => { + const { + dispatch, + hovered, + setHovered, + focusVisible, + setFocusVisible, + graphConfig, + polygonRef, + lastMoveTimeRef, + dragging, + points, + constrain, + } = statefulProps; + const { + showAngles, + showSides, + range, + snapTo = "grid", + } = statefulProps.graphState; + const {disableKeyboardInteraction} = graphConfig; const lines = getLines(points); @@ -125,7 +200,7 @@ const LimitedPolygonGraph = (props: Props) => { points={[...points]} color="transparent" svgPolygonProps={{ - ref, + ref: polygonRef, tabIndex: disableKeyboardInteraction ? -1 : 0, strokeWidth: TARGET_SIZE, style: { @@ -137,14 +212,15 @@ const LimitedPolygonGraph = (props: Props) => { // Required to remove line weighting when user clicks away // from the focused polygon onKeyDownCapture: () => { - setFocusVisible(hasFocusVisible(ref.current)); + setFocusVisible(hasFocusVisible(polygonRef.current)); }, // Required for lines to darken on focus onFocus: () => - setFocusVisible(hasFocusVisible(ref.current)), + setFocusVisible(hasFocusVisible(polygonRef.current)), // Required for line weighting to update on blur. Without this, // the user has to hover over the shape for it to update - onBlur: () => setFocusVisible(hasFocusVisible(ref.current)), + onBlur: () => + setFocusVisible(hasFocusVisible(polygonRef.current)), className: "movable-polygon", }} /> @@ -159,9 +235,9 @@ const LimitedPolygonGraph = (props: Props) => { const targetFPS = 40; const moveThresholdTime = 1000 / targetFPS; - if (now - lastMoveTime.current > moveThresholdTime) { + if (now - lastMoveTimeRef.current > moveThresholdTime) { dispatch(actions.polygon.movePoint(i, destination)); - lastMoveTime.current = now; + lastMoveTimeRef.current = now; } }} /> @@ -170,105 +246,84 @@ const LimitedPolygonGraph = (props: Props) => { ); }; -const UnlimitedPolygonGraph = (props: Props) => { - const {dispatch} = props; - const {coords, closedPolygon} = props.graphState; +const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => { + const {dispatch, graphConfig, left, top, pointsRef, points} = statefulProps; + const {coords, closedPolygon} = statefulProps.graphState; - const graphConfig = useGraphConfig(); + // If the polygon is closed, return a LimitedPolygon component. + if (closedPolygon) { + const closedPolygonProps = {...statefulProps, numSides: coords.length}; + return ; + } - const { - range: [x, y], - graphDimensionsInPixels, - } = graphConfig; + const {graphDimensionsInPixels} = graphConfig; const widthPx = graphDimensionsInPixels[0]; const heightPx = graphDimensionsInPixels[1]; - const [[left, top]] = useTransformVectorsToPixels([x[0], y[1]]); - const pointRef = React.useRef>([]); - - // TODO(benchristel): can the default set of points be removed here? I don't - // think coords can be null. - const points = coords ?? [[0, 0]]; - - React.useEffect(() => { - const focusedIndex = props.graphState.focusedPointIndex; - if (focusedIndex != null) { - pointRef.current[focusedIndex]?.focus(); - } - }, [props.graphState.focusedPointIndex, pointRef]); - - if (closedPolygon) { - const closedPolygonProps = {...props, numSides: coords.length}; - return ; - } else { - return ( - <> - {/* This rect is here to grab clicks so that new points can be added */} - {/* It's important because it stops mouse events from propogating + return ( + <> + {/* This rect is here to grab clicks so that new points can be added */} + {/* It's important because it stops mouse events from propogating when dragging a points around */} - { - const elementRect = - event.currentTarget.getBoundingClientRect(); + { + const elementRect = + event.currentTarget.getBoundingClientRect(); - const x = event.clientX - elementRect.x; - const y = event.clientY - elementRect.y; + const x = event.clientX - elementRect.x; + const y = event.clientY - elementRect.y; - const graphCoordinates = pixelsToVectors( - [[x, y]], - graphConfig, - ); - dispatch(actions.polygon.addPoint(graphCoordinates[0])); + const graphCoordinates = pixelsToVectors( + [[x, y]], + graphConfig, + ); + dispatch(actions.polygon.addPoint(graphCoordinates[0])); + }} + /> + + {coords.map((point, i) => ( + + dispatch(actions.polygon.movePoint(i, destination)) + } + ref={(ref) => { + pointsRef.current[i] = ref; }} - /> - { + dispatch(actions.polygon.focusPoint(i)); }} - /> - {props.graphState.coords.map((point, i) => ( - - dispatch(actions.polygon.movePoint(i, destination)) + onClick={() => { + // If the point being clicked is the first point and + // there's enough points to form a polygon (3 or more) + // Close the shape before setting focus. + if (i === 0 && coords.length >= 3) { + dispatch(actions.polygon.closePolygon()); } - ref={(ref) => { - pointRef.current[i] = ref; - }} - onFocus={() => { - dispatch(actions.polygon.focusPoint(i)); - }} - onClick={() => { - // If the point being clicked is the first point and - // there's enough points to form a polygon (3 or more) - // Close the shape before setting focus. - if ( - i === 0 && - props.graphState.coords.length >= 3 - ) { - dispatch(actions.polygon.closePolygon()); - } - dispatch(actions.polygon.clickPoint(i)); - }} - /> - ))} - - ); - } + dispatch(actions.polygon.clickPoint(i)); + }} + /> + ))} + + ); }; function getLines(points: readonly vec.Vector2[]): CollinearTuple[] { @@ -291,11 +346,3 @@ export const hasFocusVisible = ( return matches(":focus"); } }; - -const PolygonGraph = (props: Props) => { - const numSides = props.graphState.numSides; - - return numSides === "unlimited" - ? UnlimitedPolygonGraph(props) - : LimitedPolygonGraph(props); -};