diff --git a/.github/ISSUE_TEMPLATE/port-extension-to-framework.md b/.github/ISSUE_TEMPLATE/port-extension-to-framework.md new file mode 100644 index 000000000..9360ceb61 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/port-extension-to-framework.md @@ -0,0 +1,25 @@ +--- +name: Port Extension to Framework +about: Task for converting an existing extension to the new PRG framework form +title: '' +labels: extension framework +assignees: '' + +--- + +# Details + +Old extension Location: `packages/scratch-vm/extensions/___` + +Branch name: `___` + +Folder name: `___` + +# Getting started + +## Checkout branch +```bash +git checkout dev +git pull +git checkout -b +``` diff --git a/.github/workflows/generate-extension-docs.yml b/.github/workflows/generate-extension-docs.yml new file mode 100644 index 000000000..89f6a82cc --- /dev/null +++ b/.github/workflows/generate-extension-docs.yml @@ -0,0 +1,27 @@ +name: Generate Extension Documentation + +on: [push] +permissions: + contents: write + pages: write + id-token: write +jobs: + generate: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.17.1] + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: Initialize + run: npm run init + - name: Generate + run: npm run document:extensions + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: 're-generate extension documentation\n\nskip-checks:true' + file_pattern: 'extensions/README.md' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 60dd6415f..06b67e472 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* +.DS_Store +**/.DS_Store + # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7956be3e9..5051561ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## 2023-03-25 (pending) +## 2023-07-14 + +### Changed +- Custom Argument UI now support updating the value of the argument whenever it's `setter` is called (instead of having to explicitly click "Apply") + - This is a lot more intuitive and really how it should've been implemented from the beginning (it just didn't occur to me how) -- these changes also bring some serious cleanup and simplification of the custom argument code to hopefully make maintenance easier in the future + - In the process of implemeting this, we were able to remove the need for any changes to the `scratch-vm` to make custom arguments work + - **BREAKING CHANGE:** If you're a developer who uses custom arguments in their extension, you will have to change how you specify the `component` to associate with your custom argument. Instead of specifying the name of the svelte file to load (in a string), you'll actually now import the svelte component directly: + - Before: + ```ts + arg: self.makeCustomArgument({ + component: "MyArgUI", + initial: { value: { a: 10, b: "Hello world", c: false }, text: "[10, Hello world, false]", } + }), + ``` + - Now: + ```ts + // At top of file + import MyArgUI from "./MyArgUI.svelte" + + // Within block definition + arg: self.makeCustomArgument({ + component: MyArgUI, + initial: { value: { a: 10, b: "Hello world", c: false }, text: "[10, Hello world, false]", } + }), + ``` + +## 2023-03 - 2023-06 (backfill) + +### Added +- Support for changing the color of blocks. (docs coming soon -- ping @pmalacho-mit if you need 'em and can't find em) +- Support for inline image arguments. [See docs](https://github.com/mitmedialab/prg-extension-boilerplate/tree/dev/extensions#adding-inline-images-to-the-text-of-blocks) +- Support for indicators (docs coming soon -- ping @pmalacho-mit if you need 'em and can't find em)) +- Support for accessing block's ID form `BlockUtility`. [See docs](https://github.com/mitmedialab/prg-extension-boilerplate/tree/dev/extensions#block-id) +- Ability to tag extensions to control how they are sorted in the extensions menu: [See docs](https://github.com/mitmedialab/prg-extension-boilerplate/tree/dev/extensions#extension-menu-tags--categories) + +### Changed + +- Teachable machine extension is now a `configurable` extension (as opposed to a `generic` extension (the old way of doing things)) + +## 2023-03-25 ### Changed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..910dce469 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,30 @@ +# Current development (ordered by priority) + +Items will be crossed off as they are completed and merged to dev. The merge & review process is documented [here](); + +@pmalacho-mit will manage merging dev to main periodically (at first as needed, but eventually on a regular schedule). + +- [Hat block update to support App Inventor interoperability](https://github.com/mitmedialab/prg-extension-boilerplate/issues/203) (easy) +- [Document bundling/build process & project architecture]() (medium) +- [First class Google Drive support]() (hard) +- [Tutorial support all from extension's folder]() (medium) +- [Loading curricullum 'recipes']() (medium) +- Support all extension fields + - [branchCount]() (easy) + - [filter]() (easy) + - [launchPeripheralConnectionFlow]() (easy) + - [terminal]() (easy) +- [Save/load data per extension]() +- [Workers]() +- First class support for other cloud providers (re-order based on feedback from teacher's schools) + - Dropbox + - OneDrive + - Microsoft + +# Wishlist +- Opencv / feature perception object classification and localization extension (maybe 2 seperate extensions) +- Fly tello using scratch + +# Things that'd be awesome to do, but are likely more work than is currently worth it +- Split every extension into it's own package +- Custom block arguments diff --git a/README.md b/README.md index b1d549a84..538f038b5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Assuming you have... Run the following from the command line: +(**NOTE:** If using gitpod, this all will be done for you on startup) + ```shell script git clone git@github.com:mitmedialab/prg-extension-boilerplate.git # Clone the repository onto your computer. This could take a while (~5m), grab a beverage! @@ -165,7 +167,7 @@ Also, try hovering over fields to view their documentation (typically a [summary ![Gif of video hovering over fields to peak documentation](/assets/hover.gif) -Still stuck? Check out our [From 0 to Extension guide](#-from-0-to-extension) and/or contact more experienced Scratch developers, like [Parker](https://github.com/pmalacho-mit) or [Randi](https://github.com/randi-c-dubs) +Still stuck? Check out our [From 0 to Extension guide](#-from-0-to-extension) and/or contact more experienced extension developers, like [Parker](https://github.com/pmalacho-mit) or [Randi](https://github.com/randi-c-dubs) ### 🪜 From 0 to Extension @@ -174,7 +176,7 @@ Still stuck? Check out our [From 0 to Extension guide](#-from-0-to-extension) an Currently, depending on what's new to you, here are some recommendations: - ***New to Javascript and Typescript?*** Follow this [javascript tutorial](https://www.w3schools.com/js/) and then check out the [Typescript handbook](https://www.typescriptlang.org/docs/handbook/intro.html) - ***Know javascript but new to Typescript?*** Check out the [Typescript handbook](https://www.typescriptlang.org/docs/handbook/intro.html) -- ***Know javascript/typescript but never made an extension before?*** Nice! The documentation of the template `index.ts` should be enough to get you started (and if not, please give that feedback) +- ***Know javascript/typescript but never made an extension before?*** Nice! The documentation of the template `index.ts` should be enough to get you started (and if not, please give us that feedback) - ***New to the extension framework (but had developed extensions in the past)?*** The [Porting an extension to Typescript guide](#-porting-an-extension-to-typescript) is likely for you! Probably will have: @@ -212,7 +214,7 @@ Like many web development projects, this project requires [node](https://nodejs. Also, [due to a Webpack 4 issue](https://github.com/webpack/webpack/issues/14532), we require a node version <=16. -Please locate the [latest v16 release](https://nodejs.org/en/blog/release) and install a suitable version for your operating system. +Please locate the [latest v16 release](https://nodejs.org/en/blog/release) and install a suitable version for your operating system. If you already have node and need to downgrade to a version <= 16, please see these [instructions for downgrading node](https://www.educative.io/answers/how-to-downgrade-node-version). #### Maintainer Note (9/15/22) diff --git a/extensions/README.md b/extensions/README.md index 90a9b1032..47c2cf17f 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -18,9 +18,12 @@ This document will be most helpful for people doing more complex development, li 4. [Creating UI for Extensions](#creating-ui-for-extensions) 5. [Porting an Extension to use our Framework & Typescript](#porting-an-extension-to-use-our-framework--typescript) 6. [Saving Custom Data for an Extension](#saving-custom-data-for-an-extension) -7. [Making use of the Block Utility](#making-use-of-the-block-utility) -8. [Adding Custom Arguments](#adding-custom-arguments) -9. [Reference](#reference) +7. [App Inventor Cross-Compilation / Interoperability](#app-inventor-crosscompilation--interoperability) +8. [Making use of the Block Utility & Block ID](#making-use-of-the-block-utility--block-id) +9. [Adding Custom Arguments](#adding-custom-arguments) +10. [Extension Menu Tags / Categories](#extension-menu-tags--categories) +11. [Adding inline images to the text of blocks](#adding-inline-images-to-the-text-of-blocks) +12. [Reference](#reference) ## Anatomy of an Extension Directory @@ -1179,19 +1182,87 @@ export default class SaveLoadExample extends extension({ name }, "customSaveData ``` -## Making use of the Block Utility +## App Inventor Cross-Compilation / Interoperability + +> NOTE: This is a generated README section, so no edits you make to it in this file will be saved. +If you want to edit it, please go to [extensions/documentation/src/appInventor/README.md](documentation/src/appInventor/README.md) + +This effort is a work in progress and **_not_** ready to use. + +Please contact @pmalacho-mit (Parker Malachowsky) if you're interested in this work! + +```ts +import { Environment, extension, block, getterBlock, PropertyBlockDetails, setterBlock, Matrix } from "$common"; + +const heightProperty: PropertyBlockDetails = { name: "Height", type: "number" }; + +export default class extends extension({ name: "App Inventor Example", tags: ["PRG Internal"] }, "appInventor") { + init(env: Environment): void { } + + field = 0; + + @getterBlock(heightProperty) + get some_property(): number { + if (this.withinAppInventor) console.log("RAISE Blocks + App Inventor = <3"); + return this.field; + } + + @setterBlock(heightProperty) + set some_property(value: number) { + this.field = value; + } + + @block({ + text: (x, y, z) => `${x} ${y} ${z}`, + args: ["number", "string", "matrix"], + type: "reporter" + }) + dummy(x: number, y: string, z: Matrix): number { + return 0; + } +} +``` + + +## Making use of the Block Utility & Block ID > NOTE: This is a generated README section, so no edits you make to it in this file will be saved. If you want to edit it, please go to [extensions/documentation/src/blockUtility/README.md](documentation/src/blockUtility/README.md) -... Coming soon ... +The Scratch runtime will pass a `BlockUtility` object to every block method when it is executed. + +This can help you do things like: +- ...TBD... + +### Block ID + +PRG has added an additional property to the `BlockUtility`, the `blockID` field, which allows you to uniquely associate an invocation of your block method with a block in the user's environment. Access it as demonstrated below: + +```ts +import { BlockUtilityWithID, Environment, block, extension } from "$common"; + +export default class extends extension({ name: "Block Utility example" }) { + override init(env: Environment) { } + + @block({ + type: "command", + text: (someArgument) => `Block text with ${someArgument}`, + arg: "number" + }) + exampleBlockMethod(someArgument: number, util: BlockUtilityWithID) { + const { blockID } = util; + console.log(`My ID is: ${blockID}`) + } +} +``` + ## Adding Custom Arguments > NOTE: This is a generated README section, so no edits you make to it in this file will be saved. If you want to edit it, please go to [extensions/documentation/src/customArguments/README.md](documentation/src/customArguments/README.md) -The Extension Framework allows us to do a lot of cool stuff that would be tricky to do if we were using the [default Scratch Extension workflow](). +The Extension Framework allows us to do a lot of cool stuff that would be tricky or impossible to do if we were using the [default Scratch Extension workflow](). One of the coolest is the ability to define custom arguments, which means both: - Introducing an arbitrary new type of argument @@ -1247,6 +1318,9 @@ When invoking the `@block` decorator function on our method that uses a custom a ```ts +/** Import our svelte component (reference below) */ +import MyArgUI from "./MyArgUI.svelte"; + export default class ExtensionWithCustomArgument extends extension(details, "customArguments") { init = notRelevantToExample; @@ -1254,10 +1328,10 @@ export default class ExtensionWithCustomArgument extends extension(details, "cus type: "command", text: (arg) => `Set custom argument ${arg}`, - /** Invoke the member funtcion `makeCustomArgument` of `self` parameter + /** Invoke the member function `makeCustomArgument` of `self` parameter * (which is an instance of our `ExtensionWithCustomArgument` class). * The `makeCustomArgument` function accepts an object with the following fields: - * - component: The name of the `.svelte` file that should be displayed when this argument is clicked on. + * - component: The `svelte` component that should be displayed when this argument is clicked on. * - initial: The value that the argument should default to. NOTE that this item has both a 'text' and 'value' field. * - This is because the value of the custom argument must be able to be represented as a string * and displayed directly in the block once the UI closes. @@ -1265,7 +1339,7 @@ export default class ExtensionWithCustomArgument extends extension(details, "cus * representation of that value. */ arg: self.makeCustomArgument({ - component: "MyArgUI", + component: MyArgUI, initial: { value: { a: 10, b: "Hello world", c: false }, text: "[10, Hello world, false]", } }), })) @@ -1283,71 +1357,136 @@ export default class ExtensionWithCustomArgument extends extension(details, "cus Then, we modify the UI (Svelte) component we created earlier to match our block function argument, like so: -```ts - +### (Advanced) Architecture - +If you're solely interested in adding custom arguments to your extension's blocks, you can skip the following section -- all you need is the above information. -
- - - -
+
+ +You can open this section to learn how the code all works together to enable this functionality. + + +To add custom arguments, we unfortunately need to make modifications to multiple packages involved in the RAISE playground (`packages/scratch-gui` in addition to `extensions`). + +> This is _unfortunate_ as we aim to keep the Scratch-based packages as similiar to their original sources as possible. This way we can more easily incorporate changes and improvements released by the Scratch team. Thus, even though we are modifying scratch packages, we try keep our changes as small and surgical as possible. + +One aspect that makes implementing this functionality tricky is that the UI of blocks is controlled by [scratch-blocks](https://github.com/scratchfoundation/scratch-blocks), which is a package not included in our repository1, so making modifications to it (perhaps at runtime) would be very difficult to maintain. Therefore, we opt for a solution that requires no changes to `scratch-blocks`. + +>1 `scratch-blocks` used to be included in this repo and linked using [lerna](https://lerna.js.org/), however we had no local changes to it and thus it made more sense to rely on the [npm package](https://www.npmjs.com/package/scratch-blocks) instead. Re-adding the package to accomplish this functionality was considered, but ultimately deemed undesirable as we want to avoid modifications to Scratch sources (see above) and it appeared very difficult to create argument UI in `scratch-blocks, especially for abitrary data types. + +At the heart of this implementation is co-opting the usage of block argument's dynamic menus. When an argument with a menu is clicked on, it will render the list of menu options to a dropdown. When that argument's menu is **_dynamic_**, it will receive the list of options to display by invoking a function. + +> In the extension framework, an argument with a dynamic menu looks like: +>```ts +>arg: { +> type: "number", +> options: () => ["option A", "option B"] // for example +>} +>``` + +This is the perfect setup for our solution, as: +- The dropdown that is opened on a menu click offers a perfect surface for rendering a custom argument UI to +- The invocation of a dynamic menu's function enables us to know when a dropdown is opened, and thus when we should render the custom argument's UI + +So at a high-level, this is how our implementation works: +- Custom arguments are implemented "under the hood" as arguments with a dynamic menu +- When a developer specifies a custom argument, they provide a svelte component that will be used as the custom argument's UI +- The extension framework takes care of providing the `options` function for the internal dynamic menu of the argument, which is responsible for rendering the custom argument's UI to the menu's dropdown when it is clicked on by the user + +To get a little more into the details... + +Block argument menu dropdown's are controlled by Blockly's [FieldDropdown](https://developers.google.com/blockly/reference/js/blockly.fielddropdown_class) class. A specific `FieldDropdown` class, tied to a specific block argument's **_dynamic_** menu, will invoke the menu's `options` function at various points during the _lifecycle_ of the field dropdown (like when it is initialized and when it is opened by the user). + +Therefore, we override a few key functions on Blockly's [FieldDropdown](https://developers.google.com/blockly/reference/js/blockly.fielddropdown_class) class (implemented in [packages/scratch-gui/src/lib/prg/customBlockOverrides.js]()) in order to collect the information about the dropdown before the dynamic `options` function is invoked. We can then use this information inside of our `options` function, while all other menus will be unnaffected. + +> Overriding this functionality does ahead overhead to every single dropdown menu, but this _cost_ should be negligible. + +From there, the extension framework handles the rest: +- The `"customArguments"` add-on handles setting up the dynamic `options` function that maps custom argument inputs from the user to menu options that Scratch can handle (as well as rendering the custom argument UI when the dropdown is first opened) +- Before arguments are passed to their corresponding block methods, the framework checks to see if the value is a custom argument idenitifier, and if so the appropriate _value_ is retrieved and passed to the method instead +
+ + + +## Extension Menu Tags / Categories + +> NOTE: This is a generated README section, so no edits you make to it in this file will be saved. +If you want to edit it, please go to [extensions/documentation/src/extensionMenuTags/README.md](documentation/src/extensionMenuTags/README.md) + +Extensions can be associated with certain `tags` (or categories), which are visible in the [Extensions Menu](https://en.scratch-wiki.info/wiki/Extension#Adding_Extensions) and allow users to more easily find the extensions they are looking for. + +`tags` are be specified within the first "details" argument of the `extension(...)` factory function invocation, like so: + +```ts +import { extension } from "$common"; + +export default class TagsExample extends extension( + { + name: "A demonstration of using tags to categorize extensions", + tags: ["Made by PRG"] + } +) { + init() { /* ignore */ } +} ``` -> Included links: -> * https://github.com/mitmedialab/prg-extension-boilerplate/tree/dev/extensions#creating-ui-for-extensions + +To add define new `tags`, add an additional string literal to the [Tag type](). + +# Adding inline images to the text of blocks + +> NOTE: This is a generated README section, so no edits you make to it in this file will be saved. +If you want to edit it, please go to [extensions/documentation/src/inlineImages/README.md](documentation/src/inlineImages/README.md) + +As noted in [Scratch's extension documentation](https://github.com/scratchfoundation/scratch-vm/blob/develop/docs/extensions.md#adding-an-inline-image), Blocks support arguments that can display images inline within their text display. + +We can make use of this feature within the framework by adding an extra argument of type `"inline image"` to our extension's method, and then seperately add an `arg` (or `args`) entry within the associated `@block` decorator invocation. + +See the below example (which assumes that a file `myPic.png` is located in the same directory as our code): + +```ts + +import { Environment, block, extension } from "$common"; +// We import our image as if it was a code file +import myPic from "./myPic.png"; + +export default class ExampleExtensionWithInlineImages extends extension({ + name: "This is an example extension with inline images", +}) { + override init(env: Environment) { } + + @block({ + type: "command", + text: (image) => `Here's an inline image: ${image}`, + arg: { + type: "image", + uri: myPic, + alt: "this is a test image", // description of the image for screen readers + flipRTL: true, + } + }) + methodWithOnlyInlineImage(image: "inline image") { + // NOTE: The `image` argument should not be used + } + + @block({ + type: "command", + text: (someNumber, image, someString) => `Here's a number ${someNumber} and picture ${image} and string ${someString}}`, + args: [ + { type: "number" }, + { type: "image", uri: myPic, alt: "this is a test image", flipRTL: true }, + "string" + ] + }) + methodWithInlineImageAndOtherArguments(someNumber: number, image: "inline image", someString: string) { + // NOTE: The `image` argument should not be used + } +} + +``` ## Reference diff --git a/extensions/documentation/scripts/utils/extract.ts b/extensions/documentation/scripts/utils/extract.ts index 3d2bb0c06..ccc7a70c6 100644 --- a/extensions/documentation/scripts/utils/extract.ts +++ b/extensions/documentation/scripts/utils/extract.ts @@ -103,7 +103,7 @@ export const extractSnippet = async ({ pathToREADME, match }: ParseMatch): Promi const pathToFile = path.resolve(dir, file); if (!fs.existsSync(pathToFile)) { - error(chalk.red(`Could not resolve file: ${file}`)); + error(chalk.red(`Could not locate file: ${file} \n\t- resolved location: ${pathToFile}`)); return { kind: "snippet", failure: true }; } diff --git a/extensions/documentation/src/appInventor/README.md b/extensions/documentation/src/appInventor/README.md new file mode 100644 index 000000000..c04c19abe --- /dev/null +++ b/extensions/documentation/src/appInventor/README.md @@ -0,0 +1,7 @@ +## App Inventor Cross-Compilation / Interoperability + +This effort is a work in progress and **_not_** ready to use. + +Please contact @pmalacho-mit (Parker Malachowsky) if you're interested in this work! + +[](../../../src/appinventor_example/index.ts) \ No newline at end of file diff --git a/extensions/documentation/src/blockUtility/README.md b/extensions/documentation/src/blockUtility/README.md index cb2ff282a..017e5edb2 100644 --- a/extensions/documentation/src/blockUtility/README.md +++ b/extensions/documentation/src/blockUtility/README.md @@ -1,3 +1,12 @@ -## Making use of the Block Utility +## Making use of the Block Utility & Block ID -... Coming soon ... \ No newline at end of file +The Scratch runtime will pass a `BlockUtility` object to every block method when it is executed. + +This can help you do things like: +- ...TBD... + +### Block ID + +PRG has added an additional property to the `BlockUtility`, the `blockID` field, which allows you to uniquely associate an invocation of your block method with a block in the user's environment. Access it as demonstrated below: + +[](./index.ts) \ No newline at end of file diff --git a/extensions/documentation/src/blockUtility/index.ts b/extensions/documentation/src/blockUtility/index.ts new file mode 100644 index 000000000..b815a9819 --- /dev/null +++ b/extensions/documentation/src/blockUtility/index.ts @@ -0,0 +1,15 @@ +import { BlockUtilityWithID, Environment, block, extension } from "$common"; + +export default class extends extension({ name: "Block Utility example" }) { + override init(env: Environment) { } + + @block({ + type: "command", + text: (someArgument) => `Block text with ${someArgument}`, + arg: "number" + }) + exampleBlockMethod(someArgument: number, util: BlockUtilityWithID) { + const { blockID } = util; + console.log(`My ID is: ${blockID}`) + } +} \ No newline at end of file diff --git a/extensions/documentation/src/customArguments/CustomArgument.svelte b/extensions/documentation/src/customArguments/MyArgUI.svelte similarity index 60% rename from extensions/documentation/src/customArguments/CustomArgument.svelte rename to extensions/documentation/src/customArguments/MyArgUI.svelte index 48c3d0933..b19f05369 100644 --- a/extensions/documentation/src/customArguments/CustomArgument.svelte +++ b/extensions/documentation/src/customArguments/MyArgUI.svelte @@ -6,55 +6,49 @@ * This type will hold onto the type of our custom argument, * and ensure this UI remains in sync with the block function argument it's associated with. * To do so, we make use of the 'ParameterOf' utility type. - * The first parameter is our Extension. - * The second parameter is the name of the block function this argument belongs to. - * The third parameter is the index of the argument (since here we want the first argument, we use an index of 0) + * The first generic argument is our Extension. + * The second generic argument is the name of the block function this argument belongs to. + * The third generic argument is the index of the argument (since here we want the first argument, we use an index of 0) */ - type Value = ParameterOf; - + type Value = ParameterOf; + /** * This function will be used to set the value of your custom argument. - * NOTE: The argument won't actually be updated until the user clicks 'Apply' which will appear underneath this UI. - * If they close the UI without clicking 'Apply', the changes won't persist. - * So in order for UI changes to take affect, you must call `setter(...)` and then the user must click apply. */ - // svelte-ignore unused-export-let export let setter: ArgumentEntrySetter; - + /** - * This is the current value of the custom argument at the time of opening this UI. + * This is the current value of the custom argument at the time of opening this UI. * Changing this value will have no effect -- instead use the `setter` function. */ - // svelte-ignore unused-export-let export let current: ArgumentEntry; /** - * This is a reference to your extension. + * This is a reference to your extension. * It should be treated as 'readonly', meaning you should only pull information FROM your extension to populate this UI. * You should NOT use this UI to modify the extension, as that would both confuse the user and anyone developing the extension. - * + * * If you need a UI to control the extension, instead use the Modal-style UI. * @see https://github.com/mitmedialab/prg-extension-boilerplate/tree/dev/extensions#creating-ui-for-extensions */ - // svelte-ignore unused-export-let export let extension: Extension; - + /** * Create variables to store the different parts of our argument's value */ - let {a, b, c} = current.value; - + let { a, b, c } = current.value; + /** * Use Svelte's reactivity to call the `setter` function whenever one of our inputs change - */ - $: setter({ value: {a, b, c}, text: `[${a}, ${b}, ${c}]` }); + */ + $: setter({ value: { a, b, c }, text: `[${a}, ${b}, ${c}]` }); +
+ + + +
+ - -
- - - -
\ No newline at end of file diff --git a/extensions/documentation/src/customArguments/README.md b/extensions/documentation/src/customArguments/README.md index bd1d2161d..7bdf3b140 100644 --- a/extensions/documentation/src/customArguments/README.md +++ b/extensions/documentation/src/customArguments/README.md @@ -1,6 +1,6 @@ ## Adding Custom Arguments -The Extension Framework allows us to do a lot of cool stuff that would be tricky to do if we were using the [default Scratch Extension workflow](). +The Extension Framework allows us to do a lot of cool stuff that would be tricky or impossible to do if we were using the [default Scratch Extension workflow](). One of the coolest is the ability to define custom arguments, which means both: - Introducing an arbitrary new type of argument @@ -40,3 +40,54 @@ When invoking the `@block` decorator function on our method that uses a custom a Then, we modify the UI (Svelte) component we created earlier to match our block function argument, like so: [](./CustomArgument.svelte) + + +### (Advanced) Architecture + +If you're solely interested in adding custom arguments to your extension's blocks, you can skip the following section -- all you need is the above information. + +
+ +You can open this section to learn how the code all works together to enable this functionality. + + +To add custom arguments, we unfortunately need to make modifications to multiple packages involved in the RAISE playground (`packages/scratch-gui` in addition to `extensions`). + +> This is _unfortunate_ as we aim to keep the Scratch-based packages as similiar to their original sources as possible. This way we can more easily incorporate changes and improvements released by the Scratch team. Thus, even though we are modifying scratch packages, we try keep our changes as small and surgical as possible. + +One aspect that makes implementing this functionality tricky is that the UI of blocks is controlled by [scratch-blocks](https://github.com/scratchfoundation/scratch-blocks), which is a package not included in our repository1, so making modifications to it (perhaps at runtime) would be very difficult to maintain. Therefore, we opt for a solution that requires no changes to `scratch-blocks`. + +>1 `scratch-blocks` used to be included in this repo and linked using [lerna](https://lerna.js.org/), however we had no local changes to it and thus it made more sense to rely on the [npm package](https://www.npmjs.com/package/scratch-blocks) instead. Re-adding the package to accomplish this functionality was considered, but ultimately deemed undesirable as we want to avoid modifications to Scratch sources (see above) and it appeared very difficult to create argument UI in `scratch-blocks, especially for abitrary data types. + +At the heart of this implementation is co-opting the usage of block argument's dynamic menus. When an argument with a menu is clicked on, it will render the list of menu options to a dropdown. When that argument's menu is **_dynamic_**, it will receive the list of options to display by invoking a function. + +> In the extension framework, an argument with a dynamic menu looks like: +>```ts +>arg: { +> type: "number", +> options: () => ["option A", "option B"] // for example +>} +>``` + +This is the perfect setup for our solution, as: +- The dropdown that is opened on a menu click offers a perfect surface for rendering a custom argument UI to +- The invocation of a dynamic menu's function enables us to know when a dropdown is opened, and thus when we should render the custom argument's UI + +So at a high-level, this is how our implementation works: +- Custom arguments are implemented "under the hood" as arguments with a dynamic menu +- When a developer specifies a custom argument, they provide a svelte component that will be used as the custom argument's UI +- The extension framework takes care of providing the `options` function for the internal dynamic menu of the argument, which is responsible for rendering the custom argument's UI to the menu's dropdown when it is clicked on by the user + +To get a little more into the details... + +Block argument menu dropdown's are controlled by Blockly's [FieldDropdown](https://developers.google.com/blockly/reference/js/blockly.fielddropdown_class) class. A specific `FieldDropdown` class, tied to a specific block argument's **_dynamic_** menu, will invoke the menu's `options` function at various points during the _lifecycle_ of the field dropdown (like when it is initialized and when it is opened by the user). + +Therefore, we override a few key functions on Blockly's [FieldDropdown](https://developers.google.com/blockly/reference/js/blockly.fielddropdown_class) class (implemented in [packages/scratch-gui/src/lib/prg/customBlockOverrides.js]()) in order to collect the information about the dropdown before the dynamic `options` function is invoked. We can then use this information inside of our `options` function, while all other menus will be unnaffected. + +> Overriding this functionality does ahead overhead to every single dropdown menu, but this _cost_ should be negligible. + +From there, the extension framework handles the rest: +- The `"customArguments"` add-on handles setting up the dynamic `options` function that maps custom argument inputs from the user to menu options that Scratch can handle (as well as rendering the custom argument UI when the dropdown is first opened) +- Before arguments are passed to their corresponding block methods, the framework checks to see if the value is a custom argument idenitifier, and if so the appropriate _value_ is retrieved and passed to the method instead +
+ diff --git a/extensions/documentation/src/customArguments/index.ts b/extensions/documentation/src/customArguments/index.ts index 3dc96d80a..d9a3cfde0 100644 --- a/extensions/documentation/src/customArguments/index.ts +++ b/extensions/documentation/src/customArguments/index.ts @@ -4,6 +4,9 @@ import { MyCustomArgument, details } from "./extension"; export const x = codeSnippet(); +/** Import our svelte component (reference below) */ +import MyArgUI from "./MyArgUI.svelte"; + export default class ExtensionWithCustomArgument extends extension(details, "customArguments") { init = notRelevantToExample; @@ -11,10 +14,10 @@ export default class ExtensionWithCustomArgument extends extension(details, "cus type: "command", text: (arg) => `Set custom argument ${arg}`, - /** Invoke the member funtcion `makeCustomArgument` of `self` parameter + /** Invoke the member function `makeCustomArgument` of `self` parameter * (which is an instance of our `ExtensionWithCustomArgument` class). * The `makeCustomArgument` function accepts an object with the following fields: - * - component: The name of the `.svelte` file that should be displayed when this argument is clicked on. + * - component: The `svelte` component that should be displayed when this argument is clicked on. * - initial: The value that the argument should default to. NOTE that this item has both a 'text' and 'value' field. * - This is because the value of the custom argument must be able to be represented as a string * and displayed directly in the block once the UI closes. @@ -22,7 +25,7 @@ export default class ExtensionWithCustomArgument extends extension(details, "cus * representation of that value. */ arg: self.makeCustomArgument({ - component: "MyArgUI", + component: MyArgUI, initial: { value: { a: 10, b: "Hello world", c: false }, text: "[10, Hello world, false]", } }), })) diff --git a/extensions/documentation/src/extensionMenuTags/README.md b/extensions/documentation/src/extensionMenuTags/README.md new file mode 100644 index 000000000..676225f16 --- /dev/null +++ b/extensions/documentation/src/extensionMenuTags/README.md @@ -0,0 +1,9 @@ +## Extension Menu Tags / Categories + +Extensions can be associated with certain `tags` (or categories), which are visible in the [Extensions Menu](https://en.scratch-wiki.info/wiki/Extension#Adding_Extensions) and allow users to more easily find the extensions they are looking for. + +`tags` are be specified within the first "details" argument of the `extension(...)` factory function invocation, like so: + +[](./index.ts) + +To add define new `tags`, add an additional string literal to the [Tag type](). \ No newline at end of file diff --git a/extensions/documentation/src/extensionMenuTags/index.ts b/extensions/documentation/src/extensionMenuTags/index.ts new file mode 100644 index 000000000..bcfe1f8d2 --- /dev/null +++ b/extensions/documentation/src/extensionMenuTags/index.ts @@ -0,0 +1,10 @@ +import { extension } from "$common"; + +export default class TagsExample extends extension( + { + name: "A demonstration of using tags to categorize extensions", + tags: ["Made by PRG"] + } +) { + init() { /* ignore */ } +} \ No newline at end of file diff --git a/extensions/documentation/src/inlineImages/README.md b/extensions/documentation/src/inlineImages/README.md new file mode 100644 index 000000000..bd66a13e4 --- /dev/null +++ b/extensions/documentation/src/inlineImages/README.md @@ -0,0 +1,9 @@ +# Adding inline images to the text of blocks + +As noted in [Scratch's extension documentation](https://github.com/scratchfoundation/scratch-vm/blob/develop/docs/extensions.md#adding-an-inline-image), Blocks support arguments that can display images inline within their text display. + +We can make use of this feature within the framework by adding an extra argument of type `"inline image"` to our extension's method, and then seperately add an `arg` (or `args`) entry within the associated `@block` decorator invocation. + +See the below example (which assumes that a file `myPic.png` is located in the same directory as our code): + +[](./index.ts?export=x) \ No newline at end of file diff --git a/extensions/documentation/src/inlineImages/index.ts b/extensions/documentation/src/inlineImages/index.ts new file mode 100644 index 000000000..47ee5095b --- /dev/null +++ b/extensions/documentation/src/inlineImages/index.ts @@ -0,0 +1,42 @@ +import { codeSnippet } from "documentation"; + +export const x = codeSnippet(); + +import { Environment, block, extension } from "$common"; +// We import our image as if it was a code file +import myPic from "./myPic.png"; + +export default class ExampleExtensionWithInlineImages extends extension({ + name: "This is an example extension with inline images", +}) { + override init(env: Environment) { } + + @block({ + type: "command", + text: (image) => `Here's an inline image: ${image}`, + arg: { + type: "image", + uri: myPic, + alt: "this is a test image", // description of the image for screen readers + flipRTL: true, + } + }) + methodWithOnlyInlineImage(image: "inline image") { + // NOTE: The `image` argument should not be used + } + + @block({ + type: "command", + text: (someNumber, image, someString) => `Here's a number ${someNumber} and picture ${image} and string ${someString}}`, + args: [ + { type: "number" }, + { type: "image", uri: myPic, alt: "this is a test image", flipRTL: true }, + "string" + ] + }) + methodWithInlineImageAndOtherArguments(someNumber: number, image: "inline image", someString: string) { + // NOTE: The `image` argument should not be used + } +} + +x.end; \ No newline at end of file diff --git a/extensions/documentation/tsconfig.json b/extensions/documentation/tsconfig.json index 68b0e8276..d1ffc29f5 100644 --- a/extensions/documentation/tsconfig.json +++ b/extensions/documentation/tsconfig.json @@ -10,5 +10,9 @@ "skipLibCheck": true, "ignoreDeprecations": "5.0" }, - "include": [".**/*.ts", "**/*.ts"], + "include": [ + ".**/*.ts", + "**/*.ts", + "../src/declaration.d.ts" + ], } \ No newline at end of file diff --git a/extensions/package.json b/extensions/package.json index 9d1398375..e519edb4c 100644 --- a/extensions/package.json +++ b/extensions/package.json @@ -11,7 +11,6 @@ "author": "", "license": "ISC", "devDependencies": { - "stacktrace-js": "^2.0.2", "svelte": "^3.55.0" } -} +} \ No newline at end of file diff --git a/extensions/scripts/bundles/auxiliaryInfo.ts b/extensions/scripts/bundles/auxiliaryInfo.ts index 32f0f37e5..16c12f695 100644 --- a/extensions/scripts/bundles/auxiliaryInfo.ts +++ b/extensions/scripts/bundles/auxiliaryInfo.ts @@ -20,9 +20,9 @@ export const setAuxiliaryInfoForExtension = (info: BundleInfo) => { } const convertToParams = ({ menuDetails, directory, id }: BundleInfo): AuxiliaryExtensionInfoParams => { - const { name } = menuDetails; - const blockIconURI = getBlockIconURI(menuDetails, directory); - return [name, id, blockIconURI]; + const { name, noBlockIcon, blockColor, menuColor, menuSelectColor } = menuDetails; + const blockIconURI = noBlockIcon ? null : getBlockIconURI(menuDetails, directory); + return [name, id, blockIconURI, blockColor, menuColor, menuSelectColor]; } const write = () => { diff --git a/extensions/scripts/bundles/extension.configurable.ts b/extensions/scripts/bundles/extension.configurable.ts index e4f7a011e..82465ec96 100644 --- a/extensions/scripts/bundles/extension.configurable.ts +++ b/extensions/scripts/bundles/extension.configurable.ts @@ -1,6 +1,7 @@ import { type Plugin } from "rollup"; import { announceWrite, finalizeConfigurableExtensionBundle, setupExtensionBundleEntry } from "./plugins"; import { getThirdPartyPlugins, BundleInfo, bundleExtensionBasedOnWatchMode } from "."; +import { extractMethodTypesFromExtension } from "scripts/typeProbing"; export default async function (info: BundleInfo) { @@ -10,6 +11,12 @@ export default async function (info: BundleInfo) { announceWrite(info) ]; - const plugins = [...customPRGPlugins, ...getThirdPartyPlugins()]; + const plugins = [ + ...customPRGPlugins, + ...getThirdPartyPlugins({ + tsTransformers: [extractMethodTypesFromExtension(info)] + }) + ]; + await bundleExtensionBasedOnWatchMode({ plugins, info }); }; \ No newline at end of file diff --git a/extensions/scripts/bundles/index.ts b/extensions/scripts/bundles/index.ts index a83532ea4..3be14f250 100644 --- a/extensions/scripts/bundles/index.ts +++ b/extensions/scripts/bundles/index.ts @@ -14,6 +14,7 @@ import commonjs from "@rollup/plugin-commonjs"; import { terser } from "rollup-plugin-terser"; import css from 'rollup-plugin-css-only'; import json from '@rollup/plugin-json'; +import image from '@rollup/plugin-image'; import nodePolyfills from 'rollup-plugin-polyfill-node'; import babel from "@rollup/plugin-babel"; import chalk from "chalk"; @@ -79,6 +80,7 @@ export const getThirdPartyPlugins = (customizations?: { tsTransformers?: Program babelHelpers: "bundled", }), css(), + image(), terser(), ]; diff --git a/extensions/scripts/bundles/plugins.ts b/extensions/scripts/bundles/plugins.ts index a5bd6cd8d..d50e6048f 100644 --- a/extensions/scripts/bundles/plugins.ts +++ b/extensions/scripts/bundles/plugins.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import { FrameworkID, untilCondition, registerExtensionDefinitionCallback } from "$common"; +import { FrameworkID, untilCondition, extensionBundleEvent, blockBundleEvent } from "$common"; import { type Plugin } from "rollup"; import { appendToRootDetailsFile, populateMenuFileForExtension } from "../extensionsMenu"; import { exportAllFromModule, toNamedDefaultExport } from "../utils/importExport"; @@ -15,6 +15,7 @@ import chalk from "chalk"; import { runOncePerBundling } from "../utils/rollupHelper"; import { sendToParent } from "$root/scripts/comms"; import { setAuxiliaryInfoForExtension } from "./auxiliaryInfo"; +import { getAppInventorGenerator } from "scripts/utils/interop"; export const clearDestinationDirectories = (): Plugin => { const runner = runOncePerBundling(); @@ -78,7 +79,7 @@ export const transpileExtensionGlobals = (): Plugin => { if (!runner.check()) return; const filename = "globals.ts"; const pathToFile = path.join(commonDirectory, filename); - const { options, host } = getSrcCompilerHost(); + const { options, host } = getSrcCompilerHost({ module: ts.ModuleKind.CommonJS }); const outDir = path.join(extensionsFolder, "dist"); const outFile = path.join(outDir, tsToJs(filename)); @@ -192,12 +193,21 @@ export const finalizeConfigurableExtensionBundle = (info: BundleInfo): Plugin => const executeBundleAndExtractMenuDetails = async () => { const framework = await frameworkBundle.content; let success = false; - registerExtensionDefinitionCallback(function (details) { + + extensionBundleEvent.registerCallback(function (extensionInfo, removeSelf) { + const { details } = extensionInfo; + for (const key in menuDetails) delete menuDetails[key]; for (const key in details) menuDetails[key] = details[key]; success = true; + removeSelf(); }); + + const generateAppInventor = getAppInventorGenerator(info); + eval(framework + "\n" + fs.readFileSync(bundleDestination, "utf-8")); - if (!success) throw new Error(`No extension registered for '${name}'. Did you forget to use the extension decorator?`); + if (!success) throw new Error(`No extension registered for '${name}'. Check your usage of the 'extension(...)' factory function.`); + + generateAppInventor(); } const runner = runOncePerBundling(); diff --git a/extensions/scripts/package-lock.json b/extensions/scripts/package-lock.json index 01b103e55..9e75d5687 100644 --- a/extensions/scripts/package-lock.json +++ b/extensions/scripts/package-lock.json @@ -13,6 +13,7 @@ "@rollup/plugin-alias": "^4.0.2", "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-commonjs": "^23.0.4", + "@rollup/plugin-image": "^3.0.2", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-typescript": "^11.0.0", @@ -552,6 +553,27 @@ } } }, + "node_modules/@rollup/plugin-image": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-image/-/plugin-image-3.0.2.tgz", + "integrity": "sha512-eGVrD6lummWH5ENo9LWX3JY62uBb9okUNQ2htXkugrG6WjACrMUVhWvss+0wW3fwJWmFYpoEny3yL4spEdh15g==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "mini-svg-data-uri": "^1.4.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-inject": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.3.tgz", @@ -1321,6 +1343,15 @@ "node": ">=4" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -2338,6 +2369,16 @@ "magic-string": "^0.27.0" } }, + "@rollup/plugin-image": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-image/-/plugin-image-3.0.2.tgz", + "integrity": "sha512-eGVrD6lummWH5ENo9LWX3JY62uBb9okUNQ2htXkugrG6WjACrMUVhWvss+0wW3fwJWmFYpoEny3yL4spEdh15g==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^5.0.1", + "mini-svg-data-uri": "^1.4.4" + } + }, "@rollup/plugin-inject": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.3.tgz", @@ -2866,6 +2907,12 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true }, + "mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true + }, "minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", diff --git a/extensions/scripts/package.json b/extensions/scripts/package.json index dd0b7698e..ed16800f1 100644 --- a/extensions/scripts/package.json +++ b/extensions/scripts/package.json @@ -13,6 +13,7 @@ "@rollup/plugin-alias": "^4.0.2", "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-commonjs": "^23.0.4", + "@rollup/plugin-image": "^3.0.2", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-typescript": "^11.0.0", diff --git a/extensions/scripts/typeProbing/index.ts b/extensions/scripts/typeProbing/index.ts index 498555c55..de74bd9b0 100644 --- a/extensions/scripts/typeProbing/index.ts +++ b/extensions/scripts/typeProbing/index.ts @@ -1,104 +1,127 @@ import ts from "typescript"; import assert from "assert"; -import { ExtensionMenuDisplayDetails, KeysWithValuesOfType, UnionToTuple, Language, identity } from "$common"; +import { ExtensionInstance, ExtensionMenuDisplayDetails, KeysWithValuesOfType, ScratchExtension, getAccessorPrefix, identity, setAccessorPrefix } from "$common"; import { BundleInfo, ProgramBasedTransformer } from "scripts/bundles"; +import type appInventor from "$common/extension/mixins/configurable/appInventor"; type MenuText = KeysWithValuesOfType; -type AllMenuText = UnionToTuple; - type MenuFlag = KeysWithValuesOfType; -type AllMenuFlags = UnionToTuple; - -//@ts-ignore -const menuDetailTextKeys: AllMenuText = ["name", "description", "iconURL", "insetIconURL", "collaborator", "connectionIconURL", "connectionSmallIconURL", "connectionTipIconURL", "connectingMessage", "helpLink", "implementationLanguage"]; -//@ts-ignore -const menuDetailFlagKeys: AllMenuFlags = ["internetConnectionRequired", "bluetoothRequired", "launchPeripheralConnectionFlow", "useAutoScan", "featured", "hidden", "disabled"]; -//@ts-ignore const requiredKeys: (MenuText | MenuFlag)[] = ["name", "description", "iconURL", "insetIconURL"]; +type MethodTypeInformation = { parameterTypes: (readonly [string, ts.Type])[], returnType: ts.Type, typeChecker: ts.TypeChecker }; +const methodsByExtension = new Map>(); + +export const getMethodsForExtension = ({ id }: BundleInfo) => methodsByExtension.get(id); + export const populateDisplayMenuDetailsTransformer = (info: BundleInfo): ProgramBasedTransformer => (program: ts.Program) => { extractExtensionDisplayMenuDetails(info, program) .forEach((value, key) => info.menuDetails[key] = value); - return () => ({ - transformSourceFile: identity, - transformBundle: identity, - }) + return identityTransformation; } -const extractExtensionType = (derived: ts.Type, checker: ts.TypeChecker) => - checker.getTypeFromTypeNode((derived.symbol.declarations[0] as ts.ClassLikeDeclarationBase).heritageClauses[0].types[0]); - -/** - * Leveraging the official type: - * https://github.com/microsoft/TypeScript/blob/86f811440484f6a91e3d2a5ddaeb05eed8bf95cc/src/compiler/types.ts#L6338 - */ -interface TypeReferenceWithInternalField extends ts.TypeReference { - /** @internal */ - resolvedTypeArguments?: readonly ts.Type[]; // Resolved type reference type arguments -} - -const getPropertyMembers = (extensionType: ts.Type) => - ((extensionType as TypeReferenceWithInternalField).resolvedTypeArguments[0].symbol.declarations[0] as ts.TypeLiteralNode).members.map(e => e as ts.PropertySignature); +export const extractMethodTypesFromExtension = (info: BundleInfo): ProgramBasedTransformer => + (program: ts.Program) => { + const { type, checker, node } = probeExtensionProgram(info, program); + const properties = checker.getPropertiesOfType(type); + + if (!shouldProbeExtensionMethods(properties)) return identityTransformation; + + const methods = properties + .map(property => { + const { name } = property; + const propertyType = checker.getTypeOfSymbolAtLocation(property, node); + const entries = new Array(); + + if (match(property, ts.SymbolFlags.GetAccessor)) entries.push({ + name: `${getAccessorPrefix}${name}`, + parameterTypes: [], + returnType: propertyType, + typeChecker: checker + }); + + if (match(property, ts.SymbolFlags.SetAccessor)) entries.push({ + name: `${setAccessorPrefix}${name}`, + parameterTypes: [["value", propertyType]], + returnType: null, + typeChecker: checker + }); + + if (match(property, ts.SymbolFlags.Method)) { + const callSignature = propertyType.getCallSignatures()?.[0]; // Only the first? Are multiple signatures for overloads? + const parameterTypes = callSignature.parameters?.map(parameter => { + const { name, valueDeclaration } = parameter; + return [name, checker.getTypeOfSymbolAtLocation(parameter, valueDeclaration)] as const + }); + const returnType = checker.getReturnTypeOfSignature(callSignature); + entries.push({ name, parameterTypes, returnType, typeChecker: checker }); + } + return entries; + }) + .flat() + .reduce((map, { name, ...types }) => map.set(name, types), new Map()); + + methodsByExtension.set(info.id, methods); + return identityTransformation; + } -const extractExpressionFromComputedProperyName = (name: ts.ComputedPropertyName) => { - const text = name.getText(); - const lastIndex = text.length - 1; - if (text[0] !== "[" || text[lastIndex] !== "]") throw Error("Un expected computed property format: " + text); - return text.substring(1, lastIndex); +const probeExtensionProgram = ({ indexFile }: BundleInfo, program: ts.Program) => { + const checker = program.getTypeChecker(); + const container = { checker, type: null as ts.InterfaceType, node: null as ts.Node, base: null as ts.BaseType }; + ts.forEachChild(program.getSourceFile(indexFile), child => { + if (child.kind !== ts.SyntaxKind.ClassDeclaration) return; + const type = checker.getTypeAtLocation(child); + if (!type.isClass() || type?.symbol?.name !== "default") return; + container.type = type; + container.node = child; + container.base = checker.getBaseTypes(type)[0]; + }); + if (!container.type) throw new Error("Unable to locate extension type"); + if (!container.base) throw new Error("Unable to locate base extension type"); + return container; } -const getNameForProperty = ({ name }: ts.PropertySignature) => { - if (ts.isIdentifier(name)) return name.text; - if (ts.isComputedPropertyName(name)) { - const expression = extractExpressionFromComputedProperyName(name); - return tryTreatAsLanguage(expression) ?? expression; - }; - return "Error -- Couldn't extract name."; -} +const shouldProbeExtensionMethods = (properties: ts.Symbol[]) => { + const propertyNames = properties.map(({ name }) => name); + type AppInventorExtension = InstanceType>; + const appInventorKey: Exclude = "withinAppInventor"; + return propertyNames.includes(appInventorKey); +}; -const tryParseAsJSON = (type: ts.TypeNode) => { - try { return JSON.parse(type.getText()) } - catch { return undefined } -} +const identityTransformation: ReturnType = () => ({ + transformSourceFile: identity, + transformBundle: identity, +}); -const returnFromIIFE = (text: string) => `(() => (${text}))()`; +const match = ({ flags }: ts.Symbol, query: ts.SymbolFlags) => flags & query; -const tryTreatAsObject = (type: ts.TypeNode) => { - try { return eval(returnFromIIFE(type.getText())) } - catch { return undefined } -} +const typeReferenceKey: keyof Omit = "target"; +const isTypeReference = (type: ts.Type): type is ts.TypeReference => typeReferenceKey in type; -const tryTreatAsLanguage = (text: string) => { - const elements = text.replace("typeof ", "").split("."); - if (elements.length !== 2) return undefined; - const key = elements[1]; - return Language[key]; -} +const tryParseJSON = (text: string) => { try { return JSON.parse(text) } catch { return undefined } } -const getValueForProperty = ({ type }: ts.PropertySignature, typeChecker: ts.TypeChecker) => { - const resolvedType = typeChecker.getTypeFromTypeNode(type); - if (resolvedType.isLiteral()) return resolvedType.value; - return tryParseAsJSON(type) ?? tryTreatAsObject(type) ?? tryTreatAsLanguage(type.getText()) ?? "Error -- couldn't extract value"; +const tryEvaluate = (text: string) => { + const cleaned = text.replaceAll(";", ","); + const iife = `(() => (${cleaned}))()`; + try { return eval(iife) } catch { return undefined } } -const extractExtensionDisplayMenuDetails = ({ indexFile }: BundleInfo, program: ts.Program) => { - const checker = program.getTypeChecker(); - +const extractExtensionDisplayMenuDetails = (info: BundleInfo, program: ts.Program) => { let details: Map & ExtensionMenuDisplayDetails; - ts.forEachChild(program.getSourceFile(indexFile), child => { - if (child.kind !== ts.SyntaxKind.ClassDeclaration) return; - const type = checker.getTypeAtLocation(child); - if (type?.symbol?.name !== "default") return; - const extensionType = extractExtensionType(type, checker); - const properties = getPropertyMembers(extensionType); - details = properties.reduce((map, property) => - map.set(getNameForProperty(property), getValueForProperty(property, checker)), - new Map() as typeof details - ); - }); - - if (!details) throw new Error("Unable to extract details"); + const { checker, node, base } = probeExtensionProgram(info, program); + + if (!isTypeReference(base)) throw new Error("Unexpected base type"); + + details = base.typeArguments[0] + .getProperties() + .map(property => { + const { name } = property; + const type = checker.getTypeOfSymbolAtLocation(property, node); + if (type.isLiteral()) return { name, value: type.value }; + const text = checker.typeToString(type); + return { name, value: tryParseJSON(text) ?? tryEvaluate(text) ?? `Error parsing value: ${text}` } + }) + .reduce((map, { name, value }) => map.set(name, value), new Map() as typeof details); requiredKeys.forEach(key => assert(details.has(key), new Error(`Required key '${key}' not found`))); return details; diff --git a/extensions/scripts/utils/interop.ts b/extensions/scripts/utils/interop.ts new file mode 100644 index 000000000..dc9b42390 --- /dev/null +++ b/extensions/scripts/utils/interop.ts @@ -0,0 +1,38 @@ +import { blockBundleEvent, extensionBundleEvent } from "$common"; +import { BundleInfo } from "scripts/bundles"; +import { getMethodsForExtension } from "scripts/typeProbing"; + +/** + * This method assists with extracting details from extensions (at _bundle time_) + * that indicate they should be "cross-compiled" to App Inventor extensions. + */ +export const getAppInventorGenerator = (info: BundleInfo) => { + const prefix = `[App Inventor Interop]`; + + let methodTypes: ReturnType; + let supported = false; + + const signatures = new Array(); + + extensionBundleEvent.registerCallback(({ addOns }, removeSelf) => { + supported = addOns.includes("appInventor"); + removeSelf(); + }); + + const cleanup = blockBundleEvent.registerCallback((metadata) => { + if (!supported) return; + methodTypes ??= getMethodsForExtension(info); + const { methodName } = metadata; + const { parameterTypes, returnType, typeChecker } = methodTypes.get(metadata.methodName); + const parameters = parameterTypes.map(([name, type]) => `${name}: ${typeChecker.typeToString(type)}`).join(", "); + const signature = `${methodName}: (${parameters}) => ${typeChecker.typeToString(returnType)}`; + signatures.push(signature); + console.log(`${prefix} Collected signature for: '${methodName}'`); + }); + + return () => { + cleanup(); + if (!supported) return; + console.log(`${prefix} All signatures:\n${JSON.stringify(signatures, null, 2)}`); + } +} \ No newline at end of file diff --git a/extensions/src/.templates/CustomArgument.svelte b/extensions/src/.templates/CustomArgument.svelte index 762d766af..5c56ca2aa 100644 --- a/extensions/src/.templates/CustomArgument.svelte +++ b/extensions/src/.templates/CustomArgument.svelte @@ -1,35 +1,38 @@ +
+ +
+ - -
- -
\ No newline at end of file diff --git a/extensions/src/.templates/default.ts b/extensions/src/.templates/default.ts index c5a431519..5ef20010a 100644 --- a/extensions/src/.templates/default.ts +++ b/extensions/src/.templates/default.ts @@ -1,5 +1,5 @@ import { ArgumentType, BlockType, Environment, ExtensionMenuDisplayDetails, extension, block } from "$common"; -import BlockUtility from "$root/packages/scratch-vm/src/engine/block-utility"; +import BlockUtility from "$scratch-vm/engine/block-utility"; /** 👋 Hi! diff --git a/extensions/src/.templates/translations.ts b/extensions/src/.templates/translations.ts deleted file mode 100644 index fbb89e6bf..000000000 --- a/extensions/src/.templates/translations.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Extension from "."; -import { Language } from "$common"; - -// Ignore this file for now! -// Translations are still a work in progress, but will be supported (woohoo!) - -const defineTranslations = (): Extension["Translations"] => ({ - [Language.Español]: undefined, -}); - -export default defineTranslations; \ No newline at end of file diff --git a/extensions/src/appinventor_example/README.md b/extensions/src/appinventor_example/README.md new file mode 100644 index 000000000..b8c8a5126 --- /dev/null +++ b/extensions/src/appinventor_example/README.md @@ -0,0 +1,36 @@ +## Important files + +- extensions/src/appinventor_example/index.ts + - Where the extension is implented (so you can experiment with adding new methods & attaching the `@block` decorator) + - Note the _new_ things: + - `@getterBlock` & `@setterBlock` + - `generateAppInventorBinding: true` as an Extension Detail + - We could also do away with this in favor of just 'looking for' the appInvetor mixin (see below) -- depends on if we think every app-inventor-interop extension will use this mixin (if they will, then we should probably use it instead of this property) + - NOTE for @parker: the `ExtensionMenuDisplayDetails` type should probably be renamed since it holds for than just diplay details -- `ExtensionSettings`? + - `"appInventor"` 'configuration' passed into `extension(...)` to apply the appInventor mixin to our extension (see more below) +- extensions/src/common/extension/decorators/blocks.ts + - Where `@block` and associated decorators are defined + - Note the use of the `blockBundleEvent` + - 'fired' from inside of this file, and subscribed to with the bundling process (see below) +- extensions/scripts/bundles/plugins.ts + - Where all our custom rollup (bundling) plugins are defined + - The one we care about here is at the bottom: `finalizeConfigurableExtensionBundle` + - Note the use of `blockBundleEvent.registerCallback` where we can parse information about blocks and use this info for code gen (which can happen at the end of `executeBundleAndExtractMenuDetails`) + - `executeBundleAndExtractMenuDetails` should be renamed... +- extensions/scripts/bundles/auxiliaryInfo.ts + - Where "bundleTime" information is stored (so it can later be passed to the extension instance when it is created) + - This is where icons are encoded to URIs +- extensions/src/common/extension/mixins/optional/appInventor/index.ts + - We can use this to setup common functionality for all app-inventor-interoping extensions +- packages/scratch-vm/src/extension-support/bundle-loader.js + - methods (on Scratch side) to handle importing extension bundles + +## Topics of discussion + +- (As given) we don't know what type is returned by 'reporter' blocks + - Also, scratch really only plays nice with 'reporting' strings & numbers + - BUT you are allowed to return anything you want... + - What we might do: + - Walk the AST for the Extension and look up the method by name + - Ability to opt-in / opt-out of blocks for either platform +- Plan: implement quadratic solver first \ No newline at end of file diff --git a/extensions/src/appinventor_example/index.test.ts b/extensions/src/appinventor_example/index.test.ts new file mode 100644 index 000000000..e468de757 --- /dev/null +++ b/extensions/src/appinventor_example/index.test.ts @@ -0,0 +1,9 @@ +import { createTestSuite } from "$testing"; +import Extension from '.'; + +createTestSuite({ Extension, __dirname }, + { + unitTests: undefined, + integrationTests: undefined + } +); \ No newline at end of file diff --git a/extensions/src/appinventor_example/index.ts b/extensions/src/appinventor_example/index.ts new file mode 100644 index 000000000..38db52e19 --- /dev/null +++ b/extensions/src/appinventor_example/index.ts @@ -0,0 +1,29 @@ +import { Environment, extension, block, getterBlock, PropertyBlockDetails, setterBlock, Matrix } from "$common"; + +const heightProperty: PropertyBlockDetails = { name: "Height", type: "number" }; + +export default class extends extension({ name: "App Inventor Example", tags: ["PRG Internal"] }, "appInventor") { + init(env: Environment): void { } + + field = 0; + + @getterBlock(heightProperty) + get some_property(): number { + if (this.withinAppInventor) console.log("RAISE Blocks + App Inventor = <3"); + return this.field; + } + + @setterBlock(heightProperty) + set some_property(value: number) { + this.field = value; + } + + @block({ + text: (x, y, z) => `${x} ${y} ${z}`, + args: ["number", "string", "matrix"], + type: "reporter" + }) + dummy(x: number, y: string, z: Matrix): number { + return 0; + } +} \ No newline at end of file diff --git a/extensions/src/appinventor_example/package-lock.json b/extensions/src/appinventor_example/package-lock.json new file mode 100644 index 000000000..32d6ecd3c --- /dev/null +++ b/extensions/src/appinventor_example/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "appinventor_example-extension", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "appinventor_example-extension", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/extensions/src/appinventor_example/package.json b/extensions/src/appinventor_example/package.json new file mode 100644 index 000000000..f2fdd46cb --- /dev/null +++ b/extensions/src/appinventor_example/package.json @@ -0,0 +1,15 @@ +{ + "name": "appinventor_example-extension", + "version": "1.0.0", + "description": "An extension created using the PRG AI Blocks framework", + "main": "index.ts", + "scripts": { + "directory": "echo appinventor_example", + "test": "npm run test --prefix ../../ -- appinventor_example/index.test.ts", + "dev": "npm run dev --prefix ../../../ -- only=appinventor_example", + "add:ui": "npm run add:ui --prefix ../../../ -- appinventor_example", + "add:arg": "npm run --prefix ../../../-- appinventor_example" + }, + "author": "", + "license": "ISC" +} diff --git a/extensions/src/common/callStack.ts b/extensions/src/common/callStack.ts index da8ee0933..ed2c546d6 100644 --- a/extensions/src/common/callStack.ts +++ b/extensions/src/common/callStack.ts @@ -1,11 +1,13 @@ -import StackTrace from "stacktrace-js"; - const blocklyPrefix = "./node_modules/imports-loader/index.js?this=>window!./node_modules/exports-loader/index.js?Blockly&goog!./node_modules/scratch-blocks/blockly_compressed_vertical.js"; export const printCallStack = () => { if (Error.stackTraceLimit) Error.stackTraceLimit = Infinity; - const traces = StackTrace.getSync() - .map(({ functionName }) => functionName) - .map(name => name?.replace(blocklyPrefix, "")); - console.log(traces); + try { + throw new Error(); + } + catch (e) { + const lines = e.stack.split("\n"); + lines[0] = "Call stack:"; + console.log(lines.map(line => line.replace(blocklyPrefix, "")).join("\n")); + } } \ No newline at end of file diff --git a/extensions/src/common/extension/ExtensionBase.ts b/extensions/src/common/extension/ExtensionBase.ts index d3d4233fd..5e9becb2b 100644 --- a/extensions/src/common/extension/ExtensionBase.ts +++ b/extensions/src/common/extension/ExtensionBase.ts @@ -51,7 +51,15 @@ export abstract class ConstructableExtension { * @param id The ID of this extension. * @param blockIconURI */ - constructor(readonly runtime: Environment["runtime"], readonly name: string, readonly id: string, readonly blockIconURI: string) { + constructor( + readonly runtime: Runtime, + readonly name: string, + readonly id: string, + readonly blockIconURI: string, + readonly blockColor: string, + readonly menuColor: string, + readonly menuSelectColor: string + ) { } } diff --git a/extensions/src/common/extension/GenericExtension.ts b/extensions/src/common/extension/GenericExtension.ts index 728c23ff8..4b65b1593 100644 --- a/extensions/src/common/extension/GenericExtension.ts +++ b/extensions/src/common/extension/GenericExtension.ts @@ -1,7 +1,7 @@ import { ExtensionMenuDisplayDetails, ExtensionBlocks, BlockDefinitions, Translations } from "$common/types"; import { isFunction } from "$common/utils"; import { extension } from "./index"; -import { getImplementationName } from "./mixins/required/scratchInfo/index"; +import { getImplementationName } from "./mixins/base/scratchInfo/index"; /** * @summary Base class for extensions implemented via the Typescript Extension Framework (using the "generic" strategy). diff --git a/extensions/src/common/extension/decorators/blocks.ts b/extensions/src/common/extension/decorators/blocks.ts index 39d81e587..6cbcbcff2 100644 --- a/extensions/src/common/extension/decorators/blocks.ts +++ b/extensions/src/common/extension/decorators/blocks.ts @@ -1,9 +1,23 @@ import type BlockUtility from "$scratch-vm/engine/block-utility"; -import { TypedMethodDecorator } from "."; +import { TypedClassDecorator, TypedGetterDecorator, TypedMethodDecorator, TypedSetterDecorator } from "."; import { BlockType } from "$common/types/enums"; -import { BlockMetadata } from "$common/types"; -import { getImplementationName } from "../mixins/required/scratchInfo/index"; +import { BlockMetadata, ScratchArgument, Argument } from "$common/types"; +import { getImplementationName } from "../mixins/base/scratchInfo/index"; import { ExtensionInstance } from ".."; +import { isFunction, isString, tryCreateBundleTimeEvent } from "$common/utils"; +import { extractArgs } from "../mixins/base/scratchInfo/args"; + +type BlockFunctionMetadata = { + methodName: string, + scratchType: string, + args: string[], + returns: string, +} + +export const blockBundleEvent = tryCreateBundleTimeEvent("blocks"); + +export const getAccessorPrefix = "__getter__"; +export const setAccessorPrefix = "__setter__"; /** * This a decorator function that should be associated with methods of your Extension class, all in order to turn your class methods @@ -52,17 +66,27 @@ export function block< return function (this: This, target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext) { const opcode = target.name; const internalFuncName = getImplementationName(opcode); + // could add check for if this block is meant for scratch context.addInitializer(function () { this.pushBlock(opcode, blockInfoOrGetter, target) }); - return (function () { return this[internalFuncName].call(this, ...arguments) }) as Function as Fn; - }; -} + const isProbableAtBundleTime = !isFunction(blockInfoOrGetter); + if (isProbableAtBundleTime) { + const { type } = blockInfoOrGetter; + blockBundleEvent?.fire({ + methodName: opcode, + args: extractArgs(blockInfoOrGetter).map(a => isString(a) ? a : a.type), + // is 'any' an issue? Likely! + returns: type === "command" ? "void" : type === "Boolean" ? "bool" : "any", + scratchType: blockInfoOrGetter.type + }); + } -type BlockFromArgsAndReturn = Args extends [...infer R extends any[], BlockUtility] - ? BlockMetadata<(...args: R) => Return> : BlockMetadata<(...args: Args) => Return>; + return (function () { return this[internalFuncName].call(this, ...arguments) }); + }; +} /** - * This is a short-hand for invoking the block command when your `blockType` is button + * This is a short-hand for invoking the block decorator when your `blockType` is button * @param text * @returns * @see {@link block} @@ -85,4 +109,75 @@ export function buttonBlock< text, type: BlockType.Button }); +} + +export type PropertyBlockDetails = { + /** + * The name of the property, which is both displayed to the user and used to associate the getter and setter blocks. + * + * **NOTE:** Associated getter and setter blocks should use the same `name`. Thus, it is best practice to define this name as a constant. + */ + name: string, + /** + * The type of property + */ + type: ScratchArgument +}; + +/** + * A block decorator for creating a reporer block out of a `get` accessor / "getter" property. + * + * @see https://www.typescriptlang.org/docs/handbook/classes.html#accessors + * @param details + * @returns + */ +export function getterBlock + (details: PropertyBlockDetails): TypedGetterDecorator { + return function (this: This, target: (this: This) => TReturn, context: ClassGetterDecoratorContext) { + const opcode = target.name.replace("get ", getAccessorPrefix); + const internalFuncName = getImplementationName(opcode); + + context.addInitializer(function () { + this[opcode] = (_, util) => this[internalFuncName].call(this, null, util);; + const text = `Get ${details.name}`; + this.pushBlock(opcode, { type: "reporter", text }, target); + }); + + blockBundleEvent?.fire({ + methodName: opcode, + args: [], + returns: details.type, + scratchType: "reporter" + }); + } +} + +/** + * A block decorator for creating a command block out of a `set` accessor / "setter" property. + * + * @param details + * @returns + */ +export function setterBlock + (details: PropertyBlockDetails): TypedSetterDecorator { + return function (this: This, target: (this: This, value: TValue) => void, context: ClassSetterDecoratorContext) { + const opcode = target.name.replace("set ", setAccessorPrefix); + const internalFuncName = getImplementationName(opcode); + + context.addInitializer(function () { + this[opcode] = (args, util) => this[internalFuncName].call(this, args, util); + const text = (value: TValue) => `Set ${details.name} to ${value}`; + const arg = details.type as Argument; + type Fn = (this: This, value: any, util: BlockUtility) => void; + const blockInfo = { type: BlockType.Command, text, arg } as BlockMetadata; + this.pushBlock(opcode, blockInfo, target); + }); + + blockBundleEvent?.fire({ + methodName: opcode, + args: [details.type], + returns: "void", + scratchType: "command" + }); + } } \ No newline at end of file diff --git a/extensions/src/common/extension/decorators/index.ts b/extensions/src/common/extension/decorators/index.ts index c3e9c6984..32b0122b9 100644 --- a/extensions/src/common/extension/decorators/index.ts +++ b/extensions/src/common/extension/decorators/index.ts @@ -8,4 +8,10 @@ export type TypedMethodDecorator< Args extends any[], Return, Fn extends (...args: Args) => Return -> = (target: Fn, context: ClassMethodDecoratorContext) => Fn; \ No newline at end of file +> = (target: Fn, context: ClassMethodDecoratorContext) => Fn; + +export type TypedSetterDecorator = + (target: (value: TValue) => void, context: ClassSetterDecoratorContext) => void; + +export type TypedGetterDecorator = + (target: () => Return, context: ClassGetterDecoratorContext) => void; \ No newline at end of file diff --git a/extensions/src/common/extension/decorators/legacySupport/index.ts b/extensions/src/common/extension/decorators/legacySupport/index.ts index 7a960a902..04e0ee913 100644 --- a/extensions/src/common/extension/decorators/legacySupport/index.ts +++ b/extensions/src/common/extension/decorators/legacySupport/index.ts @@ -1,4 +1,4 @@ -import { legacySupportWithInfoArgument } from "$common/extension/mixins/optional/legacySupport"; +import { legacySupportWithInfoArgument } from "$common/extension/mixins/configurable/legacySupport"; import { ExtensionMetadata, ExtensionBlockMetadata, ExtensionMenuItems, BlockOperation, Argument, ExtensionMenuMetadata, ExtensionDynamicMenu, Menu, DynamicMenuThatAcceptsReporters, BaseGenericExtension, VerboseArgument, DefineBlock, AbstractConstructor, NonAbstractConstructor, BlockMetadata } from "$common/types"; import { isFunction, isString } from "$common/utils"; import { block } from "../blocks"; diff --git a/extensions/src/common/extension/index.ts b/extensions/src/common/extension/index.ts index f206a4ca1..d9a36ba6d 100644 --- a/extensions/src/common/extension/index.ts +++ b/extensions/src/common/extension/index.ts @@ -1,16 +1,12 @@ import { ExtensionWithFunctionality, MixinName, optionalMixins } from "./mixins/index"; import { ExtensionBase } from "./ExtensionBase"; -import scratchInfo from "./mixins/required/scratchInfo"; -import supported from "./mixins/required/supported"; +import scratchInfo from "./mixins/base/scratchInfo"; +import supported from "./mixins/base/supported"; import { ExtensionMenuDisplayDetails, Writeable } from "$common/types"; import { tryCaptureDependencies } from "./mixins/dependencies"; +import { tryCreateBundleTimeEvent } from "$common/utils"; -const registerDetailsIdentifier = "__registerMenuDetials"; - -const tryAnnounceDetails = (details: ExtensionMenuDisplayDetails) => { - const isNode = typeof window === 'undefined'; - if (isNode) global?.[registerDetailsIdentifier]?.(details); -} +export const extensionBundleEvent = tryCreateBundleTimeEvent<{ details: ExtensionMenuDisplayDetails, addOns: MixinName[] }>("extension"); /** * Creates the base class that your Extension should 'extend' which is compatible with your request. @@ -51,7 +47,7 @@ export const extension = ( ...addOns: Writeable ): ExtensionWithFunctionality<[...TSupported]> & typeof ExtensionBase => { - tryAnnounceDetails(details); + if (details) extensionBundleEvent?.fire({ details, addOns }); const Base = scratchInfo(supported(ExtensionBase, addOns)) as ExtensionWithFunctionality<[...TSupported]>; @@ -83,12 +79,5 @@ const recursivelyApplyMixinsAndDependencies = void) => - global[registerDetailsIdentifier] = (details) => { - if (!details) return; - callback(details); - delete global[registerDetailsIdentifier]; - }; - export type ExtensionConstructor = ReturnType>; export type ExtensionInstance = InstanceType>; \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/required/index.ts b/extensions/src/common/extension/mixins/base/index.ts similarity index 100% rename from extensions/src/common/extension/mixins/required/index.ts rename to extensions/src/common/extension/mixins/base/index.ts diff --git a/extensions/src/common/extension/mixins/required/scratchInfo/args.ts b/extensions/src/common/extension/mixins/base/scratchInfo/args.ts similarity index 89% rename from extensions/src/common/extension/mixins/required/scratchInfo/args.ts rename to extensions/src/common/extension/mixins/base/scratchInfo/args.ts index 358a21771..9715ab648 100644 --- a/extensions/src/common/extension/mixins/required/scratchInfo/args.ts +++ b/extensions/src/common/extension/mixins/base/scratchInfo/args.ts @@ -1,4 +1,4 @@ -import { Argument, ArgumentType, BlockMetadata, BlockOperation, ExtensionArgumentMetadata, Menu, MultipleArgsBlock, OneArgBlock, ValidKey, ValueOf, VerboseArgument } from "$common/types"; +import { Argument, ArgumentType, BlockMetadata, BlockOperation, ExtensionArgumentMetadata, InlineImage, Menu, MultipleArgsBlock, OneArgBlock, ValidKey, ValueOf, VerboseArgument } from "$common/types"; import { assertSameLength, isPrimitive, isString } from "$common/utils"; import { extractHandlers } from "./handlers"; import { setMenu } from "./menus"; @@ -42,6 +42,8 @@ export const convertToArgumentInfo = (opcode: string, args: readonly Argument { + if (isInlineImage(element)) return { ...element, dataURI: element.uri }; + const entry = {} as ExtensionArgumentMetadata; entry.type = getArgumentType(element); @@ -70,4 +72,6 @@ const getDefaultValue = (defaultValue: any, opcode: string, index: number) => is const setDefaultValue = (entry: ExtensionArgumentMetadata, opcode: string, index: number, defaultValue: any,) => { if (defaultValue === undefined) return; entry.defaultValue = getDefaultValue(defaultValue, opcode, index) -} \ No newline at end of file +} + +const isInlineImage = (arg: Argument): arg is InlineImage => !isString(arg) && arg.type === ArgumentType.Image; \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/required/scratchInfo/handlers.ts b/extensions/src/common/extension/mixins/base/scratchInfo/handlers.ts similarity index 77% rename from extensions/src/common/extension/mixins/required/scratchInfo/handlers.ts rename to extensions/src/common/extension/mixins/base/scratchInfo/handlers.ts index 900ff526b..437f345b7 100644 --- a/extensions/src/common/extension/mixins/required/scratchInfo/handlers.ts +++ b/extensions/src/common/extension/mixins/base/scratchInfo/handlers.ts @@ -1,4 +1,4 @@ -import { Argument, DynamicMenuThatAcceptsReporters, Menu, MenuThatAcceptsReporters, VerboseArgument } from "$common/types"; +import { Argument, ArgumentType, DynamicMenuThatAcceptsReporters, Menu, MenuThatAcceptsReporters, VerboseArgument } from "$common/types"; import { isPrimitive, identity } from "$common/utils"; export type Handler = (MenuThatAcceptsReporters['handler']); @@ -8,6 +8,7 @@ const hasHandler = (options: Menu): options is MenuThatAcceptsReporters[]): Handler[] => args.map(element => { if (!isVerbose(element)) return identity; + if (element.type === ArgumentType.Image) return identity; const { options } = element; if (!hasHandler(options)) return identity; return options.handler; diff --git a/extensions/src/common/extension/mixins/required/scratchInfo/index.ts b/extensions/src/common/extension/mixins/base/scratchInfo/index.ts similarity index 88% rename from extensions/src/common/extension/mixins/required/scratchInfo/index.ts rename to extensions/src/common/extension/mixins/base/scratchInfo/index.ts index 3f54a97cb..2715b7b24 100644 --- a/extensions/src/common/extension/mixins/required/scratchInfo/index.ts +++ b/extensions/src/common/extension/mixins/base/scratchInfo/index.ts @@ -1,5 +1,5 @@ import { castToType } from "$common/cast"; -import CustomArgumentManager from "$common/extension/mixins/optional/customArguments/CustomArgumentManager"; +import CustomArgumentManager from "$common/extension/mixins/configurable/customArguments/CustomArgumentManager"; import { ArgumentType, BlockType } from "$common/types/enums"; import { BlockOperation, ValueOf, Menu, ExtensionMetadata, ExtensionBlockMetadata, ExtensionMenuMetadata, DynamicMenu, BlockMetadata, } from "$common/types"; import { registerButtonCallback } from "$common/ui"; @@ -11,10 +11,12 @@ import { BlockDefinition, getButtonID, isBlockGetter } from "./util"; import { convertToArgumentInfo, extractArgs, zipArgs } from "./args"; import { convertToDisplayText } from "./text"; import { CustomizableExtensionConstructor, MinimalExtensionInstance, } from ".."; -import { ExtensionIntanceWithFunctionality } from "../.."; +import { ExtensionInstanceWithFunctionality } from "../.."; export const getImplementationName = (opcode: string) => `internal_${opcode}`; +const inlineImageAccessError = "ERROR: This argument represents an inline image and should not be accessed."; + /** * Wraps a blocks operation so that the arguments passed from Scratch are first extracted and then passed as indices in a parameter array. * @param _this What will be bound to the 'this' context of the underlying operation @@ -27,8 +29,9 @@ export const wrapOperation = ( operation: BlockOperation, args: { name: string, type: ValueOf, handler: Handler }[] ) => _this.supports("customArguments") - ? function (this: ExtensionIntanceWithFunctionality<["customArguments"]>, argsFromScratch: Record, blockUtility: BlockUtility) { + ? function (this: ExtensionInstanceWithFunctionality<["customArguments"]>, argsFromScratch: Record, blockUtility: BlockUtility) { const castedArguments = args.map(({ name, type, handler }) => { + if (type === ArgumentType.Image) return inlineImageAccessError; const param = argsFromScratch[name]; switch (type) { case ArgumentType.Custom: @@ -43,7 +46,9 @@ export const wrapOperation = ( } : function (this: T, argsFromScratch: Record, blockUtility: BlockUtility) { const castedArguments = args.map(({ name, type, handler }) => - castToType(type, handler.call(_this, argsFromScratch[name])) + type === ArgumentType.Image + ? inlineImageAccessError + : castToType(type, handler.call(_this, argsFromScratch[name])) ); return operation.call(_this, ...castedArguments, blockUtility); } @@ -80,7 +85,10 @@ export default function (Ctor: CustomizableExtensionConstructor) { if (!this.info) { const { id, name, blockIconURI } = this; const blocks = Array.from(this.blockMap.entries()).map(entry => this.convertToInfo(entry)); - this.info = { id, blocks, name, blockIconURI, menus: this.collectMenus() }; + const color1 = this.blockColor; + const color2 = this.menuColor; + const color3 = this.menuSelectColor; + this.info = { id, blocks, name, blockIconURI, menus: this.collectMenus(), color1, color2, color3 }; } return this.info; } diff --git a/extensions/src/common/extension/mixins/required/scratchInfo/menus.ts b/extensions/src/common/extension/mixins/base/scratchInfo/menus.ts similarity index 100% rename from extensions/src/common/extension/mixins/required/scratchInfo/menus.ts rename to extensions/src/common/extension/mixins/base/scratchInfo/menus.ts diff --git a/extensions/src/common/extension/mixins/required/scratchInfo/package.json b/extensions/src/common/extension/mixins/base/scratchInfo/package.json similarity index 100% rename from extensions/src/common/extension/mixins/required/scratchInfo/package.json rename to extensions/src/common/extension/mixins/base/scratchInfo/package.json diff --git a/extensions/src/common/extension/mixins/required/scratchInfo/text.ts b/extensions/src/common/extension/mixins/base/scratchInfo/text.ts similarity index 100% rename from extensions/src/common/extension/mixins/required/scratchInfo/text.ts rename to extensions/src/common/extension/mixins/base/scratchInfo/text.ts diff --git a/extensions/src/common/extension/mixins/required/scratchInfo/util.ts b/extensions/src/common/extension/mixins/base/scratchInfo/util.ts similarity index 100% rename from extensions/src/common/extension/mixins/required/scratchInfo/util.ts rename to extensions/src/common/extension/mixins/base/scratchInfo/util.ts diff --git a/extensions/src/common/extension/mixins/required/supported.ts b/extensions/src/common/extension/mixins/base/supported.ts similarity index 78% rename from extensions/src/common/extension/mixins/required/supported.ts rename to extensions/src/common/extension/mixins/base/supported.ts index bda41c8e1..e796e17ef 100644 --- a/extensions/src/common/extension/mixins/required/supported.ts +++ b/extensions/src/common/extension/mixins/base/supported.ts @@ -1,4 +1,4 @@ -import { ExtensionIntanceWithFunctionality, MixinName, optionalMixins } from ".."; +import { ExtensionInstanceWithFunctionality, MixinName, optionalMixins } from ".."; import { ExtensionBaseConstructor } from "../../ExtensionBase"; /** @@ -10,7 +10,7 @@ import { ExtensionBaseConstructor } from "../../ExtensionBase"; export default function (Ctor: ExtensionBaseConstructor, supported: string[]) { abstract class ExtensionWithConfigurableSupport extends Ctor { - supports(mixinName: TKey): this is typeof this & ExtensionIntanceWithFunctionality<[TKey]> { + supports(mixinName: TKey): this is typeof this & ExtensionInstanceWithFunctionality<[TKey]> { return supported.includes(mixinName); } } diff --git a/extensions/src/common/extension/mixins/optional/addCostumes/MockBitmapAdapter.ts b/extensions/src/common/extension/mixins/configurable/addCostumes/MockBitmapAdapter.ts similarity index 100% rename from extensions/src/common/extension/mixins/optional/addCostumes/MockBitmapAdapter.ts rename to extensions/src/common/extension/mixins/configurable/addCostumes/MockBitmapAdapter.ts diff --git a/extensions/src/common/extension/mixins/optional/addCostumes/index.ts b/extensions/src/common/extension/mixins/configurable/addCostumes/index.ts similarity index 97% rename from extensions/src/common/extension/mixins/optional/addCostumes/index.ts rename to extensions/src/common/extension/mixins/configurable/addCostumes/index.ts index ed6f533de..7efad6666 100644 --- a/extensions/src/common/extension/mixins/optional/addCostumes/index.ts +++ b/extensions/src/common/extension/mixins/configurable/addCostumes/index.ts @@ -1,6 +1,6 @@ import type RenderedTarget from "$scratch-vm/sprites/rendered-target"; import Target from "$scratch-vm/engine/target"; -import { MinimalExtensionConstructor } from "../../required"; +import { MinimalExtensionConstructor } from "../../base"; import MockBitmapAdapter from "./MockBitmapAdapter"; import { getUrlHelper } from "./utils"; diff --git a/extensions/src/common/extension/mixins/optional/addCostumes/utils.ts b/extensions/src/common/extension/mixins/configurable/addCostumes/utils.ts similarity index 100% rename from extensions/src/common/extension/mixins/optional/addCostumes/utils.ts rename to extensions/src/common/extension/mixins/configurable/addCostumes/utils.ts diff --git a/extensions/src/common/extension/mixins/configurable/appInventor/index.ts b/extensions/src/common/extension/mixins/configurable/appInventor/index.ts new file mode 100644 index 000000000..c280a1453 --- /dev/null +++ b/extensions/src/common/extension/mixins/configurable/appInventor/index.ts @@ -0,0 +1,18 @@ +import { MinimalExtensionConstructor } from "../../base"; + +/** + * Mixin the ability for an extension to be treated as a cross-platform extension, + * spanning both the RAISE Playground and App Inventor. + * @param Ctor + * @returns + * @see https://www.typescriptlang.org/docs/handbook/mixins.html + */ +export default function (Ctor: T) { + abstract class ExtensionWithAppInventorInterop extends Ctor { + get withinAppInventor() { + return false; // TODO: Determine what/if there is a way to do this.? + } + } + + return ExtensionWithAppInventorInterop; +} diff --git a/extensions/src/common/extension/mixins/optional/blocks/setVideoTransparency.ts b/extensions/src/common/extension/mixins/configurable/blocks/setVideoTransparency.ts similarity index 94% rename from extensions/src/common/extension/mixins/optional/blocks/setVideoTransparency.ts rename to extensions/src/common/extension/mixins/configurable/blocks/setVideoTransparency.ts index a125f824e..98d230070 100644 --- a/extensions/src/common/extension/mixins/optional/blocks/setVideoTransparency.ts +++ b/extensions/src/common/extension/mixins/configurable/blocks/setVideoTransparency.ts @@ -1,6 +1,6 @@ import { block } from "$common/extension/decorators/blocks"; import { withDependencies } from "../../dependencies"; -import { MinimalExtensionConstructor } from "../../required"; +import { MinimalExtensionConstructor } from "../../base"; import video from "../video"; /** diff --git a/extensions/src/common/extension/mixins/optional/blocks/toggleVideoState.ts b/extensions/src/common/extension/mixins/configurable/blocks/toggleVideoState.ts similarity index 94% rename from extensions/src/common/extension/mixins/optional/blocks/toggleVideoState.ts rename to extensions/src/common/extension/mixins/configurable/blocks/toggleVideoState.ts index 989581fa2..fd4ac7e49 100644 --- a/extensions/src/common/extension/mixins/optional/blocks/toggleVideoState.ts +++ b/extensions/src/common/extension/mixins/configurable/blocks/toggleVideoState.ts @@ -1,6 +1,6 @@ import { block } from "$common/extension/decorators/blocks"; import { withDependencies } from "../../dependencies"; -import { MinimalExtensionConstructor } from "../../required"; +import { MinimalExtensionConstructor } from "../../base"; import video from "../video"; /** diff --git a/extensions/src/common/extension/mixins/configurable/customArguments/CustomArgumentManager.ts b/extensions/src/common/extension/mixins/configurable/customArguments/CustomArgumentManager.ts new file mode 100644 index 000000000..0fee5f0f2 --- /dev/null +++ b/extensions/src/common/extension/mixins/configurable/customArguments/CustomArgumentManager.ts @@ -0,0 +1,74 @@ +import { uuidv4 } from "$common/utils"; +import { ArgumentEntry, ArgumentEntrySetter, ArgumentID } from "./utils"; + +const entries: ArgumentEntry[] = []; + +const serialize = (entry: ArgumentEntry) => JSON.stringify(entry); + +type ArgumentEntryWithID = { entry: ArgumentEntry, id: string }; +type SaveObject = { [k in typeof CustomArgumentManager["SaveKey"]]: ArgumentEntryWithID[] } + +export default class CustomArgumentManager { + private valueLookup: Map> = new Map(); + private idLookup: Map = new Map(); + private current: ArgumentID = null; + + private setCurrent(id: ArgumentID) { return (this.current = id) } + + private setEntry(entry: ArgumentEntry) { return this.idLookup.get(serialize(entry)) ?? this.add(entry); } + + private insert({ id, entry }: ArgumentEntryWithID, serializedEntry?: string) { + this.valueLookup.set(id, entry); + this.idLookup.set(serializedEntry ?? serialize(entry), id); + entries.push({ text: entry.text, value: id }); + } + + get entries() { return entries } + + add(entry: ArgumentEntry): string { + const serialized = serialize(entry); + const cached = this.idLookup.get(serialized); + if (cached) return cached; + const id = CustomArgumentManager.GetIdentifier(); + this.insert({ id, entry }, serialized); + return id; + } + + request(id: ArgumentID, update: (id: ArgumentID) => void): ArgumentEntrySetter { + this.setCurrent(id); + return (entry) => update(this.setCurrent(this.setEntry(entry))); + } + + getCurrent() { return { text: this.getEntry(this.current).text, value: this.current }; } + + getEntry(id: string) { return this.valueLookup.get(id) } + + requiresSave() { this.valueLookup.size > 0 } + + saveTo(obj: SaveObject) { + const entries = Array.from(this.valueLookup.entries()) + .filter(([_, entry]) => entry !== null) + .map(([id, entry]) => ({ id, entry })); + if (entries.length === 0) return; + obj[CustomArgumentManager.SaveKey] = entries; + } + + loadFrom(obj: SaveObject) { + obj[CustomArgumentManager.SaveKey]?.forEach(saved => this.insert(saved)); + } + + /** + * @todo Implement this if it becomes necessary (i.e the every growing size of the internal maps become an issue) + */ + private purgeStaleIDs() { + // Somehow, tap into blockly to loop through all current blocks & their field dropdowns. + // Collect all field dropdowns values. + // Then, loop over entries in this.map -- if the values don't appear in the collected in-use values, drop those items. + // NOTE: The blocks in the 'pallette' do not show up in a target's "blocks" object, which makes this trickier. + } + + static IsIdentifier = (query: string) => query.startsWith(CustomArgumentManager.IdentifierPrefix); + static SaveKey = "internal_customArgumentsSaveData" as const; + private static GetIdentifier = () => CustomArgumentManager.IdentifierPrefix + uuidv4(); + private static IdentifierPrefix = "__customArg__"; +} \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/configurable/customArguments/index.ts b/extensions/src/common/extension/mixins/configurable/customArguments/index.ts new file mode 100644 index 000000000..d799ba0f2 --- /dev/null +++ b/extensions/src/common/extension/mixins/configurable/customArguments/index.ts @@ -0,0 +1,64 @@ +import CustomArgumentManager from "$common/extension/mixins/configurable/customArguments/CustomArgumentManager"; +import { ArgumentType } from "$common/types/enums"; +import { guiDropdownInterop } from "$common/globals"; +import { Argument, Expand } from "$common/types"; +import { MinimalExtensionConstructor } from "../../base"; +import { withDependencies } from "../../dependencies"; +import customSaveData from "../customSaveData"; +import { ArgumentEntry, ArgumentID, CustomArgumentComponent, CustomArgumentRecipe, RuntimeWithCustomArgumentSupport, renderToDropdown } from "./utils"; +import { ExtensionBase } from "$common/extension/ExtensionBase"; + +/** + * Mixin the ability for extensions to create custom argument types with their own specific UIs + * @param Ctor + * @returns + * @see https://www.typescriptlang.org/docs/handbook/mixins.html + */ +export default function mixin(Ctor: T) { + abstract class ExtensionWithCustomArgumentSupport extends withDependencies(Ctor, customSaveData) { + private argumentManager: CustomArgumentManager = null; + + /** + * Create a custom argument for one of this block's argument. Within the argument object, you must provide: + * - `component`: The svelte component to render (import it directly in your file) + * - `initial`: The arguments default value (you must provide both the value and the text representation) + */ + protected makeCustomArgument = ({ component, initial, acceptReportersHandler: handler }: Expand>): Argument => { + const id = this.customArgumentManager.add(initial); + const getItems = () => this.processMenuForCustomArgument(id, component); + return { + type: ArgumentType.Custom, + defaultValue: id, + options: handler === undefined ? getItems : { acceptsReports: true, getItems, handler }, + } as Argument + } + + public get customArgumentManager(): CustomArgumentManager { + this.argumentManager ??= new CustomArgumentManager(); + return this.argumentManager + } + + private processMenuForCustomArgument(initialID: ArgumentID, Component: CustomArgumentComponent): (ArgumentEntry)[] { + const { runtime, argumentManager } = this; + const interop = (runtime as RuntimeWithCustomArgumentSupport)[guiDropdownInterop.runtimeKey]; + + const { state, update, entry } = interop + + switch (state) { + case "init": + case "close": + return argumentManager.entries; + case "open": + const id = entry?.value ?? initialID; + const current = argumentManager.getEntry(id); + const setter = argumentManager.request(id, update); + renderToDropdown(Component, { setter, current, extension: this }); + return [{ text: current.text, value: id }]; + case "update": + return [argumentManager.getCurrent()]; + } + }; + + } + return ExtensionWithCustomArgumentSupport; +} \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/configurable/customArguments/utils.ts b/extensions/src/common/extension/mixins/configurable/customArguments/utils.ts new file mode 100644 index 000000000..3532dfd5f --- /dev/null +++ b/extensions/src/common/extension/mixins/configurable/customArguments/utils.ts @@ -0,0 +1,59 @@ +import { ExtensionBase } from "$common/extension/ExtensionBase"; +import { guiDropdownInterop, } from "$common/globals"; +import { Environment, ExpandRecursively, SvelteComponentConstructor, ValueOf } from "$common/types"; +import { untilObject } from "$common/utils"; + +export type ArgumentEntry = { text: string, value: T }; +export type ArgumentEntrySetter = (entry: ArgumentEntry) => TReturn; +export type ArgumentID = string; + +export type ComponentProps = { + extension: TExtension, + setter: ArgumentEntrySetter, + current: ArgumentEntry +} + +export type NonspecificComponentProps = ComponentProps; + +export type CustomArgumentComponent = SvelteComponentConstructor; + +export type CustomArgumentRecipe = { + /** + * The svelte component to render the custom argument UI + */ + component: SvelteComponentConstructor>, + /** + * The starting value of the the custom argument (including both its value and text representation) + */ + initial: ArgumentEntry, + /** + * A function that must be defined if you'd like for your custom argument to accept reporters + * @param x + * @returns + */ + acceptReportersHandler?: (x: any) => ArgumentEntry +}; + +type DropdownEntry = { [k in typeof guiDropdownInterop.runtimeProperties.entryKey]: ArgumentEntry }; +type DropdownState = { [k in typeof guiDropdownInterop.runtimeProperties.stateKey]: ValueOf }; +type DropdownUpdateMethod = { [k in typeof guiDropdownInterop.runtimeProperties.updateMethodKey]: (id: string) => void }; + +export type RuntimeWithCustomArgumentSupport = Environment["runtime"] & { + [k in typeof guiDropdownInterop.runtimeKey]: ExpandRecursively +} + +const findUniqueElementByClass = (container: Document | Element, className: string) => { + const elements = container.getElementsByClassName(className); + if (elements.length !== 1) throw new Error(`Uh oh! Expected 1 element with class '${className}', but found ${elements.length}`); + return elements[0] as T; +} + +const hideText = (element: Element) => (element as HTMLElement).style.display = "none"; + +export const renderToDropdown = async (Compononent: SvelteComponentConstructor, props: TProps) => { + const dropdownContainerClass = "blocklyDropDownContent"; + const target = findUniqueElementByClass(document, dropdownContainerClass); + const anchor = await untilObject(() => target.children[0]); + const component = new Compononent({ target, anchor, props }); + hideText(anchor); +} \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/optional/customSaveData.ts b/extensions/src/common/extension/mixins/configurable/customSaveData.ts similarity index 92% rename from extensions/src/common/extension/mixins/optional/customSaveData.ts rename to extensions/src/common/extension/mixins/configurable/customSaveData.ts index 66ba000d4..fbbcbcbeb 100644 --- a/extensions/src/common/extension/mixins/optional/customSaveData.ts +++ b/extensions/src/common/extension/mixins/configurable/customSaveData.ts @@ -1,6 +1,6 @@ import { BaseGenericExtension, NonAbstractConstructor } from "$common/types"; -import { MinimalExtensionConstructor } from "../required"; -import { ExtensionIntanceWithFunctionality } from ".."; +import { MinimalExtensionConstructor } from "../base"; +import { ExtensionInstanceWithFunctionality } from ".."; /** * WARNING! If you change this key, it will affect already saved projects. @@ -25,7 +25,7 @@ export const saveDataKey = "customSaveDataPerExtension" as const; * }) * @todo Remove the `BaseGenericExtension` Generic Type restraint once Generic Extensions are no longer supported */ -export class SaveDataHandler, TData> { +export class SaveDataHandler, TData> { constructor(public hooks: { // @ts-ignore Extension: NonAbstractConstructor, @@ -90,7 +90,7 @@ export default function mixin(Ctor: T) { if (!saveData) return; saveDataHandler?.hooks.onLoad(this, saveData); - if (this.supports("customArguments")) this.getOrCreateCustomArgumentManager().loadFrom(saveData); + if (this.supports("customArguments")) this.customArgumentManager.loadFrom(saveData); } } return ExtensionWithCustomSaveDataSupport; diff --git a/extensions/src/common/extension/mixins/optional/drawable.ts b/extensions/src/common/extension/mixins/configurable/drawable.ts similarity index 98% rename from extensions/src/common/extension/mixins/optional/drawable.ts rename to extensions/src/common/extension/mixins/configurable/drawable.ts index 9133cc48b..4882a869c 100644 --- a/extensions/src/common/extension/mixins/optional/drawable.ts +++ b/extensions/src/common/extension/mixins/configurable/drawable.ts @@ -1,5 +1,5 @@ import { StageLayering, ValueOf } from "$common/types"; -import { MinimalExtensionConstructor } from "../required"; +import { MinimalExtensionConstructor } from "../base"; type Handle = number; diff --git a/extensions/src/common/extension/mixins/configurable/indicators/index.ts b/extensions/src/common/extension/mixins/configurable/indicators/index.ts new file mode 100644 index 000000000..811711e08 --- /dev/null +++ b/extensions/src/common/extension/mixins/configurable/indicators/index.ts @@ -0,0 +1,53 @@ +import { MinimalExtensionConstructor } from "../../base"; +import { isSvgGroup, isSvgText, openAlert } from "./svgAlert"; + +/** + * Mixin the ability for extensions to add an indicator message to the workspace. + * @param Ctor + * @returns + * @see https://www.typescriptlang.org/docs/handbook/mixins.html + */ +export default function (Ctor: T) { + abstract class ExtensionThatIndicates extends Ctor { + readonly IndicatorType: "success" | "warning" | "error"; + + /** + * Add an indicator message to the workspace. + * @param param0 Details + * - `position`: Where to place the indicator. Currently only "category" is supported, which places the message immediately below the extensions name. + * - `msg`: The message to display. + * - `type`: The type of indicator to display. Currently "success", "warning" and "error", which effect the color of the indicator. + * @returns + */ + async indicate({ position, msg, type }: { position: "category", msg: string, type: ExtensionThatIndicates["IndicatorType"] }) { + + const elements = position === "category" + ? getCategoryElements(this.name) + : { error: "Unsupported indicator position" }; + + if ("error" in elements) throw new Error(elements.error); + const { container } = elements; + const alert = await openAlert(container, msg, type); + return alert; + } + } + + return ExtensionThatIndicates; +} + +const topLevelClass = "blocklyFlyout"; +const containerClass = "blocklyFlyoutLabel categoryLabel"; +const textClass = "blocklyFlyoutLabelText"; + +const getCategoryElements = (text: string): { error: string } | { container: SVGGElement, title: SVGTextElement } => { + const topLevel = document.body.getElementsByClassName(topLevelClass); + if (topLevel.length !== 1) return { error: "No top level element found." }; + + for (const container of topLevel[0].getElementsByClassName(containerClass)) { + for (const title of container.getElementsByClassName(textClass)) { + if (title.innerHTML !== text || !isSvgGroup(container) || !isSvgText(title)) continue; + return { container, title }; + } + } + return { error: "No title found matching given name" }; +} \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/configurable/indicators/svgAlert.ts b/extensions/src/common/extension/mixins/configurable/indicators/svgAlert.ts new file mode 100644 index 000000000..7ee81aee0 --- /dev/null +++ b/extensions/src/common/extension/mixins/configurable/indicators/svgAlert.ts @@ -0,0 +1,68 @@ +import { ExtensionInstanceWithFunctionality } from "../.."; + +export const isSvgGroup = (element: Element): element is SVGGElement => element.nodeName === "g"; +export const isSvgText = (element: Element): element is SVGTextElement => element.nodeName === "text"; + +type AlertType = Parameters["indicate"]>[0]["type"]; + +const fills = { + success: "#5ACA75", + warning: "#FF8f39", + error: "#db1f1f" +} satisfies Record; + +const textAttributes = { + fill: "white", + "font-weight": "bold", + "font-size": "14pt", + "font-family": "\"Helvetica Neue\", Helvetica, Arial, sans-serif;" +} + +export async function openAlert(container: SVGGElement, msg: string, type: AlertType) { + const elements = createElements(); + const [rect, triangle, text] = elements; + + const padding = 12; + const y = 55; + const x = 0; + const fill = fills[type]; + + applyAttributes(triangle, { points: equilateralTrianglePoints, fill }); + applyAttributes(text, { x: x + padding / 2, y }); + applyAttributes(text, textAttributes); + + text.innerHTML = msg; + + elements.forEach(el => container.appendChild(el)); + + await Promise.resolve(); // await for elements to render (is there a better way?) + + const { width, height } = text.getBBox(); + applyAttributes(rect, { x, width: width + padding, height: height + padding, y: y - height, fill, rx: 5 }); + + return { + close() { elements.forEach(element => container.removeChild(element)); } + }; +} + +const applyAttributes = >(element: Element, attributes: T) => { + for (const key in attributes) { + element.setAttribute(key, `${attributes[key]}`); + } +} + +const createElements = () => [createElement("rect"), createElement("polygon"), createElement("text")] as const; + +const createElement = (type: T) => + document.createElementNS('http://www.w3.org/2000/svg', type); + +const getEquilateralTrianglePoints = () => { + const reduction = { x: 5, y: 14 }; + const shift = { x: 10, y: 26 }; + return [[50, 15], [100, 100], [0, 100]] + .map(([x, y]) => [x / reduction.x + shift.x, y / reduction.y + shift.y]) + .map(([x, y]) => `${x} ${y}`) + .join(", "); +} + +const equilateralTrianglePoints = getEquilateralTrianglePoints(); \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/optional/legacySupport.ts b/extensions/src/common/extension/mixins/configurable/legacySupport.ts similarity index 97% rename from extensions/src/common/extension/mixins/optional/legacySupport.ts rename to extensions/src/common/extension/mixins/configurable/legacySupport.ts index a5d2192b9..8bff337cc 100644 --- a/extensions/src/common/extension/mixins/optional/legacySupport.ts +++ b/extensions/src/common/extension/mixins/configurable/legacySupport.ts @@ -2,8 +2,8 @@ import { ExtensionInstance } from "$common/extension"; import { AbstractConstructor, ExtensionArgumentMetadata, ExtensionBlockMetadata, ExtensionMenuMetadata, ExtensionMetadata } from "$common/types"; import { isString, set } from "$common/utils"; import { isDynamicMenu, parseText } from "../../decorators/legacySupport/index"; -import { MinimalExtensionConstructor } from "../required"; -import { getImplementationName, wrapOperation } from "../required/scratchInfo/index"; +import { MinimalExtensionConstructor } from "../base"; +import { getImplementationName, wrapOperation } from "../base/scratchInfo/index"; type WrappedOperation = ReturnType; type WrappedOperationParams = Parameters; diff --git a/extensions/src/common/extension/mixins/optional/ui.ts b/extensions/src/common/extension/mixins/configurable/ui.ts similarity index 94% rename from extensions/src/common/extension/mixins/optional/ui.ts rename to extensions/src/common/extension/mixins/configurable/ui.ts index c83bf54be..7fcec37c2 100644 --- a/extensions/src/common/extension/mixins/optional/ui.ts +++ b/extensions/src/common/extension/mixins/configurable/ui.ts @@ -1,5 +1,5 @@ import { openUI } from "$common/ui"; -import { MinimalExtensionConstructor } from "../required"; +import { MinimalExtensionConstructor } from "../base"; /** * Mixin the ability for extensions to open up UI at-will diff --git a/extensions/src/common/extension/mixins/optional/video.ts b/extensions/src/common/extension/mixins/configurable/video.ts similarity index 91% rename from extensions/src/common/extension/mixins/optional/video.ts rename to extensions/src/common/extension/mixins/configurable/video.ts index 0a940cf64..f547b4a2d 100644 --- a/extensions/src/common/extension/mixins/optional/video.ts +++ b/extensions/src/common/extension/mixins/configurable/video.ts @@ -1,5 +1,5 @@ import type Video from "$scratch-vm/io/video"; -import { MinimalExtensionConstructor } from "../required"; +import { MinimalExtensionConstructor } from "../base"; const Format = { image: "image-data", @@ -29,6 +29,11 @@ export default function (Ctor: T) { return this.videoDevice; }; + /** + * Dimensions of the video frame + */ + videoDimensions = { width: 480, height: 360 } as const; + /** * Access the most recent frame captured by the web cam * @param {"image" | "canvas"} format diff --git a/extensions/src/common/extension/mixins/dependencies.ts b/extensions/src/common/extension/mixins/dependencies.ts index 43c713aaa..d4aed3e64 100644 --- a/extensions/src/common/extension/mixins/dependencies.ts +++ b/extensions/src/common/extension/mixins/dependencies.ts @@ -1,6 +1,6 @@ import { ValueOf } from "$common/types"; import { Mixin, MixinName, optionalMixins } from "./index"; -import { MinimalExtensionConstructor } from "./required"; +import { MinimalExtensionConstructor } from "./base"; type DependentFunctionality[]> = TMixinDependencies extends [infer Head extends Mixin, ...infer Tail extends Mixin[]] diff --git a/extensions/src/common/extension/mixins/index.ts b/extensions/src/common/extension/mixins/index.ts index 25f034b6a..1bab3ffad 100644 --- a/extensions/src/common/extension/mixins/index.ts +++ b/extensions/src/common/extension/mixins/index.ts @@ -1,14 +1,16 @@ import { AbstractConstructor } from "$common/types"; -import addCostumes from "./optional/addCostumes/index"; -import customArguments from "./optional/customArguments/index"; -import customSaveData from "./optional/customSaveData"; -import drawable from "./optional/drawable"; -import legacySupport from "./optional/legacySupport"; -import ui from "./optional/ui"; -import video from "./optional/video"; -import setTransparencyBlock from "./optional/blocks/setVideoTransparency"; -import toggleVideoBlock from "./optional/blocks/toggleVideoState"; -import { MinimalExtensionConstructor } from "./required"; +import addCostumes from "./configurable/addCostumes/index"; +import customArguments from "./configurable/customArguments/index"; +import customSaveData from "./configurable/customSaveData"; +import drawable from "./configurable/drawable"; +import legacySupport from "./configurable/legacySupport"; +import ui from "./configurable/ui"; +import indicators from "./configurable/indicators"; +import video from "./configurable/video"; +import setTransparencyBlock from "./configurable/blocks/setVideoTransparency"; +import toggleVideoBlock from "./configurable/blocks/toggleVideoState"; +import appInventor from "./configurable/appInventor/index"; +import { MinimalExtensionConstructor } from "./base"; export type Mixin = (Ctor: MinimalExtensionConstructor) => AbstractConstructor; @@ -22,6 +24,8 @@ export const optionalMixins = { legacySupport, setTransparencyBlock, toggleVideoBlock, + appInventor, + indicators, } as const satisfies OptionalMixins satisfies Record>; export type OptionalMixins = { @@ -33,7 +37,9 @@ export type OptionalMixins, legacySupport: typeof legacySupport, setTransparencyBlock: typeof setTransparencyBlock, - toggleVideoBlock: typeof toggleVideoBlock + toggleVideoBlock: typeof toggleVideoBlock, + appInventor: typeof appInventor, + indicators: typeof indicators, } export type MixinName = keyof typeof optionalMixins; @@ -49,4 +55,4 @@ export type ExtensionWithFunctionality = InstanceType>; \ No newline at end of file +export type ExtensionInstanceWithFunctionality = InstanceType>; \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/optional/customArguments/CustomArgumentManager.ts b/extensions/src/common/extension/mixins/optional/customArguments/CustomArgumentManager.ts deleted file mode 100644 index 280cfdb49..000000000 --- a/extensions/src/common/extension/mixins/optional/customArguments/CustomArgumentManager.ts +++ /dev/null @@ -1,77 +0,0 @@ -export type ArgumentEntry = { text: string, value: T }; -export type ArgumentEntrySetter = (entry: ArgumentEntry) => void; - -export default class CustomArgumentManager { - map: Map> = new Map(); - pending: { id: string, entry: ArgumentEntry } = null; - - clearPending() { this.pending = null } - setPending(update: typeof this.pending) { this.pending = update } - - add(entry: ArgumentEntry): string { - const id = CustomArgumentManager.GetIdentifier(); - this.map.set(id, entry); - this.clearPending(); - return id; - } - - insert(id: string, entry: ArgumentEntry): string { - this.map.set(id, entry); - this.clearPending(); - return id; - } - - request(): [string, ArgumentEntrySetter] { - this.clearPending(); - const id = CustomArgumentManager.GetIdentifier(); - return [id, (entry) => this.setPending({ id, entry })]; - } - - tryResolve() { - if (!this.pending) return; - const { pending: { entry, id } } = this; - this.map.set(id, entry); - this.clearPending(); - return { entry, id }; - } - - getCurrentEntries() { - return Array.from(this.map.entries()) - .filter(([_, entry]) => entry !== null) - .map(([id, { text }]) => [text, id] as const); - } - - getEntry(id: string) { return this.map.get(id) } - - static SaveKey = "internal_customArgumentsSaveData" as const; - - requiresSave() { this.map.size > 0 } - - saveTo(obj: object) { - const entries = Array.from(this.map.entries()) - .filter(([_, entry]) => entry !== null) - .map(([id, entry]) => ({ id, entry })); - if (entries.length === 0) return; - obj[CustomArgumentManager.SaveKey] = entries; - } - - loadFrom(obj: Record }[]>) { - obj[CustomArgumentManager.SaveKey]?.forEach(({ id, entry }) => { - this.map.set(id, entry); - }); - } - - /** - * @todo Implement this if it becomes necessary (i.e the every growing size of this.map becomes an issue) - */ - private purgeStaleIDs() { - // Somehow, tap into blockly to loop through all current blocks & their field dropdowns. - // Collect all field dropdowns values. - // Then, loop over entries in this.map -- if the values don't appear in the collected in-use values, drop those items. - // NOTE: The blocks in the 'pallette' do not show up in a target's "blocks" object, which makes this tricky. - } - - static IsIdentifier = (query: string) => query.startsWith(CustomArgumentManager.IdentifierPrefix); - private static GetIdentifier = () => CustomArgumentManager.IdentifierPrefix + new Date().getTime().toString(); - private static IdentifierPrefix = "__customArg__"; -} \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/optional/customArguments/dropdownOverride.ts b/extensions/src/common/extension/mixins/optional/customArguments/dropdownOverride.ts deleted file mode 100644 index 0ed76eb5c..000000000 --- a/extensions/src/common/extension/mixins/optional/customArguments/dropdownOverride.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ExtensionInstance } from "$common/extension"; -import { untilObject } from "$common/utils"; -import { ArgumentEntry, ArgumentEntrySetter } from "./CustomArgumentManager"; - -/** Constructed based on Svelte documentation: https://svelte.dev/docs#run-time-client-side-component-api-creating-a-component */ -type CreateComponentOptions = { - target: Element | HTMLElement; - anchor?: Element | HTMLElement; - props: {}; -} - -export type CustomArgumentUIConstructor = (options: CreateComponentOptions) => void; - -export const renderToDropdown = async ( - compononentConstructor: CustomArgumentUIConstructor, - props: { - extension: ExtensionInstance, - setter: ArgumentEntrySetter, - current: ArgumentEntry - } -) => { - const dropdownContainerClass = "blocklyDropDownContent"; - const elements = document.getElementsByClassName(dropdownContainerClass); - if (elements.length !== 1) return console.error(`Uh oh! Expected 1 element with class '${dropdownContainerClass}', but found ${elements.length}`); - const [target] = elements; - const anchor = await untilObject(() => target.children[0]); - const component = new compononentConstructor({ target, anchor, props }); - centerDropdownButton(anchor); -} - -const centerDropdownButton = (container: Element) => { - type ClassAndStyleModification = [string, (syle: CSSStyleDeclaration) => void]; - - const findElementAndModifyStyle = ([className, styleMod]: ClassAndStyleModification) => { - const elements = container.getElementsByClassName(className); - console.assert(elements.length === 1, `Incorrect number of elements found with class: ${className}`); - styleMod((elements[0] as HTMLElement).style); - }; - - const elements = [ - [ - "goog-menuitem goog-option", - (style) => { - style.margin = "auto"; - style.paddingLeft = style.paddingRight = "0px"; - } - ], - [ - "goog-menuitem-content", - (style) => style.textAlign = "center" - ] - ] satisfies ClassAndStyleModification[]; - - elements.forEach(findElementAndModifyStyle); -} \ No newline at end of file diff --git a/extensions/src/common/extension/mixins/optional/customArguments/index.ts b/extensions/src/common/extension/mixins/optional/customArguments/index.ts deleted file mode 100644 index a86646382..000000000 --- a/extensions/src/common/extension/mixins/optional/customArguments/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type Runtime from "$scratch-vm/engine/runtime"; -import CustomArgumentManager, { ArgumentEntry } from "$common/extension/mixins/optional/customArguments/CustomArgumentManager"; -import { CustomArgumentUIConstructor, renderToDropdown } from "$common/extension/mixins/optional/customArguments/dropdownOverride"; -import { ArgumentType } from "$common/types/enums"; -import { openDropdownState, closeDropdownState, initDropdownState, customArgumentFlag, customArgumentCheck, dropdownStateFlag, dropdownEntryFlag } from "$common/globals"; -import { Argument, BaseGenericExtension } from "$common/types"; -import { MinimalExtensionConstructor } from "../../required"; -import { withDependencies } from "../../dependencies"; -import customSaveData from "../customSaveData"; - -type ComponentGetter = (id: string, componentName: string) => CustomArgumentUIConstructor; - -const callingContext = { - DrowpdownOpen: openDropdownState, - DropdownClose: closeDropdownState, - Init: initDropdownState, -} as const; - -/** - * Mixin the ability for extensions to create custom argument types with their own specific UIs - * @param Ctor - * @returns - * @see https://www.typescriptlang.org/docs/handbook/mixins.html - */ -export default function mixin(Ctor: T) { - abstract class ExtensionWithCustomArgumentSupport extends withDependencies(Ctor, customSaveData) { - /** - * Create a custom argument for one of this block's arguments - * @param param0 - * - component: The svelte component to render the custom argument UI - * - initial: The starting value of the the custom argument (including both its value and text representation) - * - acceptReportersHandler: A function that must be defined if you'd like for your custom argument to accept reporters - * @returns - */ - protected makeCustomArgument = ({ component, initial, acceptReportersHandler: handler }: { component: string, initial: ArgumentEntry, acceptReportersHandler?: (x: any) => ArgumentEntry }): Argument => { - this.argumentManager ??= new CustomArgumentManager(); - const id = this.argumentManager.add(initial); - const getItems = () => [{ text: customArgumentFlag, value: JSON.stringify({ component, id }) }]; - return { - type: ArgumentType.Custom, - defaultValue: id, - options: handler === undefined ? getItems : { acceptsReports: true, getItems, handler }, - } as Argument - } - - protected argumentManager: CustomArgumentManager = null; - - public get customArgumentManager(): CustomArgumentManager { - return this.argumentManager - } - - public getOrCreateCustomArgumentManager(): CustomArgumentManager { - this.argumentManager ??= new CustomArgumentManager(); - return this.argumentManager; - } - - /** - * Utilized externally by scratch-vm to check if a given argument should be treated as a 'custom argument'. - * Checks if the value returned by a dyanmic menu indicates that it should be treated as a 'custom argument' - */ - private [customArgumentCheck](arr: Array) { - if (arr.length !== 1) return false; - const item = arr[0]; - if (typeof item !== "object") return false; - const { text } = item; - return text === customArgumentFlag; - }; - - /** - * Utilized externally by scratch-vm to process custom arguments - * @param runtime NOTE: once we switch to V2, we can remove this and instead use the extension's runtime - * @param param1 - * @param getComponent - * @returns - */ - private processCustomArgumentHack(runtime: Runtime, [{ value }]: { value: string }[], getComponent: ComponentGetter): (readonly [string, string])[] { - - const { id: extensionID, customArgumentManager: argumentManager } = this; - const { component, id: initialID } = JSON.parse(value) as { component: string, id: string }; - const context = runtime[dropdownStateFlag]; - - switch (context) { - case callingContext.Init: - return argumentManager.getCurrentEntries(); - case callingContext.DropdownClose: { - const result = argumentManager.tryResolve(); - return result ? [[result.entry.text, result.id]] : argumentManager.getCurrentEntries(); - } - case callingContext.DrowpdownOpen: { - const currentEntry = runtime[dropdownEntryFlag] as ArgumentEntry; - const prevID = currentEntry?.value ?? initialID; - const current = argumentManager.getEntry(prevID); - const [id, setEntry] = argumentManager.request(); - renderToDropdown(getComponent(extensionID, component), { setter: setEntry, current, extension: this as any as BaseGenericExtension }); - return [["Apply", id]]; - } - } - - throw new Error("Error during processing -- Context:" + callingContext); - }; - - } - return ExtensionWithCustomArgumentSupport; -} \ No newline at end of file diff --git a/extensions/src/common/globals.ts b/extensions/src/common/globals.ts index 9a3fe9270..c95f50f04 100644 --- a/extensions/src/common/globals.ts +++ b/extensions/src/common/globals.ts @@ -3,10 +3,23 @@ export const registerButtonCallbackEvent = "REGISTER_BUTTON_CALLBACK_FROM_EXTENS export const FrameworkID = "ExtensionFramework"; export const AuxiliaryExtensionInfo = "AuxiliaryExtensionInfo"; -export const customArgumentFlag = "internal_IsCustomArgument"; -export const customArgumentCheck = "isCustomArgumentHack"; -export const dropdownStateFlag = "dropdownState"; -export const dropdownEntryFlag = "dropdownEntry"; -export const initDropdownState = "init"; -export const openDropdownState = "open"; -export const closeDropdownState = "close"; \ No newline at end of file +/** + * Literal values that control the interaction between the extension framework and the Scratch GUI, + * specifically how dropdowns (tied to dynamic menus) are co-opted to support custom block arguments. + */ +export const guiDropdownInterop = { + runtimeKey: "prgDropdownCustomization", + runtimeProperties: { + stateKey: "state", + entryKey: "entry", + updateMethodKey: "update", + }, + state: { + open: "open", + init: "init", + update: "update", + close: "close", + }, +} as const; + +export const blockIDKey = "blockID"; \ No newline at end of file diff --git a/extensions/src/common/index.ts b/extensions/src/common/index.ts index bd99a0e19..bcce9b4bb 100644 --- a/extensions/src/common/index.ts +++ b/extensions/src/common/index.ts @@ -4,17 +4,15 @@ export * from "./ui"; export * from "./types/enums"; export * from "./IDs"; export * from "./globals"; -export * from "./extension/mixins/optional/customSaveData"; +export * from "./extension/mixins/configurable/customSaveData"; export * from "./cast"; -export type { ArgumentEntry, ArgumentEntrySetter } from "./extension/mixins/optional/customArguments/CustomArgumentManager"; +export type { ArgumentEntry, ArgumentEntrySetter } from "./extension/mixins/configurable/customArguments/utils"; export type ReplaceWithBlockFunctionName = never; -import CustomArgumentManager from "./extension/mixins/optional/customArguments/CustomArgumentManager"; +import CustomArgumentManager from "./extension/mixins/configurable/customArguments/CustomArgumentManager"; export { CustomArgumentManager }; -export * from "./extension/mixins/optional/customArguments/dropdownOverride"; - export * from "./extension/GenericExtension"; export * from "./extension/ExtensionBase"; diff --git a/extensions/src/common/tests/customArgumentDataSaved.test.ts b/extensions/src/common/tests/customArgumentDataSaved.test.ts index 6f9b4c3af..a5109ef60 100644 --- a/extensions/src/common/tests/customArgumentDataSaved.test.ts +++ b/extensions/src/common/tests/customArgumentDataSaved.test.ts @@ -1,7 +1,7 @@ import { extension } from "$common/extension"; import { block } from "$common/extension/decorators/blocks"; -import CustomArgumentManager from "$common/extension/mixins/optional/customArguments/CustomArgumentManager"; -import customSaveData, { saveDataKey } from "$common/extension/mixins/optional/customSaveData"; +import CustomArgumentManager from "$common/extension/mixins/configurable/customArguments/CustomArgumentManager"; +import customSaveData, { saveDataKey } from "$common/extension/mixins/configurable/customSaveData"; import { Environment } from "$common/types"; import { createTestSuite, testID } from "$testing"; @@ -13,7 +13,7 @@ class ExtensionWithCustomArguments extends extension({ name: "" }, "customArgume @block((self) => ({ type: "command", text: (x) => "", - arg: self.makeCustomArgument({ component: "", initial }) + arg: self.makeCustomArgument({ component: null, initial }) })) dummy(dummy: typeof initial["value"]) { } diff --git a/extensions/src/common/tests/inlineImage.test.ts b/extensions/src/common/tests/inlineImage.test.ts new file mode 100644 index 000000000..b4bad594e --- /dev/null +++ b/extensions/src/common/tests/inlineImage.test.ts @@ -0,0 +1,76 @@ +import { extension } from "$common/extension"; +import { block } from "$common/extension/decorators/blocks"; +import { ArgumentType, Environment, ExtensionBlockMetadata, InlineImage } from "$common/types"; +import BlockUtility from "$root/packages/scratch-vm/src/engine/block-utility"; +import { createTestSuite, imageMock } from "$testing"; +import mocked from "./nonExistentFile.png"; + +class InlineImageTestExtension extends extension({ name: "Dummy" }) { + override init(env: Environment) { } + + // @ts-check + @block({ + type: "command", + text: (arg) => `${arg}`, + arg: { + type: "image", + uri: mocked, + alt: "this is a test image", + } + }) + singleArg(arg: "inline image", util: BlockUtility) { + + } + + // @ts-check + @block({ + type: "command", + text: (x, y, z) => `${x} ${y} ${z}`, + args: [ + "number", + { + type: "image", + uri: mocked, + alt: "this is a test image", + }, + { + type: ArgumentType.String, + }] + }) + multiArg(x: number, y: "inline image", z: string, util: BlockUtility) { + } +} + + +createTestSuite( + { + Extension: InlineImageTestExtension, + __dirname + }, + { + unitTests: null, + integrationTests: { + "check block info": ({ extension, testHelper: { expect } }) => { + const blocks = extension.getBlockInfo() + .reduce( + (map, metadata) => map.set(metadata.opcode as keyof InlineImageTestExtension, metadata), + new Map() + ); + const imageArgs = [ + blocks.get("singleArg").arguments[0], + blocks.get("multiArg").arguments[1] + ]; + + imageArgs + .map(arg => arg as InlineImage) + .forEach((arg) => expect(arg.uri).toBe(imageMock)); + + imageArgs + .forEach((arg) => expect(arg).toHaveProperty("dataURI")); + + imageArgs + .forEach((arg) => expect(arg["dataURI"]).toBe(imageMock)); + } + } + } +) \ No newline at end of file diff --git a/extensions/src/common/types/framework/arguments.ts b/extensions/src/common/types/framework/arguments.ts index 92d848820..9d19b61ff 100644 --- a/extensions/src/common/types/framework/arguments.ts +++ b/extensions/src/common/types/framework/arguments.ts @@ -1,4 +1,4 @@ -import type BlockUtility from "$scratch-vm/engine/block-utility"; +import { BlockUtilityWithID } from "."; import { ArgumentType } from "../enums"; import { ValueOf } from "../utils"; import { BlockOperation } from "./blocks"; @@ -10,6 +10,39 @@ export type VerboseArgument = { options?: Menu; }; +export type InlineImageSpecifier = "inline image"; + +export type InlineImage = { + /** + * This is a special type of argument that represents an inline image. + */ + type: typeof ArgumentType.Image; + /** + * The URI of the image. This can be a relative path to the image file, or a data URI. + * + * The most straightforward way is to import a file as if it was a code file, and use the default export as the URI. + * @example + * // At top of file + * import exampleImage from "./exampleImage.png"; + * + * // In block definition + * { + * type: "image", + * uri: exampleImage, + * } + * + */ + uri: string, + /** + * The description of the image for screen readers. + */ + alt: string, + /** + * Whether the image should be flipped when the user's language is right-to-left. + */ + flipRTL?: boolean +} + export type Argument = VerboseArgument | ScratchArgument; export type RGBObject = { r: number, g: number, b: number }; @@ -18,10 +51,10 @@ export type Matrix = boolean[][]; export type TypeByArgumentType> = T extends typeof ArgumentType.Number | typeof ArgumentType.Angle | typeof ArgumentType.Note ? number : T extends typeof ArgumentType.Boolean ? boolean + : T extends typeof ArgumentType.Image ? InlineImage : T extends typeof ArgumentType.String ? string : T extends typeof ArgumentType.Color ? RGBObject - : T extends typeof ArgumentType.Matrix ? boolean[][] - : T extends typeof ArgumentType.Image ? string // TODO + : T extends typeof ArgumentType.Matrix ? Matrix : T extends typeof ArgumentType.Custom ? any : never; @@ -35,16 +68,17 @@ export type AcceptableArgumentTypes = TypeByArgumentType = T extends RGBObject ? typeof ArgumentType.Color : T extends boolean[][] ? typeof ArgumentType.Matrix : + T extends InlineImage ? typeof ArgumentType.Image : T extends number ? (typeof ArgumentType.Number | typeof ArgumentType.Angle | typeof ArgumentType.Note | typeof ArgumentType.Custom) : T extends string ? (typeof ArgumentType.String | typeof ArgumentType.Custom) : T extends boolean ? (typeof ArgumentType.Boolean | typeof ArgumentType.Custom) : - T extends { dataURI: string, alt: string, flipRTL: boolean } ? typeof ArgumentType.Image : (typeof ArgumentType.Custom); -// Used to be ... not sure if it needs to be? export type ToArguments = - T extends [infer Head, ...infer Tail] + T extends [infer _ extends InlineImageSpecifier, ...infer Tail] + ? readonly [InlineImage, ...ToArguments] + : T extends [infer Head, ...infer Tail] ? readonly [Argument, ...ToArguments] : []; -export type ParamsAndUtility = [...params: Parameters, util: BlockUtility]; +export type ParamsAndUtility = [...params: Parameters, util: BlockUtilityWithID]; diff --git a/extensions/src/common/types/framework/blocks.ts b/extensions/src/common/types/framework/blocks.ts index bb196c8f6..e877735d8 100644 --- a/extensions/src/common/types/framework/blocks.ts +++ b/extensions/src/common/types/framework/blocks.ts @@ -84,7 +84,7 @@ type Text = { text: TParameters extends NonEmptyArray ? (...params: TParameters) => string : string; } -type Arguments = & +type Arguments = TParameters extends [] | [BlockUtility] ? { /** * @description The args field should not be defined for blocks that take no arguments diff --git a/extensions/src/common/types/framework/index.ts b/extensions/src/common/types/framework/index.ts index 749cec172..7c4eaf8e2 100644 --- a/extensions/src/common/types/framework/index.ts +++ b/extensions/src/common/types/framework/index.ts @@ -1,13 +1,18 @@ import { Extension } from "$common/extension/GenericExtension"; import type ExtensionManager from "$scratch-vm/extension-support/extension-manager"; +import type Runtime from "$scratch-vm/engine/runtime"; +import type BlockUtility from "$scratch-vm/engine/block-utility"; +import type { blockIDKey } from "../../globals"; import { ExtensionBlocks } from "./blocks"; import { Language } from "../enums"; import { MethodNames, ValueOf } from "../utils"; import { ExtensionInstance } from "$common/extension"; -import { Runtime } from "../scratch/vm"; +import { Tag } from "./tags"; export type BaseGenericExtension = Extension; +export type BlockUtilityWithID = BlockUtility & { [blockIDKey]: string }; + /** * @summary An object passed to extensions on initialization. * @description The Environment object should contain anything necessary for an extension to interact with the Scratch/Blockly environment @@ -61,7 +66,7 @@ export type ExtensionMenuDisplayDetails = { iconURL?: string; /** * This field encodes the smaller image (like a thumbnail) that will appear both in the extensions menu, - * as well as on the edge of each of your extensions blocks. + * as well as on the edge of each of your extension's blocks. * * **IMPORTANT:** This field should be set to the name of a file (typically an svg) that is in the same directory as your Extension's index.ts file. * @example This example assumes that there is a file _myExtensionLogo.svg_ located in our extension's directory. @@ -70,6 +75,35 @@ export type ExtensionMenuDisplayDetails = { * ``` */ insetIconURL?: string; + /** + * This field disables the inset icon that appears on the edge of each of your extension's blocks. + * + * This field can only be set to true and should not be defined if you wish to keep the inset icon on your extension's blocks. + */ + noBlockIcon?: true; + /** + * The overal color of the blocks in your extension. + * Express as a hash code (e.g. #ff0000) + */ + blockColor?: string; + /** + * The colors of the menus in your extension. + * Express as a hash code (e.g. #ff0000) + * + * **NOTE: In order for this setting to be respected, `blockColor` must also be defined** + */ + menuColor?: string; + /** + * The color of the menu slots when a menu is clicked on. + * Express as a hash code (e.g. #ff0000) + * + * **NOTE: In order for this setting to be respected, `blockColor` must also be defined** + */ + menuSelectColor?: string; + /** + * Associate certain tags with this extension so that it can be easily located within the extensions menu + */ + tags?: Tag[]; internetConnectionRequired?: boolean; collaborator?: string; bluetoothRequired?: boolean; @@ -84,4 +118,4 @@ export type ExtensionMenuDisplayDetails = { hidden?: boolean; disabled?: boolean; implementationLanguage?: ValueOf; -} & Partial, { name: string, description: string }>> +} & Partial, { name: string, description: string }>>; \ No newline at end of file diff --git a/extensions/src/common/types/framework/tags.ts b/extensions/src/common/types/framework/tags.ts new file mode 100644 index 000000000..d227f78b2 --- /dev/null +++ b/extensions/src/common/types/framework/tags.ts @@ -0,0 +1 @@ +export type Tag = "Made by PRG" | "PRG Internal" | "Made by Scratch" | "Dancing with AI"; \ No newline at end of file diff --git a/extensions/src/common/types/index.ts b/extensions/src/common/types/index.ts index 9d5d23d40..d09ecc16f 100644 --- a/extensions/src/common/types/index.ts +++ b/extensions/src/common/types/index.ts @@ -6,4 +6,13 @@ export * from "./framework/arguments"; export * from "./framework/blocks"; export * from "./framework/menus"; export * from "./framework/translations"; -export * from "./scratch/audio"; \ No newline at end of file +export * from "./scratch/audio"; + +/** Constructed based on Svelte documentation: https://svelte.dev/docs#run-time-client-side-component-api-creating-a-component */ +export type SvelteComponentConstructor> = new ( + options: { + target: Element | HTMLElement; + anchor?: Element | HTMLElement; + props?: TProps; + } +) => object; diff --git a/extensions/src/common/types/legacy.ts b/extensions/src/common/types/legacy.ts index 0de8b5b17..fb36aba20 100644 --- a/extensions/src/common/types/legacy.ts +++ b/extensions/src/common/types/legacy.ts @@ -88,6 +88,10 @@ export interface ExtensionMetadata { /** Map of menu name to metadata for each of this extension's menus. */ menus?: Record | undefined; + + color1?: string; + color2?: string; + color3?: string; } /** All the metadata needed to register an extension block. */ diff --git a/extensions/src/common/types/utils.ts b/extensions/src/common/types/utils.ts index f2e35b9b8..39f1d79d7 100644 --- a/extensions/src/common/types/utils.ts +++ b/extensions/src/common/types/utils.ts @@ -33,4 +33,18 @@ export type Primitive = IncludeSymbol ext export type AbstractConstructor = abstract new (...args: any[]) => T; export type NonAbstractConstructor = new (...args: any[]) => T; -export type ExlcudeFirst = F extends [any, ...infer R] ? R : never; \ No newline at end of file +export type ExlcudeFirst = F extends [any, ...infer R] ? R : never; + +export type Expand = T extends (...args: infer A) => infer R + ? (...args: Expand) => Expand + : T extends infer O + ? { [K in keyof O]: O[K] } + : never; + +export type ExpandRecursively = T extends (...args: infer A) => infer R + ? (...args: ExpandRecursively) => ExpandRecursively + : T extends object + ? T extends infer O + ? { [K in keyof O]: ExpandRecursively } + : never + : T; \ No newline at end of file diff --git a/extensions/src/common/utils.ts b/extensions/src/common/utils.ts index ddf3b0d86..b1c74173b 100644 --- a/extensions/src/common/utils.ts +++ b/extensions/src/common/utils.ts @@ -215,4 +215,73 @@ export const rgbToHex = (rgb: RGBObject) => decimalToHex(rgbToDecimal(rgb)); export const wrapClamp = (n, min, max) => { const range = (max - min) + 1; return n - (Math.floor((n - min) / range) * range); -} \ No newline at end of file +} + +/** + * Create an event (within extension framework code, i.e. `extensions/src`) that can be subscribed to at _bundling time_. + * + * As a mental short-hand, you can think of this as a macro-esque mechanism. + * + * @see macro-link Macros + * + * @description + * This **_works_** as it tries to create an object in the global scope, which is interacted with both from bundling-related code, + * as well as the extension framework code + * (as long as that code is executed at the _top-level_, and the runtime is NodeJS, not the browser). + * + * The reason why it's important the framework-based code is _top-level_ is because the framework sourcecode will actually be evaluated at _bundle time_, + * meaning all _top-level_ expressions will be executed. + * + * This allows for the desired event mechanism: + * > 1. Bundling-related code associates callbacks with a certain event (an entry to an object in global scope), + * > 2. Framework code tries to fire the callbacks of a given event (when it is executed after step 1) + * + * @see top-level Top-level Code + + * **NOTE:** This function returns a non-null value only in NodeJS environments. + * @param identifier + * @returns + */ +export const tryCreateBundleTimeEvent = (identifier: string) => { + const environment = typeof window === 'undefined' ? "node" : "browser"; + + if (environment !== "node") return null; + + const key = `Bundle Time Event: ${identifier}`; + + type Unregister = () => void; + type Callback = (details: Payload, removeSelf: Unregister) => void; + type Register = (callback: Callback) => Unregister; + type Callbacks = Record; + + const get = () => { + global[key] ??= {}; + return global[key] as Callbacks; + } + + const registerCallback: Register = (callback) => { + const id = Symbol(key); + get()[id] = callback; + return () => delete get()?.[id]; + }; + + type Fire = (details: Payload) => void; + + const fire: Fire = (details) => { + const callbackIDs = Object.getOwnPropertySymbols(get()); + for (const id of callbackIDs) get()[id]?.(details, () => delete get()?.[id]); + }; + + return { registerCallback, fire }; +} + +/** + * from: https://www.geeksforgeeks.org/how-to-create-a-guid-uuid-in-javascript/ + * @returns + */ +export const uuidv4 = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + .replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0, + v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); diff --git a/extensions/src/complex_example/AnimalArgument.svelte b/extensions/src/complex_example/AnimalArgument.svelte index cd50a0bfa..5f223b80f 100644 --- a/extensions/src/complex_example/AnimalArgument.svelte +++ b/extensions/src/complex_example/AnimalArgument.svelte @@ -1,12 +1,12 @@ - -
{#each Object.keys(emojiByAnimal) as animal} - + {/each} -
-

{text}

-
-
\ No newline at end of file + + + diff --git a/extensions/src/complex_example/addDefinition.ts b/extensions/src/complex_example/addDefinition.ts deleted file mode 100644 index 0b359a431..000000000 --- a/extensions/src/complex_example/addDefinition.ts +++ /dev/null @@ -1,19 +0,0 @@ -import TypeScriptFrameworkExample from "."; -import { ArgumentType, BlockType, DefineBlock } from "$common"; - -type AddDefinition = DefineBlock number>; - -const addDefinition: AddDefinition = (extension: TypeScriptFrameworkExample) => ({ - type: BlockType.Reporter, - operation(left, right) { - const sum = left + right; - return sum; - }, - args: [ - { type: ArgumentType.Number, defaultValue: 3, options: extension.lhsOptions }, - { type: ArgumentType.Number } - ], - text: (left, right) => `Add ${left} to ${right}`, -}); - -export default addDefinition; \ No newline at end of file diff --git a/extensions/src/complex_example/index.ts b/extensions/src/complex_example/index.ts index 523ae2524..d43327ce2 100644 --- a/extensions/src/complex_example/index.ts +++ b/extensions/src/complex_example/index.ts @@ -1,5 +1,6 @@ import { ArgumentType, BlockType, RGBObject, MenuItem, copyTo, SaveDataHandler, block, buttonBlock, extension } from "$common"; import BlockUtility from "$root/packages/scratch-vm/src/engine/block-utility"; +import AnimalArgument from "./AnimalArgument.svelte"; const enum MatrixDimension { Row, @@ -38,6 +39,7 @@ export default class TypeScriptFrameworkExample extends extension( description: "Demonstrating how typescript can be used to write a realistic extension", iconURL: "Typescript_logo.png", insetIconURL: "typescript-logo.svg", + tags: ["PRG Internal"] }, "ui", "customSaveData", @@ -221,7 +223,7 @@ export default class TypeScriptFrameworkExample extends extension( @block((self) => ({ type: BlockType.Command, arg: self.makeCustomArgument({ - component: "AnimalArgument", + component: AnimalArgument, initial: { value: Animal.Leopard, text: nameByAnimal[Animal.Leopard] } }), text: (animal) => `Add ${animal} to collection`, diff --git a/extensions/src/declaration.d.ts b/extensions/src/declaration.d.ts new file mode 100644 index 000000000..4d4d358f0 --- /dev/null +++ b/extensions/src/declaration.d.ts @@ -0,0 +1,31 @@ +/** Begin: Supported Image Formats */ +declare module "*.png" { + const value: string; + export default value; +}; + +declare module "*.gif" { + const value: string; + export default value; +}; + +declare module "*.jpg" { + const value: string; + export default value; +}; + +declare module "*.jpeg" { + const value: string; + export default value; +}; + +declare module "*.svg" { + const value: string; + export default value; +}; + +declare module "*.webp" { + const value: string; + export default value; +}; +/** End: Supported Image Formats */ \ No newline at end of file diff --git a/extensions/src/extensionProbe/index.ts b/extensions/src/extensionProbe/index.ts index 8d8e88ae8..75b8738bf 100644 --- a/extensions/src/extensionProbe/index.ts +++ b/extensions/src/extensionProbe/index.ts @@ -6,6 +6,7 @@ export default class ExtensionProbe extends extension( { name: "Extension Probe", description: "(INTERNAL) Use this extension to probe the info of other estensions", + tags: ["PRG Internal"] }, "ui" ) { diff --git a/extensions/src/onnxTest/index.ts b/extensions/src/onnxTest/index.ts index 51c6b1c77..1a3302fcf 100644 --- a/extensions/src/onnxTest/index.ts +++ b/extensions/src/onnxTest/index.ts @@ -6,7 +6,8 @@ type Details = { name: "Onnx Example", description: "A demonstration that an onnx model can be used (not exciting, nor educationally valuable)", iconURL: "", - insetIconURL: "" + insetIconURL: "", + tags: ["PRG Internal"] }; diff --git a/extensions/src/poseBody/index.ts b/extensions/src/poseBody/index.ts index 533f30c03..7f9fb3395 100644 --- a/extensions/src/poseBody/index.ts +++ b/extensions/src/poseBody/index.ts @@ -28,7 +28,8 @@ type Details = { name: "Body Sensing", description: "Sense body position with the camera.", iconURL: "pose-body.png", - insetIconURL: "pose-body-small.svg" + insetIconURL: "pose-body-small.svg", + tags: ["Dancing with AI", "Made by PRG"] }; /** diff --git a/extensions/src/poseFace/index.ts b/extensions/src/poseFace/index.ts index ee69dd965..cd9138df6 100644 --- a/extensions/src/poseFace/index.ts +++ b/extensions/src/poseFace/index.ts @@ -36,7 +36,8 @@ type Details = { name: "Face Sensing", description: "Sense face movement with the camera.", iconURL: "pose-face.png", - insetIconURL: "pose-face-small.svg" + insetIconURL: "pose-face-small.svg", + tags: ["Dancing with AI", "Made by PRG"] }; /** diff --git a/extensions/src/poseHand/index.ts b/extensions/src/poseHand/index.ts index 784af34c0..5e566d404 100644 --- a/extensions/src/poseHand/index.ts +++ b/extensions/src/poseHand/index.ts @@ -28,7 +28,8 @@ type Details = { name: "Hand Sensing", description: "Sense hand movement with the camera.", iconURL: "pose-hand.png", - insetIconURL: "pose-hand-small-3.svg" + insetIconURL: "pose-hand-small-3.svg", + tags: ["Dancing with AI", "Made by PRG"] }; /** diff --git a/extensions/src/projectProbe/index.ts b/extensions/src/projectProbe/index.ts index 2abe40c2a..2e2da1307 100644 --- a/extensions/src/projectProbe/index.ts +++ b/extensions/src/projectProbe/index.ts @@ -5,7 +5,8 @@ import JSZip from "jszip"; const details: ExtensionMenuDisplayDetails = { name: "Project Probe", - description: "(INTERNAL) An extension for probing the contents of .sb3 files" + description: "(INTERNAL) An extension for probing the contents of .sb3 files", + tags: ["PRG Internal"] }; export default class _ extends extension(details, "ui") { diff --git a/extensions/src/selfieSegmentation/index.ts b/extensions/src/selfieSegmentation/index.ts index 1bcb424e9..d4fa26ad1 100644 --- a/extensions/src/selfieSegmentation/index.ts +++ b/extensions/src/selfieSegmentation/index.ts @@ -5,6 +5,7 @@ import type BlockUtility from "$scratch-vm/engine/block-utility"; const details: ExtensionMenuDisplayDetails = { name: "Selfie Detector", + tags: ["Made by PRG"] }; export default class extends extension( diff --git a/extensions/src/simple_example/five.png b/extensions/src/simple_example/five.png new file mode 100644 index 000000000..c8b4592a9 Binary files /dev/null and b/extensions/src/simple_example/five.png differ diff --git a/extensions/src/simple_example/index.ts b/extensions/src/simple_example/index.ts index 04db78d04..17c8c0b4c 100644 --- a/extensions/src/simple_example/index.ts +++ b/extensions/src/simple_example/index.ts @@ -1,4 +1,6 @@ -import { ArgumentType, BlockType, Environment, ExtensionMenuDisplayDetails, Language, Menu, SaveDataHandler, block, buttonBlock, extension, tryCastToArgumentType } from "$common"; +import { ArgumentType, BlockType, BlockUtilityWithID, Environment, ExtensionMenuDisplayDetails, Language, Menu, SaveDataHandler, block, buttonBlock, extension, tryCastToArgumentType, untilTimePassed } from "$common"; +import jibo from "./jibo.png"; +import five from "./five.png"; const details: ExtensionMenuDisplayDetails = { name: "Simple Typescript Extension", @@ -7,10 +9,14 @@ const details: ExtensionMenuDisplayDetails = { [Language.Español]: { name: "Extensión simple Typescript", description: "Ejemplo de una extensión simple usando Typescript" - } + }, + blockColor: "#822fbd", + menuColor: "#4ed422", + menuSelectColor: "#9e0d2c", + tags: ["PRG Internal"], } -export default class SimpleTypescript extends extension(details, "ui", "customSaveData") { +export default class SimpleTypescript extends extension(details, "ui", "customSaveData", "indicators") { count: number = 0; logOptions: Menu = { @@ -36,15 +42,34 @@ export default class SimpleTypescript extends extension(details, "ui", "customSa this.count += amount; } - init(env: Environment) { } + async init(env: Environment) { + } @block((self) => ({ type: BlockType.Command, - text: (msg) => `Log ${msg} to the console`, + text: (msg) => `Indicate and log a ${msg} to the console`, arg: { type: ArgumentType.String, options: self.logOptions } })) - log(msg: string) { - console.log(msg); + log(value: string) { + console.log(value); + } + + @block({ + type: "command", + args: [ + { type: "string", defaultValue: "Howdy!" }, + { type: "string", options: ["error", "success", "warning"] }, + { type: "number", options: [1, 3, 5] } + ], + text: (msg, type, time) => `Indicate '${msg}' as ${type} for ${time} seconds`, + }) + async indicateMessage(value: string, type: typeof this.IndicatorType, time: number) { + const position = "category"; + const msg = `This is a ${type} indicator for ${value}!`; + const [{ close }] = await Promise.all([ + this.indicate({ position, type, msg }), untilTimePassed(time * 1000) + ]); + close(); } @block({ type: BlockType.Button, text: `Dummy UI` }) @@ -61,4 +86,31 @@ export default class SimpleTypescript extends extension(details, "ui", "customSa colorUI() { this.openUI("Palette"); } + + @block({ + type: BlockType.Command, + text: (jibo) => `This is what jibo looks like: ${jibo}`, + arg: { + type: "image", + uri: jibo, + alt: "Picture of Jibo", + flipRTL: true + } + }) + imageBlock(jibo: "inline image") { + } + + @block({ + type: "reporter", + text: (lhs, five, rhs) => `${lhs} + ${five} - ${rhs}`, + args: [ + { type: "number", defaultValue: 1 }, + { type: "image", uri: five, alt: "golden five" }, + "number" + ] + }) + addFive(lhs: number, five: "inline image", rhs: number, { blockID }: BlockUtilityWithID) { + console.log(blockID); + return lhs + 5 - rhs; + } } \ No newline at end of file diff --git a/extensions/src/simple_example/jibo.png b/extensions/src/simple_example/jibo.png new file mode 100644 index 000000000..32cea4371 Binary files /dev/null and b/extensions/src/simple_example/jibo.png differ diff --git a/extensions/src/tables/index.ts b/extensions/src/tables/index.ts index 7757ba5d4..7e1ce764f 100644 --- a/extensions/src/tables/index.ts +++ b/extensions/src/tables/index.ts @@ -8,7 +8,8 @@ type Details = { name: "Tables", description: "Make and use tables with rows and columns", iconURL: "tables.png", - insetIconURL: "tables.svg" + insetIconURL: "tables.svg", + tags: ["Made by PRG"] }; type Blocks = { @@ -17,7 +18,7 @@ type Blocks = { removeTable: (table: string) => void; insertColumn: (table: string) => void; insertRow: (table: string) => void; - insertValueAt: (table: string, value: any, row: number, column: number) => void; + insertValueAt: (table: string, value: number, row: number, column: number) => void; getValueAt: (table: string, row: number, column: number) => number; numberOfRows: (table: string) => number; numberOfColumns: (table: string) => number; diff --git a/extensions/src/teachableMachine/index.ts b/extensions/src/teachableMachine/index.ts index 610222ba5..4c01c63c1 100644 --- a/extensions/src/teachableMachine/index.ts +++ b/extensions/src/teachableMachine/index.ts @@ -1,14 +1,10 @@ -import { ArgumentType, BlockType, Extension, Block, DefineBlock, Environment, ExtensionMenuDisplayDetails, extension } from "$common"; +import { Environment, extension } from "$common"; import tmImage from '@teachablemachine/image'; import tmPose from '@teachablemachine/pose'; import { create } from '@tensorflow-models/speech-commands'; +import { legacyFullSupport, } from "./legacy"; -import { legacyFullSupport, info } from "./legacy"; - -const { legacyExtension, legacyDefinition } = legacyFullSupport.for(); - -// TODO: Implement indictator (Peripheral Stuff) to show if the model is loaded or not - +const { legacyExtension, legacyBlock } = legacyFullSupport.for(); const VideoState = { /** Video turned off. */ OFF: 'off', @@ -18,25 +14,18 @@ const VideoState = { ON_FLIPPED: 'on-flipped', } as const; -type Details = { +const dynamicClassMenu = (self: teachableMachine) => ({ + argumentMethods: { 0: { getItems: () => self.getClasses() } } +}) + +@legacyExtension() +export default class teachableMachine extends extension({ name: "Teachable Machine", description: "Use your Teachable Machine models in your Scratch project!", iconURL: "teachable-machine-blocks.png", - insetIconURL: "teachable-machine-blocks-small.svg" -}; - -type Blocks = { - useModelBlock(url: string): void; - whenModelMatches(state: string): boolean; - modelPrediction(): string; - modelMatches(state: string): boolean; - classConfidence(state: string): number; - videoToggle(state: string): void; - setVideoTransparency(state: number): void; -} - -@legacyExtension() -export default class teachableMachine extends Extension { + insetIconURL: "teachable-machine-blocks-small.svg", + tags: ["Dancing with AI", "Made by PRG"] +}) { lastUpdate: number; maxConfidence: number; @@ -321,83 +310,46 @@ export default class teachableMachine extends Extension { this.runtime.ioDevices.video.setPreviewGhost(trans); } - defineBlocks(): teachableMachine["BlockDefinitions"] { + @legacyBlock.useModelBlock() + useModelBlock(url: string) { + this.useModel(url); + } - this.setTransparency(50); - this.toggleVideo(VideoState.ON); + @legacyBlock.whenModelMatches(dynamicClassMenu) + whenModelMatches(state: string) { + return this.model_match(state); + } - const useModelBlock = legacyDefinition.useModelBlock({ - operation: (url) => { - this.useModel(url); - } - }); - - const whenModelMatches = legacyDefinition.whenModelMatches({ - operation: (state) => { - return this.model_match(state); - }, - argumentMethods: { - 0: { - getItems: () => this.getClasses() - } - } - }); + @legacyBlock.modelPrediction() + modelPrediction() { + return this.getModelPrediction(); + } - const modelPrediction = legacyDefinition.modelPrediction({ - operation: () => { - return this.getModelPrediction(); - } - }); - - const modelMatches = legacyDefinition.modelMatches({ - operation: (state) => { - return this.model_match(state); - }, - argumentMethods: { - 0: { - getItems: () => this.getClasses() - } - } - }); - - const classConfidence = legacyDefinition.classConfidence({ - operation: (state) => { - return this.getClassConfidence(state); - }, - argumentMethods: { - 0: { - getItems: () => this.getClasses() - } - } - }); - - const videoToggle = legacyDefinition.videoToggle({ - operation: (video_state) => { - this.toggleVideo(video_state); - }, - argumentMethods: { - 0: { - handler: (video_state: string) => { - return ['on', 'off', 'on-flipped'].includes(video_state) ? video_state : VideoState.ON; - }, - } - } - }); + @legacyBlock.modelMatches(dynamicClassMenu) + modelMatches(state: string) { + return this.model_match(state); + } + + @legacyBlock.classConfidence(dynamicClassMenu) + classConfidence(state: string) { + return this.getClassConfidence(state); + } - const setVideoTransparency = legacyDefinition.setVideoTransparency({ - operation: (transparency: number) => { - this.setTransparency(transparency); + @legacyBlock.videoToggle({ + argumentMethods: { + 0: { + handler: (video_state: string) => { + return ['on', 'off', 'on-flipped'].includes(video_state) ? video_state : VideoState.ON; + }, } - }); - - return { - useModelBlock, - whenModelMatches, - modelPrediction, - modelMatches, - classConfidence, - videoToggle, - setVideoTransparency, } + }) + videoToggle(state: string) { + this.toggleVideo(state); + } + + @legacyBlock.setVideoTransparency() + setVideoTransparency(transparency: number) { + this.setTransparency(transparency); } } diff --git a/extensions/src/tsconfig.json b/extensions/src/tsconfig.json index 52af09da2..ec9cbc63c 100644 --- a/extensions/src/tsconfig.json +++ b/extensions/src/tsconfig.json @@ -18,6 +18,7 @@ }, "include": [ ".**/*.ts", - "**/*.ts" + "**/*.ts", + "./declaration.d.ts" ], } \ No newline at end of file diff --git a/extensions/testing/BlockRunner.ts b/extensions/testing/BlockRunner.ts index c751d7156..36f406260 100644 --- a/extensions/testing/BlockRunner.ts +++ b/extensions/testing/BlockRunner.ts @@ -1,10 +1,10 @@ import { BlockType, ExtensionBlockMetadata, ExtensionConstructorParams, ExtensionInstance, NonAbstractConstructor } from "$common"; -import { isLegacy } from "$common/extension/mixins/optional/legacySupport"; +import { isLegacy } from "$common/extension/mixins/configurable/legacySupport"; import BlockUtility from "$root/packages/scratch-vm/src/engine/block-utility"; import { buildKeyBlockMap } from "$testing"; import testable from "./mixins/testable"; import { BlockKey, InputArray, KeyToBlockIndexMap, RenderedUI, RuntimeForTest, Testable, ReportedValue } from "./types"; -import { getEngineFile } from "./utils"; +import { extensionConstructorArgs, getEngineFile } from "./utils"; export class BlockRunner { private blockData: ExtensionBlockMetadata[]; @@ -47,9 +47,8 @@ export class BlockRunner { createCompanion(constructor: NonAbstractConstructor) { const { instance: { runtime } } = this; - const args: ExtensionConstructorParams = [runtime, "", "", ""]; const TestClass = testable(constructor); - const companion = new TestClass(...args); + const companion = new TestClass(...extensionConstructorArgs(runtime)); companion.initialize(); return new BlockRunner(buildKeyBlockMap(companion), companion as Testable); diff --git a/extensions/testing/index.ts b/extensions/testing/index.ts index 26f5f54b5..580800912 100644 --- a/extensions/testing/index.ts +++ b/extensions/testing/index.ts @@ -1,15 +1,16 @@ -import { untilCondition, openUIEvent, openUI, isFunction, splitOnCapitals, NonAbstractConstructor, ExtensionInstance, ExtensionConstructorParams } from "$common"; +import { untilCondition, openUIEvent, openUI, isFunction, splitOnCapitals, NonAbstractConstructor, ExtensionInstance, } from "$common"; import { describe, expect, jest, test } from '@jest/globals'; import path from "path"; import { BlockKey, BlockTestCase, RuntimeForTest, TestHelper, UnitTests, GetTestCase, TestCaseEntry, InputArray, KeyToBlockIndexMap, IntegrationTest, Testable } from "./types"; import { render, fireEvent } from '@testing-library/svelte'; import glob from "glob"; import fs from "fs"; -import { executeAndSquashWarnings, getEngineFile } from "./utils"; +import { executeAndSquashWarnings, extensionConstructorArgs, getEngineFile } from "./utils"; import { BlockRunner } from "./BlockRunner"; import testable from "./mixins/testable"; +import imageMock from "./mocks/image"; -export { describe, expect, test }; +export { describe, expect, test, imageMock }; export const testID = "extensionUnderTest"; export const testName = "Extension Under Test"; @@ -61,9 +62,8 @@ const mockRuntime = (details: TestDetails): const getInstance = async (details: TestDetails): Promise> => { const runtime = mockRuntime(details); - const args: ExtensionConstructorParams = [runtime, testName, testID, ""]; const TestClass = testable(details.Extension); - const instance = new TestClass(...args); + const instance = new TestClass(...extensionConstructorArgs(runtime, testName, testID)); await instance.initialize(); return instance as Testable; diff --git a/extensions/testing/jest.config.ts b/extensions/testing/jest.config.ts index 19995562e..7a786f0c1 100644 --- a/extensions/testing/jest.config.ts +++ b/extensions/testing/jest.config.ts @@ -3,6 +3,7 @@ * https://jestjs.io/docs/configuration */ import { pathsToModuleNameMapper } from 'ts-jest'; + // In the following statement, replace `./tsconfig` with the path to your `tsconfig` file // which contains the path mapping (ie the `compilerOptions.paths` option): import path from "path"; @@ -89,7 +90,10 @@ export default { moduleFileExtensions: ['js', 'ts', 'svelte'], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - moduleNameMapper: pathsToModuleNameMapper(paths, { prefix: pathsBase }), + moduleNameMapper: { + "^.+\\.(jpg|jpeg|png|gif|webp|svg)$": path.resolve(".", "mocks", "image.ts"), + ...pathsToModuleNameMapper(paths, { prefix: pathsBase }), + }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], diff --git a/extensions/testing/mocks/image.ts b/extensions/testing/mocks/image.ts new file mode 100644 index 000000000..64e3e17a5 --- /dev/null +++ b/extensions/testing/mocks/image.ts @@ -0,0 +1 @@ +export default 'dummy:image:data'; \ No newline at end of file diff --git a/extensions/testing/tsconfig.json b/extensions/testing/tsconfig.json index 0bd31bb53..89c4e03bc 100644 --- a/extensions/testing/tsconfig.json +++ b/extensions/testing/tsconfig.json @@ -10,5 +10,10 @@ "skipLibCheck": true, "ignoreDeprecations": "5.0" }, - "include": [".**/*.ts", "**/*.ts", "jest.config.js"] + "include": [ + ".**/*.ts", + "**/*.ts", + "jest.config.js", + "../src/declaration.d.ts" + ] } \ No newline at end of file diff --git a/extensions/testing/utils.ts b/extensions/testing/utils.ts index 64b74d807..091fecba3 100644 --- a/extensions/testing/utils.ts +++ b/extensions/testing/utils.ts @@ -1,4 +1,5 @@ -import { isString } from "$common"; +import { ExtensionConstructorParams, isString } from "$common"; +import type Runtime from "$scratch-vm/engine/runtime"; import { vmSrc } from "$root/scripts/paths"; import path from "path"; @@ -18,7 +19,6 @@ export const executeAndSquashWarnings = any>(oper export const getEngineFile = (name: string) => path.join(vmSrc, "engine", name); - const stubbed: Map> = new Map(); export const stub = ( @@ -38,4 +38,15 @@ export const restore = { container[key] = stubbed.get(container)[key]; -} \ No newline at end of file +} + +export const extensionConstructorArgs = ( + runtime: Runtime, + testName = "Unamed Test", + testID = "Default Test ID", + blockIconURI: string = null, + blockColor = "#000", + menuColor = "#111", + menuSelectColor = "#222" +): ExtensionConstructorParams => + [runtime, testName, testID, blockIconURI, blockColor, menuColor, menuSelectColor]; \ No newline at end of file diff --git a/package.json b/package.json index 29c80859c..8524347bf 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "add:package": "npm run ts:node:extensions -- ./extensions/scripts/factories/package.ts", "rebuild:blocks": "npm run prepublish --prefix ./packages/scratch-blocks && npm run dev", "test:extensions": "npm run test --prefix ./extensions/", - "edocument": "npm run document --prefix ./extensions/", + "document:extensions": "npm run document --prefix ./extensions/", + "edocument": "npm run document:extensions", "etest": "npm run test:extensions" }, "devDependencies": { @@ -35,4 +36,4 @@ "typescript": "$typescript" } } -} +} \ No newline at end of file diff --git a/packages/scratch-gui/src/containers/extension-library.jsx b/packages/scratch-gui/src/containers/extension-library.jsx index 622cb74da..3b5c482a4 100644 --- a/packages/scratch-gui/src/containers/extension-library.jsx +++ b/packages/scratch-gui/src/containers/extension-library.jsx @@ -2,7 +2,7 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import VM from 'scratch-vm'; -import {addLocaleData, defineMessages, injectIntl, intlShape} from 'react-intl'; +import { addLocaleData, defineMessages, injectIntl, intlShape } from 'react-intl'; import extensionLibraryContent from '../lib/libraries/extensions/index.jsx'; @@ -23,8 +23,14 @@ const messages = defineMessages({ } }); +const makeTranslationLabel = (tag) => ({ + defaultMessage: tag, + description: `${tag} -- Tag for filtering a library for everything`, + id: `gui.extensionTags.${tag}` +}); + class ExtensionLibrary extends React.PureComponent { - constructor (props) { + constructor(props) { super(props); bindAll(this, [ 'handleItemSelect' @@ -39,7 +45,7 @@ class ExtensionLibrary extends React.PureComponent { }); }); } - handleItemSelect (item) { + handleItemSelect(item) { const id = item.extensionId; let url = item.extensionURL ? item.extensionURL : id; if (!item.disabled && !id) { @@ -56,15 +62,26 @@ class ExtensionLibrary extends React.PureComponent { } } } - render () { - const extensionLibraryThumbnailData = extensionLibraryContent.map(extension => ({ - rawURL: extension.iconURL || extensionIcon, - ...extension - })); + render() { + const extensionLibraryThumbnailData = extensionLibraryContent + .map(({ iconURL, extensionId, tags, ...rest }) => { + const uniqueURL = iconURL ? `${iconURL}?key=${extensionId}` : extensionIcon; + return { rawURL: uniqueURL, iconURL: uniqueURL, extensionId, tags: tags ?? [], ...rest }; + }) + .sort((a, b) => { + if (a.tags?.includes('PRG Internal') || a.tags?.length === 0) return 1; + if (b.tags?.includes('PRG Internal') || b.tags?.length === 0) return -1; + return 0; + }) + + const uniqueTags = Array.from(new Set(extensionLibraryThumbnailData.map(({ tags }) => tags).flat())); + const tags = uniqueTags.map(tag => ({ tag, intlLabel: makeTranslationLabel(tag) })); + return ( 0) { return vm.runtime.targets[0].getCostumes().map(costume => [costume.name, costume.name]) .concat([[next, 'next backdrop'], - [previous, 'previous backdrop'], - [random, 'random backdrop']]); + [previous, 'previous backdrop'], + [random, 'random backdrop']]); } return [['', '']]; }; diff --git a/packages/scratch-gui/src/lib/customBlockOverrides.js b/packages/scratch-gui/src/lib/customBlockOverrides.js deleted file mode 100644 index be88b3373..000000000 --- a/packages/scratch-gui/src/lib/customBlockOverrides.js +++ /dev/null @@ -1,40 +0,0 @@ -import {dropdownStateFlag, openDropdownState, closeDropdownState, initDropdownState, dropdownEntryFlag} from "../dist/globals"; - -/** - * @param {import("scratch-blocks")} blocks - * @param {import("scratch-vm")} vm - * @returns - */ -export const overridesForCustomArgumentSupport = (blocks, vm) => { - const { FieldDropdown } = blocks; - const {fromJson, prototype} = FieldDropdown; - const {setValue, showEditor_ } = prototype; - - const { runtime } = vm; - - const setState = (state, dropdown) => { - runtime[dropdownStateFlag] = state; - runtime[dropdownEntryFlag] = dropdown ? {text: dropdown.text_, value: dropdown.value_} : undefined; - } - - const resetAndReturn = (result) => { - setState(null); - return result; - }; - - FieldDropdown.fromJson = (...args) => { - setState(initDropdownState, undefined); - const item = resetAndReturn(fromJson(...args)); - return item; - }; - - FieldDropdown.prototype.setValue = function (...args) { - setState(closeDropdownState, this); - return resetAndReturn(setValue.bind(this)(...args)); - }; - - FieldDropdown.prototype.showEditor_ = function (...args) { - setState(openDropdownState, this); - return resetAndReturn(showEditor_.bind(this)(...args)); - } -} \ No newline at end of file diff --git a/packages/scratch-gui/src/lib/libraries/extensions/index.jsx b/packages/scratch-gui/src/lib/libraries/extensions/index.jsx index bb3ddaa8a..b2b9415f4 100644 --- a/packages/scratch-gui/src/lib/libraries/extensions/index.jsx +++ b/packages/scratch-gui/src/lib/libraries/extensions/index.jsx @@ -83,10 +83,12 @@ export default [ id="gui.extension.text-classification.description" /> ), - featured: true + featured: true, + tags: ["Made by PRG"] + }, { - name: ( + name: ( ), - featured: true + featured: true, + tags: ["Made by PRG"] }, { - name: ( + name: ( ), - featured: true - },{ - name: ( + featured: true, + tags: ["Made by PRG"] + }, { + name: ( ), - featured: true + featured: true, + tags: ["Made by PRG"] }, //*/ // RANDI Removing extensions I don't want students to play with { name: ( @@ -162,7 +167,8 @@ export default [ id="gui.extension.music.description" /> ), - featured: true + featured: true, + tags: ["Made by Scratch"] }, { name: ( @@ -182,7 +188,8 @@ export default [ id="gui.extension.pen.description" /> ), - featured: true + featured: true, + tags: ["Made by Scratch"] }, { name: ( @@ -202,7 +209,8 @@ export default [ id="gui.extension.videosensing.description" /> ), - featured: true + featured: true, + tags: ["Made by Scratch"] }, { name: ( @@ -224,7 +232,8 @@ export default [ /> ), featured: true, - internetConnectionRequired: true + internetConnectionRequired: true, + tags: ["Made by Scratch"] }, { name: ( @@ -246,7 +255,8 @@ export default [ /> ), featured: true, - internetConnectionRequired: true + internetConnectionRequired: true, + tags: ["Made by Scratch"] }, { name: 'Makey Makey', @@ -261,7 +271,8 @@ export default [ id="gui.extension.makeymakey.description" /> ), - featured: true + featured: true, + tags: ["Made by Scratch"] }, { name: 'micro:bit', @@ -291,7 +302,8 @@ export default [ id="gui.extension.microbit.connectingMessage" /> ), - helpLink: 'https://scratch.mit.edu/microbit' + helpLink: 'https://scratch.mit.edu/microbit', + tags: ["Made by Scratch"] }, { name: 'LEGO MINDSTORMS EV3', @@ -321,7 +333,8 @@ export default [ id="gui.extension.ev3.connectingMessage" /> ), - helpLink: 'https://scratch.mit.edu/ev3' + helpLink: 'https://scratch.mit.edu/ev3', + tags: ["Made by Scratch"] }, { name: 'LEGO BOOST', @@ -352,7 +365,8 @@ export default [ id="gui.extension.boost.connectingMessage" /> ), - helpLink: 'https://scratch.mit.edu/boost' + helpLink: 'https://scratch.mit.edu/boost', + tags: ["Made by Scratch"] }, { name: 'LEGO Education WeDo 2.0', @@ -383,7 +397,8 @@ export default [ id="gui.extension.wedo2.connectingMessage" /> ), - helpLink: 'https://scratch.mit.edu/wedo' + helpLink: 'https://scratch.mit.edu/wedo', + tags: ["Made by Scratch"] }, { name: 'Go Direct Force & Acceleration', @@ -413,6 +428,7 @@ export default [ id="gui.extension.gdxfor.connectingMessage" /> ), - helpLink: 'https://scratch.mit.edu/vernier' + helpLink: 'https://scratch.mit.edu/vernier', + tags: ["Made by Scratch"] }, ]; diff --git a/packages/scratch-gui/src/lib/prg/customBlockOverrides.js b/packages/scratch-gui/src/lib/prg/customBlockOverrides.js new file mode 100644 index 000000000..f2431ad4a --- /dev/null +++ b/packages/scratch-gui/src/lib/prg/customBlockOverrides.js @@ -0,0 +1,46 @@ +import { guiDropdownInterop } from "../../dist/globals"; + +/** + * @param {import("scratch-blocks")} blocks + * @param {import("scratch-vm")} vm + * @returns + */ +export const overridesForCustomArgumentSupport = (blocks, vm) => { + const { FieldDropdown } = blocks; + const { fromJson, prototype } = FieldDropdown; + const { setValue, showEditor_ } = prototype; + const { state, runtimeKey, runtimeProperties } = guiDropdownInterop; + const { runtime } = vm; + const shared = (runtime[runtimeKey] = {}); + const { stateKey, entryKey, updateMethodKey } = runtimeProperties; + + /** + * @type {FieldDropdown} + */ + let current = null; + + const setState = (state, dropdown) => { + shared[stateKey] = state; + shared[entryKey] = dropdown ? { text: dropdown.text_, value: dropdown.value_ } : undefined; + } + + const executeWithState = (state, dropdown, fn, args) => { + setState(state, dropdown); + const result = fn.apply(dropdown, args); + setState(null); + return result; + } + + FieldDropdown.fromJson = (...args) => executeWithState(state.init, null, fromJson, args); + + FieldDropdown.prototype.showEditor_ = function (...args) { + return executeWithState(state.open, (current = this), showEditor_, args); + }; + + FieldDropdown.prototype.setValue = function (...args) { + current = null; + return executeWithState(state.close, this, setValue, args); + }; + + shared[updateMethodKey] = (...args) => executeWithState(state.update, current, setValue, args); +} \ No newline at end of file diff --git a/packages/scratch-gui/src/svelte/Modal.svelte b/packages/scratch-gui/src/svelte/Modal.svelte index 41666f42c..c83adb498 100644 --- a/packages/scratch-gui/src/svelte/Modal.svelte +++ b/packages/scratch-gui/src/svelte/Modal.svelte @@ -1,5 +1,7 @@ diff --git a/packages/scratch-vm/src/engine/execute.js b/packages/scratch-vm/src/engine/execute.js index 2c28338a5..773dcb7fb 100644 --- a/packages/scratch-vm/src/engine/execute.js +++ b/packages/scratch-vm/src/engine/execute.js @@ -2,8 +2,9 @@ const BlockUtility = require('./block-utility'); const BlocksExecuteCache = require('./blocks-execute-cache'); const log = require('../util/log'); const Thread = require('./thread'); -const {Map} = require('immutable'); +const { Map } = require('immutable'); const cast = require('../util/cast'); +const { blockIDKey } = require("../dist/globals"); /** * Single BlockUtility instance reused by execute for every pritimive ran. @@ -160,7 +161,7 @@ const handlePromise = (primitiveReportedValue, sequencer, thread, blockCached, l * @param {object} cached default set of cached values */ class BlockCached { - constructor (blockContainer, cached) { + constructor(blockContainer, cached) { /** * Block id in its parent set of blocks. * @type {string} @@ -284,9 +285,9 @@ class BlockCached { */ this._ops = []; - const {runtime} = blockUtility.sequencer; + const { runtime } = blockUtility.sequencer; - const {opcode, fields, inputs} = this; + const { opcode, fields, inputs } = this; // Assign opcode isHat and blockFunction data to avoid dynamic lookups. this._isHat = runtime.getIsHat(opcode); @@ -439,7 +440,7 @@ const execute = function (sequencer, thread) { const reported = currentStackFrame.reported; // Reinstate all the previous values. for (; i < reported.length; i++) { - const {opCached: oldOpCached, inputValue} = reported[i]; + const { opCached: oldOpCached, inputValue } = reported[i]; const opCached = ops.find(op => op.id === oldOpCached); @@ -520,6 +521,7 @@ const execute = function (sequencer, thread) { // Inputs are set during previous steps in the loop. + blockUtility[blockIDKey] = opCached.id; const primitiveReportedValue = blockFunction(argValues, blockUtility); // If it's a promise, wait until promise resolves. diff --git a/packages/scratch-vm/src/extension-support/extension-manager.js b/packages/scratch-vm/src/extension-support/extension-manager.js index 412c32f00..7bd5a4a3e 100644 --- a/packages/scratch-vm/src/extension-support/extension-manager.js +++ b/packages/scratch-vm/src/extension-support/extension-manager.js @@ -2,8 +2,7 @@ const dispatch = require('../dispatch/central-dispatch'); const log = require('../util/log'); const maybeFormatMessage = require('../util/maybe-format-message'); const BlockType = require('./block-type'); -const { tryInitExtension, tryGetExtensionConstructorFromBundle, tryGetAuxiliaryObjectFromLoadedBundle } = require('./bundle-loader'); -const { customArgumentCheck } = require('../dist/globals'); +const { tryInitExtension, tryGetExtensionConstructorFromBundle, tryGetAuxiliaryObjectFromLoadedBundle } = require('./prg/bundle-loader'); const tryRetrieveExtensionConstructor = async (extensionId) => await extensionId in builtinExtensions @@ -367,11 +366,6 @@ class ExtensionManager { const menuFunc = extensionObject[menuItemFunctionName]; const menuResult = menuFunc.call(extensionObject, editingTargetID); - if (extensionObject[customArgumentCheck]?.(menuResult)) { - const { runtime, getAuxiliaryObject } = this; - return extensionObject.processCustomArgumentHack(runtime, menuResult, getAuxiliaryObject); - } - const menuItems = menuResult.map( item => { item = maybeFormatMessage(item, extensionMessageContext); diff --git a/packages/scratch-vm/src/extension-support/bundle-loader.d.ts b/packages/scratch-vm/src/extension-support/prg/bundle-loader.d.ts similarity index 100% rename from packages/scratch-vm/src/extension-support/bundle-loader.d.ts rename to packages/scratch-vm/src/extension-support/prg/bundle-loader.d.ts diff --git a/packages/scratch-vm/src/extension-support/bundle-loader.js b/packages/scratch-vm/src/extension-support/prg/bundle-loader.js similarity index 97% rename from packages/scratch-vm/src/extension-support/bundle-loader.js rename to packages/scratch-vm/src/extension-support/prg/bundle-loader.js index e0c918a8d..089d85228 100644 --- a/packages/scratch-vm/src/extension-support/bundle-loader.js +++ b/packages/scratch-vm/src/extension-support/prg/bundle-loader.js @@ -1,4 +1,4 @@ -import { FrameworkID, AuxiliaryExtensionInfo } from "../dist/globals"; +import { FrameworkID, AuxiliaryExtensionInfo } from "../../dist/globals"; /** * Initialize an extension (if it supports the PRG Framework strategy of initialization)