diff --git a/.changeset/modern-sheep-bathe.md b/.changeset/modern-sheep-bathe.md new file mode 100644 index 0000000000..5f8f73a020 --- /dev/null +++ b/.changeset/modern-sheep-bathe.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Locked Figure Labels] Add/edit/delete locked vector labels diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx index 33a98ced01..883bfbac00 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.test.tsx @@ -3,6 +3,8 @@ import {render, screen} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; import * as React from "react"; +import {flags} from "../../../__stories__/flags-for-api-options"; + import LockedVectorSettings from "./locked-vector-settings"; import {getDefaultFigureForType} from "./util"; @@ -10,12 +12,21 @@ import type {Props} from "./locked-vector-settings"; import type {UserEvent} from "@testing-library/user-event"; const defaultProps = { + flags: { + ...flags, + mafs: { + ...flags.mafs, + "locked-line-settings": true, + }, + }, ...getDefaultFigureForType("vector"), onChangeProps: () => {}, onMove: () => {}, onRemove: () => {}, } as Props; +const defaultLabel = getDefaultFigureForType("label"); + describe("Locked Vector Settings", () => { let userEvent: UserEvent; beforeEach(() => { @@ -104,7 +115,10 @@ describe("Locked Vector Settings", () => { await userEvent.click(colorOption); // Assert - expect(onChangeProps).toHaveBeenCalledWith({color: "green"}); + expect(onChangeProps).toHaveBeenCalledWith({ + color: "green", + labels: [], + }); }); test("shows an error when the vector length is zero", () => { @@ -176,4 +190,184 @@ describe("Locked Vector Settings", () => { } }); }); + + describe("Labels", () => { + test("Updates the label coords when the vector coords change", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const point1XInput = screen.getAllByLabelText("x coord")[1]; + // Change the x coord of the second point to 20 + await userEvent.type(point1XInput, "0"); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + points: [ + [0, 0], + [20, 2], + ], + labels: [ + { + ...defaultLabel, + coord: [10.5, 1.5], + }, + ], + }); + }); + + test("Updates the label color when the vector color changes", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const colorSelect = screen.getByLabelText("color"); + await userEvent.click(colorSelect); + const colorOption = screen.getByText("pink"); + await userEvent.click(colorOption); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + color: "pink", + labels: [ + { + ...defaultLabel, + color: "pink", + }, + ], + }); + }); + + test("Updates the label when the label text changes", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const labelText = screen.getByLabelText("TeX"); + await userEvent.type(labelText, "!"); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + labels: [{...defaultLabel, text: "label text!"}], + }); + }); + + test("Removes label when delete button is clicked", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const deleteButton = screen.getByRole("button", { + name: "Delete locked label", + }); + await userEvent.click(deleteButton); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + labels: [], + }); + }); + + test("Adds a new label when the add label button is clicked", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const addLabelButtons = screen.getAllByRole("button", { + name: "Add visible label", + }); + // The last button is the one for the whole line, not for + // the points the define the line. + const addLabelButton = addLabelButtons[addLabelButtons.length - 1]; + await userEvent.click(addLabelButton); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({ + labels: [ + { + ...defaultLabel, + text: "label text", + }, + { + ...defaultLabel, + // Midpoint of line [[0, 0], [2, 2]] is [1, 1]. + // Offset 1 down vertically for each preceding label. + coord: [1, 0], + }, + ], + }); + }); + }); }); diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx index 65f435fa39..e6a343e20c 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-vector-settings.tsx @@ -5,11 +5,14 @@ * Used in the interactive graph editor's locked figures section. */ import {vector as kvector} from "@khanacademy/kmath"; +import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {spacing, color as wbColor} from "@khanacademy/wonder-blocks-tokens"; import {LabelLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography"; +import plusCircle from "@phosphor-icons/core/regular/plus-circle.svg"; import {StyleSheet} from "aphrodite"; +import {vec} from "mafs"; import * as React from "react"; import CoordinatePairInput from "../../../components/coordinate-pair-input"; @@ -18,12 +21,15 @@ import PerseusEditorAccordion from "../../../components/perseus-editor-accordion import ColorSelect from "./color-select"; import LineSwatch from "./line-swatch"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; +import LockedLabelSettings from "./locked-label-settings"; +import {getDefaultFigureForType} from "./util"; import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings"; import type { Coord, LockedFigure, LockedFigureColor, + LockedLabelType, LockedVectorType, } from "@khanacademy/perseus"; @@ -38,7 +44,15 @@ export type Props = LockedVectorType & }; const LockedVectorSettings = (props: Props) => { - const {points, color: lineColor, onChangeProps, onMove, onRemove} = props; + const { + flags, + points, + color: lineColor, + labels, + onChangeProps, + onMove, + onRemove, + } = props; const [tail, tip] = points; const lineLabel = `Vector (${tail[0]}, ${tail[1]}), (${tip[0]}, ${tip[1]})`; @@ -49,16 +63,54 @@ const LockedVectorSettings = (props: Props) => { if (typeof newCoord !== "undefined") { const newPoints = [...points] satisfies [tail: Coord, tip: Coord]; newPoints[index] = [...newCoord]; + + // Update labels to match the new points + const oldMidpoint = vec.midpoint(tail, tip); + const newMidpoint = vec.midpoint(newPoints[0], newPoints[1]); + const offset = vec.sub(newMidpoint, oldMidpoint); + const newLabels = labels.map((label) => ({ + ...label, + coord: vec.add(label.coord, offset), + })); + onChangeProps({ points: newPoints, + labels: newLabels, }); } } function handleColorChange(newColor: LockedFigureColor) { - onChangeProps({ + const newProps: Partial = { + color: newColor, + }; + + // Update the color of the all labels to match the point + newProps.labels = labels.map((label) => ({ + ...label, color: newColor, - }); + })); + + onChangeProps(newProps); + } + + function handleLabelChange( + updatedLabel: LockedLabelType, + labelIndex: number, + ) { + const updatedLabels = [...labels]; + updatedLabels[labelIndex] = { + ...labels[labelIndex], + ...updatedLabel, + }; + + onChangeProps({labels: updatedLabels}); + } + + function handleLabelRemove(labelIndex: number) { + const updatedLabels = labels.filter((_, index) => index !== labelIndex); + + onChangeProps({labels: updatedLabels}); } return ( @@ -127,6 +179,54 @@ const LockedVectorSettings = (props: Props) => { /> + {flags?.["mafs"]?.["locked-vector-labels"] && ( + <> + + + {labels.map((label, labelIndex) => ( + { + handleLabelChange(newLabel, labelIndex); + }} + onRemove={() => { + handleLabelRemove(labelIndex); + }} + containerStyle={styles.labelContainer} + /> + ))} + + + + )} + {/* Actions */}