diff --git a/.changeset/chilly-apples-accept.md b/.changeset/chilly-apples-accept.md new file mode 100644 index 0000000000..83502a77a3 --- /dev/null +++ b/.changeset/chilly-apples-accept.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus-editor": minor +"@khanacademy/perseus": patch +--- + +Add/Edit/Delete Locked Function in Interactive Graph Editor diff --git a/.changeset/clean-glasses-watch.md b/.changeset/clean-glasses-watch.md new file mode 100644 index 0000000000..58a9e8c45e --- /dev/null +++ b/.changeset/clean-glasses-watch.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-editor": patch +--- + +[Hint Mode: Start Coords] Correct flag logic for polygon graph start coords UI diff --git a/.changeset/eleven-paws-serve.md b/.changeset/eleven-paws-serve.md new file mode 100644 index 0000000000..96aa3c3e29 --- /dev/null +++ b/.changeset/eleven-paws-serve.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Hint Mode: Start Coords] Build the foundation for adding start coords UI for angle graphs diff --git a/.changeset/flat-badgers-matter.md b/.changeset/flat-badgers-matter.md new file mode 100644 index 0000000000..94e88f9f4e --- /dev/null +++ b/.changeset/flat-badgers-matter.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/math-input": patch +--- + +Remove old buttons that we weren't using anymore diff --git a/.changeset/four-laws-wash.md b/.changeset/four-laws-wash.md new file mode 100644 index 0000000000..3ec25c421a --- /dev/null +++ b/.changeset/four-laws-wash.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": patch +"@khanacademy/perseus-editor": patch +--- + +Miscellaneous type improvements diff --git a/.changeset/four-windows-retire.md b/.changeset/four-windows-retire.md new file mode 100644 index 0000000000..cf95d50f3e --- /dev/null +++ b/.changeset/four-windows-retire.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": major +"@khanacademy/perseus-editor": patch +--- + +Remove PropCheckBox component from Perseus; use WB instead diff --git a/.changeset/fresh-tips-guess.md b/.changeset/fresh-tips-guess.md new file mode 100644 index 0000000000..54b32c8e4b --- /dev/null +++ b/.changeset/fresh-tips-guess.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/math-input": patch +--- + +update wonder blocks popover versions diff --git a/.changeset/gentle-planes-rest.md b/.changeset/gentle-planes-rest.md new file mode 100644 index 0000000000..d12b4eeb20 --- /dev/null +++ b/.changeset/gentle-planes-rest.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": patch +"@khanacademy/perseus-editor": patch +--- + +Correct `hints` type on ItemRenderer diff --git a/.changeset/grumpy-sheep-mate.md b/.changeset/grumpy-sheep-mate.md new file mode 100644 index 0000000000..f7a01a3aed --- /dev/null +++ b/.changeset/grumpy-sheep-mate.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": patch +"@khanacademy/perseus-editor": patch +--- + +[Interactive Graph Editor: Storybook] Add a story for Point graph type start coords diff --git a/.changeset/purple-dots-beam.md b/.changeset/purple-dots-beam.md new file mode 100644 index 0000000000..f4c3d53be9 --- /dev/null +++ b/.changeset/purple-dots-beam.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus-editor": patch +--- + +[Interactive Graph Editor] Stop page scrolling on number text field focused scroll diff --git a/.changeset/strange-trains-unite.md b/.changeset/strange-trains-unite.md new file mode 100644 index 0000000000..cebdd4074d --- /dev/null +++ b/.changeset/strange-trains-unite.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/math-input": patch +--- + +Remove unused buttons from MathInput; add Lato diff --git a/.changeset/tasty-eggs-drop.md b/.changeset/tasty-eggs-drop.md new file mode 100644 index 0000000000..396913e65c --- /dev/null +++ b/.changeset/tasty-eggs-drop.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Hint Mode: Start Coords] Add start coords UI for point graphs diff --git a/.changeset/tender-tools-perform.md b/.changeset/tender-tools-perform.md new file mode 100644 index 0000000000..752551bf71 --- /dev/null +++ b/.changeset/tender-tools-perform.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +add non-visual text as a description for expression widget diff --git a/.changeset/unlucky-lamps-draw.md b/.changeset/unlucky-lamps-draw.md new file mode 100644 index 0000000000..e08481ca5a --- /dev/null +++ b/.changeset/unlucky-lamps-draw.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Hint Mode: Start Coords] Add start coords UI for polygon graphs (snap to grid only) diff --git a/.storybook/main.ts b/.storybook/main.ts index 4a814f3d38..ea5272561d 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -72,6 +72,7 @@ const config: StorybookConfig = { docs: { autodocs: true, }, + staticDirs: ["../static"], }; export default config; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000000..8b04d3a37d --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,59 @@ + + + + + + + + + + + diff --git a/packages/math-input/package.json b/packages/math-input/package.json index a288c34394..a960d9b3aa 100644 --- a/packages/math-input/package.json +++ b/packages/math-input/package.json @@ -44,7 +44,7 @@ "@khanacademy/mathjax-renderer": "^2.0.1", "@khanacademy/wonder-blocks-clickable": "4.2.1", "@khanacademy/wonder-blocks-core": "6.4.0", - "@khanacademy/wonder-blocks-popover": "3.2.9", + "@khanacademy/wonder-blocks-popover": "3.2.11", "@khanacademy/wonder-blocks-timing": "5.0.0", "@khanacademy/wonder-blocks-tokens": "1.3.0", "@khanacademy/wonder-stuff-core": "1.5.2", @@ -64,7 +64,7 @@ "@khanacademy/mathjax-renderer": "^2.0.1", "@khanacademy/wonder-blocks-clickable": "4.2.1", "@khanacademy/wonder-blocks-core": "6.4.0", - "@khanacademy/wonder-blocks-popover": "3.2.9", + "@khanacademy/wonder-blocks-popover": "3.2.11", "@khanacademy/wonder-blocks-timing": "5.0.0", "@khanacademy/wonder-blocks-tokens": "1.3.0", "@khanacademy/wonder-stuff-core": "1.5.2", diff --git a/packages/math-input/src/components/key-handlers/key-translator.ts b/packages/math-input/src/components/key-handlers/key-translator.ts index 88f9785695..adf4ea762e 100644 --- a/packages/math-input/src/components/key-handlers/key-translator.ts +++ b/packages/math-input/src/components/key-handlers/key-translator.ts @@ -93,7 +93,6 @@ export const getKeyTranslator = ( LEFT_PAREN: buildGenericCallback("(", ActionType.CMD), RIGHT_PAREN: buildGenericCallback(")", ActionType.CMD), SQRT: buildGenericCallback("sqrt", ActionType.CMD), - PHI: buildGenericCallback("\\phi", ActionType.CMD), PI: buildGenericCallback("pi", ActionType.CMD), THETA: buildGenericCallback("theta", ActionType.CMD), RADICAL: buildGenericCallback("nthroot", ActionType.CMD), @@ -119,14 +118,6 @@ export const getKeyTranslator = ( } }, - LOG_B: (mathQuill) => { - mathQuill.typedText("log_"); - mathQuill.keystroke("Right"); - mathQuill.typedText("("); - mathQuill.keystroke("Left"); - mathQuill.keystroke("Left"); - }, - LOG_N: (mathQuill) => { mathQuill.write("log_{ }\\left(\\right)"); mathQuill.keystroke("Left"); // into parentheses @@ -134,28 +125,9 @@ export const getKeyTranslator = ( mathQuill.keystroke("Left"); // into index }, - NTHROOT3: (mathQuill) => { - mathQuill.typedText("nthroot3"); - mathQuill.keystroke("Right"); - }, - - POW: (mathQuill) => { - const contents = mathQuill.latex(); - mathQuill.typedText("^"); - - // If the input hasn't changed (for example, if we're - // attempting to add an exponent on an empty input or an empty - // denominator), insert our own "a^b" - if (mathQuill.latex() === contents) { - mathQuill.typedText("a^b"); - } - }, - // These need to be overwritten by the consumer // if they're going to be used DISMISS: () => {}, - NOOP: () => {}, - MANY: () => {}, NUM_0: buildGenericCallback("0"), NUM_1: buildGenericCallback("1"), diff --git a/packages/math-input/src/components/keypad/button-assets.tsx b/packages/math-input/src/components/keypad/button-assets.tsx index a8027f9919..e27d39368f 100644 --- a/packages/math-input/src/components/keypad/button-assets.tsx +++ b/packages/math-input/src/components/keypad/button-assets.tsx @@ -1837,42 +1837,6 @@ export default function ButtonAsset({id}: Props): React.ReactElement { ); - - /** - * ANYTHING BELOW IS NOT YET HANDLED - */ - case "MANY": - case "NOOP": - case "PHI": - case "NTHROOT3": - case "POW": - case "LOG_B": - // placeholder - return ( - - - {id} - - - placeholder - - - ); default: // this line forces an exhaustive check of all keys; // if a key is not handled, the compiler will complain. diff --git a/packages/math-input/src/data/key-configs.ts b/packages/math-input/src/data/key-configs.ts index bb2549b489..5ef9a03c4f 100644 --- a/packages/math-input/src/data/key-configs.ts +++ b/packages/math-input/src/data/key-configs.ts @@ -272,12 +272,6 @@ const KeyConfigs = ( ariaLabel: strings.theta, }), }, - NOOP: { - ...getDefaultOperatorFields({ - key: "NOOP", - keyType: "EMPTY", - }), - }, // Input navigation UP: { ...getDefaultOperatorFields({ @@ -366,14 +360,6 @@ const KeyConfigs = ( }), }, - // TODO(charlie): Use the numeral color for the 'Many' key. - MANY: { - ...getDefaultOperatorFields({ - key: "MANY", - keyType: "MANY", - }), - }, - // NUMBERS NUM_0: { ...getDefaultNumberFields({ @@ -687,26 +673,6 @@ const KeyConfigs = ( key: "z", }), }, - PHI: { - ...getDefaultValueFields({ - key: "PHI", - }), - }, - NTHROOT3: { - ...getDefaultValueFields({ - key: "NTHROOT3", - }), - }, - POW: { - ...getDefaultValueFields({ - key: "POW", - }), - }, - LOG_B: { - ...getDefaultValueFields({ - key: "LOG_B", - }), - }, }); export default KeyConfigs; diff --git a/packages/math-input/src/data/keys.ts b/packages/math-input/src/data/keys.ts index dffeea5fb5..b118107844 100644 --- a/packages/math-input/src/data/keys.ts +++ b/packages/math-input/src/data/keys.ts @@ -45,8 +45,6 @@ export const KeyArray = [ "JUMP_INTO_NUMERATOR", "JUMP_OUT_NUMERATOR", "JUMP_OUT_DENOMINATOR", // Multi-functional keys. - "NOOP", // mobile native only - "MANY", // A custom key that captures an arbitrary number of symbols but has no 'default' symbol or action. "NUM_0", "NUM_1", "NUM_2", @@ -109,13 +107,6 @@ export const KeyArray = [ "X", "Y", "Z", - - // Currently only used by - // Perseus' Expression MathInput - "PHI", - "NTHROOT3", - "POW", - "LOG_B", ] as const; type Key = (typeof KeyArray)[number]; diff --git a/packages/math-input/src/enums.ts b/packages/math-input/src/enums.ts index 219167d0bd..0908fe9ad2 100644 --- a/packages/math-input/src/enums.ts +++ b/packages/math-input/src/enums.ts @@ -20,8 +20,5 @@ export const KeyTypes = [ // For buttons that modify the broader keypad state (e.g., by changing // the visible pane). "KEYPAD_NAVIGATION", - // For buttons that house multiple buttons and have no action - // themselves. - "MANY", ]; export type KeyType = (typeof KeyTypes)[number]; diff --git a/packages/math-input/src/types.ts b/packages/math-input/src/types.ts index 505f640a4c..d5c3648fcb 100644 --- a/packages/math-input/src/types.ts +++ b/packages/math-input/src/types.ts @@ -9,20 +9,13 @@ export type IconConfig = { data: string; }; -export type NonManyKeyConfig = { +export type KeyConfig = { id: Key; - type: Exclude; + type: KeyType; icon: IconConfig; ariaLabel: string; }; -export type ManyKeyConfig = Omit & { - type: Extract; - childKeyIds: ReadonlyArray; -}; - -export type KeyConfig = NonManyKeyConfig | ManyKeyConfig; - export type KeypadConfiguration = { keypadType: KeypadType; extraKeys?: ReadonlyArray; diff --git a/packages/perseus-editor/src/__stories__/flags-for-api-options.ts b/packages/perseus-editor/src/__stories__/flags-for-api-options.ts index 15c052a188..479768047a 100644 --- a/packages/perseus-editor/src/__stories__/flags-for-api-options.ts +++ b/packages/perseus-editor/src/__stories__/flags-for-api-options.ts @@ -11,6 +11,7 @@ export const flags = { linear: true, "linear-system": true, ray: true, + point: true, // Locked figures flags "interactive-graph-locked-features-m2b": true, @@ -19,6 +20,9 @@ export const flags = { // TODO(LEMS-2228): Remove flags once this is fully released "start-coords-ui-phase-1": true, "start-coords-ui-phase-2": true, + "start-coords-ui-point": true, + "start-coords-ui-polygon": true, + "start-coords-ui-angle": true, }, } satisfies APIOptions["flags"]; diff --git a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx index e2d1c5d4e1..f672376960 100644 --- a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx +++ b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx @@ -8,10 +8,12 @@ import * as React from "react"; import {EditorPage} from ".."; import { + angleWithStartingCoordsQuestion, circleWithStartingCoordsQuestion, linearSystemWithStartingCoordsQuestion, linearWithStartingCoordsQuestion, - polygonQuestion, + pointQuestionWithStartingCoords, + polygonWithStartingCoordsQuestion, quadraticWithStartingCoordsQuestion, rayWithStartingCoordsQuestion, segmentWithLockedFigures, @@ -39,43 +41,39 @@ export default { const onChangeAction = action("onChange"); -export const InteractiveGraphSegmentWithStartingCoords = - (): React.ReactElement => { - return ( - - ); - }; +export const InteractiveGraphSegment = (): React.ReactElement => { + return ( + + ); +}; -export const InteractiveGraphSegmentsWithStartingCoords = - (): React.ReactElement => { - return ( - - ); - }; +export const InteractiveGraphSegments = (): React.ReactElement => { + return ( + + ); +}; -export const InteractiveGraphLinearWithStartingCoords = - (): React.ReactElement => { - return ( - - ); - }; +export const InteractiveGraphLinear = (): React.ReactElement => { + return ( + + ); +}; -export const InteractiveGraphLinearSystemWithStartingCoords = - (): React.ReactElement => { - return ( - - ); - }; +export const InteractiveGraphLinearSystem = (): React.ReactElement => { + return ( + + ); +}; -export const InteractiveGraphRayWithStartingCoords = (): React.ReactElement => { +export const InteractiveGraphRay = (): React.ReactElement => { return ( { ); }; -export const InteractiveGraphCircleWithStartingCoords = - (): React.ReactElement => { - return ( - - ); - }; +export const InteractiveGraphCircle = (): React.ReactElement => { + return ( + + ); +}; -export const InteractiveGraphQuadraticWithStartingCoords = - (): React.ReactElement => { - return ( - - ); - }; +export const InteractiveGraphQuadratic = (): React.ReactElement => { + return ( + + ); +}; -export const InteractiveGraphSinusoidWithStartingCoords = - (): React.ReactElement => { - return ( - - ); - }; +export const InteractiveGraphSinusoid = (): React.ReactElement => { + return ( + + ); +}; + +export const InteractiveGraphPoint = (): React.ReactElement => ( + +); export const InteractiveGraphPolygon = (): React.ReactElement => { - return ; + return ( + + ); +}; + +export const InteractiveGraphAngle = (): React.ReactElement => { + return ( + + ); }; export const MafsWithLockedFiguresCurrent = (): React.ReactElement => { diff --git a/packages/perseus-editor/src/article-editor.tsx b/packages/perseus-editor/src/article-editor.tsx index 644e5e1b4a..980be9777e 100644 --- a/packages/perseus-editor/src/article-editor.tsx +++ b/packages/perseus-editor/src/article-editor.tsx @@ -15,7 +15,7 @@ import SectionControlButton from "./components/section-control-button"; import Editor from "./editor"; import IframeContentRenderer from "./iframe-content-renderer"; -import type {APIOptions, Changeable} from "@khanacademy/perseus"; +import type {APIOptions, Changeable, ImageUploader} from "@khanacademy/perseus"; const {HUD, InlineIcon} = components; const {iconCircleArrowDown, iconCircleArrowUp, iconPlus, iconTrash} = icons; @@ -39,7 +39,7 @@ type DefaultProps = { }; type Props = DefaultProps & { apiOptions?: APIOptions; - imageUploader?: (arg1: string, arg2: (arg1: string) => unknown) => unknown; + imageUploader?: ImageUploader; // URL of the route to show on initial load of the preview frames. previewURL: string; } & Changeable.ChangeableProps; diff --git a/packages/perseus-editor/src/components/__stories__/locked-function-settings.stories.tsx b/packages/perseus-editor/src/components/__stories__/locked-function-settings.stories.tsx new file mode 100644 index 0000000000..81aac84bf0 --- /dev/null +++ b/packages/perseus-editor/src/components/__stories__/locked-function-settings.stories.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; + +import LockedFunctionSettings from "../graph-locked-figures/locked-function-settings"; +import {getDefaultFigureForType} from "../util"; + +import type {Meta, StoryObj} from "@storybook/react"; + +export default { + title: "PerseusEditor/Components/Locked Function Settings", + component: LockedFunctionSettings, +} as Meta; + +export const Default = (args): React.ReactElement => { + return ; +}; + +const defaultProps = { + ...getDefaultFigureForType("function"), + onChangeProps: () => {}, + onMove: () => {}, + onRemove: () => {}, +}; + +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + +export const Expanded: StoryComponentType = { + render: function Render() { + const [props, setProps] = React.useState(defaultProps); + + const handlePropsUpdate = (newProps) => { + setProps({ + ...props, + ...newProps, + }); + }; + + return ( + + ); + }, +}; diff --git a/packages/perseus-editor/src/components/__stories__/scrollless-number-text-field.stories.tsx b/packages/perseus-editor/src/components/__stories__/scrollless-number-text-field.stories.tsx new file mode 100644 index 0000000000..7a00a45a63 --- /dev/null +++ b/packages/perseus-editor/src/components/__stories__/scrollless-number-text-field.stories.tsx @@ -0,0 +1,82 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; +import * as React from "react"; + +import ScrolllessNumberTextField from "../scrollless-number-text-field"; + +import type {StoryObj, Meta} from "@storybook/react"; + +export default { + title: "PerseusEditor/Components/Scrollless Number Text Field", + component: ScrolllessNumberTextField, +} as Meta; + +/** + * Uncontrolled story. Interact with the control panel to see the component + * reflect the props. + */ +export const Default = (args): React.ReactElement => { + return ; +}; + +const defaultProps = { + value: "", + onChange: () => {}, +}; + +type StoryComponentType = StoryObj; + +// Set the default values in the control panel. +Default.args = defaultProps; + +/** + * Controlled story. The text field's state is managed by its parent. + * Typing in the input field should work as expected. + */ +export const Controlled: StoryComponentType = { + render: function Render() { + const [value, setValue] = React.useState(""); + + return ; + }, +}; + +Controlled.parameters = { + chromatic: { + // Disable the snapshot for this story because it's testing + // behavior, not visuals. + disable: true, + }, +}; + +/** + * In this example, we can see how the input field behaves when it is placed + * in a long page. Scrolling on the input field with a mouse wheel or trackpad + * changes the number, but does not scroll the page. + */ +export const LongPageScroll: StoryComponentType = { + render: function Render() { + const [value, setValue] = React.useState(""); + + return ( + <> + Scroll down to see the input. + + + Observe that scrolling on the input field with a mouse wheel + changes the number, but does not scroll the page. + + + + + ); + }, +}; + +LongPageScroll.parameters = { + chromatic: { + // Disable the snapshot for this story because it's testing + // behavior, not visuals. + disable: true, + }, +}; diff --git a/packages/perseus-editor/src/components/__tests__/locked-function-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-function-settings.test.tsx new file mode 100644 index 0000000000..642de1a439 --- /dev/null +++ b/packages/perseus-editor/src/components/__tests__/locked-function-settings.test.tsx @@ -0,0 +1,368 @@ +import {RenderStateRoot} from "@khanacademy/wonder-blocks-core"; +import {render, screen} from "@testing-library/react"; +import {userEvent as userEventLib} from "@testing-library/user-event"; +import * as React from "react"; + +import LockedFunctionSettings from "../graph-locked-figures/locked-function-settings"; +import {getDefaultFigureForType} from "../util"; + +import type {Props} from "../graph-locked-figures/locked-function-settings"; +import type {UserEvent} from "@testing-library/user-event"; + +const defaultProps = { + ...getDefaultFigureForType("function"), + onChangeProps: () => {}, + onMove: () => {}, + onRemove: () => {}, +} as Props; + +describe("Locked Function Settings", () => { + let userEvent: UserEvent; + const onChangeProps = jest.fn(); + + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + }); + + test("renders as expected with default values", () => { + // Act + render(, { + wrapper: RenderStateRoot, + }); + + // Assert + const titleText = screen.getByText("Function (y=x^2)"); + expect(titleText).toBeInTheDocument(); + }); + + describe("Heading interactions", () => { + test("should show the function's color and stroke by default", () => { + // Arrange + render(, { + wrapper: RenderStateRoot, + }); + + // Act + const lineSwatch = screen.getByLabelText("grayH, solid"); + + // Assert + expect(lineSwatch).toBeInTheDocument(); + }); + + test("should use the supplied color in the label", () => { + // Arrange + render(, { + wrapper: RenderStateRoot, + }); + + // Act + const lineSwatch = screen.getByLabelText("green, solid"); + + // Assert + expect(lineSwatch).toBeInTheDocument(); + }); + + test("should use the supplied stroke style in the label", () => { + // Arrange + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const lineSwatch = screen.getByLabelText("grayH, dashed"); + + // Assert + expect(lineSwatch).toBeInTheDocument(); + }); + + test("should use the supplied equation in the label", () => { + // Arrange + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const header = screen.getByRole("button", { + name: "Function (y=sin(x)) grayH, solid", + }); + + // Assert + expect(header).toBeInTheDocument(); + }); + + test("should use the appropriate axis reference in the label", () => { + // Arrange + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const header = screen.getByRole("button", { + name: "Function (x=cos(y)) grayH, solid", + }); + + // Assert + expect(header).toBeInTheDocument(); + }); + + test("calls 'onToggle' when header is clicked", async () => { + // Arrange + const onToggle = jest.fn(); + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const header = screen.getByRole("button", { + name: "Function (y=x^2) grayH, solid", + }); + await userEvent.click(header); + + // Assert + expect(onToggle).toHaveBeenCalled(); + }); + }); + + describe("Settings interactions", () => { + test("calls 'onChangeProps' when color is changed", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + // Change the color + const colorSwitch = screen.getByLabelText("color"); + await userEvent.click(colorSwitch); + const colorOption = screen.getByText("green"); + await userEvent.click(colorOption); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({color: "green"}); + }); + + test("calls 'onChangeProps' when stroke style is changed", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + // Change the stroke + const strokeSwitch = screen.getByLabelText("stroke"); + await userEvent.click(strokeSwitch); + const strokeOption = screen.getByText("dashed"); + await userEvent.click(strokeOption); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({strokeStyle: "dashed"}); + }); + + test("calls 'onChangeProps' when equation is changed (as keys are pressed)", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + // Change the equation + const equationField = screen.getByLabelText("equation"); + await userEvent.type(equationField, "zot"); + + // Assert + expect(onChangeProps).toHaveBeenCalledTimes(3); + // NOTE: Since the 'onChangeProps' function is being mocked, + // the equation doesn't get updated, + // and therefore the keystrokes don't accumulate. + // This is reflected in the calls to 'onChangeProps' being just 1 character at a time. + expect(onChangeProps).toHaveBeenNthCalledWith(1, {equation: "z"}); + expect(onChangeProps).toHaveBeenNthCalledWith(2, {equation: "o"}); + expect(onChangeProps).toHaveBeenNthCalledWith(3, {equation: "t"}); + }); + + test("calls 'onChangeProps' when directional axis is changed", async () => { + // Arrange + const onChangeProps = jest.fn(); + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + // Change the axis direction + const directionalAxisSwitch = + screen.getByLabelText("equation prefix"); + await userEvent.click(directionalAxisSwitch); + const axisOption = screen.getByText("x ="); + await userEvent.click(axisOption); + + // Assert + expect(onChangeProps).toHaveBeenCalledWith({directionalAxis: "y"}); + }); + + describe("Domain interactions", () => { + test("valid entries update component properties", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const domainMinField = screen.getByLabelText("domain min"); + await userEvent.type(domainMinField, "1"); + const domainMaxField = screen.getByLabelText("domain max"); + await userEvent.type(domainMaxField, "5"); + + // Assert + expect(onChangeProps).toHaveBeenCalledTimes(2); + expect(onChangeProps).toHaveBeenNthCalledWith(1, { + domain: [1, Infinity], + }); + expect(onChangeProps).toHaveBeenNthCalledWith(2, { + domain: [-Infinity, 5], + }); + }); + + test("negative entries are handled appropriately", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const domainMinField = screen.getByLabelText("domain min"); + await userEvent.type(domainMinField, "-5"); + await userEvent.type(domainMinField, "[Backspace/]4"); + await userEvent.type( + domainMinField, + "[Backspace/][Backspace/]-3", + ); + const domainMaxField = screen.getByLabelText("domain max"); + await userEvent.type(domainMaxField, "-2"); + + // Assert + expect(onChangeProps).toHaveBeenNthCalledWith(1, { + domain: [-5, Infinity], + }); + expect(onChangeProps).toHaveBeenNthCalledWith(3, { + domain: [-4, Infinity], + }); + expect(onChangeProps).toHaveBeenNthCalledWith(5, { + domain: [-3, Infinity], + }); + expect(onChangeProps).toHaveBeenNthCalledWith(6, { + domain: [-Infinity, -2], + }); + }); + + test("invalid entries don't update component properties", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + // Try different invalid entries (not numbers) + const domainMinField = screen.getByLabelText("domain min"); + await userEvent.type(domainMinField, " "); + await userEvent.type(domainMinField, "e"); + + // Assert + expect(onChangeProps).toHaveBeenCalledTimes(0); + }); + + test("deleted values are replaced with +/- Infinity", async () => { + // Arrange + render( + , + {wrapper: RenderStateRoot}, + ); + + // Act + const domainMinField = screen.getByLabelText("domain min"); + await userEvent.type( + domainMinField, + "[ArrowRight/][Backspace/]", // ensure that the cursor is on the right side of the digit before deleting it + ); + const domainMaxField = screen.getByLabelText("domain max"); + await userEvent.type( + domainMaxField, + "[ArrowRight/][Backspace/]", + ); + + // Assert + expect(onChangeProps).toHaveBeenCalledTimes(2); + expect(onChangeProps).toHaveBeenNthCalledWith(1, { + domain: [-Infinity, 5], + }); + expect(onChangeProps).toHaveBeenNthCalledWith(2, { + domain: [3, Infinity], + }); + }); + }); + }); +}); diff --git a/packages/perseus-editor/src/components/__tests__/scrollless-number-text-field.test.tsx b/packages/perseus-editor/src/components/__tests__/scrollless-number-text-field.test.tsx new file mode 100644 index 0000000000..1bce852dff --- /dev/null +++ b/packages/perseus-editor/src/components/__tests__/scrollless-number-text-field.test.tsx @@ -0,0 +1,56 @@ +import {render, screen} from "@testing-library/react"; +import {userEvent as userEventLib} from "@testing-library/user-event"; +import * as React from "react"; + +import ScrolllessNumberTextField from "../scrollless-number-text-field"; + +import type {UserEvent} from "@testing-library/user-event"; + +describe("ScrolllessNumberTextField", () => { + let userEvent: UserEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + }); + + test("Should render a number input", () => { + // Arrange + const onChange = jest.fn(); + + // Act + render(); + + const input = screen.getByRole("spinbutton"); + + // Assert + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(42); + }); + + test("Should call the onChange callback when the value changes", async () => { + // Arrange + const onChange = jest.fn(); + render(); + const input = screen.getByRole("spinbutton"); + + // Act + await userEvent.type(input, "2"); + + // Assert + expect(onChange).toHaveBeenLastCalledWith("2"); + }); + + test("Should not call the onChange callback when the value is not a number", async () => { + // Arrange + const onChange = jest.fn(); + render(); + const input = screen.getByRole("spinbutton"); + + // Act + await userEvent.type(input, "a"); + + // Assert + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx index c0f3cb7cc9..fd169b014b 100644 --- a/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx @@ -523,4 +523,173 @@ describe("StartCoordSettings", () => { }, ); }); + + describe("point graph", () => { + test("shows the start coordinates UI: 1 point (default)", () => { + // Arrange + + // Act + render( + {}} + />, + {wrapper: RenderStateRoot}, + ); + + // Assert + expect(screen.getByText("Start coordinates")).toBeInTheDocument(); + expect(screen.getByText("Point 1:")).toBeInTheDocument(); + }); + + test("shows the start coordinates UI: 6 points", () => { + // Arrange + + // Act + render( + {}} + />, + {wrapper: RenderStateRoot}, + ); + + // Assert + expect(screen.getByText("Start coordinates")).toBeInTheDocument(); + expect(screen.getByText("Point 1:")).toBeInTheDocument(); + expect(screen.getByText("Point 2:")).toBeInTheDocument(); + expect(screen.getByText("Point 3:")).toBeInTheDocument(); + expect(screen.getByText("Point 4:")).toBeInTheDocument(); + expect(screen.getByText("Point 5:")).toBeInTheDocument(); + expect(screen.getByText("Point 6:")).toBeInTheDocument(); + }); + + test.each` + pointIndex | coord + ${0} | ${"x"} + ${0} | ${"y"} + ${1} | ${"x"} + ${1} | ${"y"} + `( + "calls onChange when $coord coord is changed (line $pointIndex)", + async ({pointIndex, coord}) => { + // Arrange + const onChangeMock = jest.fn(); + + // Act + render( + , + ); + + // Assert + const input = screen.getAllByRole("spinbutton", { + name: `${coord}`, + })[pointIndex]; + await userEvent.clear(input); + await userEvent.type(input, "101"); + + const expectedCoords = [ + [-5, 0], + [5, 0], + ]; + expectedCoords[pointIndex][coord === "x" ? 0 : 1] = 101; + + expect(onChangeMock).toHaveBeenLastCalledWith(expectedCoords); + }, + ); + }); + + describe("polygon graph", () => { + test("shows the start coordinates UI: 3 sides (default)", () => { + // Arrange + + // Act + render( + {}} + />, + {wrapper: RenderStateRoot}, + ); + + // Assert + expect(screen.getByText("Start coordinates")).toBeInTheDocument(); + expect(screen.getByText("Point 1:")).toBeInTheDocument(); + expect(screen.getByText("Point 2:")).toBeInTheDocument(); + expect(screen.getByText("Point 3:")).toBeInTheDocument(); + }); + + test("shows the start coordinates UI: 6 sides", () => { + // Arrange + + // Act + render( + {}} + />, + {wrapper: RenderStateRoot}, + ); + + // Assert + expect(screen.getByText("Start coordinates")).toBeInTheDocument(); + expect(screen.getByText("Point 1:")).toBeInTheDocument(); + expect(screen.getByText("Point 2:")).toBeInTheDocument(); + expect(screen.getByText("Point 3:")).toBeInTheDocument(); + expect(screen.getByText("Point 4:")).toBeInTheDocument(); + expect(screen.getByText("Point 5:")).toBeInTheDocument(); + expect(screen.getByText("Point 6:")).toBeInTheDocument(); + }); + + test.each` + pointIndex | coord + ${0} | ${"x"} + ${0} | ${"y"} + ${1} | ${"x"} + ${1} | ${"y"} + ${2} | ${"x"} + ${2} | ${"y"} + `( + "calls onChange when $coord coord is changed (line $pointIndex)", + async ({pointIndex, coord}) => { + // Arrange + const onChangeMock = jest.fn(); + render( + , + ); + + // Act + const input = screen.getAllByRole("spinbutton", { + name: `${coord}`, + })[pointIndex]; + await userEvent.clear(input); + await userEvent.type(input, "101"); + + // Assert + const expectedCoords = [ + [3, -2], + [0, 4], + [-3, -2], + ]; + expectedCoords[pointIndex][coord === "x" ? 0 : 1] = 101; + + expect(onChangeMock).toHaveBeenLastCalledWith(expectedCoords); + }, + ); + }); }); diff --git a/packages/perseus-editor/src/components/__tests__/util.test.ts b/packages/perseus-editor/src/components/__tests__/util.test.ts index bda3983a98..67dd1c28c7 100644 --- a/packages/perseus-editor/src/components/__tests__/util.test.ts +++ b/packages/perseus-editor/src/components/__tests__/util.test.ts @@ -256,6 +256,135 @@ describe("getDefaultGraphStartCoords", () => { expect(defaultCoords).toEqual({center: [0, 0], radius: 2}); }); + + test("should get default start coords for a sinusoid graph", () => { + // Arrange + const graph: PerseusGraphType = {type: "sinusoid"}; + const range = [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range]; + const step = [1, 1] satisfies [number, number]; + + // Act + const defaultCoords = getDefaultGraphStartCoords(graph, range, step); + + expect(defaultCoords).toEqual([ + [0, 0], + [3, 2], + ]); + }); + + test("should get default start coords for a quadratic graph", () => { + // Arrange + const graph: PerseusGraphType = {type: "quadratic"}; + const range = [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range]; + const step = [1, 1] satisfies [number, number]; + + // Act + const defaultCoords = getDefaultGraphStartCoords(graph, range, step); + + expect(defaultCoords).toEqual([ + [-5, 5], + [0, -5], + [5, 5], + ]); + }); + + test("should get default start coords for a point graph", () => { + // Arrange + const graph: PerseusGraphType = {type: "point"}; + const range = [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range]; + const step = [1, 1] satisfies [number, number]; + + // Act + const defaultCoords = getDefaultGraphStartCoords(graph, range, step); + + expect(defaultCoords).toEqual([[0, 0]]); + }); + + test("should get default start coords for a point graph with multiple points", () => { + // Arrange + const graph: PerseusGraphType = {type: "point", numPoints: 2}; + const range = [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range]; + const step = [1, 1] satisfies [number, number]; + + // Act + const defaultCoords = getDefaultGraphStartCoords(graph, range, step); + + expect(defaultCoords).toEqual([ + [-5, 0], + [5, 0], + ]); + }); + + test("should get default start coords for a polygon graph, triangle (default)", () => { + // Arrange + const graph: PerseusGraphType = {type: "polygon"}; + const range = [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range]; + const step = [1, 1] satisfies [number, number]; + + // Act + const defaultCoords = getDefaultGraphStartCoords(graph, range, step); + + expect(defaultCoords).toEqual([ + [3, -2], + [0, 4], + [-3, -2], + ]); + }); + + test("should get default start coords for a polygon graph, square", () => { + // Arrange + const graph: PerseusGraphType = {type: "polygon", numSides: 4}; + const range = [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range]; + const step = [1, 1] satisfies [number, number]; + + // Act + const defaultCoords = getDefaultGraphStartCoords(graph, range, step); + + expect(defaultCoords).toEqual([ + [3, -3], + [3, 3], + [-3, 3], + [-3, -3], + ]); + }); + + test("should get default start coords for an angle graph", () => { + // Arrange + const graph: PerseusGraphType = {type: "angle"}; + const range = [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range]; + const step = [1, 1] satisfies [number, number]; + + // Act + const defaultCoords = getDefaultGraphStartCoords(graph, range, step); + + // Default correct answer is 20 degree angle at (0, 0) + expect(defaultCoords).toEqual([ + [7, 0], + [0, 0], + [6.5778483455013586, 2.394141003279681], + ]); + }); }); describe("getSinusoidEquation", () => { diff --git a/packages/perseus-editor/src/components/angle-input.tsx b/packages/perseus-editor/src/components/angle-input.tsx index 67d6374c2f..77e99df55e 100644 --- a/packages/perseus-editor/src/components/angle-input.tsx +++ b/packages/perseus-editor/src/components/angle-input.tsx @@ -1,10 +1,10 @@ -import {TextField} from "@khanacademy/wonder-blocks-form"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; import {StyleSheet} from "aphrodite"; import * as React from "react"; +import ScrolllessNumberTextField from "./scrollless-number-text-field"; import {degreeToRadian, radianToDegree} from "./util"; type Props = { @@ -37,8 +37,7 @@ const AngleInput = (props: Props) => { angle (degrees) - { {labels ? labels[0] : "x coord"} - handleCoordChange(newValue, 0)} style={[ @@ -74,8 +74,7 @@ const CoordinatePairInput = (props: Props) => { {labels ? labels[1] : "y coord"} - handleCoordChange(newValue, 1)} style={[ diff --git a/packages/perseus-editor/src/components/graph-locked-figures/line-stroke-select.tsx b/packages/perseus-editor/src/components/graph-locked-figures/line-stroke-select.tsx new file mode 100644 index 0000000000..0ee8765bdd --- /dev/null +++ b/packages/perseus-editor/src/components/graph-locked-figures/line-stroke-select.tsx @@ -0,0 +1,44 @@ +import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; +import {StyleSheet} from "aphrodite"; +import * as React from "react"; + +export type StyleOptions = "solid" | "dashed"; +type Props = { + selectedValue: StyleOptions; + onChange: (newValue: string) => void; +}; + +const LineStrokeSelect = (props: Props) => { + const {selectedValue, onChange} = props; + + return ( + + stroke + + + + + + + ); +}; + +const styles = StyleSheet.create({ + lineStrokeSelect: { + display: "flex", + flexDirection: "row", + alignItems: "center", + // Allow truncation, stop bleeding over the edge. + minWidth: 0, + }, +}); + +export default LineStrokeSelect; diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-ellipse-settings.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-ellipse-settings.tsx index 5673ba369e..a87449f6ac 100644 --- a/packages/perseus-editor/src/components/graph-locked-figures/locked-ellipse-settings.tsx +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-ellipse-settings.tsx @@ -13,6 +13,7 @@ import PerseusEditorAccordion from "../perseus-editor-accordion"; import ColorSelect from "./color-select"; import EllipseSwatch from "./ellipse-swatch"; +import LineStrokeSelect from "./line-stroke-select"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; import type {LockedFigureSettingsCommonProps} from "./locked-figure-settings"; @@ -139,21 +140,12 @@ const LockedEllipseSettings = (props: Props) => { {/* Stroke style */} - - stroke - - - onChangeProps({strokeStyle: value}) - } - // Placeholder is required, but never gets used. - placeholder="" - > - - - - + + onChangeProps({strokeStyle: value}) + } + /> {/* Actions */} { @@ -68,6 +71,11 @@ const LockedFigureSettings = (props: Props) => { return ; case "polygon": return ; + case "function": + if (props.showM2bFeatures) { + return ; + } + break; } return null; diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx index d9c7582dd1..2e48b1dcdd 100644 --- a/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx @@ -159,12 +159,6 @@ const LockedFiguresSection = (props: Props) => { {isExpanded && ( {figures?.map((figure, index) => { - if (figure.type === "function") { - // TODO(LEMS-1947): Add locked function settings. - // Remove this block once function locked figure settings are - // implemented. - return; - } return ( ) => void; + }; + +const LockedFunctionSettings = (props: Props) => { + const { + color: lineColor, + strokeStyle, + equation, + directionalAxis, + domain, + onChangeProps, + onMove, + onRemove, + } = props; + const equationPrefix = directionalAxis === "x" ? "y=" : "x="; + const lineLabel = `Function (${equationPrefix}${equation})`; + + // Tracking the string value of domain constraints to handle interim state of + // entering a negative value, as well as representing Infinity as an empty string. + // This variable is used when specifying the values of the input fields. + const [domainEntries, setDomainEntries] = useState([ + domain && domain[0] !== -Infinity ? domain[0].toString() : "", + domain && domain[1] !== Infinity ? domain[1].toString() : "", + ]); + + useEffect(() => { + // "useEffect" used to maintain parity between domain constraints and their string representation. + setDomainEntries([ + domain && domain[0] !== -Infinity ? domain[0].toString() : "", + domain && domain[1] !== Infinity ? domain[1].toString() : "", + ]); + }, [domain]); + + // Generic function for handling property changes (except for 'domain') + function handlePropChange(property: string, newValue: string) { + const updatedProps: Partial = {}; + updatedProps[property] = newValue; + onChangeProps(updatedProps); + } + + /* + Reason for having a separate 'propChange' function for 'domain': + Domain entries are optional. Their default value is +/- Infinity. + Since input fields that are empty evaluate to zero, there needs to be + dedicated code to convert empty to Infinity. + */ + function handleDomainChange(limitIndex: number, newValueString: string) { + const newDomainEntries = [...domainEntries]; + newDomainEntries[limitIndex] = newValueString; + setDomainEntries(newDomainEntries); + const newDomain: Interval = domain + ? [...domain] + : [-Infinity, Infinity]; + + let newValue = parseFloat(newValueString); + if (newValueString === "" && limitIndex === 0) { + newValue = -Infinity; + } else if (newValueString === "" && limitIndex === 1) { + newValue = Infinity; + } + newDomain[limitIndex] = newValue; + onChangeProps({domain: newDomain}); + } + + return ( + + + {lineLabel} + + + + + } + > + + {/* Line color settings */} + { + handlePropChange("color", newValue); + }} + /> + + + {/* Line style settings */} + { + handlePropChange("strokeStyle", newValue); + }} + /> + + + + {/* Directional axis (x or y) */} + { + handlePropChange("directionalAxis", newValue); + }} + aria-label="equation prefix" + style={styles.equationPrefix} + // Placeholder is required, but never gets used. + placeholder="" + > + + + + + {/* Equation entry */} + { + handlePropChange("equation", newValue); + }} + style={[styles.textField]} + /> + + + {/* Domain restrictions */} + + + {"domain min"} + + + { + handleDomainChange(0, newValue); + }} + /> + + + + {"max"} + + + { + handleDomainChange(1, newValue); + }} + /> + + + + {/* Actions */} + + + ); +}; + +const styles = StyleSheet.create({ + accordionHeader: { + textOverflow: "ellipsis", + // A maximum width needs to be specified in order for the ellipsis to work. + // The 64px in the calculation accounts for the line swatch (56px) and the preceding strut (8px). + // Margin and padding won't work here because they would create space between the header text and the swatch. + maxWidth: "calc(100% - 64px)", + overflow: "hidden", + whiteSpace: "nowrap", + }, + equationPrefix: { + minWidth: "auto", + }, + domainMin: { + alignItems: "center", + display: "flex", + // The 'width' property is applied to the label, which wraps the text and the input field. + // The width of the input fields (min/max) should be the same (to have a consistent look), + // so the following calculation distributes the space accordingly. + // For the "domain min" block, the text is 82.7px, and the strut is 6px (88.7px total). + // The "domain max" block is 30.23px, and the strut is 6px (36.23px total). + // The calculation takes the remain space after the text & struts (141px total) are removed, + // and divides it between the two input fields equally. + // The calculation reads: "Take 1/2 of the non-text space, and add the required space for this label's text" + width: "calc(((100% - 141px) / 2) + 88.7px)", + // @ts-expect-error // TS2353: textWrap does not exist in type CSSProperties + textWrap: "nowrap", + }, + domainMinField: { + width: "calc(100% - 88.7px)", // make room for the label + }, + domainMax: { + alignItems: "center", + display: "flex", + // See explanation for "domainMin" for the calculation below. + width: "calc(((100% - 141px) / 2) + 36.2px)", + }, + domainMaxField: { + width: "calc(100% - 36.2px)", // make room for the label + }, + rowSpace: { + marginTop: spacing.xSmall_8, + }, + row: { + display: "flex", + flexDirection: "row", + alignItems: "center", + }, + textField: { + flexGrow: "1", + }, +}); + +export default LockedFunctionSettings; diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-line-settings.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-line-settings.tsx index 29f220b4ec..b1afeba30f 100644 --- a/packages/perseus-editor/src/components/graph-locked-figures/locked-line-settings.tsx +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-line-settings.tsx @@ -17,6 +17,7 @@ import PerseusEditorAccordion from "../perseus-editor-accordion"; import ColorSelect from "./color-select"; import DefiningPointSettings from "./defining-point-settings"; +import LineStrokeSelect from "./line-stroke-select"; import LineSwatch from "./line-swatch"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; @@ -129,25 +130,12 @@ const LockedLineSettings = (props: Props) => { {/* Line style settings */} - - style - - - onChangeProps({lineStyle: value}) - } - // Placeholder is required, but never gets used. - placeholder="" - style={styles.selectMarginOffset} - > - - - - + + onChangeProps({lineStyle: value}) + } + /> {/* Points error message */} @@ -198,17 +186,9 @@ const styles = StyleSheet.create({ spaceUnder: { marginBottom: spacing.xSmall_8, }, - selectMarginOffset: { - // Align with the point settings accordions. - marginInlineEnd: -spacing.xxxSmall_4, - }, errorText: { color: wbColor.red, }, - truncatedWidth: { - // Allow truncation, stop bleeding over the edge. - minWidth: 0, - }, }); export default LockedLineSettings; diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx index af73527d4a..ea0328ae6d 100644 --- a/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-polygon-settings.tsx @@ -22,6 +22,7 @@ import PerseusEditorAccordion from "../perseus-editor-accordion"; import ColorSelect from "./color-select"; import LabeledSwitch from "./labeled-switch"; +import LineStrokeSelect from "./line-stroke-select"; import LockedFigureSettingsActions from "./locked-figure-settings-actions"; import PolygonSwatch from "./polygon-swatch"; @@ -105,21 +106,12 @@ const LockedPolygonSettings = (props: Props) => { {/* Stroke style */} - - stroke - - - onChangeProps({strokeStyle: value}) - } - // Placeholder is required, but never gets used. - placeholder="" - > - - - - + + onChangeProps({strokeStyle: value}) + } + /> {/* Show vertices switch */} { return ( {/* Give the points alphabet labels */} diff --git a/packages/perseus-editor/src/components/graph-settings.tsx b/packages/perseus-editor/src/components/graph-settings.tsx index 17ee3c5f85..b65498183f 100644 --- a/packages/perseus-editor/src/components/graph-settings.tsx +++ b/packages/perseus-editor/src/components/graph-settings.tsx @@ -10,13 +10,14 @@ import { KhanMath, Util, } from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import createReactClass from "create-react-class"; import PropTypes from "prop-types"; import * as React from "react"; import ReactDOM from "react-dom"; import _ from "underscore"; -const {ButtonGroup, InfoTip, PropCheckBox, RangeInput} = components; +const {ButtonGroup, InfoTip, RangeInput} = components; const defaultBackgroundImage = { url: null, @@ -521,10 +522,12 @@ const GraphSettings = createReactClass({ />
- { + this.change({showTooltips: value}); + }} />
@@ -566,17 +569,21 @@ const GraphSettings = createReactClass({
- { + this.change({showRuler: value}); + }} />
- { + this.change({showProtractor: value}); + }} />
diff --git a/packages/perseus-editor/src/components/interactive-graph-settings.tsx b/packages/perseus-editor/src/components/interactive-graph-settings.tsx index 4af8a5d37f..7d957f7fec 100644 --- a/packages/perseus-editor/src/components/interactive-graph-settings.tsx +++ b/packages/perseus-editor/src/components/interactive-graph-settings.tsx @@ -10,6 +10,7 @@ import { } from "@khanacademy/perseus"; import Banner from "@khanacademy/wonder-blocks-banner"; import {View} from "@khanacademy/wonder-blocks-core"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; import {css, StyleSheet} from "aphrodite"; import * as React from "react"; @@ -20,7 +21,7 @@ import Heading from "./heading"; import type {PerseusImageBackground} from "@khanacademy/perseus"; -const {ButtonGroup, InfoTip, PropCheckBox, RangeInput} = components; +const {ButtonGroup, InfoTip, RangeInput} = components; const defaultBackgroundImage = { url: null, @@ -547,10 +548,12 @@ class InteractiveGraphSettings extends React.Component {
- { + this.change({showTooltips: value}); + }} />
@@ -585,10 +588,12 @@ class InteractiveGraphSettings extends React.Component { - { + this.change({showProtractor: value}); + }} style={styles.resetSpaceTop} /> diff --git a/packages/perseus-editor/src/components/scrollless-number-text-field.tsx b/packages/perseus-editor/src/components/scrollless-number-text-field.tsx new file mode 100644 index 0000000000..53553c0e64 --- /dev/null +++ b/packages/perseus-editor/src/components/scrollless-number-text-field.tsx @@ -0,0 +1,45 @@ +import {TextField} from "@khanacademy/wonder-blocks-form"; +import * as React from "react"; + +import type {PropsFor} from "@khanacademy/wonder-blocks-core"; + +/** + * This is a custom text field of type="number" for use in Perseus Editors. + * + * This makes it so that the text field's input number updates on scroll + * without scrolling the page. + * + * NOTE 1: Native HTML number inputs do not update the number value on scroll, + * they only scroll the page. Inputs in React do NOT work this way (explanation + * here: https://stackoverflow.com/a/68266494). By default, scrolling on a + * focused number input in React causes BOTH the input value to change AND + * the page to scroll. The behavior in this component is an improvement on + * the React behavior, but it's the opposite of the native HTML behavior. + * + * NOTE 2: Firefox seems to have a custom override for this. Even with this + * stopPropogation, Firefox matches the native HTML behavior. + */ +const ScrolllessNumberTextField = (props: PropsFor) => { + const inputRef = React.useRef(null); + + React.useEffect(() => { + const ref = inputRef.current; + + // stopPropogation makes it so that the page scroll event is not + // triggered when the input is focused and the user scrolls. + // The input value will still change on scroll. + const ignoreScroll = (e) => { + e.stopPropagation(); + }; + + ref?.addEventListener("wheel", ignoreScroll); + + return () => { + ref?.removeEventListener("wheel", ignoreScroll); + }; + }, [inputRef]); + + return ; +}; + +export default ScrolllessNumberTextField; diff --git a/packages/perseus-editor/src/components/start-coords-angle.tsx b/packages/perseus-editor/src/components/start-coords-angle.tsx new file mode 100644 index 0000000000..34f8f0f0f3 --- /dev/null +++ b/packages/perseus-editor/src/components/start-coords-angle.tsx @@ -0,0 +1,28 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import * as React from "react"; + +import type {Coord, PerseusGraphType} from "@khanacademy/perseus"; + +type Props = { + startCoords: [Coord, Coord, Coord]; + onChange: (startCoords: PerseusGraphType["startCoords"]) => void; +}; + +const StartCoordsAngle = (props: Props) => { + const {startCoords} = props; + return ( + + WIP +
Start coords: + {startCoords.map((coord, index) => { + return ( + + {`Point ${index + 1}: (${coord[0]}, ${coord[1]})`} + + ); + })} +
+ ); +}; + +export default StartCoordsAngle; diff --git a/packages/perseus-editor/src/components/start-coords-circle.tsx b/packages/perseus-editor/src/components/start-coords-circle.tsx index d3eadf1ec7..d494dd06b9 100644 --- a/packages/perseus-editor/src/components/start-coords-circle.tsx +++ b/packages/perseus-editor/src/components/start-coords-circle.tsx @@ -1,5 +1,4 @@ import {View} from "@khanacademy/wonder-blocks-core"; -import {TextField} from "@khanacademy/wonder-blocks-form"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; @@ -7,6 +6,7 @@ import {StyleSheet} from "aphrodite"; import * as React from "react"; import CoordinatePairInput from "./coordinate-pair-input"; +import ScrolllessNumberTextField from "./scrollless-number-text-field"; import type {Coord, PerseusGraphType} from "@khanacademy/perseus"; @@ -15,7 +15,6 @@ type Props = { center: Coord; radius: number; }; - // center: number; onChange: (startCoords: PerseusGraphType["startCoords"]) => void; }; @@ -42,9 +41,8 @@ const StartCoordsCircle = (props: Props) => { Radius: - { onChange({ center: startCoords.center, diff --git a/packages/perseus-editor/src/components/start-coords-point.tsx b/packages/perseus-editor/src/components/start-coords-point.tsx new file mode 100644 index 0000000000..604c5def8e --- /dev/null +++ b/packages/perseus-editor/src/components/start-coords-point.tsx @@ -0,0 +1,54 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; +import {StyleSheet} from "aphrodite"; +import * as React from "react"; + +import CoordinatePairInput from "./coordinate-pair-input"; + +import type {Coord, PerseusGraphType} from "@khanacademy/perseus"; + +type Props = { + startCoords: ReadonlyArray; + onChange: (startCoords: PerseusGraphType["startCoords"]) => void; +}; + +const StartCoordsPoint = (props: Props) => { + const {startCoords, onChange} = props; + + return ( + <> + {startCoords.map((coord, index) => { + return ( + + {`Point ${index + 1}:`} + + { + const newStartCoords = [...startCoords]; + newStartCoords[index] = newCoord; + onChange(newStartCoords); + }} + /> + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + tile: { + backgroundColor: color.fadedBlue8, + marginTop: spacing.xSmall_8, + padding: spacing.small_12, + borderRadius: spacing.xSmall_8, + flexDirection: "row", + alignItems: "center", + }, +}); + +export default StartCoordsPoint; diff --git a/packages/perseus-editor/src/components/start-coords-settings.tsx b/packages/perseus-editor/src/components/start-coords-settings.tsx index a34170fbc6..6018780a5f 100644 --- a/packages/perseus-editor/src/components/start-coords-settings.tsx +++ b/packages/perseus-editor/src/components/start-coords-settings.tsx @@ -1,8 +1,11 @@ import {vector as kvector} from "@khanacademy/kmath"; import { + getAngleCoords, getCircleCoords, getLineCoords, getLinearSystemCoords, + getPointCoords, + getPolygonCoords, getQuadraticCoords, getSegmentCoords, getSinusoidCoords, @@ -15,9 +18,11 @@ import arrowCounterClockwise from "@phosphor-icons/core/bold/arrow-counter-clock import * as React from "react"; import Heading from "./heading"; +import StartCoordsAngle from "./start-coords-angle"; import StartCoordsCircle from "./start-coords-circle"; import StartCoordsLine from "./start-coords-line"; import StartCoordsMultiline from "./start-coords-multiline"; +import StartCoordsPoint from "./start-coords-point"; import StartCoordsQuadratic from "./start-coords-quadratic"; import StartCoordsSinusoid from "./start-coords-sinusoid"; import {getDefaultGraphStartCoords} from "./util"; @@ -89,6 +94,27 @@ const StartCoordsSettingsInner = (props: Props) => { onChange={onChange} /> ); + // Graphs with startCoords of type ReadonlyArray + case "point": + case "polygon": + const pointCoords = + type === "point" + ? getPointCoords(props, range, step) + : getPolygonCoords(props, range, step); + return ( + + ); + case "angle": + const angleCoords = getAngleCoords({graph: props, range, step}); + return ( + + ); default: return null; } diff --git a/packages/perseus-editor/src/components/util.ts b/packages/perseus-editor/src/components/util.ts index bfeeb3c239..3b15594fd7 100644 --- a/packages/perseus-editor/src/components/util.ts +++ b/packages/perseus-editor/src/components/util.ts @@ -1,8 +1,11 @@ import {vector as kvector} from "@khanacademy/kmath"; import { + getAngleCoords, getCircleCoords, getLineCoords, getLinearSystemCoords, + getPointCoords, + getPolygonCoords, getQuadraticCoords, getSegmentCoords, getSinusoidCoords, @@ -194,6 +197,24 @@ export function getDefaultGraphStartCoords( range, step, ); + case "point": + return getPointCoords( + {...graph, startCoords: undefined}, + range, + step, + ); + case "polygon": + return getPolygonCoords( + {...graph, startCoords: undefined}, + range, + step, + ); + case "angle": + return getAngleCoords({ + graph: {...graph, startCoords: undefined}, + range, + step, + }); default: return undefined; } @@ -253,3 +274,54 @@ export const getQuadraticEquation = (startCoords: [Coord, Coord, Coord]) => { "y = " + a.toFixed(3) + "x^2 + " + b.toFixed(3) + "x + " + c.toFixed(3) ); }; + +export const shouldShowStartCoordsUI = (flags, graph) => { + // TODO(LEMS-2228): Remove flags once this is fully released + const startCoordsUiPhase1Types = [ + "linear", + "linear-system", + "ray", + "segment", + "circle", + ]; + const startCoordsUiPhase2Types = ["sinusoid", "quadratic"]; + + const startCoordsPhase1 = flags?.mafs?.["start-coords-ui-phase-1"]; + const startCoordsPhase2 = flags?.mafs?.["start-coords-ui-phase-2"]; + const startCoordsPoint = flags?.mafs?.["start-coords-ui-point"]; + const startCoordsPolygon = flags?.mafs?.["start-coords-ui-polygon"]; + const startCoordsAngle = flags?.mafs?.["start-coords-ui-angle"]; + + if (startCoordsPhase1 && startCoordsUiPhase1Types.includes(graph.type)) { + return true; + } + + if (startCoordsPhase2 && startCoordsUiPhase2Types.includes(graph.type)) { + return true; + } + + if (startCoordsAngle && graph.type === "angle") { + return true; + } + + if ( + startCoordsPoint && + graph.type === "point" && + graph.numPoints !== "unlimited" + ) { + return true; + } + + if ( + startCoordsPolygon && + graph.type === "polygon" && + graph.numSides !== "unlimited" && + // Pre-initialized graph with undefined snapTo value + // initializes to snapTo="grid" + (graph.snapTo === "grid" || graph.snapTo === undefined) + ) { + return true; + } + + return false; +}; diff --git a/packages/perseus-editor/src/editor.tsx b/packages/perseus-editor/src/editor.tsx index 2024467be4..0a976964a8 100644 --- a/packages/perseus-editor/src/editor.tsx +++ b/packages/perseus-editor/src/editor.tsx @@ -24,7 +24,11 @@ import WidgetEditor from "./components/widget-editor"; import WidgetSelect from "./components/widget-select"; import TexErrorView from "./tex-error-view"; -import type {ChangeHandler, PerseusWidget} from "@khanacademy/perseus"; +import type { + ChangeHandler, + ImageUploader, + PerseusWidget, +} from "@khanacademy/perseus"; // like [[snowman input-number 1]] const widgetPlaceholder = "[[\u2603 {id}]]"; @@ -122,10 +126,7 @@ type Props = Readonly<{ showWordCount: boolean; warnNoPrompt: boolean; warnNoWidgets: boolean; - imageUploader?: ( - file: string, - callback: (url: string) => unknown, - ) => unknown; + imageUploader?: ImageUploader; onChange: ChangeHandler; }>; diff --git a/packages/perseus-editor/src/hint-editor.tsx b/packages/perseus-editor/src/hint-editor.tsx index 82d13f747c..c9fa8fc345 100644 --- a/packages/perseus-editor/src/hint-editor.tsx +++ b/packages/perseus-editor/src/hint-editor.tsx @@ -19,16 +19,12 @@ import type { Hint, ChangeHandler, DeviceType, + ImageUploader, } from "@khanacademy/perseus"; const {InfoTip, InlineIcon} = components; const {iconCircleArrowDown, iconCircleArrowUp, iconPlus, iconTrash} = icons; -type ImageUploader = ( - file: string, - callback: (url: string) => unknown, -) => unknown; - type HintEditorProps = { itemId?: string; apiOptions?: APIOptions; @@ -322,14 +318,13 @@ class CombinedHintsEditor extends React.Component { silent: boolean, ) => { // TODO(joel) - lens - const hints = _(this.props.hints).clone(); + const hints = [...this.props.hints]; hints[i] = _.extend( {}, this.serializeHint(i, {keepDeletedWidgets: true}), newProps, ); - // @ts-expect-error - TS2740 - Type 'Hint' is missing the following properties from type 'readonly Hint[]': length, concat, join, slice, and 18 more. this.props.onChange({hints: hints}, cb, silent); }; @@ -339,10 +334,8 @@ class CombinedHintsEditor extends React.Component { return; } - const hints = _(this.props.hints).clone(); - // @ts-expect-error - TS2339 - Property 'splice' does not exist on type 'Hint'. + const hints = [...this.props.hints]; hints.splice(i, 1); - // @ts-expect-error - TS2322 - Type 'Hint' is not assignable to type 'readonly Hint[]'. this.props.onChange({hints: hints}); }; @@ -350,12 +343,9 @@ class CombinedHintsEditor extends React.Component { i: number, dir: number, ) => { - const hints = _(this.props.hints).clone(); - // @ts-expect-error - TS2339 - Property 'splice' does not exist on type 'Hint'. + const hints = [...this.props.hints]; const hint = hints.splice(i, 1)[0]; - // @ts-expect-error - TS2339 - Property 'splice' does not exist on type 'Hint'. hints.splice(i + dir, 0, hint); - // @ts-expect-error - TS2322 - Type 'Hint' is not assignable to type 'readonly Hint[]'. this.props.onChange({hints: hints}, () => { // eslint-disable-next-line react/no-string-refs // @ts-expect-error - TS2339 - Property 'focus' does not exist on type 'ReactInstance'. @@ -364,10 +354,9 @@ class CombinedHintsEditor extends React.Component { }; addHint: () => void = () => { - const hints = _(this.props.hints) - .clone() - // @ts-expect-error - TS2339 - Property 'concat' does not exist on type 'Hint'. - .concat([{content: ""}]); + const hints = this.props.hints.concat([ + {content: "", images: {}, widgets: {}}, + ]); this.props.onChange({hints: hints}, () => { const i = hints.length - 1; // eslint-disable-next-line react/no-string-refs diff --git a/packages/perseus-editor/src/item-editor.tsx b/packages/perseus-editor/src/item-editor.tsx index b73f7c130a..e3c6d6ab57 100644 --- a/packages/perseus-editor/src/item-editor.tsx +++ b/packages/perseus-editor/src/item-editor.tsx @@ -12,6 +12,7 @@ import type { ImageUploader, ChangeHandler, DeviceType, + PerseusRenderer, } from "@khanacademy/perseus"; const ITEM_DATA_VERSION = itemDataVersion; @@ -22,7 +23,7 @@ type Props = { gradeMessage?: string; imageUploader?: ImageUploader; wasAnswered?: boolean; - question?: any; + question?: PerseusRenderer; answerArea?: any; // URL of the route to show on initial load of the preview frames. previewURL: string; diff --git a/packages/perseus-editor/src/widgets/__stories__/radio-editor.stories.tsx b/packages/perseus-editor/src/widgets/__stories__/radio-editor.stories.tsx index a147290f73..71596a5155 100644 --- a/packages/perseus-editor/src/widgets/__stories__/radio-editor.stories.tsx +++ b/packages/perseus-editor/src/widgets/__stories__/radio-editor.stories.tsx @@ -9,12 +9,11 @@ import type { PerseusRenderer, APIOptions, } from "@khanacademy/perseus"; +import type {Meta, StoryObj} from "@storybook/react"; -type StoryArgs = Record; +type StoryArgs = StoryObj; -type Story = { - title: string; -}; +type Story = Meta; export default { title: "PerseusEditor/Widgets/Radio Editor", diff --git a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx index 7dfb80b1c4..5a3dc92cca 100644 --- a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor-locked-figures.test.tsx @@ -72,12 +72,13 @@ describe("InteractiveGraphEditor locked figures", () => { // Basic functionality describe.each` - figureType | figureName - ${"point"} | ${"Point"} - ${"line"} | ${"Line"} - ${"vector"} | ${"Vector"} - ${"ellipse"} | ${"Ellipse"} - ${"polygon"} | ${"Polygon"} + figureType | figureName + ${"point"} | ${"Point"} + ${"line"} | ${"Line"} + ${"vector"} | ${"Vector"} + ${"ellipse"} | ${"Ellipse"} + ${"polygon"} | ${"Polygon"} + ${"function"} | ${"Function"} `(`$figureType basics`, ({figureType, figureName}) => { test("Calls onChange when a locked $figureType is added", async () => { // Arrange @@ -558,7 +559,7 @@ describe("InteractiveGraphEditor locked figures", () => { // Act const styleInput = screen.getByRole("button", { - name: "style", + name: "stroke", }); await userEvent.click(styleInput); const styleSelection = screen.getByText("dashed"); diff --git a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx index b90e75d301..4b10a9940f 100644 --- a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx @@ -673,6 +673,8 @@ describe("InteractiveGraphEditor", () => { ); }); + // TODO(LEMS-2228): Remove flag-related code once + // start coords UI is rolled out 100% test.each` type | shouldRender ${"linear"} | ${true} @@ -700,8 +702,11 @@ describe("InteractiveGraphEditor", () => { ...flags, mafs: { ...flags.mafs, - "start-coords-ui-phase-1": shouldRender, + "start-coords-ui-phase-1": true, "start-coords-ui-phase-2": false, + "start-coords-ui-point": false, + "start-coords-ui-polygon": false, + "start-coords-ui-angle": false, }, }, }} @@ -737,7 +742,7 @@ describe("InteractiveGraphEditor", () => { ${"linear-system"} | ${false} ${"segment"} | ${false} ${"circle"} | ${false} - ${"quadratic"} | ${false} + ${"quadratic"} | ${true} ${"sinusoid"} | ${true} ${"polygon"} | ${false} ${"angle"} | ${false} @@ -758,7 +763,190 @@ describe("InteractiveGraphEditor", () => { mafs: { ...flags.mafs, "start-coords-ui-phase-1": false, - "start-coords-ui-phase-2": shouldRender, + "start-coords-ui-phase-2": true, + "start-coords-ui-point": false, + "start-coords-ui-polygon": false, + "start-coords-ui-angle": false, + }, + }, + }} + graph={{type}} + correct={{type}} + />, + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + if (shouldRender) { + expect( + await screen.findByRole("button", { + name: "Use default start coordinates", + }), + ).toBeInTheDocument(); + } else { + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + } + }, + ); + + test.each` + type | shouldRender + ${"linear"} | ${false} + ${"ray"} | ${false} + ${"linear-system"} | ${false} + ${"segment"} | ${false} + ${"circle"} | ${false} + ${"quadratic"} | ${false} + ${"sinusoid"} | ${false} + ${"polygon"} | ${false} + ${"angle"} | ${false} + ${"point"} | ${true} + `( + "should render for $type graphs if point flag is on: $shouldRender", + async ({type, shouldRender}) => { + // Arrange + + // Act + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + if (shouldRender) { + expect( + await screen.findByRole("button", { + name: "Use default start coordinates", + }), + ).toBeInTheDocument(); + } else { + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + } + }, + ); + + test.each` + type | shouldRender + ${"linear"} | ${false} + ${"ray"} | ${false} + ${"linear-system"} | ${false} + ${"segment"} | ${false} + ${"circle"} | ${false} + ${"quadratic"} | ${false} + ${"sinusoid"} | ${false} + ${"polygon"} | ${true} + ${"angle"} | ${false} + ${"point"} | ${false} + `( + "should render for $type graphs if polygon flag is on: $shouldRender", + async ({type, shouldRender}) => { + // Arrange + + // Act + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + if (shouldRender) { + expect( + await screen.findByRole("button", { + name: "Use default start coordinates", + }), + ).toBeInTheDocument(); + } else { + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + } + }, + ); + + test.each` + type | shouldRender + ${"linear"} | ${false} + ${"ray"} | ${false} + ${"linear-system"} | ${false} + ${"segment"} | ${false} + ${"circle"} | ${false} + ${"quadratic"} | ${false} + ${"sinusoid"} | ${false} + ${"polygon"} | ${false} + ${"angle"} | ${true} + ${"point"} | ${false} + `( + "should render for $type graphs if angle flag is on: $shouldRender", + async ({type, shouldRender}) => { + // Arrange + + // Act + render( + { } }, ); + + test("should not render for point graphs with unlimited points", async () => { + // Arrange + + // Act + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + }); + + test("should not render for polygon graphs with unlimited sides", async () => { + // Arrange + + // Act + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + }); + + test("should not render for polygon graphs with non-grid snapTo (angles)", async () => { + // Arrange + + // Act + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + }); + + test("should not render for polygon graphs with non-grid snapTo (sides)", async () => { + // Arrange + + // Act + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + }); }); diff --git a/packages/perseus-editor/src/widgets/__tests__/numeric-input-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/numeric-input-editor.test.tsx index 738f5beeea..0858fdc222 100644 --- a/packages/perseus-editor/src/widgets/__tests__/numeric-input-editor.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/numeric-input-editor.test.tsx @@ -84,6 +84,33 @@ describe("numeric-input-editor", () => { expect(onChangeMock).toBeCalledWith({coefficient: true}); }); + it("should be possible to select strictly match only these formats", async () => { + const onChangeMock = jest.fn(); + + render(); + + await userEvent.click(screen.getByLabelText("Toggle options")); + await userEvent.click( + screen.getByRole("checkbox", { + name: "Strictly match only these formats", + }), + ); + + expect(onChangeMock).toBeCalledWith({ + answers: [ + { + answerForms: [], + maxError: null, + message: "", + simplify: "required", + status: "correct", + strict: true, + value: null, + }, + ], + }); + }); + it("should be possible to update label text", async () => { const onChangeMock = jest.fn(); diff --git a/packages/perseus-editor/src/widgets/categorizer-editor.tsx b/packages/perseus-editor/src/widgets/categorizer-editor.tsx index 37d029f3d1..d2425ffaff 100644 --- a/packages/perseus-editor/src/widgets/categorizer-editor.tsx +++ b/packages/perseus-editor/src/widgets/categorizer-editor.tsx @@ -5,11 +5,12 @@ import { Changeable, EditorJsonify, } from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; -const {PropCheckBox, TextListEditor} = components; +const {TextListEditor} = components; const Categorizer = CategorizerWidget.widget; type Props = any; @@ -45,11 +46,12 @@ class CategorizerEditor extends React.Component { return (
- { + this.props.onChange({randomizeItems: value}); + }} />
Categories: diff --git a/packages/perseus-editor/src/widgets/cs-program-editor.tsx b/packages/perseus-editor/src/widgets/cs-program-editor.tsx index 384fa89418..adf4dd4369 100644 --- a/packages/perseus-editor/src/widgets/cs-program-editor.tsx +++ b/packages/perseus-editor/src/widgets/cs-program-editor.tsx @@ -11,6 +11,7 @@ import { Log, } from "@khanacademy/perseus"; import {Errors} from "@khanacademy/perseus-core"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import $ from "jquery"; import PropTypes from "prop-types"; import * as React from "react"; @@ -18,7 +19,7 @@ import _ from "underscore"; import BlurInput from "../components/blur-input"; -const {InfoTip, PropCheckBox} = components; +const {InfoTip} = components; const DEFAULT_WIDTH = 400; const DEFAULT_HEIGHT = 400; @@ -220,20 +221,24 @@ class CSProgramEditor extends React.Component { />
- { + this.props.onChange({showEditor: value}); + }} /> If you show the editor, you should use the "full-width" alignment to make room for the width of the editor.
- { + this.props.onChange({showButtons: value}); + }} />
- { + this.props.onChange({times: value}); + }} />

@@ -542,11 +543,12 @@ class AnswerOption extends React.Component<

- { + this.props.onChange({form: value}); + }} />

@@ -558,12 +560,12 @@ class AnswerOption extends React.Component<

- { + this.props.onChange({simplify: value}); + }} />

diff --git a/packages/perseus-editor/src/widgets/iframe-editor.tsx b/packages/perseus-editor/src/widgets/iframe-editor.tsx index ad95bb5d74..de45b219b9 100644 --- a/packages/perseus-editor/src/widgets/iframe-editor.tsx +++ b/packages/perseus-editor/src/widgets/iframe-editor.tsx @@ -1,14 +1,13 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable react/sort-comp */ -import {components, Changeable, EditorJsonify} from "@khanacademy/perseus"; +import {Changeable, EditorJsonify} from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; import BlurInput from "../components/blur-input"; -const {PropCheckBox} = components; - type PairEditorProps = any; /** @@ -174,16 +173,20 @@ class IframeEditor extends React.Component { onChange={this.change("height")} /> - { + this.props.onChange({allowFullScreen: value}); + }} />
- { + this.props.onChange({allowTopNavigation: value}); + }} />

); diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx index 58ebbdd6e5..914b0be911 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx @@ -25,6 +25,7 @@ import {InteractiveGraphCorrectAnswer} from "../components/interactive-graph-cor import InteractiveGraphSettings from "../components/interactive-graph-settings"; import SegmentCountSelector from "../components/segment-count-selector"; import StartCoordsSettings from "../components/start-coords-settings"; +import {shouldShowStartCoordsUI} from "../components/util"; import {parsePointCount} from "../util/points"; import type { @@ -62,16 +63,6 @@ const POLYGON_SIDES = _.map(_.range(3, 13), function (value) { ); }); -// TODO(LEMS-2228): Remove flags once this is fully released -const startCoordsUiPhase1Types = [ - "linear", - "linear-system", - "ray", - "segment", - "circle", -]; -const startCoordsUiPhase2Types = ["sinusoid", "quadratic"]; - type Range = [min: number, max: number]; export type Props = { @@ -270,18 +261,6 @@ class InteractiveGraphEditor extends React.Component { graph =
{this.props.valid}
; } - const startCoordsPhase1 = - this.props.apiOptions?.flags?.mafs?.["start-coords-ui-phase-1"]; - const startCoordsPhase2 = - this.props.apiOptions?.flags?.mafs?.["start-coords-ui-phase-2"]; - - const displayStartCoordsUI = - this.props.graph && - ((startCoordsPhase1 && - startCoordsUiPhase1Types.includes(this.props.graph.type)) || - (startCoordsPhase2 && - startCoordsUiPhase2Types.includes(this.props.graph.type))); - return ( @@ -375,7 +354,10 @@ class InteractiveGraphEditor extends React.Component { coords: null, }; - this.props.onChange({correct: graph}); + this.props.onChange({ + correct: graph, + graph: graph, + }); }} style={styles.singleSelectShort} > @@ -496,7 +478,10 @@ class InteractiveGraphEditor extends React.Component { )} {this.props.graph?.type && // TODO(LEMS-2228): Remove flags once this is fully released - displayStartCoordsUI && ( + shouldShowStartCoordsUI( + this.props.apiOptions.flags, + this.props.graph, + ) && ( { />
- { + this.props.onChange({orderMatters: value}); + }} />

@@ -100,10 +103,12 @@ class MatcherEditor extends React.Component {

- { + this.props.onChange({padding: value}); + }} />

diff --git a/packages/perseus-editor/src/widgets/measurer-editor.tsx b/packages/perseus-editor/src/widgets/measurer-editor.tsx index 1b4060c9ea..67251326b5 100644 --- a/packages/perseus-editor/src/widgets/measurer-editor.tsx +++ b/packages/perseus-editor/src/widgets/measurer-editor.tsx @@ -1,11 +1,12 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable react/sort-comp */ import {components, Changeable, EditorJsonify} from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; -const {InfoTip, NumberInput, PropCheckBox, RangeInput} = components; +const {InfoTip, NumberInput, RangeInput} = components; const defaultImage = { url: null, @@ -103,17 +104,21 @@ class MeasurerEditor extends React.Component {

- { + this.props.onChange({showRuler: value}); + }} />
- { + this.props.onChange({showProtractor: value}); + }} />
diff --git a/packages/perseus-editor/src/widgets/number-line-editor.tsx b/packages/perseus-editor/src/widgets/number-line-editor.tsx index 088b612b08..b8575da54f 100644 --- a/packages/perseus-editor/src/widgets/number-line-editor.tsx +++ b/packages/perseus-editor/src/widgets/number-line-editor.tsx @@ -1,12 +1,12 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ import {number as knumber} from "@khanacademy/kmath"; import {components, EditorJsonify} from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; -const {ButtonGroup, InfoTip, NumberInput, PropCheckBox, RangeInput} = - components; +const {ButtonGroup, InfoTip, NumberInput, RangeInput} = components; type Range = [number, number]; @@ -365,28 +365,34 @@ class NumberLineEditor extends React.Component {
{!this.props.static && (
- { + this.props.onChange({isTickCtrl: value}); + }} />
)}
- { + this.props.onChange({labelTicks: value}); + }} />
{!this.props.static && ( - { + this.props.onChange({showTooltips: value}); + }} /> )}
diff --git a/packages/perseus-editor/src/widgets/numeric-input-editor.tsx b/packages/perseus-editor/src/widgets/numeric-input-editor.tsx index 3c655d1db0..8fb01b35cb 100644 --- a/packages/perseus-editor/src/widgets/numeric-input-editor.tsx +++ b/packages/perseus-editor/src/widgets/numeric-input-editor.tsx @@ -7,6 +7,7 @@ import { Util, PerseusI18nContext, } from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import * as React from "react"; import _ from "underscore"; @@ -20,7 +21,6 @@ const { InlineIcon, MultiButtonGroup, NumberInput, - PropCheckBox, TextInput, } = components; const {iconGear, iconTrash} = icons; @@ -197,11 +197,12 @@ class NumericInputEditor extends React.Component {
- { + this.updateAnswer.bind(this, i)({strict: value}); + }} />
@@ -245,10 +246,12 @@ class NumericInputEditor extends React.Component { const rightAlign = (
- { + this.props.onChange({rightAlign: value}); + }} />
); @@ -274,10 +277,12 @@ class NumericInputEditor extends React.Component { const coefficientCheck = (
- { + this.props.onChange({coefficient: value}); + }} />

diff --git a/packages/perseus-editor/src/widgets/passage-editor.tsx b/packages/perseus-editor/src/widgets/passage-editor.tsx index f2d913b34d..c0755a1b32 100644 --- a/packages/perseus-editor/src/widgets/passage-editor.tsx +++ b/packages/perseus-editor/src/widgets/passage-editor.tsx @@ -1,11 +1,12 @@ import {components, Changeable, EditorJsonify} from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; import Editor from "../editor"; -const {InfoTip, PropCheckBox} = components; +const {InfoTip} = components; type Props = any; @@ -68,11 +69,12 @@ class PassageEditor extends React.Component { return (

- { + this.props.onChange({showLineNumbers: value}); + }} />
diff --git a/packages/perseus-editor/src/widgets/radio/editor.tsx b/packages/perseus-editor/src/widgets/radio/editor.tsx index f106d66c9b..fc50cefad7 100644 --- a/packages/perseus-editor/src/widgets/radio/editor.tsx +++ b/packages/perseus-editor/src/widgets/radio/editor.tsx @@ -6,13 +6,14 @@ import { BaseRadio, Changeable, } from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; import Editor from "../../editor"; -const {InlineIcon, PropCheckBox} = components; +const {InlineIcon} = components; const {iconPlus, iconTrash} = icons; class ChoiceEditor extends React.Component { @@ -150,28 +151,35 @@ class RadioEditor extends React.Component {
- { + this.onMultipleSelectChange({ + multipleSelect: value, + }); + }} />
- { + this.props.onChange({randomize: value}); + }} />
{this.props.multipleSelect && (
- { + this.onCountChoicesChange({ + countChoices: value, + }); + }} />
)} diff --git a/packages/perseus-editor/src/widgets/sorter-editor.tsx b/packages/perseus-editor/src/widgets/sorter-editor.tsx index 4aed056341..6840b62243 100644 --- a/packages/perseus-editor/src/widgets/sorter-editor.tsx +++ b/packages/perseus-editor/src/widgets/sorter-editor.tsx @@ -1,11 +1,12 @@ /* eslint-disable @khanacademy/ts-no-error-suppressions */ /* eslint-disable react/forbid-prop-types */ import {components} from "@khanacademy/perseus"; +import {Checkbox} from "@khanacademy/wonder-blocks-form"; import PropTypes from "prop-types"; import * as React from "react"; import _ from "underscore"; -const {InfoTip, PropCheckBox, TextListEditor} = components; +const {InfoTip, TextListEditor} = components; const HORIZONTAL = "horizontal"; const VERTICAL = "vertical"; @@ -84,10 +85,12 @@ class SorterEditor extends React.Component {
- { + this.props.onChange({padding: value}); + }} />

diff --git a/packages/perseus/package.json b/packages/perseus/package.json index d71e3435b3..98bd0801cd 100644 --- a/packages/perseus/package.json +++ b/packages/perseus/package.json @@ -47,7 +47,8 @@ "@khanacademy/pure-markdown": "^0.3.7", "@khanacademy/simple-markdown": "^0.13.0", "@use-gesture/react": "^10.2.27", - "mafs": "0.19.0" + "mafs": "0.19.0", + "uuid": "^10.0.0" }, "devDependencies": { "@khanacademy/wonder-blocks-banner": "3.0.42", @@ -61,7 +62,7 @@ "@khanacademy/wonder-blocks-layout": "2.0.32", "@khanacademy/wonder-blocks-link": "6.1.1", "@khanacademy/wonder-blocks-pill": "2.2.1", - "@khanacademy/wonder-blocks-popover": "3.2.9", + "@khanacademy/wonder-blocks-popover": "3.2.11", "@khanacademy/wonder-blocks-progress-spinner": "2.1.1", "@khanacademy/wonder-blocks-switch": "1.1.16", "@khanacademy/wonder-blocks-tokens": "1.3.0", @@ -95,7 +96,7 @@ "@khanacademy/wonder-blocks-layout": "2.0.32", "@khanacademy/wonder-blocks-link": "6.1.1", "@khanacademy/wonder-blocks-pill": "2.2.1", - "@khanacademy/wonder-blocks-popover": "3.2.9", + "@khanacademy/wonder-blocks-popover": "3.2.11", "@khanacademy/wonder-blocks-progress-spinner": "2.1.1", "@khanacademy/wonder-blocks-switch": "1.1.16", "@khanacademy/wonder-blocks-tokens": "1.3.0", diff --git a/packages/perseus/src/__stories__/server-item-renderer.stories.tsx b/packages/perseus/src/__stories__/server-item-renderer.stories.tsx index 7599ed3894..d3c92a9fee 100644 --- a/packages/perseus/src/__stories__/server-item-renderer.stories.tsx +++ b/packages/perseus/src/__stories__/server-item-renderer.stories.tsx @@ -13,11 +13,11 @@ import { } from "../__testdata__/server-item-renderer.testdata"; import {ServerItemRenderer} from "../server-item-renderer"; -type StoryArgs = Record; +import type {StoryObj, Meta} from "@storybook/react"; -type Story = { - title: string; -}; +type StoryArgs = StoryObj; + +type Story = Meta; export default { title: "Perseus/Renderers/Server Item Renderer", diff --git a/packages/perseus/src/components.ts b/packages/perseus/src/components.ts index 9c529c7159..ca12de1252 100644 --- a/packages/perseus/src/components.ts +++ b/packages/perseus/src/components.ts @@ -8,7 +8,6 @@ export {default as InlineIcon} from "./components/inline-icon"; export {default as MathInput} from "./components/math-input"; export {default as MultiButtonGroup} from "./components/multi-button-group"; export {default as NumberInput} from "./components/number-input"; -export {default as PropCheckBox} from "./components/prop-check-box"; export {default as RangeInput} from "./components/range-input"; export {default as SvgImage} from "./components/svg-image"; export {default as TextInput} from "./components/text-input"; diff --git a/packages/perseus/src/components/__stories__/button-group.stories.tsx b/packages/perseus/src/components/__stories__/button-group.stories.tsx index 39350d209e..6388d80164 100644 --- a/packages/perseus/src/components/__stories__/button-group.stories.tsx +++ b/packages/perseus/src/components/__stories__/button-group.stories.tsx @@ -2,11 +2,11 @@ import * as React from "react"; import ButtonGroup from "../button-group"; -type StoryArgs = Record; +import type {Meta, StoryObj} from "@storybook/react"; -type Story = { - title: string; -}; +type StoryArgs = StoryObj; + +type Story = Meta; export default { title: "Perseus/Components/Button Group", diff --git a/packages/perseus/src/components/__stories__/fixed-to-responsive.stories.tsx b/packages/perseus/src/components/__stories__/fixed-to-responsive.stories.tsx index 632826339a..9619fccf15 100644 --- a/packages/perseus/src/components/__stories__/fixed-to-responsive.stories.tsx +++ b/packages/perseus/src/components/__stories__/fixed-to-responsive.stories.tsx @@ -3,11 +3,11 @@ import * as React from "react"; import {getDependencies} from "../../dependencies"; import FixedToResponsive from "../fixed-to-responsive"; -type StoryArgs = Record; +import type {Meta, StoryObj} from "@storybook/react"; -type Story = { - title: string; -}; +type StoryArgs = StoryObj; + +type Story = Meta; const svgUrl = "https://www.khanacademy.org/images/ohnoes-concerned.svg"; const imgUrl = "https://www.khanacademy.org/images/hand-tree.new.png"; diff --git a/packages/perseus/src/components/__stories__/graph.stories.tsx b/packages/perseus/src/components/__stories__/graph.stories.tsx index 92ed37d60f..5215c417ce 100644 --- a/packages/perseus/src/components/__stories__/graph.stories.tsx +++ b/packages/perseus/src/components/__stories__/graph.stories.tsx @@ -2,11 +2,11 @@ import * as React from "react"; import Graph from "../graph"; -type StoryArgs = Record; +import type {StoryObj, Meta} from "@storybook/react"; -type Story = { - title: string; -}; +type StoryArgs = StoryObj; + +type Story = Meta; const size = 200; diff --git a/packages/perseus/src/components/__stories__/graphie.stories.tsx b/packages/perseus/src/components/__stories__/graphie.stories.tsx index e6c31d8c66..d0ee574c36 100644 --- a/packages/perseus/src/components/__stories__/graphie.stories.tsx +++ b/packages/perseus/src/components/__stories__/graphie.stories.tsx @@ -4,11 +4,11 @@ import {ServerItemRendererWithDebugUI} from "../../../../../testing/server-item- import {itemWithPieChart} from "../../__testdata__/graphie.testdata"; import Graphie from "../graphie"; -type StoryArgs = Record; +import type {StoryObj, Meta} from "@storybook/react"; -type Story = { - title: string; -}; +type StoryArgs = StoryObj; + +type Story = Meta; const size = 200; diff --git a/packages/perseus/src/components/__stories__/hud.stories.tsx b/packages/perseus/src/components/__stories__/hud.stories.tsx index aa657e9baf..cc3a402c76 100644 --- a/packages/perseus/src/components/__stories__/hud.stories.tsx +++ b/packages/perseus/src/components/__stories__/hud.stories.tsx @@ -2,11 +2,11 @@ import * as React from "react"; import Hud from "../hud"; -type StoryArgs = Record; +import type {StoryObj, Meta} from "@storybook/react"; -type Story = { - title: string; -}; +type StoryArgs = StoryObj; + +type Story = Meta; export default { title: "Perseus/Components/HUD", diff --git a/packages/perseus/src/components/__stories__/icon.stories.tsx b/packages/perseus/src/components/__stories__/icon.stories.tsx index c6f63d2d7e..08cbd36e90 100644 --- a/packages/perseus/src/components/__stories__/icon.stories.tsx +++ b/packages/perseus/src/components/__stories__/icon.stories.tsx @@ -3,36 +3,11 @@ import * as React from "react"; import * as IconPaths from "../../icon-paths"; import IconComponent from "../icon"; -type StorybookStoryArgs = { - options?: ReadonlyArray; - mapping?: { - [value: string]: any; - }; - defaultValue?: string; - control?: string; -}; +import type {StoryObj, Meta} from "@storybook/react"; -type StoryArgs = { - color?: string; - size?: number; - title?: string; - icon?: typeof IconPaths.iconCheck; -}; +type StoryArgs = StoryObj; -type SetValueType = { - [Property in keyof T]: V; -}; - -type StoryArgTypes = SetValueType< - StoryArgs, - StorybookStoryArgs | null | undefined ->; - -type Story = { - title: string; - args?: StoryArgs; - argTypes: StoryArgTypes; -}; +type Story = Meta; export default { title: "Perseus/Components", diff --git a/packages/perseus/src/components/__stories__/prop-check-box.stories.tsx b/packages/perseus/src/components/__stories__/prop-check-box.stories.tsx deleted file mode 100644 index 09eed8aa89..0000000000 --- a/packages/perseus/src/components/__stories__/prop-check-box.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from "react"; - -import PropCheckBox from "../prop-check-box"; - -type StoryArgs = Record; - -type Story = { - title: string; -}; - -export default { - title: "Perseus/Components/Prop Check Box", -} as Story; - -export const TestLabelWithCheckedObject = ( - args: StoryArgs, -): React.ReactElement => { - return ( - {}} - labelAlignment="left" - /> - ); -}; - -export const TestLabelWithUncheckedObject = ( - args: StoryArgs, -): React.ReactElement => { - return ( - {}} - labelAlignment="left" - /> - ); -}; - -export const TestLabelWithCheckedObjectLabelOnTheRight = ( - args: StoryArgs, -): React.ReactElement => { - return ( - {}} - labelAlignment="right" - /> - ); -}; - -export const TestLabelWithUncheckedObjectLabelOnTheRight = ( - args: StoryArgs, -): React.ReactElement => { - return ( - {}} - labelAlignment="right" - /> - ); -}; diff --git a/packages/perseus/src/components/math-input.tsx b/packages/perseus/src/components/math-input.tsx index f606ef092f..88623e296c 100644 --- a/packages/perseus/src/components/math-input.tsx +++ b/packages/perseus/src/components/math-input.tsx @@ -13,12 +13,15 @@ import Clickable from "@khanacademy/wonder-blocks-clickable"; import {View} from "@khanacademy/wonder-blocks-core"; import {Popover, PopoverContentCore} from "@khanacademy/wonder-blocks-popover"; import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {HeadingMedium} from "@khanacademy/wonder-blocks-typography"; import {StyleSheet} from "aphrodite"; import classNames from "classnames"; import $ from "jquery"; import * as React from "react"; import _ from "underscore"; +import {v4 as uuid} from "uuid"; +import a11y from "../util/a11y"; import {debounce} from "../util/debounce"; import {PerseusI18nContext} from "./i18n-context"; @@ -288,6 +291,8 @@ class InnerMathInput extends React.Component { "mq-math-mode": true, }); + const popoverContentUniqueId = uuid().slice(0, 8); + if (this.props.className) { className = className + " " + this.props.className; } @@ -330,25 +335,38 @@ class InnerMathInput extends React.Component { opened={this.state.keypadOpen} onClose={() => this.closeKeypad()} dismissEnabled + aria-label={this.context.strings.mathInputTitle} + aria-describedby={`popover-content-${popoverContentUniqueId}`} content={() => ( - - - + <> + + {this.context.strings.mathInputDescription} + + + + + )} > {this.props.buttonsVisible === "never" ? ( diff --git a/packages/perseus/src/components/prop-check-box.tsx b/packages/perseus/src/components/prop-check-box.tsx deleted file mode 100644 index 13380aef6d..0000000000 --- a/packages/perseus/src/components/prop-check-box.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* eslint-disable @babel/no-invalid-this */ -/* eslint-disable react/sort-comp */ -import {Errors, PerseusError} from "@khanacademy/perseus-core"; -import {Checkbox} from "@khanacademy/wonder-blocks-form"; -import {LabelSmall} from "@khanacademy/wonder-blocks-typography"; -import {css, StyleSheet} from "aphrodite"; -import createReactClass from "create-react-class"; -import PropTypes from "prop-types"; -import * as React from "react"; -import _ from "underscore"; - -/* A checkbox that syncs its value to props using the - * renderer's onChange method, and gets the prop name - * dynamically from its props list - */ -const PropCheckBox: any = createReactClass({ - displayName: "PropCheckBox", - - propTypes: { - labelAlignment: PropTypes.oneOf(["left", "right"]), - }, - - DEFAULT_PROPS: { - label: null, - onChange: null, - labelAlignment: "left", - }, - - getDefaultProps: function () { - return this.DEFAULT_PROPS; - }, - - propName: function () { - const propName = _.find( - _.keys(this.props), - function (localPropName) { - // @ts-expect-error - TS2683 - 'this' implicitly has type 'any' because it does not have a type annotation. - return !_.has(this.DEFAULT_PROPS, localPropName); - }, - this, - ); - - if (!propName) { - throw new PerseusError( - "Attempted to create a PropCheckBox with no prop!", - Errors.InvalidInput, - ); - } - - return propName; - }, - - _labelAlignLeft: function () { - return this.props.labelAlignment === "left"; - }, - - render: function () { - const propName = this.propName(); - return ( - - ); - }, - - toggle: function () { - const propName = this.propName(); - const changes: Record = {}; - changes[propName] = !this.props[propName]; - this.props.onChange(changes); - }, -}); - -export const styles = StyleSheet.create({ - labeledCheckbox: { - display: "flex", - flexDirection: "row", - alignItems: "center", - }, -}); - -export default PropCheckBox; diff --git a/packages/perseus/src/hints-renderer.tsx b/packages/perseus/src/hints-renderer.tsx index 7e4248d537..8cf5145039 100644 --- a/packages/perseus/src/hints-renderer.tsx +++ b/packages/perseus/src/hints-renderer.tsx @@ -20,15 +20,14 @@ import mediaQueries from "./styles/media-queries"; import sharedStyles from "./styles/shared"; import Util from "./util"; +import type {Hint} from "./perseus-types"; import type Renderer from "./renderer"; import type {APIOptionsWithDefaults} from "./types"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; type Props = PropsFor & { className?: string; - // note (mcurtis): I think this should be $ReadOnlyArray, - // but things spiraled out of control when I tried to change it - hints: ReadonlyArray; + hints: ReadonlyArray; hintsVisible?: number; }; diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 775d13241b..0952b5f37a 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -122,9 +122,12 @@ export { getCircleCoords, getLineCoords, getLinearSystemCoords, + getPointCoords, + getPolygonCoords, getSegmentCoords, getSinusoidCoords, getQuadraticCoords, + getAngleCoords, } from "./widgets/interactive-graphs/reducer/initialize-graph-state"; /** @@ -150,7 +153,6 @@ export type { DomInsertCheckFn, EditorMode, FocusPath, - Hint, ImageDict, ImageUploader, JiptLabelStore, @@ -165,6 +167,7 @@ export type { } from "./types"; export type {ParsedValue} from "./util"; export type { + Hint, LockedFigure, LockedFigureColor, LockedFigureFillType, diff --git a/packages/perseus/src/multi-items/multi-renderer.tsx b/packages/perseus/src/multi-items/multi-renderer.tsx index f20f5f6615..f29e28d655 100644 --- a/packages/perseus/src/multi-items/multi-renderer.tsx +++ b/packages/perseus/src/multi-items/multi-renderer.tsx @@ -319,6 +319,14 @@ class MultiRenderer extends React.Component { ), diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index 85d2d94111..e3582b2ca7 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -87,7 +87,7 @@ export type PerseusItem = { // The details of the question being asked to the user. question: PerseusRenderer; // A collection of hints to be offered to the user that support answering the question. - hints: ReadonlyArray; + hints: ReadonlyArray; // Details about the tools the user might need to answer the question answerArea: PerseusAnswerArea | null | undefined; // Multi-item should only show up in Test Prep content and it is a variant of a PerseusItem @@ -116,8 +116,6 @@ export type PerseusRenderer = { content: string; // A dictionary of {[widgetName]: Widget} to be referenced from the content field widgets: PerseusWidgetsMap; - // Used only for PerseusItem.hints. If true, it replaces the previous hint in the list with the current one. This allows for hints that build upon each other. - replace?: boolean; // Used in the PerseusGradedGroup widget. A list of "tags" that are keys that represent other content in the system. Not rendered to the user. // NOTE: perseus_data.go says this is required even though it isn't necessary. metadata?: ReadonlyArray; @@ -127,6 +125,15 @@ export type PerseusRenderer = { }; }; +export type Hint = PerseusRenderer & { + /** + * When `true`, causes the previous hint to be replaced with this hint when + * displayed. When `false`, the previous hint remains visible when this one + * is displayed. This allows for hints that build upon each other. + */ + replace?: boolean; +}; + export type PerseusImageDetail = { // The width of the image width: number; @@ -754,14 +761,6 @@ export type LockedFunctionType = { color: LockedFigureColor; strokeStyle: "solid" | "dashed"; equation: string; // This is the user-defined equation (as it was typed) - equationParsed?: { - // This is the parsed (tokenized) version of the equation. - // Since the function that is passed to Mafs is executed many times, - // it would be expensive to have KAS parse the equation each time. - // This is parsed version is included to aid in performance. - // KAS doesn't have any types, so making this generic - [k: string]: any; - }; directionalAxis: "x" | "y"; domain?: Interval; }; diff --git a/packages/perseus/src/renderer.tsx b/packages/perseus/src/renderer.tsx index f84f5bbf04..47458d6b59 100644 --- a/packages/perseus/src/renderer.tsx +++ b/packages/perseus/src/renderer.tsx @@ -47,6 +47,7 @@ import type { PerseusScore, WidgetProps, } from "./types"; +import type {KeypadAPI} from "@khanacademy/math-input"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; import "./styles/perseus-renderer.less"; @@ -169,7 +170,7 @@ type Props = Partial> & { findExternalWidgets: any; highlightedWidgets?: ReadonlyArray; images: PerseusRenderer["images"]; - keypadElement?: any; // TODO(kevinb): add proper types, + keypadElement?: KeypadAPI | null; onInteractWithWidget: (id: string) => void; onRender: (node?: any) => void; problemNum?: number; diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index e2027b91cf..41c35574ea 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -123,6 +123,8 @@ export type PerseusStrings = { videoTranscript: string; somethingWrong: string; videoWrapper: string; + mathInputTitle: string; + mathInputDescription: string; }; /** @@ -286,6 +288,9 @@ export const strings: { videoTranscript: "See video transcript", somethingWrong: "Something went wrong.", videoWrapper: "Khan Academy video wrapper", + mathInputTitle: "mathematics keyboard", + mathInputDescription: + "Use keyboard/mouse to interact with math-based input fields", }; /** @@ -433,4 +438,7 @@ export const mockStrings: PerseusStrings = { videoTranscript: "See video transcript", somethingWrong: "Something went wrong.", videoWrapper: "Khan Academy video wrapper", + mathInputTitle: "mathematics keyboard", + mathInputDescription: + "Use keyboard/mouse to interact with math-based input fields", }; diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index f0b3d386ab..0ec64276b9 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -1,8 +1,8 @@ import type {ILogger} from "./logging/log"; import type {Item} from "./multi-items/item-types"; import type { + Hint, PerseusAnswerArea, - PerseusRenderer, PerseusWidget, PerseusWidgetsMap, } from "./perseus-types"; @@ -47,10 +47,6 @@ export type PerseusScore = message?: string | null | undefined; }; -export type Hint = PerseusRenderer & { - replace?: boolean; -}; - export type Version = { major: number; minor: number; @@ -101,7 +97,7 @@ export type ChangeHandler = ( ) => unknown; export type ImageUploader = ( - file: string, + file: File, callback: (url: string) => unknown, ) => unknown; @@ -164,9 +160,24 @@ export const InteractiveGraphEditorFlags = [ "start-coords-ui-phase-1", /** * Enables the UI for setting the start coordinates of a graph. - * Includes sinusoid graph. + * Includes sinusoid and quadratic graphs. */ "start-coords-ui-phase-2", + /** + * Enables the UI for setting the start coordinates of a graph. + * Includes point graph. + */ + "start-coords-ui-point", + /** + * Enables the UI for setting the start coordinates of a graph. + * Includes polygon graph. + */ + "start-coords-ui-polygon", + /** + * Enables the UI for setting the start coordinates of a graph. + * Includes angle graph. + */ + "start-coords-ui-angle", ] as const; /** diff --git a/packages/perseus/src/widgets/__testdata__/grapher.testdata.ts b/packages/perseus/src/widgets/__testdata__/grapher.testdata.ts index ff9e0b8277..9a6ab0e705 100644 --- a/packages/perseus/src/widgets/__testdata__/grapher.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/grapher.testdata.ts @@ -218,7 +218,6 @@ export const quadraticQuestion: PerseusRenderer = { content: "In conclusion, the vertex of the parabola is at\n\n$(3,-8)$\n\nand the zeros are\n\n$(5,0)$ and $(1,0)$\n\nIn order to graph, we need the vertex and another point. That other point can be one of the zeros we found, like $(1,0)$:\n\n[[☃ grapher 1]]", images: {}, - replace: false, widgets: { "grapher 1": { alignment: "default", @@ -268,7 +267,6 @@ export const sinusoidQuestion: PerseusRenderer = { content: "###The answer\n\nWe found that the graph of $y=-4\\cos\\left(x\\right)+3$ has a minimum point at $(0,-1)$ and then intersects its midline at $\\left(\\dfrac{1}{2}\\pi,3\\right)$.\n\n[[☃ grapher 3]]\n ", images: {}, - replace: false, widgets: { "grapher 3": { alignment: "default", diff --git a/packages/perseus/src/widgets/__testdata__/interaction.testdata.ts b/packages/perseus/src/widgets/__testdata__/interaction.testdata.ts index 91c2e187c3..03e8edbacc 100644 --- a/packages/perseus/src/widgets/__testdata__/interaction.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/interaction.testdata.ts @@ -4,7 +4,6 @@ export const question1: PerseusRenderer = { content: "Drag the dot all the way to the right.\n\n[[☃ interaction 1]]\n\n\n*Notice that we add a zero to the empty place value.* ", images: {}, - replace: false, widgets: { "interaction 1": { alignment: "default", diff --git a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts index b3038d03d0..f0022b5fa6 100644 --- a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts @@ -32,6 +32,17 @@ export const angleQuestion: PerseusRenderer = interactiveGraphQuestionBuilder() export const angleQuestionWithDefaultCorrect: PerseusRenderer = interactiveGraphQuestionBuilder().withAngle().build(); +export const angleWithStartingCoordsQuestion: PerseusRenderer = + interactiveGraphQuestionBuilder() + .withAngle({ + startCoords: [ + [5, 1], + [1, 1], + [4, 5], + ], + }) + .build(); + export const circleQuestion: PerseusRenderer = interactiveGraphQuestionBuilder() .withCircle({center: [-2, -4], radius: 2}) .build(); @@ -97,6 +108,16 @@ export const pointQuestion: PerseusRenderer = interactiveGraphQuestionBuilder() export const pointQuestionWithDefaultCorrect: PerseusRenderer = interactiveGraphQuestionBuilder().withPoints(1).build(); +export const pointQuestionWithStartingCoords: PerseusRenderer = + interactiveGraphQuestionBuilder() + .withPoints(2, { + startCoords: [ + [0, 0], + [2, 2], + ], + }) + .build(); + export const polygonQuestion: PerseusRenderer = interactiveGraphQuestionBuilder() .withContent( @@ -120,6 +141,18 @@ export const polygonQuestion: PerseusRenderer = }) .build(); +export const polygonWithStartingCoordsQuestion: PerseusRenderer = + interactiveGraphQuestionBuilder() + .withPolygon("grid", { + startCoords: [ + [6, 6], + [8, 6], + [8, 8], + [6, 8], + ], + }) + .build(); + export const polygonWithAnglesQuestion: PerseusRenderer = interactiveGraphQuestionBuilder() .withContent( @@ -765,6 +798,9 @@ export const segmentWithLockedFigures: PerseusRenderer = ], {color: "pink"}, ) + .addLockedFunction("sin(x)", { + color: "red", + }) .build(); export const quadraticQuestion: PerseusRenderer = diff --git a/packages/perseus/src/widgets/__testdata__/video.testdata.ts b/packages/perseus/src/widgets/__testdata__/video.testdata.ts index 36b2578d42..6e8a33125e 100644 --- a/packages/perseus/src/widgets/__testdata__/video.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/video.testdata.ts @@ -17,7 +17,6 @@ export const question1: PerseusRenderer = { alignment: "block", }, }, - replace: false, }; export const question2: PerseusRenderer = { @@ -37,7 +36,6 @@ export const question2: PerseusRenderer = { alignment: "block", }, }, - replace: false, }; export const question3: PerseusRenderer = { @@ -57,5 +55,4 @@ export const question3: PerseusRenderer = { alignment: "block", }, }, - replace: false, }; diff --git a/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx b/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx index d1e9468a67..8c3a2a0eb2 100644 --- a/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx @@ -1,5 +1,4 @@ import {describe, beforeEach, it} from "@jest/globals"; -import * as KAS from "@khanacademy/kas"; import {color as wbColor} from "@khanacademy/wonder-blocks-tokens"; import {act, waitFor} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; @@ -822,49 +821,6 @@ describe("locked layer", () => { }); }); - it("parses equation only when needed for a locked function", () => { - const PARSED = "equation needed parsing"; - const PREPARSED = "equation was pre-parsed"; - // Arrange - equation needs parsing - const KasParseMock = jest - .spyOn(KAS, "parse") - .mockReturnValue({expr: {eval: () => PARSED}}); - const PlotMock = jest.spyOn(Plot, "OfX"); - renderQuestion(segmentWithLockedFunction("x^2"), { - flags: { - mafs: { - segment: true, - }, - }, - }); - let plotFn = PlotMock.mock.calls[0][0]["y"]; - - // Assert - expect(KasParseMock).toHaveBeenCalledTimes(1); - expect(plotFn(0)).toEqual(PARSED); - - // Arrange - equation does NOT need parsing - KasParseMock.mockClear(); - PlotMock.mockClear(); - renderQuestion( - segmentWithLockedFunction("x^2", { - equationParsed: {eval: () => PREPARSED}, - }), - { - flags: { - mafs: { - segment: true, - }, - }, - }, - ); - plotFn = PlotMock.mock.calls[0][0]["y"]; - - // Assert - expect(KasParseMock).toHaveBeenCalledTimes(0); - expect(plotFn(0)).toEqual(PREPARSED); - }); - it("plots the supplied equation on the axis specified", () => { // Arrange const apiOptions = { @@ -880,7 +836,6 @@ describe("locked layer", () => { const PlotOfYMock = jest .spyOn(Plot, "OfY") .mockReturnValue(

OfY
); - const equationFnMock = jest.fn(); // Act - Render f(x) renderQuestion(segmentWithLockedFunction("x^2"), apiOptions); @@ -896,9 +851,6 @@ describe("locked layer", () => { renderQuestion( segmentWithLockedFunction("x^2", { directionalAxis: "y", - equationParsed: { - eval: equationFnMock, - }, }), apiOptions, ); @@ -906,8 +858,5 @@ describe("locked layer", () => { // Assert expect(PlotOfXMock).toHaveBeenCalledTimes(0); expect(PlotOfYMock).toHaveBeenCalledTimes(1); - PlotOfYMock.mock.calls[0][0]["x"](1.21); // Execute the plot function - expect(equationFnMock).toHaveBeenCalledTimes(1); - expect(equationFnMock).toHaveBeenCalledWith({y: 1.21}); }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts index cb494b822d..b38a8dc6c4 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts @@ -637,7 +637,6 @@ describe("InteractiveGraphQuestionBuilder", () => { correct: { type: "point", numPoints: "unlimited", - coords: [[0, 0]], }, }), ); diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts index 7db0b1dbf1..dbd381f900 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts @@ -684,7 +684,7 @@ class PolygonGraphConfig implements InteractiveFigureConfig { class PointsGraphConfig implements InteractiveFigureConfig { private numPoints: number | "unlimited"; - private coords: Coord[]; + private coords?: Coord[]; private startCoords?: Coord[]; constructor( @@ -695,7 +695,7 @@ class PointsGraphConfig implements InteractiveFigureConfig { }, ) { this.numPoints = numPoints; - this.coords = options?.coords ?? [[0, 0]]; + this.coords = options?.coords; this.startCoords = options?.startCoords; } diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx index 10fb49345d..a7bcf04745 100644 --- a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx @@ -1,19 +1,37 @@ import * as KAS from "@khanacademy/kas"; import {Plot} from "mafs"; import * as React from "react"; +import {useState, useEffect} from "react"; import {lockedFigureColors} from "../../../perseus-types"; import type {LockedFunctionType} from "../../../perseus-types"; const LockedFunction = (props: LockedFunctionType) => { + type Equation = { + [k: string]: any; + eval: (number) => number; + }; + const [equation, setEquation]: [ + Equation | undefined, + React.Dispatch>, + ] = useState(); const {color, strokeStyle, directionalAxis, domain} = props; const plotProps = { color: lockedFigureColors[color], style: strokeStyle, domain, }; - const equation = props.equationParsed || KAS.parse(props.equation).expr; + + useEffect(() => { + // Parsing the equation in a "useEffect" hook saves about 2ms each frame + // when the learner is interacting with the graph (i.e. moving points). + setEquation(KAS.parse(props.equation).expr); + }, [props.equation]); + + if (typeof equation === "undefined") { + return null; + } return ( diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts index 2b594c827e..6768d821b1 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts @@ -110,7 +110,7 @@ export function initializeGraphState( } } -function getPointCoords( +export function getPointCoords( graph: PerseusGraphTypePoint, range: [x: Interval, y: Interval], step: [x: number, y: number], @@ -277,7 +277,7 @@ export function getLinearSystemCoords( ); } -function getPolygonCoords( +export function getPolygonCoords( graph: PerseusGraphTypePolygon, range: [x: Interval, y: Interval], step: [x: number, y: number], @@ -394,7 +394,7 @@ export function getCircleCoords(graph: PerseusGraphTypeCircle): { }; } -const getAngleCoords = (params: { +export const getAngleCoords = (params: { graph: PerseusGraphTypeAngle; range: [x: Interval, y: Interval]; step: [x: number, y: number]; @@ -404,6 +404,10 @@ const getAngleCoords = (params: { return graph.coords; } + if (graph.startCoords) { + return graph.startCoords; + } + const {snapDegrees, angleOffsetDeg} = graph; const snap = snapDegrees || 1; let angle = snap; diff --git a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx index bd6e412c4b..8bf543d9fd 100644 --- a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx @@ -96,7 +96,11 @@ export const StatefulMafsGraph = React.forwardRef< ); }, [dispatch, xMinRange, xMaxRange, yMinRange, yMaxRange]); + // Update the graph whenever any of the following values changes. + // This is necessary to keep the graph previews in sync with the updated + // graph settings within the interative graph editor. const numSegments = graph.type === "segment" ? graph.numSegments : null; + const numPoints = graph.type === "point" ? graph.numPoints : null; const numSides = graph.type === "polygon" ? graph.numSides : null; const snapTo = graph.type === "polygon" ? graph.snapTo : null; const showAngles = graph.type === "polygon" ? graph.showAngles : null; @@ -114,6 +118,7 @@ export const StatefulMafsGraph = React.forwardRef< } }, [ graph.type, + numPoints, numSegments, numSides, snapTo, diff --git a/static/fonts/lato/Lato-Black.woff2 b/static/fonts/lato/Lato-Black.woff2 new file mode 100644 index 0000000000..37a5749e40 Binary files /dev/null and b/static/fonts/lato/Lato-Black.woff2 differ diff --git a/static/fonts/lato/Lato-Bold.woff2 b/static/fonts/lato/Lato-Bold.woff2 new file mode 100644 index 0000000000..c9f92ef3c1 Binary files /dev/null and b/static/fonts/lato/Lato-Bold.woff2 differ diff --git a/static/fonts/lato/Lato-Italic.woff2 b/static/fonts/lato/Lato-Italic.woff2 new file mode 100644 index 0000000000..19a47c9059 Binary files /dev/null and b/static/fonts/lato/Lato-Italic.woff2 differ diff --git a/static/fonts/lato/Lato-Regular.woff2 b/static/fonts/lato/Lato-Regular.woff2 new file mode 100644 index 0000000000..70171778ad Binary files /dev/null and b/static/fonts/lato/Lato-Regular.woff2 differ diff --git a/static/fonts/lato/LatoLatin-Black.woff2 b/static/fonts/lato/LatoLatin-Black.woff2 new file mode 100644 index 0000000000..4127b4d0b9 Binary files /dev/null and b/static/fonts/lato/LatoLatin-Black.woff2 differ diff --git a/static/fonts/lato/LatoLatin-Bold.woff2 b/static/fonts/lato/LatoLatin-Bold.woff2 new file mode 100644 index 0000000000..2615c853d5 Binary files /dev/null and b/static/fonts/lato/LatoLatin-Bold.woff2 differ diff --git a/static/fonts/lato/LatoLatin-Italic.woff2 b/static/fonts/lato/LatoLatin-Italic.woff2 new file mode 100644 index 0000000000..aaa5a35c3d Binary files /dev/null and b/static/fonts/lato/LatoLatin-Italic.woff2 differ diff --git a/static/fonts/lato/LatoLatin-Regular.woff2 b/static/fonts/lato/LatoLatin-Regular.woff2 new file mode 100644 index 0000000000..a4d084bfb7 Binary files /dev/null and b/static/fonts/lato/LatoLatin-Regular.woff2 differ diff --git a/static/fonts/lato/LatoLatinExtended-Black.woff2 b/static/fonts/lato/LatoLatinExtended-Black.woff2 new file mode 100644 index 0000000000..43d862b8f8 Binary files /dev/null and b/static/fonts/lato/LatoLatinExtended-Black.woff2 differ diff --git a/static/fonts/lato/LatoLatinExtended-Bold.woff2 b/static/fonts/lato/LatoLatinExtended-Bold.woff2 new file mode 100644 index 0000000000..e78612f949 Binary files /dev/null and b/static/fonts/lato/LatoLatinExtended-Bold.woff2 differ diff --git a/static/fonts/lato/LatoLatinExtended-Italic.woff2 b/static/fonts/lato/LatoLatinExtended-Italic.woff2 new file mode 100644 index 0000000000..58d8579f32 Binary files /dev/null and b/static/fonts/lato/LatoLatinExtended-Italic.woff2 differ diff --git a/static/fonts/lato/LatoLatinExtended-Regular.woff2 b/static/fonts/lato/LatoLatinExtended-Regular.woff2 new file mode 100644 index 0000000000..39f33b85ee Binary files /dev/null and b/static/fonts/lato/LatoLatinExtended-Regular.woff2 differ diff --git a/static/lato.css b/static/lato.css new file mode 100644 index 0000000000..648ef82d1f --- /dev/null +++ b/static/lato.css @@ -0,0 +1,128 @@ +/* Adapted from https://fonts.googleapis.com/css?family=Lato:400,400i,700,900 */ + +/* non-latin (cyrillic, hungarian, serbian) */ +@font-face { + font-family: "Lato"; + font-style: italic; + font-weight: 400; + src: + local("Lato Italic"), + local("Lato-Italic"), + url(fonts/lato/Lato-Italic.woff2) format("woff2"); + unicode-range: U+0400-04FF, U+0500-052F, U+2DE0-2DFF, U+A640-A69F, + U+1D00-1D7F; +} + +@font-face { + font-family: "Lato"; + font-style: normal; + font-weight: 400; + src: + local("Lato Regular"), + local("Lato-Regular"), + url(fonts/lato/Lato-Regular.woff2) format("woff2"); + unicode-range: U+0400-04FF, U+0500-052F, U+2DE0-2DFF, U+A640-A69F, + U+1D00-1D7F; +} + +@font-face { + font-family: "Lato"; + font-style: normal; + font-weight: 700; + src: + local("Lato Bold"), + local("Lato-Bold"), + url(fonts/lato/Lato-Bold.woff2) format("woff2"); + unicode-range: U+0400-04FF, U+0500-052F, U+2DE0-2DFF, U+A640-A69F, + U+1D00-1D7F; +} + +@font-face { + font-family: "Lato"; + font-style: normal; + font-weight: 900; + src: + local("Lato Black"), + local("Lato-Black"), + url(fonts/lato/Lato-Black.woff2) format("woff2"); + unicode-range: U+0400-04FF, U+0500-052F, U+2DE0-2DFF, U+A640-A69F, + U+1D00-1D7F; +} + +/* latin */ +@font-face { + font-family: "Lato"; + font-style: italic; + font-weight: 400; + src: + local("Lato Italic"), + local("Lato-Italic"), + url(fonts/lato/LatoLatin-Italic.woff2) format("woff2"); +} + +@font-face { + font-family: "Lato"; + font-style: normal; + font-weight: 400; + src: + local("Lato Regular"), + local("Lato-Regular"), + url(fonts/lato/LatoLatin-Regular.woff2) format("woff2"); +} + +@font-face { + font-family: "Lato"; + font-style: normal; + font-weight: 700; + src: + local("Lato Bold"), + local("Lato-Bold"), + url(fonts/lato/LatoLatin-Bold.woff2) format("woff2"); +} + +@font-face { + font-family: "Lato"; + font-style: normal; + font-weight: 900; + src: + local("Lato Black"), + local("Lato-Black"), + url(fonts/lato/LatoLatin-Black.woff2) format("woff2"); +} + +/* Latin Extended (e.g. vietnamese) */ +@font-face { + font-family: "Lato"; + font-style: italic; + font-weight: 400; + src: + local("Lato Italic"), + local("Lato-Italic"), + url(fonts/lato/LatoLatinExtended-Italic.woff2) format("woff2"); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, + U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Lato"; + font-weight: 400; + src: url("fonts/lato/LatoLatinExtended-Regular.woff2"); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, + U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Lato"; + font-weight: 700; + src: url("fonts/lato/LatoLatinExtended-Bold.woff2"); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, + U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Lato"; + font-weight: 900; + src: url("fonts/lato/LatoLatinExtended-Black.woff2"); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, + U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} diff --git a/yarn.lock b/yarn.lock index 5399a4ab6b..ec9ff67904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2765,17 +2765,17 @@ "@khanacademy/wonder-blocks-tokens" "^1.3.0" "@khanacademy/wonder-blocks-typography" "^2.1.11" -"@khanacademy/wonder-blocks-popover@3.2.9": - version "3.2.9" - resolved "https://registry.yarnpkg.com/@khanacademy/wonder-blocks-popover/-/wonder-blocks-popover-3.2.9.tgz#f82212edd117832b7eab6066d8b6381fc982126e" - integrity sha512-WjstA4acn/gOfo1CedA90oq5hWZnkUl4Lj2hpMCHYSTflfEhz0mjcFWzWXPAta+rtk6pAEnAKzqWT1oJ5q+Hvg== +"@khanacademy/wonder-blocks-popover@3.2.11": + version "3.2.11" + resolved "https://registry.yarnpkg.com/@khanacademy/wonder-blocks-popover/-/wonder-blocks-popover-3.2.11.tgz#75d6353fa8faa8e8b1d2bb202878565655d75ec2" + integrity sha512-JxH0UTg1rYhUG2rr/QV8PlJaIzSV8HvlhTiu7NxUhhciU4dWPtdZKURGthdybWb4lCuWxxQEumeoFrPspN6JjQ== dependencies: "@babel/runtime" "^7.18.6" "@khanacademy/wonder-blocks-core" "^6.4.3" "@khanacademy/wonder-blocks-icon-button" "^5.3.3" "@khanacademy/wonder-blocks-modal" "^5.1.8" "@khanacademy/wonder-blocks-tokens" "^1.3.1" - "@khanacademy/wonder-blocks-tooltip" "^2.3.7" + "@khanacademy/wonder-blocks-tooltip" "^2.3.8" "@khanacademy/wonder-blocks-typography" "^2.1.14" "@khanacademy/wonder-blocks-progress-spinner@2.1.1", "@khanacademy/wonder-blocks-progress-spinner@^2.1.1": @@ -2868,10 +2868,10 @@ "@khanacademy/wonder-blocks-tokens" "^1.3.0" "@khanacademy/wonder-blocks-typography" "^2.1.11" -"@khanacademy/wonder-blocks-tooltip@^2.3.7": - version "2.3.7" - resolved "https://registry.yarnpkg.com/@khanacademy/wonder-blocks-tooltip/-/wonder-blocks-tooltip-2.3.7.tgz#b09d16311b75609eaf8c0df68a43cf1a410fe7cb" - integrity sha512-bVLM0+cmGoFro0c6hQkJ3naLWnfrqfOXpNJSJl9HLiZZwWuWEg68cRZRP4KFM9b/2E2LM8GLpK95W7tFrhv6iw== +"@khanacademy/wonder-blocks-tooltip@^2.3.8": + version "2.3.8" + resolved "https://registry.yarnpkg.com/@khanacademy/wonder-blocks-tooltip/-/wonder-blocks-tooltip-2.3.8.tgz#8ecb9238a515ac33212e525dc548f80389c50375" + integrity sha512-3jsgqjIX2V2zwMlTiTabvAwhRL70TU+xmaIdJWy7M0JiQXeOFXX5mb9/vhcw0Pv/JFWEOBDAvShaKmQptSQpQg== dependencies: "@babel/runtime" "^7.18.6" "@khanacademy/wonder-blocks-core" "^6.4.3" @@ -15135,7 +15135,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15153,6 +15153,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -15237,7 +15246,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15251,6 +15260,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -16141,6 +16157,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^3.3.2, uuid@^3.3.3: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" @@ -16464,7 +16485,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16482,6 +16503,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"