diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8f1f27cd8..f080a209ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ env: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - + jobs: lint: if: github.event.pull_request.draft == false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c32d7e20c..a251aba277 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ To start contributing, create a fork from our repo and send a PR. Refer to [this The frontend comes with an extensive test suite. To run the tests after you made your modifications, run `yarn test`. Regression tests are run automatically when you want to push changes to this repository. -The regression tests are generated using `jest` and stored as snapshots in `src/\_\_tests\_\_`. After modifying the frontend, carefully inspect any failing regression tests reported in red in the command line. If you are convinced that the regression tests and not your changes are at fault, you can update the regression tests by running: +The regression tests are generated using `jest` and stored as snapshots in `src/\_\_tests\_\_`. After modifying the frontend, carefully inspect any failing regression tests reported in red in the command line. If you are convinced that the regression tests and not your changes are at fault, you can update the regression tests by running: ```bash yarn test --updateSnapshot diff --git a/README.md b/README.md index 16ebdfb83d..5d0afb0ee7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The Source Academy () is an immersive online experie 1. Clone this repository and navigate to it using your command line -1. Install the version of `yarn` as specified in `package.json`, `packageManager`. +1. Install the version of `yarn` as specified in `package.json`, `packageManager`. > We recommend using `corepack` to manage the version of `yarn`, you may simply run `corepack enable` to complete this step. diff --git a/_config.yml b/_config.yml index c4192631f2..277f1f2c51 100644 --- a/_config.yml +++ b/_config.yml @@ -1 +1 @@ -theme: jekyll-theme-cayman \ No newline at end of file +theme: jekyll-theme-cayman diff --git a/eslint.config.mjs b/eslint.config.mjs index 89a6d244f6..d08bbe8adf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,7 +4,7 @@ import { config, configs } from 'typescript-eslint'; import reactPlugin from 'eslint-plugin-react'; import reactHooksPlugin from 'eslint-plugin-react-hooks'; -import simpleImportSort from 'eslint-plugin-simple-import-sort' +import simpleImportSort from 'eslint-plugin-simple-import-sort'; // import reactRefresh from 'eslint-plugin-react-refresh'; export default config( @@ -24,7 +24,7 @@ export default config( files: ['**/*.ts*'], plugins: { 'react-hooks': reactHooksPlugin, - 'react': reactPlugin, + react: reactPlugin, 'simple-import-sort': simpleImportSort }, rules: { diff --git a/public/externalLibs/sound/soundToneMatrix.js b/public/externalLibs/sound/soundToneMatrix.js index 8638a90378..d0246757bf 100644 --- a/public/externalLibs/sound/soundToneMatrix.js +++ b/public/externalLibs/sound/soundToneMatrix.js @@ -36,7 +36,7 @@ var timeout_matrix; // for coloring the matrix accordingly while it's being played var timeout_color; -var timeout_objects = new Array(); +var timeout_objects = []; // vector_to_list returns a list that contains the elements of the argument vector // in the given order. @@ -54,7 +54,7 @@ function vector_to_list(vector) { function x_y_to_row_column(x, y) { var row = Math.floor((y - margin_length) / (square_side_length + distance_between_squares)); var column = Math.floor((x - margin_length) / (square_side_length + distance_between_squares)); - return Array(row, column); + return [row, column]; } // given the row number of a square, return the leftmost coordinate @@ -365,5 +365,5 @@ function clear_all_timeout() { clearTimeout(timeout_objects[i]); } - timeout_objects = new Array(); + timeout_objects = []; } diff --git a/public/manifest.json b/public/manifest.json index b799aebed9..891708851b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -13,16 +13,16 @@ "type": "image/png" }, { - "src": "icons/android-chrome-256x256.png", - "sizes": "256x256", - "type": "image/png" + "src": "icons/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" }, { "src": "icons/maskable.png", "sizes": "196x196", "type": "image/png", "purpose": "maskable" - } + } ], "start_url": "./", "display": "standalone", diff --git a/src/features/cseMachine/CseMachineAnimation.tsx b/src/features/cseMachine/CseMachineAnimation.tsx index 9249c7bca4..9f549351fa 100644 --- a/src/features/cseMachine/CseMachineAnimation.tsx +++ b/src/features/cseMachine/CseMachineAnimation.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { ArrayAccessAnimation } from './animationComponents/ArrayAccessAnimation'; import { ArrayAssignmentAnimation } from './animationComponents/ArrayAssignmentAnimation'; +import { ArraySpreadAnimation } from './animationComponents/ArraySpreadAnimation'; import { AssignmentAnimation } from './animationComponents/AssignmentAnimation'; import { Animatable } from './animationComponents/base/Animatable'; import { lookupBinding } from './animationComponents/base/AnimationUtils'; @@ -112,6 +113,11 @@ export class CseAnimation { ); } break; + case 'SpreadElement': + CseAnimation.animations.push( + new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems()) + ); + break; case 'AssignmentExpression': case 'ArrayExpression': case 'BinaryExpression': @@ -276,6 +282,35 @@ export class CseAnimation { ) ); break; + case InstrType.SPREAD: + const control = Layout.controlComponent.stackItemComponents; + const array = Layout.previousStashComponent.stashItemComponents.at(-1)!.arrow! + .target! as ArrayValue; + + let currCallInstr; + + for (let i = 1; control.at(-i) != undefined; i++) { + if (control.at(-i)?.text.includes('call ')) { + // find call instr above + currCallInstr = control.at(-i); + break; + } + } + + const resultItems = + array.data.length !== 0 + ? Layout.stashComponent.stashItemComponents.slice(-array.data.length) + : []; + + CseAnimation.animations.push( + new ArraySpreadAnimation( + lastControlComponent, + Layout.previousStashComponent.stashItemComponents.at(-1)!, + resultItems!, + currCallInstr! + ) + ); + break; case InstrType.ARRAY_LENGTH: case InstrType.BREAK: case InstrType.BREAK_MARKER: diff --git a/src/features/cseMachine/animationComponents/ArrayAccessAnimation.tsx b/src/features/cseMachine/animationComponents/ArrayAccessAnimation.tsx index d233117238..7219cafe92 100644 --- a/src/features/cseMachine/animationComponents/ArrayAccessAnimation.tsx +++ b/src/features/cseMachine/animationComponents/ArrayAccessAnimation.tsx @@ -28,6 +28,7 @@ export class ArrayAccessAnimation extends Animatable { private resultAnimation: AnimatedTextbox; private resultArrowAnimation?: AnimatedGenericArrow; private arrayUnit: ArrayUnit; + private outOfRange: boolean; constructor( private accInstr: ControlItemComponent, @@ -46,12 +47,29 @@ export class ArrayAccessAnimation extends Animatable { rectProps: { stroke: defaultDangerColor() } }); this.arrayArrowAnimation = new AnimatedGenericArrow(arrayItem.arrow!); + // if index is out of range + this.outOfRange = false; // the target should always be an array value const array = arrayItem.arrow!.target! as ArrayValue; - this.arrayUnit = array.units[parseInt(indexItem.text)]; + + // if index access is out of range. if index access is negative, error should be thrown from js-slang at this point + const arraylen = array.data.length; + + if (parseInt(indexItem.text) >= arraylen) { + this.outOfRange = true; + this.arrayUnit = array.units[arraylen - 1]; + } else { + this.arrayUnit = array.units[parseInt(indexItem.text)]; + } + this.resultAnimation = new AnimatedTextbox(resultItem.text, { ...getNodeDimensions(resultItem), - x: this.arrayUnit.x() + this.arrayUnit.width() / 2 - this.resultItem.width() / 2, + // if array index out of range, animate to one unit beyond array + x: + this.arrayUnit.x() + + this.arrayUnit.width() / 2 - + this.resultItem.width() / 2 + + (this.outOfRange ? this.arrayUnit.width() : 0), y: this.arrayUnit.y() + this.arrayUnit.height() / 2 - this.resultItem.height() / 2, opacity: 0 }); @@ -79,7 +97,12 @@ export class ArrayAccessAnimation extends Animatable { const minInstrItemWidth = getTextWidth(this.accInstr.text) + ControlStashConfig.ControlItemTextPadding * 2; const indexAboveArrayLocation = { - x: this.arrayUnit.x() + this.arrayUnit.width() / 2 - this.indexItem.width() / 2, + // if array index out of range, animate to one unit beyond array + x: + this.arrayUnit.x() + + this.arrayUnit.width() / 2 - + this.indexItem.width() / 2 + + (this.outOfRange ? this.arrayUnit.width() : 0), y: this.arrayUnit.y() - this.indexItem.height() - 8 }; const indexInArrayLocation = { diff --git a/src/features/cseMachine/animationComponents/ArraySpreadAnimation.tsx b/src/features/cseMachine/animationComponents/ArraySpreadAnimation.tsx new file mode 100644 index 0000000000..cca8b4a375 --- /dev/null +++ b/src/features/cseMachine/animationComponents/ArraySpreadAnimation.tsx @@ -0,0 +1,149 @@ +//import { Easings } from 'konva/lib/Tween'; +import React from 'react'; +import { Group } from 'react-konva'; + +import { ControlItemComponent } from '../components/ControlItemComponent'; +import { StashItemComponent } from '../components/StashItemComponent'; +import { Visible } from '../components/Visible'; +import { ControlStashConfig } from '../CseMachineControlStashConfig'; +import { + defaultActiveColor, + defaultDangerColor, + defaultStrokeColor, + getTextWidth +} from '../CseMachineUtils'; +import { Animatable, AnimationConfig } from './base/Animatable'; +import { AnimatedGenericArrow } from './base/AnimatedGenericArrow'; +import { AnimatedTextbox } from './base/AnimatedTextbox'; +import { getNodePosition } from './base/AnimationUtils'; + +/** + * Adapted from InstructionApplicationAnimation, but changed resultAnimation to [], among others + */ +export class ArraySpreadAnimation extends Animatable { + private controlInstrAnimation: AnimatedTextbox; // the array literal control item + private stashItemAnimation: AnimatedTextbox; + private resultAnimations: AnimatedTextbox[]; + private arrowAnimation?: AnimatedGenericArrow; + private currCallInstrAnimation: AnimatedTextbox; + + private endX: number; + + constructor( + private controlInstrItem: ControlItemComponent, + private stashItem: StashItemComponent, + private resultItems: StashItemComponent[], + private currCallInstrItem: ControlItemComponent + ) { + super(); + + this.endX = stashItem!.x() + stashItem!.width(); + this.controlInstrAnimation = new AnimatedTextbox( + controlInstrItem.text, + getNodePosition(controlInstrItem), + { rectProps: { stroke: defaultActiveColor() } } + ); + this.stashItemAnimation = new AnimatedTextbox(stashItem.text, getNodePosition(stashItem), { + rectProps: { + stroke: defaultDangerColor() + } + }); + + // call instr above + this.currCallInstrAnimation = new AnimatedTextbox( + this.currCallInstrItem.text, + getNodePosition(this.currCallInstrItem), + { rectProps: { stroke: defaultActiveColor() } } + ); + + this.resultAnimations = resultItems.map(item => { + return new AnimatedTextbox(item.text, { + ...getNodePosition(item), + opacity: 0 + }); + }); + if (stashItem.arrow) { + this.arrowAnimation = new AnimatedGenericArrow(stashItem.arrow, { opacity: 0 }); + } + } + + draw(): React.ReactNode { + return ( + + {this.controlInstrAnimation.draw()} + {this.stashItemAnimation.draw()} + {this.currCallInstrAnimation.draw()} + {this.resultAnimations.map(a => a.draw())} + {this.arrowAnimation?.draw()} + + ); + } + + async animate(animationConfig?: AnimationConfig) { + this.resultItems?.map(a => a.ref.current?.hide()); + this.resultItems?.map(a => a.arrow?.ref.current?.hide()); + const minInstrWidth = + getTextWidth(this.controlInstrItem.text) + ControlStashConfig.ControlItemTextPadding * 2; + const resultX = (idx: number) => this.resultItems[idx]?.x() ?? this.stashItem.x(); + const resultY = this.resultItems[0]?.y() ?? this.stashItem.y(); + const startX = resultX(0); + const fadeDuration = ((animationConfig?.duration ?? 1) * 3) / 4; + const fadeInDelay = (animationConfig?.delay ?? 0) + (animationConfig?.duration ?? 1) / 4; + + // Move spread instruction next to stash item (array pointer) + await Promise.all([ + ...this.resultAnimations.flatMap(a => [ + a.animateTo( + { x: startX + (this.endX - startX) / 2 - this.resultItems[0]?.width() / 2 }, + { duration: 0 } + ) + ]), + this.controlInstrAnimation.animateRectTo({ stroke: defaultStrokeColor() }, animationConfig), + this.controlInstrAnimation.animateTo( + { + x: startX, + y: resultY + (this.resultItems[0]?.height() ?? this.stashItem.height()), + width: minInstrWidth + }, + animationConfig + ), + this.stashItemAnimation.animateRectTo({ stroke: defaultDangerColor() }, animationConfig) + ]); + + animationConfig = { ...animationConfig, delay: 0 }; + // Merge all elements together to form the result + await Promise.all([ + this.controlInstrAnimation.animateTo({ x: resultX(0), y: resultY }, animationConfig), + this.controlInstrAnimation.animateTo( + { opacity: 0 }, + { ...animationConfig, duration: fadeDuration } + ), + this.stashItemAnimation.animateTo({ x: resultX(0) }, animationConfig), + this.stashItemAnimation.animateTo( + { opacity: 0 }, + { ...animationConfig, duration: fadeDuration } + ), + + ...this.resultAnimations.flatMap((a, idx) => [ + a.animateTo({ x: resultX(idx) }, animationConfig), + a.animateRectTo({ stroke: defaultDangerColor() }, animationConfig), + a.animateTo( + { opacity: 1 }, + { ...animationConfig, duration: fadeDuration, delay: fadeInDelay } + ) + ]) + ]); + + this.destroy(); + } + + destroy() { + this.ref.current?.hide(); + this.resultItems.map(a => a.ref.current?.show()); + this.resultItems.map(a => a.arrow?.ref.current?.show()); + this.controlInstrAnimation.destroy(); + this.stashItemAnimation.destroy(); + this.resultAnimations.map(a => a.destroy()); + this.arrowAnimation?.destroy(); + } +} diff --git a/src/i18n/locales/en/login.json b/src/i18n/locales/en/login.json index 01617e7fc6..ca4bf45059 100644 --- a/src/i18n/locales/en/login.json +++ b/src/i18n/locales/en/login.json @@ -1,4 +1,4 @@ { - "Logging In": "Logging In...", - "Log in with": "Log in with {{name}}" + "Logging In": "Logging In...", + "Log in with": "Log in with {{name}}" } diff --git a/src/i18n/locales/pseudo/login.json b/src/i18n/locales/pseudo/login.json index e95fc90dda..eeb499fe79 100644 --- a/src/i18n/locales/pseudo/login.json +++ b/src/i18n/locales/pseudo/login.json @@ -1,4 +1,4 @@ { - "Logging In": "Lògging Ìn...", - "Log in with": "Lòg in with {{name}}" + "Logging In": "Lògging Ìn...", + "Log in with": "Lòg in with {{name}}" }