diff --git a/.changeset/nasty-cobras-try.md b/.changeset/nasty-cobras-try.md deleted file mode 100644 index 622dd930d8..0000000000 --- a/.changeset/nasty-cobras-try.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -Migrate Lint component to use WonderBlocks ToolTip diff --git a/.changeset/quiet-glasses-join.md b/.changeset/quiet-glasses-join.md deleted file mode 100644 index e7a6c4d810..0000000000 --- a/.changeset/quiet-glasses-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus-editor": minor ---- - -Add ContentPreview component diff --git a/.changeset/selfish-kiwis-collect.md b/.changeset/selfish-kiwis-collect.md new file mode 100644 index 0000000000..d8b111d244 --- /dev/null +++ b/.changeset/selfish-kiwis-collect.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus-dev-ui": patch +"@khanacademy/perseus": patch +--- + +Adds a finite point question to dev gallery diff --git a/.changeset/stupid-steaks-flow.md b/.changeset/stupid-steaks-flow.md deleted file mode 100644 index a281b092aa..0000000000 --- a/.changeset/stupid-steaks-flow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus-editor": minor ---- - -Replace the "(un)set as static" button in the widget editor with a toggle switch diff --git a/.changeset/tough-bananas-arrive.md b/.changeset/tough-bananas-arrive.md deleted file mode 100644 index cb5bfcc186..0000000000 --- a/.changeset/tough-bananas-arrive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -Update internal imports to use relative paths instead of package name diff --git a/.changeset/unlucky-carrots-sparkle.md b/.changeset/unlucky-carrots-sparkle.md deleted file mode 100644 index 148fc56cc0..0000000000 --- a/.changeset/unlucky-carrots-sparkle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus": patch ---- - -[Interactive Graph] Stop the Mafs graphs from being user selectable diff --git a/.changeset/wise-cougars-hunt.md b/.changeset/wise-cougars-hunt.md deleted file mode 100644 index d609006d7e..0000000000 --- a/.changeset/wise-cougars-hunt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/perseus-dev-ui": patch ---- - -Update vite config to alias `/strings` expots to correct strings.ts file per package diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 0fecbc61de..58c8fc7ddd 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -1,5 +1,26 @@ # @khanacademy/perseus-dev-ui +## 3.0.1 + +### Patch Changes + +- Updated dependencies [[`f5a2cf521`](https://github.com/Khan/perseus/commit/f5a2cf521291180dbbd448adc97700f7c52c8b50), [`e19c58eb9`](https://github.com/Khan/perseus/commit/e19c58eb9f0ef84c32dfdb40a4382cfa4c82392d), [`96f0337ce`](https://github.com/Khan/perseus/commit/96f0337ce459dea6a0860b45704e188876d38720), [`f5a2cf521`](https://github.com/Khan/perseus/commit/f5a2cf521291180dbbd448adc97700f7c52c8b50)]: + - @khanacademy/perseus-linter@1.2.0 + - @khanacademy/kas@0.3.12 + - @khanacademy/math-input@21.0.1 + +## 3.0.0 + +### Major Changes + +- [#1536](https://github.com/Khan/perseus/pull/1536) [`78a5558f9`](https://github.com/Khan/perseus/commit/78a5558f93c966a076a35b74c5c01d697408ce84) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Revert introduction of ContentPreview component (broke editor linting tooltip)" + +## 2.0.9 + +### Patch Changes + +- [#1521](https://github.com/Khan/perseus/pull/1521) [`a9292af78`](https://github.com/Khan/perseus/commit/a9292af78f569b703fcae07de01852f264861158) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Update vite config to alias `/strings` expots to correct strings.ts file per package + ## 2.0.8 ### Patch Changes diff --git a/dev/gallery.tsx b/dev/gallery.tsx index 04850452b1..1810c8b8a9 100644 --- a/dev/gallery.tsx +++ b/dev/gallery.tsx @@ -26,6 +26,7 @@ import "../packages/perseus/src/styles/perseus-renderer.less"; const questions: [PerseusRenderer, number][] = pairWithIndices([ interactiveGraph.segmentQuestion, interactiveGraph.pointQuestion, + interactiveGraph.finitePointQuestion, interactiveGraph.angleQuestion, interactiveGraph.linearSystemQuestion, interactiveGraph.circleQuestion, diff --git a/dev/package.json b/dev/package.json index 3258b3ae34..b30483e4bb 100644 --- a/dev/package.json +++ b/dev/package.json @@ -3,7 +3,7 @@ "description": "Perseus dev UI", "author": "Khan Academy", "license": "MIT", - "version": "2.0.8", + "version": "3.0.1", "private": true, "repository": { "type": "git", @@ -14,11 +14,11 @@ "dev": "vite" }, "dependencies": { - "@khanacademy/kas": "^0.3.11", + "@khanacademy/kas": "^0.3.12", "@khanacademy/kmath": "^0.1.13", - "@khanacademy/math-input": "^21.0.0", + "@khanacademy/math-input": "^21.0.1", "@khanacademy/perseus-core": "1.5.0", - "@khanacademy/perseus-linter": "^1.1.0", + "@khanacademy/perseus-linter": "^1.2.0", "@khanacademy/pure-markdown": "^0.3.7", "@khanacademy/simple-markdown": "^0.13.0", "@khanacademy/wonder-blocks-banner": "3.0.42", diff --git a/dev/vite.config.ts b/dev/vite.config.ts index 92e6da7bad..2185e291a7 100644 --- a/dev/vite.config.ts +++ b/dev/vite.config.ts @@ -10,41 +10,7 @@ const packageAliases = {}; glob.sync(resolve(__dirname, "../packages/*/package.json")).forEach( (packageJsonPath) => { const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); - - // "exports" is the more modern way to declare package exports. Some, - // but not all, Perseus packages declare "exports". - if ("exports" in pkg) { - // Not all packages export strings, but for those that do we need - // to set up an alias so Vite properly resolves them. - // Eg `import {strings, mockStrings} from "@khanacademy/perseus/strings";` - // And MOST IMPORTANTLY, this alias _must_ precede the main - // import, otherwise Vite will just use the main export and tack - // `/strings` onto the end, resulting in a path like this: - // `packages/perseus/src/index.ts/strings` - const stringsSource = pkg.exports["./strings"]?.source; - if (stringsSource != null) { - packageAliases[`${pkg.name}/strings`] = join( - dirname(packageJsonPath), - stringsSource, - ); - } - - const mainSource = pkg.exports["."]?.source; - if (mainSource == null) { - throw new Error( - `Package declares 'exports', but not provide a main export (exports["."])`, - ); - } - packageAliases[pkg.name] = join( - dirname(packageJsonPath), - mainSource, - ); - } else { - packageAliases[pkg.name] = join( - dirname(packageJsonPath), - pkg.source, - ); - } + packageAliases[pkg.name] = join(dirname(packageJsonPath), pkg.source); }, ); diff --git a/packages/kas/CHANGELOG.md b/packages/kas/CHANGELOG.md index 9781501dec..46c5b6dd97 100644 --- a/packages/kas/CHANGELOG.md +++ b/packages/kas/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/kas +## 0.3.12 + +### Patch Changes + +- [#1507](https://github.com/Khan/perseus/pull/1507) [`e19c58eb9`](https://github.com/Khan/perseus/commit/e19c58eb9f0ef84c32dfdb40a4382cfa4c82392d) Thanks [@handeyeco](https://github.com/handeyeco)! - Add some tests for mixed numbers in KAS. + ## 0.3.11 ### Patch Changes diff --git a/packages/kas/package.json b/packages/kas/package.json index a21810bcb5..707646e2b5 100644 --- a/packages/kas/package.json +++ b/packages/kas/package.json @@ -3,7 +3,7 @@ "description": "A lightweight JavaScript CAS for comparing expressions and equations.", "author": "Khan Academy", "license": "MIT", - "version": "0.3.11", + "version": "0.3.12", "publishConfig": { "access": "public" }, diff --git a/packages/kas/src/__tests__/evaluating.test.ts b/packages/kas/src/__tests__/evaluating.test.ts index 099ee575ce..207ada0d60 100644 --- a/packages/kas/src/__tests__/evaluating.test.ts +++ b/packages/kas/src/__tests__/evaluating.test.ts @@ -13,11 +13,16 @@ expect.extend({ vars: Variables = {}, functions?: ReadonlyArray, ) { - const actual = KAS.parse(input, {functions: functions}).expr.eval( - vars, - {functions: functions}, - ); + const parsed = KAS.parse(input, {functions: functions}); + if (parsed.false || parsed.error) { + return { + pass: false, + message: () => + `unable to parse: ${input} (error: ${parsed.error})`, + }; + } + const actual = parsed.expr.eval(vars, {functions: functions}); if (actual !== expected) { return { pass: false, @@ -96,4 +101,40 @@ describe("evaluating", () => { expect("f(x-1)-f(x)").toEvaluateAs(-7, {f: "x^3", x: 2}, ["f"]); expect("g(1)").toEvaluateAs(-1, {f: "x", g: "-f(x)"}, ["f", "g"]); }); + + // TODO (LEMS-2198): these are tests from a failed attempt + // to support mixed numbers correctly. Keeping so we have a record + // of what's wrong and what's expected. + test("fraction expressions", () => { + // wrong + expect("2\\frac{1}{2} + 1").toEvaluateAs(2); + // correct + // expect("2\\frac{1}{2} + 1").toEvaluateAs(3.5); + + // wrong + expect("(2\\frac{1}{2}) + 1").toEvaluateAs(2); + // correct + // expect("(2\\frac{1}{2}) + 1").toEvaluateAs(3.5); + + // wrong + expect("-2\\frac{1}{2} + 1").toEvaluateAs(0); + // correct + // expect("-2\\frac{1}{2} + 1").toEvaluateAs(-1.5); + + // wrong + expect("(-2\\frac{1}{2}) + 1").toEvaluateAs(0); + // correct + // expect("(-2\\frac{1}{2}) + 1").toEvaluateAs(-1.5); + + // should continue to pass after LEMS-2198 is done + expect("2-\\frac{1}{2} + 1").toEvaluateAs(2.5); + expect("(2-\\frac{1}{2}) + 1").toEvaluateAs(2.5); + expect("(2)\\frac{1}{2} + 1").toEvaluateAs(2); + expect("2(\\frac{1}{2}) + 1").toEvaluateAs(2); + expect("\\frac{1}{2}2 + 1").toEvaluateAs(2); + expect("2 + \\frac{1}{2} + 1").toEvaluateAs(3.5); + expect("2 * \\frac{1}{2}").toEvaluateAs(1); + expect("2 2").toEvaluateAs(4); + expect("2\\pi").toEvaluateAs(6.283185307179586); + }); }); diff --git a/packages/kas/src/__tests__/parsing.test.ts b/packages/kas/src/__tests__/parsing.test.ts index b59e1f4c36..6753d6e885 100644 --- a/packages/kas/src/__tests__/parsing.test.ts +++ b/packages/kas/src/__tests__/parsing.test.ts @@ -13,7 +13,8 @@ expect.extend({ if (actual !== expected) { return { pass: false, - message: () => `${input} parses as ${expected}`, + message: () => + `input: ${input}\nexpected:${expected}\nactual: ${actual}`, }; } @@ -113,6 +114,8 @@ describe("parsing", () => { expect("\\frac{42}{1}").toParseAs("42/1"); expect("\\frac{0}{42}").toParseAs("0/42"); + // TODO (LEMS-2198): this should actually be: + // expect("2\\frac{1}{2}").toParseAs("2+1/2"); expect("2\\frac{1}{2}").toParseAs("2*1/2"); expect("\\frac{1}{2}\\frac{1}{2}").toParseAs("1/2*1/2"); expect("-\\frac{1}{2}").toParseAs("-1/2"); @@ -128,6 +131,8 @@ describe("parsing", () => { expect("\\dfrac{42}{1}").toParseAs("42/1"); expect("\\dfrac{0}{42}").toParseAs("0/42"); + // TODO (LEMS-2198): this should actually be: + // expect("2\\dfrac{1}{2}").toParseAs("2+1/2"); expect("2\\dfrac{1}{2}").toParseAs("2*1/2"); expect("\\dfrac{1}{2}\\dfrac{1}{2}").toParseAs("1/2*1/2"); expect("-\\dfrac{1}{2}").toParseAs("-1/2"); diff --git a/packages/math-input/CHANGELOG.md b/packages/math-input/CHANGELOG.md index 112241eb89..8ee437b58e 100644 --- a/packages/math-input/CHANGELOG.md +++ b/packages/math-input/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/math-input +## 21.0.1 + +### Patch Changes + +- [#1538](https://github.com/Khan/perseus/pull/1538) [`96f0337ce`](https://github.com/Khan/perseus/commit/96f0337ce459dea6a0860b45704e188876d38720) Thanks [@handeyeco](https://github.com/handeyeco)! - Use Portuguese sen and tg when updating Mathquill from the keypad + ## 21.0.0 ### Major Changes diff --git a/packages/math-input/package.json b/packages/math-input/package.json index b6ced23513..347ee0b45c 100644 --- a/packages/math-input/package.json +++ b/packages/math-input/package.json @@ -3,7 +3,7 @@ "description": "Khan Academy's new expression editor for the mobile web.", "author": "Khan Academy", "license": "MIT", - "version": "21.0.0", + "version": "21.0.1", "publishConfig": { "access": "public" }, diff --git a/packages/math-input/src/components/input/math-wrapper.ts b/packages/math-input/src/components/input/math-wrapper.ts index e42b54e35a..5b09985b18 100644 --- a/packages/math-input/src/components/input/math-wrapper.ts +++ b/packages/math-input/src/components/input/math-wrapper.ts @@ -63,7 +63,7 @@ class MathWrapper { this.callbacks = callbacks; this.mobileKeyTranslator = { - ...getKeyTranslator(locale), + ...getKeyTranslator(locale, strings), // note(Matthew): our mobile backspace logic is really complicated // and for some reason doesn't really work in the desktop experience. // So we default to the basic backspace functionality in the 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 adf4ea762e..37eb8deabe 100644 --- a/packages/math-input/src/components/key-handlers/key-translator.ts +++ b/packages/math-input/src/components/key-handlers/key-translator.ts @@ -40,6 +40,30 @@ function buildGenericCallback( }; } +/** + * This lets us use translated functions + * (like tg->tan and sen->sin) when we know it's safe to. + * This lets us progressively support translations without needing + * to support every language all at once. + * + * @param {string} command - the translated command/function to check + * @param {string[]} supportedTranslations - list of translations we support + * @param {string} defaultCommand - what to fallback to if the command isn't supported + */ +function buildTranslatableFunctionCallback( + command: string, + supportedTranslations: string[], + defaultCommand: string, +) { + const cmd = supportedTranslations.includes(command) + ? command + : defaultCommand; + return function (mathField: MathFieldInterface) { + mathField.write(`${cmd}\\left(\\right)`); + mathField.keystroke("Left"); + }; +} + function buildNormalFunctionCallback(command: string) { return function (mathField: MathFieldInterface) { mathField.write(`\\${command}\\left(\\right)`); @@ -47,8 +71,15 @@ function buildNormalFunctionCallback(command: string) { }; } +type KeyTranslatorStrings = { + sin: string; + cos: string; + tan: string; +}; + export const getKeyTranslator = ( locale: string, + strings: KeyTranslatorStrings, ): Record => ({ EXP: handleExponent, EXP_2: handleExponent, @@ -66,9 +97,10 @@ export const getKeyTranslator = ( LOG: buildNormalFunctionCallback("log"), LN: buildNormalFunctionCallback("ln"), - SIN: buildNormalFunctionCallback("sin"), - COS: buildNormalFunctionCallback("cos"), - TAN: buildNormalFunctionCallback("tan"), + + COS: buildNormalFunctionCallback(strings.cos), + SIN: buildTranslatableFunctionCallback(strings.sin, ["sin", "sen"], "sin"), + TAN: buildTranslatableFunctionCallback(strings.tan, ["tan", "tg"], "tan"), CDOT: buildGenericCallback("\\cdot"), DECIMAL: buildGenericCallback(getDecimalSeparator(locale)), diff --git a/packages/math-input/src/components/keypad/__tests__/keypad-v2-mathquill.test.tsx b/packages/math-input/src/components/keypad/__tests__/keypad-v2-mathquill.test.tsx index 2a060c5c34..b55ba70046 100644 --- a/packages/math-input/src/components/keypad/__tests__/keypad-v2-mathquill.test.tsx +++ b/packages/math-input/src/components/keypad/__tests__/keypad-v2-mathquill.test.tsx @@ -18,6 +18,7 @@ type Props = { onChangeMathInput: (mathInputTex: string) => void; keypadClosed?: boolean; onAnalyticsEvent?: AnalyticsEventHandlerFn; + portuguese?: boolean; }; function V2KeypadWithMathquill(props: Props) { @@ -27,6 +28,14 @@ function V2KeypadWithMathquill(props: Props) { const [keypadOpen, setKeypadOpen] = React.useState(!keypadClosed); const {strings} = useMathInputI18n(); + if (props.portuguese) { + strings.sin = "sen"; + strings.tan = "tg"; + } else { + strings.sin = "sin"; + strings.tan = "tan"; + } + React.useEffect(() => { if (!mathField && mathFieldWrapperRef.current) { const mathFieldInstance = createMathField( @@ -48,7 +57,7 @@ function V2KeypadWithMathquill(props: Props) { } }, [mathField, strings, onChangeMathInput]); - const keyTranslator = getKeyTranslator("en"); + const keyTranslator = getKeyTranslator("en", strings); function handleClickKey(key: Key) { if (!mathField) { @@ -325,4 +334,94 @@ describe("Keypad v2 with MathQuill", () => { payload: {virtualKeypadVersion: "MATH_INPUT_KEYPAD_V2"}, }); }); + + it("handles english sin trig function", async () => { + // Arrange + const mockMathInputCallback = jest.fn(); + render( + , + ); + + // Act + await userEvent.click(screen.getByRole("tab", {name: "Geometry"})); + await userEvent.click(screen.getByRole("button", {name: "Sine"})); + await userEvent.click(screen.getByRole("tab", {name: "Numbers"})); + await userEvent.click(screen.getByRole("button", {name: "4"})); + await userEvent.click(screen.getByRole("button", {name: "2"})); + + // Assert + expect(mockMathInputCallback).toHaveBeenLastCalledWith( + "\\sin\\left(42\\right)", + ); + }); + + it("handles portuguese sen trig function", async () => { + // Arrange + const mockMathInputCallback = jest.fn(); + render( + , + ); + + // Act + await userEvent.click(screen.getByRole("tab", {name: "Geometry"})); + // This needs to stay as "getByText" because we're validating translations + // and aria-labels are in English + await userEvent.click(screen.getByText("sen")); + await userEvent.click(screen.getByRole("tab", {name: "Numbers"})); + await userEvent.click(screen.getByRole("button", {name: "4"})); + await userEvent.click(screen.getByRole("button", {name: "2"})); + + // Assert + expect(mockMathInputCallback).toHaveBeenLastCalledWith( + "\\operatorname{sen}\\left(42\\right)", + ); + }); + + it("handles english tan trig function", async () => { + // Arrange + const mockMathInputCallback = jest.fn(); + render( + , + ); + + // Act + await userEvent.click(screen.getByRole("tab", {name: "Geometry"})); + await userEvent.click(screen.getByRole("button", {name: "Tangent"})); + await userEvent.click(screen.getByRole("tab", {name: "Numbers"})); + await userEvent.click(screen.getByRole("button", {name: "4"})); + await userEvent.click(screen.getByRole("button", {name: "2"})); + + // Assert + expect(mockMathInputCallback).toHaveBeenLastCalledWith( + "\\tan\\left(42\\right)", + ); + }); + + it("handles portuguese tg trig function", async () => { + // Arrange + const mockMathInputCallback = jest.fn(); + render( + , + ); + + // Act + await userEvent.click(screen.getByRole("tab", {name: "Geometry"})); + // This needs to stay as "getByText" because we're validating translations + // and aria-labels are in English + await userEvent.click(screen.getByText("tg")); + await userEvent.click(screen.getByRole("tab", {name: "Numbers"})); + await userEvent.click(screen.getByRole("button", {name: "4"})); + await userEvent.click(screen.getByRole("button", {name: "2"})); + + // Assert + expect(mockMathInputCallback).toHaveBeenLastCalledWith( + "\\operatorname{tg}\\left(42\\right)", + ); + }); }); diff --git a/packages/math-input/src/components/keypad/keypad-mathquill.stories.tsx b/packages/math-input/src/components/keypad/keypad-mathquill.stories.tsx index 6bd72e0a66..56861e3c92 100644 --- a/packages/math-input/src/components/keypad/keypad-mathquill.stories.tsx +++ b/packages/math-input/src/components/keypad/keypad-mathquill.stories.tsx @@ -44,7 +44,11 @@ export function V2KeypadWithMathquill() { } }, [mathField]); - const keyTranslator = getKeyTranslator("en"); + const keyTranslator = getKeyTranslator("en", { + sin: "sin", + cos: "cos", + tan: "tan", + }); function handleClickKey(key: Key) { if (!mathField) { @@ -86,10 +90,7 @@ export function V2KeypadWithMathquill() { convertDotToTimes preAlgebra trigonometry - onAnalyticsEvent={async (event) => { - // eslint-disable-next-line no-console - console.log("Send Event:", event); - }} + onAnalyticsEvent={async () => {}} showDismiss /> diff --git a/packages/math-input/src/components/keypad/keypad-pages/geometry-page.tsx b/packages/math-input/src/components/keypad/keypad-pages/geometry-page.tsx index c6c104ed6f..f35178cb1e 100644 --- a/packages/math-input/src/components/keypad/keypad-pages/geometry-page.tsx +++ b/packages/math-input/src/components/keypad/keypad-pages/geometry-page.tsx @@ -14,6 +14,7 @@ export default function GeometryPage(props: Props) { const {onClickKey} = props; const {strings} = useMathInputI18n(); const Keys = KeyConfigs(strings); + return ( <> {/* Row 1 */} diff --git a/packages/perseus-editor/CHANGELOG.md b/packages/perseus-editor/CHANGELOG.md index 9078b7111f..f2b1dfb482 100644 --- a/packages/perseus-editor/CHANGELOG.md +++ b/packages/perseus-editor/CHANGELOG.md @@ -1,5 +1,44 @@ # @khanacademy/perseus-editor +## 12.0.1 + +### Patch Changes + +- [#1518](https://github.com/Khan/perseus/pull/1518) [`0667abecf`](https://github.com/Khan/perseus/commit/0667abecfc40990033ec46babf92f752e22c6444) Thanks [@handeyeco](https://github.com/handeyeco)! - Revert reorder of NumericInputEditor fields + +- Updated dependencies [[`e19c58eb9`](https://github.com/Khan/perseus/commit/e19c58eb9f0ef84c32dfdb40a4382cfa4c82392d), [`96f0337ce`](https://github.com/Khan/perseus/commit/96f0337ce459dea6a0860b45704e188876d38720), [`811f914cb`](https://github.com/Khan/perseus/commit/811f914cbded3a9a3af1c08cc6aa79cadb1dbb23)]: + - @khanacademy/kas@0.3.12 + - @khanacademy/math-input@21.0.1 + - @khanacademy/perseus@30.0.1 + +## 12.0.0 + +### Major Changes + +- [#1536](https://github.com/Khan/perseus/pull/1536) [`78a5558f9`](https://github.com/Khan/perseus/commit/78a5558f93c966a076a35b74c5c01d697408ce84) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Revert introduction of ContentPreview component (broke editor linting tooltip)" + +### Patch Changes + +- Updated dependencies [[`78a5558f9`](https://github.com/Khan/perseus/commit/78a5558f93c966a076a35b74c5c01d697408ce84)]: + - @khanacademy/perseus@30.0.0 + +## 11.6.0 + +### Minor Changes + +- [#1521](https://github.com/Khan/perseus/pull/1521) [`a9292af78`](https://github.com/Khan/perseus/commit/a9292af78f569b703fcae07de01852f264861158) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Add ContentPreview component + +* [#1517](https://github.com/Khan/perseus/pull/1517) [`93ad3c638`](https://github.com/Khan/perseus/commit/93ad3c638878d1238393c71703b63cef9b93871b) Thanks [@benchristel](https://github.com/benchristel)! - Replace the "(un)set as static" button in the widget editor with a toggle switch + +### Patch Changes + +- [#1525](https://github.com/Khan/perseus/pull/1525) [`426a3ae1d`](https://github.com/Khan/perseus/commit/426a3ae1d5a7f0aef20ccea6b99ada6929e1abc4) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Change PerseusItem to no longer include multi items + +* [#1526](https://github.com/Khan/perseus/pull/1526) [`487aa464a`](https://github.com/Khan/perseus/commit/487aa464ad450aa37ec2b8ef11596a585112a6fd) Thanks [@nishasy](https://github.com/nishasy)! - [Interactive Graph Locked Figures] Add controls to move a whole locked polygon + +* Updated dependencies [[`426a3ae1d`](https://github.com/Khan/perseus/commit/426a3ae1d5a7f0aef20ccea6b99ada6929e1abc4), [`3e6a65378`](https://github.com/Khan/perseus/commit/3e6a6537842ce2659ff2a12523a75b41a90681e6), [`a9292af78`](https://github.com/Khan/perseus/commit/a9292af78f569b703fcae07de01852f264861158), [`da65a54a2`](https://github.com/Khan/perseus/commit/da65a54a2cadc381c19255e9c2a402ed74733449), [`250971357`](https://github.com/Khan/perseus/commit/25097135792ecb1b5679d6fc8b41dc0c5bb1da9b)]: + - @khanacademy/perseus@29.0.0 + ## 11.5.0 ### Minor Changes diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index ee6c866767..ed1a3a9d13 100644 --- a/packages/perseus-editor/package.json +++ b/packages/perseus-editor/package.json @@ -3,7 +3,7 @@ "description": "Perseus editors", "author": "Khan Academy", "license": "MIT", - "version": "11.5.0", + "version": "12.0.1", "publishConfig": { "access": "public" }, @@ -34,11 +34,10 @@ "test": "bash -c 'yarn --silent --cwd \"../..\" test ${@:0} $($([[ ${@: -1} = -* ]] || [[ ${@: -1} = bash ]]) && echo $PWD)'" }, "dependencies": { - "@khanacademy/kas": "^0.3.11", - "@khanacademy/keypad-context": "^1.0.0", + "@khanacademy/kas": "^0.3.12", "@khanacademy/kmath": "^0.1.13", - "@khanacademy/math-input": "^21.0.0", - "@khanacademy/perseus": "^28.2.0", + "@khanacademy/math-input": "^21.0.1", + "@khanacademy/perseus": "^30.0.1", "@khanacademy/perseus-core": "1.5.0" }, "devDependencies": { diff --git a/packages/perseus-editor/src/__stories__/content-preview.stories.tsx b/packages/perseus-editor/src/__stories__/content-preview.stories.tsx deleted file mode 100644 index 55980bedfe..0000000000 --- a/packages/perseus-editor/src/__stories__/content-preview.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import {View} from "@khanacademy/wonder-blocks-core"; -import {spacing} from "@khanacademy/wonder-blocks-tokens"; -import {useState} from "react"; - -import {articleWithImages} from "../../../perseus/src/__testdata__/article-renderer.testdata"; -import {mockStrings} from "../../../perseus/src/strings"; -import {question} from "../../../perseus/src/widgets/__testdata__/radio.testdata"; -import DeviceFramer from "../components/device-framer"; -import ViewportResizer from "../components/viewport-resizer"; -import ContentPreview from "../content-preview"; - -import type {DeviceType} from "@khanacademy/perseus"; -import type {Meta, StoryObj} from "@storybook/react"; - -import "../styles/perseus-editor.less"; - -const PreviewWrapper = (props) => { - const [previewDevice, setPreviewDevice] = useState("phone"); - - return ( - - - - - - - ); -}; - -const meta: Meta = { - title: "PerseusEditor/Content Preview", - component: ContentPreview, - args: { - strings: mockStrings, - }, - decorators: [ - (Story) => ( - - - - ), - ], - render: (props) => , -}; - -export default meta; -type Story = StoryObj; - -export const Exercise: Story = { - args: { - question, - }, -}; - -export const Article: Story = { - args: { - question: articleWithImages, - }, -}; - -export const WithLintErrors: Story = { - args: { - linterContext: { - contentType: "exercise", - highlightLint: true, - stack: [], - paths: [], - }, - question: { - content: `# H1s bad - -Here is some unclosed math: $1+1=3 - -We should use \`\\dfrac{}\` instead of \`\\frac{}\`: $\\frac{3}{5}$ - -What is the best color in the world? - -[[☃ radio 1]]`, - widgets: { - "radio 1": { - type: "radio", - options: { - choices: [ - {content: "Red"}, - {content: "# Green"}, - {content: "Blue", correct: true}, - { - content: "None of these!", - isNoneOfTheAbove: true, - }, - ], - }, - }, - }, - images: {}, - }, - }, -}; diff --git a/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx index 083f606d80..d969162de4 100644 --- a/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/locked-polygon-settings.test.tsx @@ -164,4 +164,140 @@ describe("LockedPolygonSettings", () => { // Assert expect(onToggle).toHaveBeenCalled(); }); + + test("calls onChange when the whole polygon is moved up", async () => { + // Arrange + const onChangeSpy = jest.fn(); + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const moveUpButton = screen.getByLabelText("Move polygon up"); + await userEvent.click(moveUpButton); + + // Assert + expect(onChangeSpy).toHaveBeenCalledWith({ + points: [ + [1, 2], + [2, 2], + [2, 3], + [1, 3], + ], + }); + }); + + test("calls onChange when the whole polygon is moved down", async () => { + // Arrange + const onChangeSpy = jest.fn(); + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const moveDownButton = screen.getByLabelText("Move polygon down"); + await userEvent.click(moveDownButton); + + // Assert + expect(onChangeSpy).toHaveBeenCalledWith({ + points: [ + [1, 0], + [2, 0], + [2, 1], + [1, 1], + ], + }); + }); + + test("calls onChange when the whole polygon is moved left", async () => { + // Arrange + const onChangeSpy = jest.fn(); + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const moveLeftButton = screen.getByLabelText("Move polygon left"); + await userEvent.click(moveLeftButton); + + // Assert + expect(onChangeSpy).toHaveBeenCalledWith({ + points: [ + [0, 1], + [1, 1], + [1, 2], + [0, 2], + ], + }); + }); + + test("calls onChange when the whole polygon is moved right", async () => { + // Arrange + const onChangeSpy = jest.fn(); + render( + , + { + wrapper: RenderStateRoot, + }, + ); + + // Act + const moveRightButton = screen.getByLabelText("Move polygon right"); + await userEvent.click(moveRightButton); + + // Assert + expect(onChangeSpy).toHaveBeenCalledWith({ + points: [ + [2, 1], + [3, 1], + [3, 2], + [2, 2], + ], + }); + }); }); 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 ea0328ae6d..c6f528d133 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 @@ -9,9 +9,13 @@ import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; import IconButton from "@khanacademy/wonder-blocks-icon-button"; -import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {Spring, Strut} from "@khanacademy/wonder-blocks-layout"; import {spacing, color as wbColor} from "@khanacademy/wonder-blocks-tokens"; import {LabelLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography"; +import arrowFatDown from "@phosphor-icons/core/regular/arrow-fat-down.svg"; +import arrowFatLeft from "@phosphor-icons/core/regular/arrow-fat-left.svg"; +import arrowFatRight from "@phosphor-icons/core/regular/arrow-fat-right.svg"; +import arrowFatUp from "@phosphor-icons/core/regular/arrow-fat-up.svg"; import minusCircle from "@phosphor-icons/core/regular/minus-circle.svg"; import plusCircle from "@phosphor-icons/core/regular/plus-circle.svg"; import {StyleSheet} from "aphrodite"; @@ -54,6 +58,31 @@ const LockedPolygonSettings = (props: Props) => { onChangeProps({color: newValue}); } + function handlePolygonMove(movement: "up" | "down" | "left" | "right") { + switch (movement) { + case "up": + onChangeProps({ + points: points.map(([x, y]) => [x, y + 1]), + }); + break; + case "down": + onChangeProps({ + points: points.map(([x, y]) => [x, y - 1]), + }); + break; + case "left": + onChangeProps({ + points: points.map(([x, y]) => [x - 1, y]), + }); + break; + case "right": + onChangeProps({ + points: points.map(([x, y]) => [x + 1, y]), + }); + break; + } + } + return ( { ); })} - + + + + + + {/* Buttons to move the entire polygon */} + + handlePolygonMove("up")} + /> + + handlePolygonMove("left")} + /> + handlePolygonMove("down")} + /> + handlePolygonMove("right")} + /> + + + {/* Actions */} @@ -210,6 +277,18 @@ const styles = StyleSheet.create({ icon: { marginInlineStart: spacing.xxxSmall_4, }, + polygonActionsContainer: { + width: "100%", + }, + iconButton: { + margin: 0, + }, + movementButtonsContainer: { + display: "flex", + flexDirection: "column", + alignItems: "center", + minWidth: "fit-content", + }, spaceUnder: { marginBottom: spacing.xSmall_8, }, diff --git a/packages/perseus-editor/src/content-preview.tsx b/packages/perseus-editor/src/content-preview.tsx deleted file mode 100644 index 7f017e734a..0000000000 --- a/packages/perseus-editor/src/content-preview.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { - KeypadContext, - StatefulKeypadContextProvider, -} from "@khanacademy/keypad-context"; -import {MobileKeypad} from "@khanacademy/math-input"; -import { - Renderer, - constants, - type APIOptions, - type DeviceType, - type PerseusRenderer, -} from "@khanacademy/perseus"; -import {View} from "@khanacademy/wonder-blocks-core"; -import {spacing} from "@khanacademy/wonder-blocks-tokens"; -import {StyleSheet} from "aphrodite"; - -import type {LinterContextProps} from "@khanacademy/perseus-linter"; -import type {PropsFor} from "@khanacademy/wonder-blocks-core"; - -/** - * The `ContentPreview` component provides a simple preview system for Perseus - * Content. Due to how Persus styles are built, the preview styling matches the - * current device based on the viewport width (using `@media` queries for - * `min-width` and `max-width`). - * - * The preview will render the mobile variant (styling and layout) when the - * `previewDevice` is phone or tablet. Note that the styling cannot be matched - * 100% due to the above `@media` query limitation. - */ -function ContentPreview({ - question, - apiOptions, - seamless, - linterContext, - legacyPerseusLint, - previewDevice, - strings, -}: { - question?: PerseusRenderer; - apiOptions?: APIOptions; - seamless?: boolean; - linterContext?: LinterContextProps; - legacyPerseusLint?: ReadonlyArray; - previewDevice: DeviceType; - strings: PropsFor["strings"]; -}) { - const isMobile = previewDevice !== "desktop"; - - const className = isMobile ? "perseus-mobile" : ""; - - return ( - - - - {({setKeypadActive, keypadElement, setKeypadElement}) => ( - <> - - - Promise.resolve()} - onDismiss={() => setKeypadActive(false)} - onElementMounted={setKeypadElement} - /> - - )} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - padding: spacing.xxxSmall_4, - containerType: "inline-size", - containerName: "perseus-root", - }, - gutter: {marginRight: constants.lintGutterWidth}, -}); - -export default ContentPreview; diff --git a/packages/perseus-editor/src/index.ts b/packages/perseus-editor/src/index.ts index cf11fc0c8b..1f3114764c 100644 --- a/packages/perseus-editor/src/index.ts +++ b/packages/perseus-editor/src/index.ts @@ -9,7 +9,6 @@ export {default as StructuredItemDiff} from "./diffs/structured-item-diff"; export {default as EditorPage} from "./editor-page"; export {default as Editor} from "./editor"; export {default as i18n} from "./i18n"; -export {default as ContentPreview} from "./content-preview"; export {default as IframeContentRenderer} from "./iframe-content-renderer"; export {default as MultiRendererEditor} from "./multirenderer-editor"; export {default as StatefulEditorPage} from "./stateful-editor-page"; diff --git a/packages/perseus-editor/src/widgets/numeric-input-editor.tsx b/packages/perseus-editor/src/widgets/numeric-input-editor.tsx index 97eaf5c92b..6e770259ea 100644 --- a/packages/perseus-editor/src/widgets/numeric-input-editor.tsx +++ b/packages/perseus-editor/src/widgets/numeric-input-editor.tsx @@ -499,16 +499,16 @@ class NumericInputEditor extends React.Component { return (
- {labelText} - {inputSize} - {rightAlign} - {coefficientCheck}
User input
Message shown to user on attempt
{generateInputAnswerEditors()} {addAnswerButton} + {inputSize} + {rightAlign} + {coefficientCheck} + {labelText}
); } diff --git a/packages/perseus-linter/CHANGELOG.md b/packages/perseus-linter/CHANGELOG.md index 04f6677bdc..0f153c0515 100644 --- a/packages/perseus-linter/CHANGELOG.md +++ b/packages/perseus-linter/CHANGELOG.md @@ -1,5 +1,15 @@ # @khanacademy/perseus-linter +## 1.2.0 + +### Minor Changes + +- [#1499](https://github.com/Khan/perseus/pull/1499) [`f5a2cf521`](https://github.com/Khan/perseus/commit/f5a2cf521291180dbbd448adc97700f7c52c8b50) Thanks [@handeyeco](https://github.com/handeyeco)! - Add expression-widget lint rule + +### Patch Changes + +- [#1499](https://github.com/Khan/perseus/pull/1499) [`f5a2cf521`](https://github.com/Khan/perseus/commit/f5a2cf521291180dbbd448adc97700f7c52c8b50) Thanks [@handeyeco](https://github.com/handeyeco)! - Cleaning up some types in perseus-linter + ## 1.1.0 ### Minor Changes diff --git a/packages/perseus-linter/package.json b/packages/perseus-linter/package.json index 3a6ccad26f..991f25b5d7 100644 --- a/packages/perseus-linter/package.json +++ b/packages/perseus-linter/package.json @@ -3,7 +3,7 @@ "description": "Linter engine for Perseus", "author": "Khan Academy", "license": "MIT", - "version": "1.1.0", + "version": "1.2.0", "publishConfig": { "access": "public" }, diff --git a/packages/perseus-linter/src/__tests__/rule.test.ts b/packages/perseus-linter/src/__tests__/rule.test.ts index c155b6a8ea..25eaf40fcc 100644 --- a/packages/perseus-linter/src/__tests__/rule.test.ts +++ b/packages/perseus-linter/src/__tests__/rule.test.ts @@ -3,6 +3,8 @@ import * as PureMarkdown from "@khanacademy/pure-markdown"; import Rule from "../rule"; import TreeTransformer from "../tree-transformer"; +import type {MakeRuleOptions} from "../rule"; + describe("PerseusLinter lint Rules class", () => { const markdown = ` ## This Heading is in Title Case @@ -12,11 +14,11 @@ This paragraph contains an unescaped $ sign. #### This heading skipped a level `; - const ruleDescriptions = [ + const ruleDescriptions: MakeRuleOptions[] = [ { name: "heading-title-case", selector: "heading", - pattern: "\\s[A-Z][a-z]", + pattern: /\s[A-Z][a-z]/, message: `Title case in heading: Only capitalize the first word of headings.`, }, @@ -45,7 +47,7 @@ Otherwise escape it by writing \\$.`, this heading is level ${currentHeading.level} but the previous heading was level ${previousHeading.level}`; } - return false; + return; }, }, ]; diff --git a/packages/perseus-linter/src/__tests__/rules.test.ts b/packages/perseus-linter/src/__tests__/rules.test.ts index f6c95d8e38..fd775fdfd7 100644 --- a/packages/perseus-linter/src/__tests__/rules.test.ts +++ b/packages/perseus-linter/src/__tests__/rules.test.ts @@ -4,6 +4,7 @@ import absoluteUrlRule from "../rules/absolute-url"; import blockquotedMathRule from "../rules/blockquoted-math"; import blockquotedWidgetRule from "../rules/blockquoted-widget"; import doubleSpacingAfterTerminalRule from "../rules/double-spacing-after-terminal"; +import expressionWidgetRule from "../rules/expression-widget"; import extraContentSpacingRule from "../rules/extra-content-spacing"; import headingLevel1Rule from "../rules/heading-level-1"; import headingLevelSkipRule from "../rules/heading-level-skip"; @@ -512,6 +513,120 @@ describe("Individual lint rules tests", () => { }, }); + expectWarning(expressionWidgetRule, "[[☃ expression 1]]", { + widgets: { + "expression 1": { + options: { + answerForms: [ + { + value: "\\sqrt{42}", + form: true, + simplify: true, + considered: "correct", + key: "0", + }, + ], + buttonSets: ["basic"], + }, + }, + }, + }); + + expectPass(expressionWidgetRule, "[[☃ expression 1]]", { + widgets: { + "expression 1": { + options: { + answerForms: [ + { + value: "\\sqrt{42}", + form: true, + simplify: true, + considered: "correct", + key: "0", + }, + ], + buttonSets: ["basic", "prealgebra"], + }, + }, + }, + }); + + expectWarning(expressionWidgetRule, "[[☃ expression 1]]", { + widgets: { + "expression 1": { + options: { + answerForms: [ + { + value: "\\sin\\left(42\\right)", + form: true, + simplify: true, + considered: "correct", + key: "0", + }, + ], + buttonSets: ["basic"], + }, + }, + }, + }); + + expectPass(expressionWidgetRule, "[[☃ expression 1]]", { + widgets: { + "expression 1": { + options: { + answerForms: [ + { + value: "\\sin\\left(42\\right)", + form: true, + simplify: true, + considered: "correct", + key: "0", + }, + ], + buttonSets: ["basic", "trig"], + }, + }, + }, + }); + + expectWarning(expressionWidgetRule, "[[☃ expression 1]]", { + widgets: { + "expression 1": { + options: { + answerForms: [ + { + value: "\\log\\left(5\\right)", + form: true, + simplify: true, + considered: "correct", + key: "0", + }, + ], + buttonSets: ["basic"], + }, + }, + }, + }); + + expectPass(expressionWidgetRule, "[[☃ expression 1]]", { + widgets: { + "expression 1": { + options: { + answerForms: [ + { + value: "\\log\\left(5\\right)", + form: true, + simplify: true, + considered: "correct", + key: "0", + }, + ], + buttonSets: ["basic", "logarithms"], + }, + }, + }, + }); + // @ts-expect-error - TS2554 - Expected 3 arguments, but got 2. expectWarning(doubleSpacingAfterTerminalRule, [ "Good times. Great oldies.", diff --git a/packages/perseus-linter/src/__tests__/tree-transformer.test.ts b/packages/perseus-linter/src/__tests__/tree-transformer.test.ts index 390cc49ffb..0d37cb1c7d 100644 --- a/packages/perseus-linter/src/__tests__/tree-transformer.test.ts +++ b/packages/perseus-linter/src/__tests__/tree-transformer.test.ts @@ -56,7 +56,6 @@ describe("PerseusLinter tree transformer", () => { function getTraversalOrder(tree: any) { const order: Array = []; new TreeTransformer(tree).traverse((n, state) => { - // @ts-expect-error - TS2339 - Property 'id' does not exist on type 'TreeNode'. order.push(n.id); }); return order; diff --git a/packages/perseus-linter/src/rule.ts b/packages/perseus-linter/src/rule.ts index f63c5318cb..772745725d 100644 --- a/packages/perseus-linter/src/rule.ts +++ b/packages/perseus-linter/src/rule.ts @@ -127,13 +127,30 @@ import Selector from "./selector"; import type {TraversalState, TreeNode} from "./tree-transformer"; +export type MakeRuleOptions = { + name: string; + pattern?: RegExp; + severity?: number; + selector?: string; + locale?: string; + message?: string; + lint?: ( + state: TraversalState, + content: string, + nodes: any, + match: any, + context: LintRuleContextObject, + ) => string | undefined; + applies?: AppliesTester; +}; + // This represents the type returned by String.match(). It is an // array of strings, but also has index:number and input:string properties. // TypeScript doesn't handle it well, so we punt and just use any. -export type PatternMatchType = any; +type PatternMatchType = any; // This is the return type of the check() method of a Rule object -export type RuleCheckReturnType = +type RuleCheckReturnType = | { rule: string; message: string; @@ -149,13 +166,21 @@ export type RuleCheckReturnType = // object containing a string and two numbers. // prettier-ignore // (prettier formats this in a way that ka-lint does not like) -export type LintTesterReturnType = string | { +type LintTesterReturnType = string | { message: string, start: number, end: number } | null | undefined; -export type LintRuleContextObject = any | null | undefined; +type LintRuleContextObject = + | { + content: string; + contentType: "article" | "exercise"; + stack: string[]; + widgets: any[]; + } + | null + | undefined; // This is the type of the lint detection function that the Rule() constructor // expects as its fourth argument. It is passed the TraversalState object and @@ -163,7 +188,7 @@ export type LintRuleContextObject = any | null | undefined; // nodes returned by the selector match and the array of strings returned by // the pattern match. It should return null if no lint is detected or an // error message or an object contining an error message. -export type LintTester = ( +type LintTester = ( state: TraversalState, content: string, selectorMatch: ReadonlyArray, @@ -175,7 +200,7 @@ export type LintTester = ( // be checked by context. For example, some rules only apply in exercises, // and should never be applied to articles. Defaults to true, so if we // omit the applies function in a rule, it'll be tested everywhere. -export type AppliesTester = (context: LintRuleContextObject) => boolean; +type AppliesTester = (context: LintRuleContextObject) => boolean; /** * A Rule object describes a Perseus lint rule. See the comment at the top of @@ -198,8 +223,8 @@ export default class Rule { severity: number | null | undefined, selector: Selector | null | undefined, pattern: RegExp | null | undefined, - lint: LintTester | string, - applies: AppliesTester, + lint: LintTester | string | undefined, + applies: AppliesTester | undefined, ) { if (!selector && !pattern) { throw new PerseusError( @@ -233,7 +258,7 @@ export default class Rule { // A factory method for use with rules described in JSON files // See the documentation at the start of this file for details. - static makeRule(options: any): Rule { + static makeRule(options: MakeRuleOptions): Rule { return new Rule( options.name, options.severity, diff --git a/packages/perseus-linter/src/rules/all-rules.ts b/packages/perseus-linter/src/rules/all-rules.ts index 10db73cc22..148ff660b7 100644 --- a/packages/perseus-linter/src/rules/all-rules.ts +++ b/packages/perseus-linter/src/rules/all-rules.ts @@ -8,6 +8,7 @@ import AbsoluteUrl from "./absolute-url"; import BlockquotedMath from "./blockquoted-math"; import BlockquotedWidget from "./blockquoted-widget"; import DoubleSpacingAfterTerminal from "./double-spacing-after-terminal"; +import ExpressionWidget from "./expression-widget"; import ExtraContentSpacing from "./extra-content-spacing"; import HeadingLevel1 from "./heading-level-1"; import HeadingLevelSkip from "./heading-level-skip"; @@ -41,6 +42,7 @@ export default [ BlockquotedMath, BlockquotedWidget, DoubleSpacingAfterTerminal, + ExpressionWidget, ExtraContentSpacing, HeadingLevel1, HeadingLevelSkip, diff --git a/packages/perseus-linter/src/rules/expression-widget.ts b/packages/perseus-linter/src/rules/expression-widget.ts new file mode 100644 index 0000000000..0010510d53 --- /dev/null +++ b/packages/perseus-linter/src/rules/expression-widget.ts @@ -0,0 +1,53 @@ +import Rule from "../rule"; + +function buttonNotInButtonSet(name: string, set: string): string { + return `Answer requires a button not found in the button sets: ${name} (in ${set})`; +} + +const stringToButtonSet = { + "\\sqrt": "prealgebra", + "\\sin": "trig", + "\\cos": "trig", + "\\tan": "trig", + "\\log": "logarithms", + "\\ln": "logarithms", +}; + +/** + * Rule to make sure that Expression questions that require + * a specific math symbol to answer have that math symbol + * available in the keypad (desktop learners can use a keyboard, + * but mobile learners must use the MathInput keypad) + */ +export default Rule.makeRule({ + name: "expression-widget", + severity: Rule.Severity.WARNING, + selector: "widget", + lint: function (state, content, nodes, match, context) { + // This rule only looks at image widgets + if (state.currentNode().widgetType !== "expression") { + return; + } + + const nodeId = state.currentNode().id; + if (!nodeId) { + return; + } + + // If it can't find a definition for the widget it does nothing + const widget = context?.widgets?.[nodeId]; + if (!widget) { + return; + } + + const answers = widget.options.answerForms; + const buttons = widget.options.buttonSets; + for (const answer of answers) { + for (const [str, set] of Object.entries(stringToButtonSet)) { + if (answer.value.includes(str) && !buttons.includes(set)) { + return buttonNotInButtonSet(str, set); + } + } + } + }, +}) as Rule; diff --git a/packages/perseus-linter/src/rules/extra-content-spacing.ts b/packages/perseus-linter/src/rules/extra-content-spacing.ts index 96c88fb77c..08121d3b95 100644 --- a/packages/perseus-linter/src/rules/extra-content-spacing.ts +++ b/packages/perseus-linter/src/rules/extra-content-spacing.ts @@ -5,7 +5,7 @@ export default Rule.makeRule({ selector: "paragraph", pattern: /\s+$/, applies: function (context) { - return context.contentType === "article"; + return context?.contentType === "article"; }, message: `No extra whitespace at the end of content blocks.`, }) as Rule; diff --git a/packages/perseus-linter/src/rules/image-widget.ts b/packages/perseus-linter/src/rules/image-widget.ts index 92f2faa29c..9f0a8cd679 100644 --- a/packages/perseus-linter/src/rules/image-widget.ts +++ b/packages/perseus-linter/src/rules/image-widget.ts @@ -16,11 +16,13 @@ export default Rule.makeRule({ return; } + const nodeId = state.currentNode().id; + if (!nodeId) { + return; + } + // If it can't find a definition for the widget it does nothing - const widget = - context && - context.widgets && - context.widgets[state.currentNode().id]; + const widget = context && context.widgets && context.widgets[nodeId]; if (!widget) { return; } diff --git a/packages/perseus-linter/src/rules/math-align-linebreaks.ts b/packages/perseus-linter/src/rules/math-align-linebreaks.ts index e3aafc5771..7728a02922 100644 --- a/packages/perseus-linter/src/rules/math-align-linebreaks.ts +++ b/packages/perseus-linter/src/rules/math-align-linebreaks.ts @@ -18,7 +18,7 @@ export default Rule.makeRule({ const index = text.indexOf("\\\\"); if (index === -1) { // No more backslash pairs, so we found no lint - return null; + return; } text = text.substring(index + 2); diff --git a/packages/perseus-linter/src/rules/static-widget-in-question-stem.ts b/packages/perseus-linter/src/rules/static-widget-in-question-stem.ts index 02e717e670..57adf99b0b 100644 --- a/packages/perseus-linter/src/rules/static-widget-in-question-stem.ts +++ b/packages/perseus-linter/src/rules/static-widget-in-question-stem.ts @@ -5,7 +5,7 @@ export default Rule.makeRule({ severity: Rule.Severity.WARNING, selector: "widget", lint: (state, content, nodes, match, context) => { - if (context.contentType !== "exercise") { + if (context?.contentType !== "exercise") { return; } @@ -13,7 +13,12 @@ export default Rule.makeRule({ return; } - const widget = context?.widgets?.[state.currentNode().id]; + const nodeId = state.currentNode().id; + if (!nodeId) { + return; + } + + const widget = context?.widgets?.[nodeId]; if (!widget) { return; } diff --git a/packages/perseus-linter/src/tree-transformer.ts b/packages/perseus-linter/src/tree-transformer.ts index 8826c64487..327e235ba1 100644 --- a/packages/perseus-linter/src/tree-transformer.ts +++ b/packages/perseus-linter/src/tree-transformer.ts @@ -62,6 +62,8 @@ import {Errors, PerseusError} from "@khanacademy/perseus-core"; // that every node has a string-valued `type` property export type TreeNode = { type: string; + widgetType?: string; + id?: string; }; // TraversalCallback is the type of the callback function passed to the diff --git a/packages/perseus/CHANGELOG.md b/packages/perseus/CHANGELOG.md index 5f90b13e8c..12fe59d75a 100644 --- a/packages/perseus/CHANGELOG.md +++ b/packages/perseus/CHANGELOG.md @@ -1,5 +1,40 @@ # @khanacademy/perseus +## 30.0.1 + +### Patch Changes + +- [#1538](https://github.com/Khan/perseus/pull/1538) [`96f0337ce`](https://github.com/Khan/perseus/commit/96f0337ce459dea6a0860b45704e188876d38720) Thanks [@handeyeco](https://github.com/handeyeco)! - Use Portuguese sen and tg when updating Mathquill from the keypad + +* [#1530](https://github.com/Khan/perseus/pull/1530) [`811f914cb`](https://github.com/Khan/perseus/commit/811f914cbded3a9a3af1c08cc6aa79cadb1dbb23) Thanks [@handeyeco](https://github.com/handeyeco)! - Add SharedRendererProps type + +* Updated dependencies [[`f5a2cf521`](https://github.com/Khan/perseus/commit/f5a2cf521291180dbbd448adc97700f7c52c8b50), [`e19c58eb9`](https://github.com/Khan/perseus/commit/e19c58eb9f0ef84c32dfdb40a4382cfa4c82392d), [`96f0337ce`](https://github.com/Khan/perseus/commit/96f0337ce459dea6a0860b45704e188876d38720), [`f5a2cf521`](https://github.com/Khan/perseus/commit/f5a2cf521291180dbbd448adc97700f7c52c8b50)]: + - @khanacademy/perseus-linter@1.2.0 + - @khanacademy/kas@0.3.12 + - @khanacademy/math-input@21.0.1 + +## 30.0.0 + +### Major Changes + +- [#1536](https://github.com/Khan/perseus/pull/1536) [`78a5558f9`](https://github.com/Khan/perseus/commit/78a5558f93c966a076a35b74c5c01d697408ce84) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Revert introduction of ContentPreview component (broke editor linting tooltip)" + +## 29.0.0 + +### Major Changes + +- [#1525](https://github.com/Khan/perseus/pull/1525) [`426a3ae1d`](https://github.com/Khan/perseus/commit/426a3ae1d5a7f0aef20ccea6b99ada6929e1abc4) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Change PerseusItem to no longer include multi items + +### Patch Changes + +- [#275](https://github.com/Khan/perseus/pull/275) [`3e6a65378`](https://github.com/Khan/perseus/commit/3e6a6537842ce2659ff2a12523a75b41a90681e6) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Perseus no longer depends on window.KhanUtil nor window.Exercises + +* [#1521](https://github.com/Khan/perseus/pull/1521) [`a9292af78`](https://github.com/Khan/perseus/commit/a9292af78f569b703fcae07de01852f264861158) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Migrate Lint component to use WonderBlocks ToolTip + +- [#1522](https://github.com/Khan/perseus/pull/1522) [`da65a54a2`](https://github.com/Khan/perseus/commit/da65a54a2cadc381c19255e9c2a402ed74733449) Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Update internal imports to use relative paths instead of package name + +* [#1523](https://github.com/Khan/perseus/pull/1523) [`250971357`](https://github.com/Khan/perseus/commit/25097135792ecb1b5679d6fc8b41dc0c5bb1da9b) Thanks [@nishasy](https://github.com/nishasy)! - [Interactive Graph] Stop the Mafs graphs from being user selectable + ## 28.2.0 ### Minor Changes diff --git a/packages/perseus/package.json b/packages/perseus/package.json index 79c8da5d03..5eb3fe3f03 100644 --- a/packages/perseus/package.json +++ b/packages/perseus/package.json @@ -3,7 +3,7 @@ "description": "Core Perseus API (includes renderers and widgets)", "author": "Khan Academy", "license": "MIT", - "version": "28.2.0", + "version": "30.0.1", "publishConfig": { "access": "public" }, @@ -40,12 +40,12 @@ "test": "bash -c 'yarn --silent --cwd \"../..\" test ${@:0} $($([[ ${@: -1} = -* ]] || [[ ${@: -1} = bash ]]) && echo $PWD)'" }, "dependencies": { - "@khanacademy/kas": "^0.3.11", + "@khanacademy/kas": "^0.3.12", "@khanacademy/kmath": "^0.1.13", "@khanacademy/keypad-context": "^1.0.0", - "@khanacademy/math-input": "^21.0.0", + "@khanacademy/math-input": "^21.0.1", "@khanacademy/perseus-core": "1.5.0", - "@khanacademy/perseus-linter": "^1.1.0", + "@khanacademy/perseus-linter": "^1.2.0", "@khanacademy/pure-markdown": "^0.3.7", "@khanacademy/simple-markdown": "^0.13.0", "@use-gesture/react": "^10.2.27", diff --git a/packages/perseus/src/__testdata__/article-renderer.testdata.ts b/packages/perseus/src/__testdata__/article-renderer.testdata.ts index 3783830d4b..57b446bd11 100644 --- a/packages/perseus/src/__testdata__/article-renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/article-renderer.testdata.ts @@ -33,96 +33,6 @@ export const singleSectionArticle: PerseusRenderer = { }, }; -export const articleWithImages: PerseusRenderer = { - content: - "The word \"radiation\" sometimes gets a bad rap. People often associate radiation with something dangerous or scary, without really knowing what it is. In reality, we're surrounded by radiation all the time. \n\n**Radiation** is energy that travels through space (not just \"outer space\"—any space). Radiation can also interact with matter. How radiation interacts with matter depends on the type of radiation and the type of matter.\n\nRadiation comes in many forms, one of which is *electromagnetic radiation*. Without electromagnetic radiation life on Earth would not be possible, nor would most modern technologies.\n\n[[☃ image 13]]\n\nLet's take a closer look at this important and fascinating type of radiation.\n\n##Electromagnetic radiation\n\nAs the name suggests, **electromagnetic (EM) radiation** is energy transferred by *electromagnetic fields* oscillating through space.\n\nEM radiation is strange—it has both wave and particle properties. Let's take a look at both.\n\n###Electromagnetic waves\n\nAn animated model of an EM wave is shown below.\n[[☃ image 1]]\nThe electric field $(\\vec{\\textbf{E}})$ is shown in $\\color{blue}\\textbf{blue}$, and the magnetic field $(\\vec{\\textbf{B}})$ is shown in $\\color{red}\\textbf{red}$. They're perpendicular to each other.\n\nA changing electric field creates a magnetic field, and a changing magnetic field creates an electric field. So, once the EM wave is generated it propagates itself through space!\n\nAs with any wave, EM waves have wavelength, frequency, and speed. The wave model of EM radiation works best on large scales. But what about the atomic scale?\n\n###Photons\n\nAt the quantum level, EM radiation exists as particles. A particle of EM radiation is called a **photon**.\n\nWe can think of photons as wave *packets*—tiny bundles of EM radiation containing specific amounts of energy. Photons are visually represented using the following symbol.\n\n[[☃ image 3]]\n\nAll EM radiation, whether modeled as waves or photons, travels at the **speed of light** $\\textbf{(c)}$ in a vacuum: \n\n$\\text{c}=3\\times10^8\\space\\pu{m/s}=300{,}000{,}000\\space\\pu{m/s}$\n\nBut, EM radiation travels at a slower speed in matter, such as water or glass.", - images: {}, - widgets: { - "image 13": { - type: "image", - alignment: "block", - static: false, - graded: true, - options: { - static: false, - title: "", - range: [ - [0, 10], - [0, 10], - ], - box: [600, 254], - backgroundImage: { - url: "https://cdn.kastatic.org/ka-content-images/358a87c20ab6ee70447f5fcb547010f69986828e.jpg", - width: 600, - height: 254, - }, - labels: [], - alt: "From space, the sun appears over Earth's horizon, illuminating the atmosphere as a blue layer above Earth. Above the atmosphere, space appears black.", - caption: - "*Sunrise photo from the International Space Station. Earth's atmosphere scatters electromagnetic radiation from the sun, producing a bright sky during the day.*", - }, - version: { - major: 0, - minor: 0, - }, - }, - "image 1": { - type: "image", - alignment: "block", - static: false, - graded: true, - options: { - static: false, - title: "", - range: [ - [0, 10], - [0, 10], - ], - box: [627, 522], - backgroundImage: { - url: "https://cdn.kastatic.org/ka-content-images/8100369eaf3b581d4e7bfc9f1062625309def486.gif", - width: 627, - height: 522, - }, - labels: [], - alt: "An animation shows a blue electric field arrow oscillating up and down. Connected to the base of the electric field arrow is a magnetic field arrow, which oscillates from side to side. The two fields oscillate in unison: when one extends the other extends too, creating a repeating wave pattern.", - caption: "", - }, - version: { - major: 0, - minor: 0, - }, - }, - "image 3": { - type: "image", - alignment: "block", - static: false, - graded: true, - options: { - static: false, - title: "", - range: [ - [0, 10], - [0, 10], - ], - box: [350, 130], - backgroundImage: { - url: "https://cdn.kastatic.org/ka-content-images/74edeeb6c6605a4e854e3a3e9db69c01dcf5508f.svg", - width: 350, - height: 130, - }, - labels: [], - alt: "A squiggly curve drawn from left to right. The right end of the curve has an arrow point. The curve begins with a small amount of wiggle on the left, which grows in amplitude in the middle and then decreases again on the right. The result is a small wave packet.", - caption: "", - }, - version: { - major: 0, - minor: 0, - }, - }, - }, -}; - export const passageArticle: PerseusRenderer = { content: "###Group/Pair Activity \n\nThis passage is adapted from Ed Yong, “Turtles Use the Earth’s Magnetic Field as Global GPS.” ©2011 by Kalmbach Publishing Co.\n\n[[☃ passage 1]]\n\n**Question 9**\n\nThe passage most strongly suggests that Adelita used which of the following to navigate her 9,000-mile journey?\n\nA) The current of the North Atlantic gyre\n\nB) Cues from electromagnetic coils designed by Putman and Lohmann\n\nC) The inclination and intensity of Earth’s magnetic field\n\nD) A simulated “magnetic signature” configured by Lohmann\n\n10) Which choice provides the best evidence for the answer to the previous question?\n\nA) Lines 1–2 (“In 1996...way”)\n\nB) Lines 20–21 (“Using...surface”)\n\nC) Lines 36–37 (“In the wild...stars”)\n\nD) Lines 43–45 (“Neither...it is”)\n\n**Question 12** \n\nBased on the passage, which choice best describes the relationship between Putman’s and Lohmann’s research?\n\nA) Putman’s research contradicts Lohmann’s.\n\nB) Putman’s research builds on Lohmann’s.\n\nC) Lohmann’s research confirms Putman’s.\n\nD) Lohmann’s research corrects Putman’s.", diff --git a/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts b/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts index f5fef528f6..0e51260dbf 100644 --- a/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts +++ b/packages/perseus/src/__testdata__/extract-perseus-data.testdata.ts @@ -80,7 +80,6 @@ export const PerseusItemWithRadioWidget = generateTestPerseusItem({ {content: "Hint #3", images: {}, widgets: {}}, ], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }); diff --git a/packages/perseus/src/__testdata__/graphie.testdata.ts b/packages/perseus/src/__testdata__/graphie.testdata.ts index bab19a3b31..141dac4b87 100644 --- a/packages/perseus/src/__testdata__/graphie.testdata.ts +++ b/packages/perseus/src/__testdata__/graphie.testdata.ts @@ -46,6 +46,5 @@ export const itemWithPieChart: PerseusItem = { }, }, }, - _multi: null, answer: null, }; diff --git a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts index d212f7cb68..159d199386 100644 --- a/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts +++ b/packages/perseus/src/__testdata__/server-item-renderer.testdata.ts @@ -36,7 +36,6 @@ export const itemWithInput: PerseusItem = { {content: "Hint #3", images: {}, widgets: {}}, ], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }; @@ -79,7 +78,6 @@ export const itemWithMultipleInputNumbers: PerseusItem = { {content: "Hint #3", images: {}, widgets: {}}, ], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }; @@ -132,7 +130,6 @@ export const itemWithNumericAndNumberInputs: PerseusItem = { {content: "Hint #3", images: {}, widgets: {}}, ], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }; @@ -224,7 +221,6 @@ export const itemWithRadioAndExpressionWidgets: PerseusItem = { {content: "Hint #3", images: {}, widgets: {}}, ], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }; @@ -233,7 +229,6 @@ export const labelImageItem: PerseusItem = { answerArea: Object.fromEntries( ItemExtras.map((extra) => [extra, false]), ) as PerseusAnswerArea, - _multi: null, answer: null, hints: [], itemDataVersion: {major: 0, minor: 1}, @@ -342,7 +337,6 @@ export const mockedItem: PerseusItem = { } as PerseusRenderer, hints: [], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }; @@ -355,7 +349,6 @@ export const itemWithLintingError: PerseusItem = { }, hints: [], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }; @@ -548,7 +541,6 @@ And what follows are _hints_... }, ], answerArea: null, - _multi: null, itemDataVersion: {major: 0, minor: 0}, answer: null, }; diff --git a/packages/perseus/src/__tests__/mock-asset-loading-widget.tsx b/packages/perseus/src/__tests__/mock-asset-loading-widget.tsx index 0d14f38ed1..596bdff9e4 100644 --- a/packages/perseus/src/__tests__/mock-asset-loading-widget.tsx +++ b/packages/perseus/src/__tests__/mock-asset-loading-widget.tsx @@ -26,7 +26,6 @@ export const mockedAssetItem: PerseusItem = { ) as PerseusAnswerArea, itemDataVersion: {major: 0, minor: 1}, hints: [], - _multi: null, answer: null, } as const; diff --git a/packages/perseus/src/__tests__/server-item-renderer.test.tsx b/packages/perseus/src/__tests__/server-item-renderer.test.tsx index 9870da73e1..46ca6ef3aa 100644 --- a/packages/perseus/src/__tests__/server-item-renderer.test.tsx +++ b/packages/perseus/src/__tests__/server-item-renderer.test.tsx @@ -588,7 +588,7 @@ describe("server item renderer", () => { ).not.toBeInTheDocument(); }); - it("should show linting errors when highlightLint is true", async () => { + it("should show linting errors when highlightLint is true", () => { // Arrange and Act renderQuestion(itemWithLintingError, undefined, { linterContext: { @@ -599,12 +599,6 @@ describe("server item renderer", () => { }, }); - // Linting errors are surfaced as a link with a warning or error - // icon inside them. We need to click on it to open the tooltip - // that contains the error message. - const lintIcon = screen.getByRole("link"); - await userEvent.click(lintIcon); - expect( screen.getByText("Don't use level-1 headings", {exact: false}), ).toBeInTheDocument(); diff --git a/packages/perseus/src/article-renderer.tsx b/packages/perseus/src/article-renderer.tsx index de7ed70531..a01b90ef50 100644 --- a/packages/perseus/src/article-renderer.tsx +++ b/packages/perseus/src/article-renderer.tsx @@ -15,24 +15,22 @@ import Renderer from "./renderer"; import Util from "./util"; import type {PerseusRenderer} from "./perseus-types"; -import type {APIOptions, PerseusDependenciesV2} from "./types"; +import type {PerseusDependenciesV2, SharedRendererProps} from "./types"; import type {KeypadAPI} from "@khanacademy/math-input"; import type {KeypadContextRendererInterface} from "@khanacademy/perseus-core"; -import type {LinterContextProps} from "@khanacademy/perseus-linter"; - -type Props = Partial> & { - apiOptions: APIOptions; - json: PerseusRenderer | ReadonlyArray; - // Whether to use the new Bibliotron styles for articles - /** - * @deprecated Does nothing - */ - useNewStyles: boolean; - linterContext: LinterContextProps; - legacyPerseusLint?: ReadonlyArray; - keypadElement?: KeypadAPI | null | undefined; - dependencies: PerseusDependenciesV2; -}; + +type Props = Partial> & + SharedRendererProps & { + json: PerseusRenderer | ReadonlyArray; + // Whether to use the new Bibliotron styles for articles + /** + * @deprecated Does nothing + */ + useNewStyles: boolean; + legacyPerseusLint?: ReadonlyArray; + keypadElement?: KeypadAPI | null | undefined; + dependencies: PerseusDependenciesV2; + }; type DefaultProps = { apiOptions: Props["apiOptions"]; diff --git a/packages/perseus/src/components/lint.tsx b/packages/perseus/src/components/lint.tsx index f43fcee1df..d53dadf9d9 100644 --- a/packages/perseus/src/components/lint.tsx +++ b/packages/perseus/src/components/lint.tsx @@ -1,7 +1,6 @@ -import {color, font} from "@khanacademy/wonder-blocks-tokens"; -import Tooltip from "@khanacademy/wonder-blocks-tooltip"; import {StyleSheet, css} from "aphrodite"; import * as React from "react"; +import ReactDOM from "react-dom"; import * as constants from "../styles/constants"; @@ -39,6 +38,10 @@ type Props = { severity?: Severity; }; +type State = { + tooltipAbove: boolean; +}; + /** * This component renders "lint" nodes in a markdown parse tree. Lint nodes * are inserted into the tree by the Perseus linter (see @@ -61,12 +64,43 @@ type Props = { * that has a right margin (like anything blockquoted) the circle will appear * to the left of where it belongs. And if there is more **/ -class Lint extends React.Component { +class Lint extends React.Component { _positionTimeout: number | undefined; + state: State = { + tooltipAbove: true, + }; + + componentDidMount() { + // TODO(somewhatabstract): Use WB timing + // eslint-disable-next-line no-restricted-syntax + this._positionTimeout = window.setTimeout(this.getPosition); + } + + componentWillUnmount() { + // TODO(somewhatabstract): Use WB timing + // eslint-disable-next-line no-restricted-syntax + window.clearTimeout(this._positionTimeout); + } + + // We can't call setState in componentDidMount without risking a render + // thrash, and we can't call getBoundingClientRect in render, so we + // borrow a timeout approach from learnstorm-dashboard.jsx and set our + // state once the component has mounted and we can get what we need. + getPosition: () => void = () => { + // @ts-expect-error - TS2531 - Object is possibly 'null'. | TS2339 - Property 'getBoundingClientRect' does not exist on type 'Element | Text'. + const rect = ReactDOM.findDOMNode(this).getBoundingClientRect(); + // TODO(scottgrant): This is a magic number! We don't know the size + // of the tooltip at this point, so we're arbitrarily choosing a + // point at which to flip the tooltip's position. + this.setState({tooltipAbove: rect.top > 100}); + }; + // Render the element that holds the indicator icon and the tooltip // We pass different styles for the inline and block cases renderLink: (arg1: any) => React.ReactElement = (style) => { + const tooltipAbove = this.state.tooltipAbove; + let severityStyle; let warningText; let warningTextStyle; @@ -85,33 +119,38 @@ class Lint extends React.Component { } return ( - - {this.props.message.split("\n\n").map((m, i) => ( -

- - {warningText}:{" "} - - {m} -

- ))} - - } +
- + {this.props.severity === 1 && ( + + )} + +
- - {this.props.severity === 1 && ( - + {this.props.message.split("\n\n").map((m, i) => ( +

+ + {warningText}:{" "} + + {m} +

+ ))} +
- - + /> +
+ ); }; @@ -347,11 +386,60 @@ const styles = StyleSheet.create({ backgroundColor: "#ffbe26", }, + // These are the styles for the tooltip + tooltip: { + // Absolute positioning relative to the lint indicator circle. + position: "absolute", + right: -12, + + // The tooltip is hidden by default; only displayed on hover + display: "none", + + // When it is displayed, it goes on top! + zIndex: 1000, + + // These styles control what the tooltip looks like + color: constants.white, + backgroundColor: constants.gray17, + opacity: 0.9, + fontFamily: constants.baseFontFamily, + fontSize: "12px", + lineHeight: "15px", + width: "320px", + borderRadius: "4px", + }, + // If we're going to render the tooltip above the warning circle, we use + // the previous rules in tooltip, but change the position slightly. + tooltipAbove: { + bottom: 32, + }, + + // We give the tooltip a little triangular "tail" that points down at + // the lint indicator circle. This is inside the tooltip and positioned + // relative to it. It also shares the opacity of the tooltip. We're using + // the standard CSS trick for drawing triangles with a thick border. + tail: { + position: "absolute", + top: -12, + right: 16, + width: 0, + height: 0, + + // This is the CSS triangle trick + borderLeft: "8px solid transparent", + borderRight: "8px solid transparent", + borderBottom: "12px solid " + constants.gray17, + }, + tailAbove: { + bottom: -12, + borderBottom: "none", + borderTop: "12px solid " + constants.gray17, + top: "auto", + }, + // Each warning in the tooltip is its own

. They are 12 pixels from // the edges of the tooltip and 12 pixels from each other. tooltipParagraph: { - fontFamily: font.family.sans, - color: color.white, margin: 12, }, diff --git a/packages/perseus/src/components/math-input.tsx b/packages/perseus/src/components/math-input.tsx index 2040672fe9..3ccd9d38db 100644 --- a/packages/perseus/src/components/math-input.tsx +++ b/packages/perseus/src/components/math-input.tsx @@ -132,7 +132,7 @@ class InnerMathInput extends React.Component { const input = this.mathField(); const {locale} = this.context; const customKeyTranslator = { - ...getKeyTranslator(locale), + ...getKeyTranslator(locale, this.context.strings), // If there's something in the input that can become part of a // fraction, typing "/" puts it in the numerator. If not, typing // "/" does nothing. In that case, enter a \frac. @@ -259,7 +259,7 @@ class InnerMathInput extends React.Component { handleKeypadPress: (key: Keys, e: any) => void = (key, e) => { const {locale} = this.context; - const translator = getKeyTranslator(locale)[key]; + const translator = getKeyTranslator(locale, this.context.strings)[key]; const mathField = this.mathField(); if (mathField) { diff --git a/packages/perseus/src/hint-renderer.tsx b/packages/perseus/src/hint-renderer.tsx index 9de03b58e4..ad98597dc1 100644 --- a/packages/perseus/src/hint-renderer.tsx +++ b/packages/perseus/src/hint-renderer.tsx @@ -8,11 +8,9 @@ import Renderer from "./renderer"; import {baseUnitPx, hintBorderWidth, kaGreen, gray97} from "./styles/constants"; import mediaQueries from "./styles/media-queries"; -import type {APIOptions} from "./types"; -import type {LinterContextProps} from "@khanacademy/perseus-linter"; +import type {SharedRendererProps} from "./types"; -type Props = { - apiOptions: APIOptions; +type Props = SharedRendererProps & { className?: string; hint: any; lastHint?: boolean; @@ -20,7 +18,6 @@ type Props = { pos: number; totalHints?: number; findExternalWidgets?: any; - linterContext: LinterContextProps; }; type DefaultProps = { diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 574c31e64a..9bdf3385b4 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -164,6 +164,7 @@ export type { VideoKind, WidgetDict, WidgetExports, + SharedRendererProps, } from "./types"; export type {ParsedValue} from "./util"; export type { @@ -202,6 +203,7 @@ export type { PerseusRenderer, PerseusWidget, PerseusWidgetsMap, + MultiItem, } from "./perseus-types"; export type {Coord} from "./interactive2/types"; export type {MarkerType} from "./widgets/label-image/types"; diff --git a/packages/perseus/src/perseus-env.ts b/packages/perseus/src/perseus-env.ts deleted file mode 100644 index 60bd042049..0000000000 --- a/packages/perseus/src/perseus-env.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Sets up the basic environment for running Perseus in. - */ - -// @ts-expect-error - TS2339 - Property 'KhanUtil' does not exist on type 'Window & typeof globalThis'. -window.KhanUtil = { - debugLog: function () {}, - localeToFixed: function (num, precision) { - return num.toFixed(precision); - }, -}; - -// @ts-expect-error - TS2339 - Property 'Exercises' does not exist on type 'Window & typeof globalThis'. -window.Exercises = { - localMode: true, - - khanExercisesUrlBase: "../", - - getCurrentFramework: function () { - return "khan-exercises"; - }, - PerseusBridge: { - cleanupProblem: function () { - return false; - }, - }, -}; diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index e3582b2ca7..83160d7818 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -83,6 +83,13 @@ export type PerseusWidgetsMap = { [key in `video ${number}`]: VideoWidget; }; +/** + * A "PerseusItem" is a classic Perseus item. It is rendered by the + * `ServerItemRenderer` and the layout is pre-set. + * + * To render more complex Perseus items, see the `Item` type in the multi item + * area. + */ export type PerseusItem = { // The details of the question being asked to the user. question: PerseusRenderer; @@ -90,14 +97,22 @@ export type PerseusItem = { 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 - _multi: any; // The version of the item. Not used by Perseus itemDataVersion: Version; // Deprecated field answer: any; }; +/** + * A "MultiItem" is an advanced Perseus item. It is rendered by the + * `MultiRenderer` and you can control the layout of individual parts of the + * item. + */ +export type MultiItem = { + // Multi-item should only show up in Test Prep content and it is a variant of a PerseusItem + _multi: any; +}; + export type PerseusArticle = ReadonlyArray; export type Version = { diff --git a/packages/perseus/src/server-item-renderer.tsx b/packages/perseus/src/server-item-renderer.tsx index 2a7f56024f..9a902817c5 100644 --- a/packages/perseus/src/server-item-renderer.tsx +++ b/packages/perseus/src/server-item-renderer.tsx @@ -23,23 +23,24 @@ import Renderer from "./renderer"; import Util from "./util"; import type {PerseusItem, ShowSolutions} from "./perseus-types"; -import type {APIOptions, FocusPath, PerseusDependenciesV2} from "./types"; +import type { + FocusPath, + PerseusDependenciesV2, + SharedRendererProps, +} from "./types"; import type {KeypadAPI} from "@khanacademy/math-input"; import type { KeypadContextRendererInterface, RendererInterface, KEScore, } from "@khanacademy/perseus-core"; -import type {LinterContextProps} from "@khanacademy/perseus-linter"; import type {PropsFor} from "@khanacademy/wonder-blocks-core"; const {mapObject} = Objective; type OwnProps = { - apiOptions: APIOptions; hintsVisible?: number; item: PerseusItem; - linterContext: LinterContextProps; problemNum?: number; reviewMode?: boolean; keypadElement?: KeypadAPI | null | undefined; @@ -51,7 +52,7 @@ type HOCProps = { onRendered: (isRendered: boolean) => void; }; -type Props = OwnProps & HOCProps; +type Props = SharedRendererProps & OwnProps & HOCProps; type DefaultProps = Required< Pick diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index 41c35574ea..1aacc27d58 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -125,6 +125,9 @@ export type PerseusStrings = { videoWrapper: string; mathInputTitle: string; mathInputDescription: string; + sin: string; + cos: string; + tan: string; }; /** @@ -291,6 +294,9 @@ export const strings: { mathInputTitle: "mathematics keyboard", mathInputDescription: "Use keyboard/mouse to interact with math-based input fields", + sin: "sin", + cos: "cos", + tan: "tan", }; /** @@ -441,4 +447,7 @@ export const mockStrings: PerseusStrings = { mathInputTitle: "mathematics keyboard", mathInputDescription: "Use keyboard/mouse to interact with math-based input fields", + sin: "sin", + cos: "cos", + tan: "tan", }; diff --git a/packages/perseus/src/styles/perseus-renderer.less b/packages/perseus/src/styles/perseus-renderer.less index f66d1dbb95..388c1a317c 100644 --- a/packages/perseus/src/styles/perseus-renderer.less +++ b/packages/perseus/src/styles/perseus-renderer.less @@ -28,7 +28,7 @@ // counterintuitive, so we simply don't let you see anything you draw under // a widget. .blank-background { - background-color: #fdfdfd; // Keep in sync with KhanUtil._BACKGROUND + background-color: #fdfdfd; } // There is no scratchpad in the answer_area, and it has a different color // background, so just make it transparent diff --git a/packages/perseus/src/styles/shared.ts b/packages/perseus/src/styles/shared.ts index 59bd463ce4..39106b6c6c 100644 --- a/packages/perseus/src/styles/shared.ts +++ b/packages/perseus/src/styles/shared.ts @@ -26,7 +26,7 @@ export default StyleSheet.create({ }, blankBackground: { - // TODO(emily): Use KhanUtil._BACKGROUND? + // TODO(emily): Use WB colors? backgroundColor: "#FDFDFD", }, diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 6e6f6959b8..03b2cbacb9 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -612,3 +612,8 @@ export type ChangeFn = ( propValue?: any, callback?: () => unknown, ) => any | null | undefined; + +export type SharedRendererProps = { + apiOptions: APIOptions; + linterContext: LinterContextProps; +}; diff --git a/packages/perseus/src/util/fix-passage-refs.ts b/packages/perseus/src/util/fix-passage-refs.ts index 9980d065cb..080a861f99 100644 --- a/packages/perseus/src/util/fix-passage-refs.ts +++ b/packages/perseus/src/util/fix-passage-refs.ts @@ -2,6 +2,8 @@ import _ from "underscore"; import {traverse} from "../traversal"; +import type {MultiItem, PerseusItem} from "../perseus-types"; + const findPassageRefR = new RegExp( // [[ passage-ref 1]] // capture 1: widget markdown @@ -101,8 +103,8 @@ const fixRendererPassageRefs = (options: any) => { return traverse(options, null, fixRadioWidget, fixWholeOptions); }; -const FixPassageRefs = (itemData: any): any => { - if (itemData._multi) { +const fixPassageRefs = (itemData: PerseusItem | MultiItem): any => { + if ("_multi" in itemData) { // We're in a multi-item. Don't do anything, just return the original // item data. return itemData; @@ -118,4 +120,4 @@ const FixPassageRefs = (itemData: any): any => { }); }; -export default FixPassageRefs; +export default fixPassageRefs; diff --git a/packages/perseus/src/util/math.ts b/packages/perseus/src/util/math.ts index 715f1c1c4f..3771bef104 100644 --- a/packages/perseus/src/util/math.ts +++ b/packages/perseus/src/util/math.ts @@ -130,7 +130,7 @@ const KhanMath = { * with an approx symbol if num had to be rounded, and trailing 0s */ toFixedApprox: function (num: number, precision: number): string { - // TODO(aria): Make this locale-dependent like KhanUtil.localeToFixed + // TODO(aria): Make this locale-dependent const fixedStr = num.toFixed(precision); if (knumber.equal(+fixedStr, num)) { return fixedStr; diff --git a/packages/perseus/src/util/test-utils.testdata.ts b/packages/perseus/src/util/test-utils.testdata.ts index 15db0d7140..afb36d3b8f 100644 --- a/packages/perseus/src/util/test-utils.testdata.ts +++ b/packages/perseus/src/util/test-utils.testdata.ts @@ -22,7 +22,6 @@ export const basicObject: PerseusItem = { minor: 1, }, hints: [], - _multi: null, answer: null, }; @@ -82,7 +81,6 @@ export const expectedQuestionInfoAdded: PerseusItem = { minor: 1, }, hints: [], - _multi: null, answer: null, }; @@ -122,7 +120,6 @@ export const expectedAnswerAreaInfoAdded: PerseusItem = { minor: 1, }, hints: [], - _multi: null, answer: null, }; @@ -214,6 +211,5 @@ export const expectedHintsInfoAdded: PerseusItem = { }, }, ], - _multi: null, answer: null, }; diff --git a/packages/perseus/src/util/test-utils.ts b/packages/perseus/src/util/test-utils.ts index f01470d493..15d45ac899 100644 --- a/packages/perseus/src/util/test-utils.ts +++ b/packages/perseus/src/util/test-utils.ts @@ -29,7 +29,6 @@ export const genericPerseusItemData: PerseusItem = { minor: 1, }, hints: [], - _multi: null, answer: null, } as const; diff --git a/packages/perseus/src/widgets/__stories__/number-line.stories.tsx b/packages/perseus/src/widgets/__stories__/number-line.stories.tsx index 1c9845d087..44b16bc37a 100644 --- a/packages/perseus/src/widgets/__stories__/number-line.stories.tsx +++ b/packages/perseus/src/widgets/__stories__/number-line.stories.tsx @@ -35,7 +35,6 @@ export const ShowTickControllerMobile = ( item={ { question: question2, - _multi: null, answer: null, answerArea: null, itemDataVersion: { @@ -43,7 +42,7 @@ export const ShowTickControllerMobile = ( minor: 1, }, hints: [], - } as PerseusItem + } satisfies PerseusItem } apiOptions={{ isMobile: true, diff --git a/packages/perseus/src/widgets/__testdata__/expression.testdata.ts b/packages/perseus/src/widgets/__testdata__/expression.testdata.ts index 9b28e70a75..be6f45ed05 100644 --- a/packages/perseus/src/widgets/__testdata__/expression.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/expression.testdata.ts @@ -35,7 +35,6 @@ const createItemJson = ( }, }, }, - _multi: null, answer: null, answerArea: Object.fromEntries( ItemExtras.map((extra) => [extra, false]), diff --git a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts index ca1d93ebf9..6aba5372bc 100644 --- a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts @@ -118,6 +118,27 @@ export const pointQuestionWithStartingCoords: PerseusRenderer = }) .build(); +export const finitePointQuestion: PerseusRenderer = + interactiveGraphQuestionBuilder() + .withContent( + "Vector $\\vec v$ is graphed in the interactive graph below.\n\n**Assuming $3\\vec v$ starts at the origin, plot its endpoint.**\n\n[[\u2603 interactive-graph 1]]", + ) + .withBackgroundImage( + "web+graphie://ka-perseus-graphie.s3.amazonaws.com/d6983eff3063dac5815cc4d48c565cddba819765", + 400, + 400, + ) + .withMarkings("none") + .withGridStep(1, 1) + .withSnapStep(1, 1) + .withTickStep(1, 1) + .withXRange(-8, 8) + .withYRange(-8, 8) + .withPoints(1, { + coords: [[0, 0]], + }) + .build(); + export const polygonQuestion: PerseusRenderer = interactiveGraphQuestionBuilder() .withContent( diff --git a/packages/perseus/src/widgets/__tests__/expression.test.tsx b/packages/perseus/src/widgets/__tests__/expression.test.tsx index 9ab6cdc03f..716661b825 100644 --- a/packages/perseus/src/widgets/__tests__/expression.test.tsx +++ b/packages/perseus/src/widgets/__tests__/expression.test.tsx @@ -238,6 +238,16 @@ describe("Expression Widget", function () { const item = expressionItemWithAnswer("sin(x)"); await assertIncorrect(userEvent, item, "2"); }); + + it("allows portugese sen", async () => { + const item = expressionItemWithAnswer("sin(42)"); + await assertCorrect(userEvent, item, "sen(42)"); + }); + + it("allows portugese tg", async () => { + const item = expressionItemWithAnswer("tan(42)"); + await assertCorrect(userEvent, item, "tg(42)"); + }); }); describe("analytics", () => {