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 */}