diff --git a/docs/extensions.md b/docs/extensions.md index 6cb632e1..add90188 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -120,6 +120,124 @@ class SomeBlocks { } ``` +### Defining a Menu + +To display a drop-down menu for a block argument, specify the `menu` property of that argument and a matching item in +the `menus` section of your extension's definition: + +```js +return { + // ... + blocks: [ + { + // ... + arguments: { + FOO: { + type: ArgumentType.NUMBER, + menu: 'fooMenu' + } + } + } + ], + menus: { + fooMenu: { + items: ['a', 'b', 'c'] + } + } +} +``` + +The items in a menu may be specified with an array or with the name of a function which returns an array. The two +simplest forms for menu definitions are: + +```js +getInfo () { + return { + menus: { + staticMenu: ['static 1', 'static 2', 'static 3'], + dynamicMenu: 'getDynamicMenuItems' + } + }; +} +// this member function will be called each time the menu opens +getDynamicMenuItems () { + return ['dynamic 1', 'dynamic 2', 'dynamic 3']; +} +``` + +The examples above are shorthand for these equivalent definitions: + +```js +getInfo () { + return { + menus: { + staticMenu: { + items: ['static 1', 'static 2', 'static 3'] + }, + dynamicMenu: { + items: 'getDynamicMenuItems' + } + } + }; +} +// this member function will be called each time the menu opens +getDynamicMenuItems () { + return ['dynamic 1', 'dynamic 2', 'dynamic 3']; +} +``` + +If a menu item needs a label that doesn't match its value -- for example, if the label needs to be displayed in the +user's language but the value needs to stay constant -- the menu item may be an object instead of a string. This works +for both static and dynamic menu items: + +```js +menus: { + staticMenu: [ + { + text: formatMessage(/* ... */), + value: 42 + } + ] +} +``` + +#### Accepting reporters ("droppable" menus) + +By default it is not possible to specify the value of a dropdown menu by inserting a reporter block. While we +encourage extension authors to make their menus accept reporters when possible, doing so requires careful +consideration to avoid confusion and frustration on the part of those using the extension. + +A few of these considerations include: + +* The valid values for the menu should not change when the user changes the Scratch language setting. + * In particular, changing languages should never break a working project. +* The average Scratch user should be able to figure out the valid values for this input without referring to extension + documentation. + * One way to ensure this is to make an item's text match or include the item's value. For example, the official Music + extension contains menu items with names like "(1) Piano" with value 1, "(8) Cello" with value 8, and so on. +* The block should accept any value as input, even "invalid" values. + * Scratch has no concept of a runtime error! + * For a command block, sometimes the best option is to do nothing. + * For a reporter, returning zero or the empty string might make sense. +* The block should be forgiving in its interpretation of inputs. + * For example, if the block expects a string and receives a number it may make sense to interpret the number as a + string instead of treating it as invalid input. + +The `acceptReporters` flag indicates that the user can drop a reporter onto the menu input: + +```js +menus: { + staticMenu: { + acceptReporters: true, + items: [/*...*/] + }, + dynamicMenu: { + acceptReporters: true, + items: 'getDynamicMenuItems' + } +} +``` + ## Annotated Example ```js @@ -152,6 +270,10 @@ class SomeBlocks { // Will be used as the extension's namespace. id: 'someBlocks', + // Core extensions only: override the default extension block colors. + color1: '#FF8C1A', + color2: '#DB6E00', + // Optional: the human-readable name of this extension as string. // This and any other string to be displayed in the Scratch UI may either be // a string or a call to `formatMessage`; a plain string will not be @@ -307,7 +429,16 @@ class SomeBlocks { // Dynamic menu: returns an array as above. // Called each time the menu is opened. - menuB: 'getItemsForMenuB' + menuB: 'getItemsForMenuB', + + // The examples above are shorthand for setting only the `items` property in this full form: + menuC: { + // This flag makes a "droppable" menu: the menu will allow dropping a reporter in for the input. + acceptReporters: true, + + // The `item` property may be an array or function name as in previous menu examples. + items: [/*...*/] || 'getItemsForMenuC' + } }, // Optional: translations (UNSTABLE - NOT YET SUPPORTED) diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 8f427ec4..189a595b 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -1116,8 +1116,14 @@ class Blocks { let mutationString = `<${mutation.tagName}`; for (const prop in mutation) { if (prop === 'children' || prop === 'tagName') continue; - const mutationValue = (typeof mutation[prop] === 'string') ? + let mutationValue = (typeof mutation[prop] === 'string') ? xmlEscape(mutation[prop]) : mutation[prop]; + + // Handle dynamic extension blocks + if (prop === 'blockInfo') { + mutationValue = xmlEscape(JSON.stringify(mutation[prop])); + } + mutationString += ` ${prop}="${mutationValue}"`; } mutationString += '>'; diff --git a/src/engine/mutation-adapter.js b/src/engine/mutation-adapter.js index d1984d30..6c3bd84d 100644 --- a/src/engine/mutation-adapter.js +++ b/src/engine/mutation-adapter.js @@ -13,6 +13,12 @@ const mutatorTagToObject = function (dom) { for (const prop in dom.attribs) { if (prop === 'xmlns') continue; obj[prop] = decodeHtml(dom.attribs[prop]); + // Note: the capitalization of block info in the following lines is important. + // The lowercase is read in from xml which normalizes case. The VM uses camel case everywhere else. + if (prop === 'blockinfo') { + obj.blockInfo = JSON.parse(obj.blockinfo); + delete obj.blockinfo; + } } for (let i = 0; i < dom.children.length; i++) { obj.children.push( diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 15f59556..a3976e0e 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -42,6 +42,8 @@ const defaultBlockPackages = { scratch3_procedures: require('../blocks/scratch3_procedures') }; +const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69']; + /** * Information used for converting Scratch argument types into scratch-blocks data. * @type {object.} @@ -509,6 +511,14 @@ class Runtime extends EventEmitter { return 'PROJECT_CHANGED'; } + /** + * Event name for report that a change was made to an extension in the toolbox. + * @const {string} + */ + static get TOOLBOX_EXTENSIONS_NEED_UPDATE () { + return 'TOOLBOX_EXTENSIONS_NEED_UPDATE'; + } + /** * Event name for targets update report. * @const {string} @@ -776,24 +786,26 @@ class Runtime extends EventEmitter { name: maybeFormatMessage(extensionInfo.name), showStatusButton: extensionInfo.showStatusButton, blockIconURI: extensionInfo.blockIconURI, - menuIconURI: extensionInfo.menuIconURI, - color1: extensionInfo.colour || '#0FBD8C', - color2: extensionInfo.colourSecondary || '#0DA57A', - color3: extensionInfo.colourTertiary || '#0B8E69', - customFieldTypes: {}, - blocks: [], - menus: [] + menuIconURI: extensionInfo.menuIconURI }; + if (extensionInfo.color1) { + categoryInfo.color1 = extensionInfo.color1; + categoryInfo.color2 = extensionInfo.color2; + categoryInfo.color3 = extensionInfo.color3; + } else { + categoryInfo.color1 = defaultExtensionColors[0]; + categoryInfo.color2 = defaultExtensionColors[1]; + categoryInfo.color3 = defaultExtensionColors[2]; + } + this._blockInfo.push(categoryInfo); this._fillExtensionCategory(categoryInfo, extensionInfo); - const fieldTypeDefinitionsForScratch = []; for (const fieldTypeName in categoryInfo.customFieldTypes) { if (extensionInfo.customFieldTypes.hasOwnProperty(fieldTypeName)) { const fieldTypeInfo = categoryInfo.customFieldTypes[fieldTypeName]; - fieldTypeDefinitionsForScratch.push(fieldTypeInfo.scratchBlocksDefinition); // Emit events for custom field types from extension this.emit(Runtime.EXTENSION_FIELD_ADDED, { @@ -803,9 +815,7 @@ class Runtime extends EventEmitter { } } - const allBlocks = fieldTypeDefinitionsForScratch.concat(categoryInfo.blocks).concat(categoryInfo.menus); - - this.emit(Runtime.EXTENSION_ADDED, allBlocks); + this.emit(Runtime.EXTENSION_ADDED, categoryInfo); } /** @@ -814,33 +824,34 @@ class Runtime extends EventEmitter { * @private */ _refreshExtensionPrimitives (extensionInfo) { - let extensionBlocks = []; - for (const categoryInfo of this._blockInfo) { - if (extensionInfo.id === categoryInfo.id) { - categoryInfo.name = maybeFormatMessage(extensionInfo.name); - categoryInfo.blocks = []; - categoryInfo.menus = []; - this._fillExtensionCategory(categoryInfo, extensionInfo); - extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus); - } - } + const categoryInfo = this._blockInfo.find(info => info.id === extensionInfo.id); + if (categoryInfo) { + categoryInfo.name = maybeFormatMessage(extensionInfo.name); + this._fillExtensionCategory(categoryInfo, extensionInfo); - this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks); + this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo); + } } /** - * Read extension information, convert menus, blocks and custom field types + * Read extension information, convert menus, blocks and custom field types * and store the results in the provided category object. * @param {CategoryInfo} categoryInfo - the category to be filled * @param {ExtensionMetadata} extensionInfo - the extension metadata to read * @private */ _fillExtensionCategory (categoryInfo, extensionInfo) { + categoryInfo.blocks = []; + categoryInfo.customFieldTypes = {}; + categoryInfo.menus = []; + categoryInfo.menuInfo = {}; + for (const menuName in extensionInfo.menus) { if (extensionInfo.menus.hasOwnProperty(menuName)) { - const menuItems = extensionInfo.menus[menuName]; - const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo); + const menuInfo = extensionInfo.menus[menuName]; + const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuInfo, categoryInfo); categoryInfo.menus.push(convertedMenu); + categoryInfo.menuInfo[menuName] = menuInfo; } } for (const fieldTypeName in extensionInfo.customFieldTypes) { @@ -880,21 +891,16 @@ class Runtime extends EventEmitter { } /** - * Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block. - * @param {string} menuName - the name of the menu - * @param {array} menuItems - the list of items for this menu - * @param {CategoryInfo} categoryInfo - the category for this block - * @returns {object} - a JSON-esque object ready for scratch-blocks' consumption + * Convert the given extension menu items into the scratch-blocks style of list of pairs. + * If the menu is dynamic (e.g. the passed in argument is a function), return the input unmodified. + * @param {object} menuItems - an array of menu items or a function to retrieve such an array + * @returns {object} - an array of 2 element arrays or the original input function * @private */ - _buildMenuForScratchBlocks (menuName, menuItems, categoryInfo) { - const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id); - let options = null; - if (typeof menuItems === 'function') { - options = menuItems; - } else { + _convertMenuItems (menuItems) { + if (typeof menuItems !== 'function') { const extensionMessageContext = this.makeMessageContextForTarget(); - options = menuItems.map(item => { + return menuItems.map(item => { const formattedItem = maybeFormatMessage(item, extensionMessageContext); switch (typeof formattedItem) { case 'string': @@ -906,6 +912,22 @@ class Runtime extends EventEmitter { } }); } + return menuItems; + } + + /** + * Build the scratch-blocks JSON for a menu. Note that scratch-blocks treats menus as a special kind of block. + * @param {string} menuName - the name of the menu + * @param {object} menuInfo - a description of this menu and its items + * @property {*} items - an array of menu items or a function to retrieve such an array + * @property {boolean} [acceptReporters] - if true, allow dropping reporters onto this menu + * @param {CategoryInfo} categoryInfo - the category for this block + * @returns {object} - a JSON-esque object ready for scratch-blocks' consumption + * @private + */ + _buildMenuForScratchBlocks (menuName, menuInfo, categoryInfo) { + const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id); + const menuItems = this._convertMenuItems(menuInfo.items); return { json: { message0: '%1', @@ -915,12 +937,13 @@ class Runtime extends EventEmitter { colour: categoryInfo.color1, colourSecondary: categoryInfo.color2, colourTertiary: categoryInfo.color3, - outputShape: ScratchBlocksConstants.OUTPUT_SHAPE_ROUND, + outputShape: menuInfo.acceptReporters ? + ScratchBlocksConstants.OUTPUT_SHAPE_ROUND : ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE, args0: [ { type: 'field_dropdown', name: menuName, - options: options + options: menuItems } ] } @@ -1011,8 +1034,7 @@ class Runtime extends EventEmitter { category: categoryInfo.name, colour: categoryInfo.color1, colourSecondary: categoryInfo.color2, - colourTertiary: categoryInfo.color3, - extensions: ['scratch_extension'] + colourTertiary: categoryInfo.color3 }; const context = { // TODO: store this somewhere so that we can map args appropriately after translation. @@ -1032,6 +1054,7 @@ class Runtime extends EventEmitter { const iconURI = blockInfo.blockIconURI || categoryInfo.blockIconURI; if (iconURI) { + blockJSON.extensions = ['scratch_extension']; blockJSON.message0 = '%1 %2'; const iconJSON = { type: 'field_image', @@ -1135,7 +1158,9 @@ class Runtime extends EventEmitter { ++outLineNum; } - const blockXML = `${context.inputList.join('')}`; + const mutation = blockInfo.isDynamic ? `` : ''; + const inputs = context.inputList.join(''); + const blockXML = `${mutation}${inputs}`; return { info: context.blockInfo, @@ -1215,29 +1240,51 @@ class Runtime extends EventEmitter { argJSON.check = argTypeInfo.check; } - const shadowType = (argInfo.menu ? - this._makeExtensionMenuId(argInfo.menu, context.categoryInfo.id) : - argTypeInfo.shadowType); - const fieldType = argInfo.menu || argTypeInfo.fieldType; + let valueName; + let shadowType; + let fieldName; + if (argInfo.menu) { + const menuInfo = context.categoryInfo.menuInfo[argInfo.menu]; + if (menuInfo.acceptReporters) { + valueName = placeholder; + shadowType = this._makeExtensionMenuId(argInfo.menu, context.categoryInfo.id); + fieldName = argInfo.menu; + } else { + argJSON.type = 'field_dropdown'; + argJSON.options = this._convertMenuItems(menuInfo.items); + valueName = null; + shadowType = null; + fieldName = placeholder; + } + } else { + valueName = placeholder; + shadowType = argTypeInfo.shadowType; + fieldName = argTypeInfo.fieldType; + } // is the ScratchBlocks name for a block input. - context.inputList.push(``); + if (valueName) { + context.inputList.push(``); + } // The is a placeholder for a reporter and is visible when there's no reporter in this input. // Boolean inputs don't need to specify a shadow in the XML. if (shadowType) { context.inputList.push(``); + } - // is a text field that the user can type into. Some shadows, like the color picker, don't allow - // text input and therefore don't need a field element. - if (fieldType) { - context.inputList.push(`${defaultValue}`); - } + // A displays a dynamic value: a user-editable text field, a drop-down menu, etc. + if (fieldName) { + context.inputList.push(`${defaultValue}`); + } + if (shadowType) { context.inputList.push(''); } - context.inputList.push(''); + if (valueName) { + context.inputList.push(''); + } const argsName = `args${context.outLineNum}`; const blockArgs = (context.blockJSON[argsName] = context.blockJSON[argsName] || []); @@ -1249,11 +1296,12 @@ class Runtime extends EventEmitter { } /** - * @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in elements. + * @returns {Array.} scratch-blocks XML for each category of extension blocks, in category order. + * @property {string} id - the category / extension ID + * @property {string} xml - the XML text for this category, starting with `` and ending with `` */ getBlocksXML () { - const xmlParts = []; - for (const categoryInfo of this._blockInfo) { + return this._blockInfo.map(categoryInfo => { const {name, color1, color2} = categoryInfo; const paletteBlocks = categoryInfo.blocks.filter(block => !block.info.hideFromPalette); const colorXML = `colour="${color1}" secondaryColour="${color2}"`; @@ -1274,12 +1322,12 @@ class Runtime extends EventEmitter { statusButtonXML = 'showStatusButton="true"'; } - xmlParts.push(``); - xmlParts.push.apply(xmlParts, paletteBlocks.map(block => block.xml)); - xmlParts.push(''); - } - return xmlParts.join('\n'); + return { + id: categoryInfo.id, + xml: `${ + paletteBlocks.map(block => block.xml).join('')}` + }; + }); } /** @@ -1981,10 +2029,15 @@ class Runtime extends EventEmitter { * @param {!Target} editingTarget New editing target. */ setEditingTarget (editingTarget) { + const oldEditingTarget = this._editingTarget; this._editingTarget = editingTarget; // Script glows must be cleared. this._scriptGlowsPreviousFrame = []; this._updateGlows(); + + if (oldEditingTarget !== this._editingTarget) { + this.requestToolboxExtensionsUpdate(); + } } /** @@ -2402,12 +2455,19 @@ class Runtime extends EventEmitter { } /** - * Emit an event that indicate that the blocks on the workspace need updating. + * Emit an event that indicates that the blocks on the workspace need updating. */ requestBlocksUpdate () { this.emit(Runtime.BLOCKS_NEED_UPDATE); } + /** + * Emit an event that indicates that the toolbox extension blocks need updating. + */ + requestToolboxExtensionsUpdate () { + this.emit(Runtime.TOOLBOX_EXTENSIONS_NEED_UPDATE); + } + /** * Set up timers to repeatedly step in a browser. */ diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index b27b0d64..7cb556c5 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -294,7 +294,7 @@ class ExtensionManager { } return results; }, []); - extensionInfo.menus = extensionInfo.menus || []; + extensionInfo.menus = extensionInfo.menus || {}; extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus); return extensionInfo; } @@ -309,15 +309,24 @@ class ExtensionManager { _prepareMenuInfo (serviceName, menus) { const menuNames = Object.getOwnPropertyNames(menus); for (let i = 0; i < menuNames.length; i++) { - const item = menuNames[i]; - // If the value is a string, it should be the name of a function in the - // extension object to call to populate the menu whenever it is opened. - // Set up the binding for the function object here so - // we can use it later when converting the menu for Scratch Blocks. - if (typeof menus[item] === 'string') { + const menuName = menuNames[i]; + let menuInfo = menus[menuName]; + + // If the menu description is in short form (items only) then normalize it to general form: an object with + // its items listed in an `items` property. + if (!menuInfo.items) { + menuInfo = { + items: menuInfo + }; + menus[menuName] = menuInfo; + } + // If `items` is a string, it should be the name of a function in the extension object. Calling the + // function should return an array of items to populate the menu when it is opened. + if (typeof menuInfo.items === 'string') { + const menuItemFunctionName = menuInfo.items; const serviceObject = dispatch.services[serviceName]; - const menuName = menus[item]; - menus[item] = this._getExtensionMenuItems.bind(this, serviceObject, menuName); + // Bind the function here so we can pass a simple item generation function to Scratch Blocks later. + menuInfo.items = this._getExtensionMenuItems.bind(this, serviceObject, menuItemFunctionName); } } return menus; @@ -326,11 +335,11 @@ class ExtensionManager { /** * Fetch the items for a particular extension menu, providing the target ID for context. * @param {object} extensionObject - the extension object providing the menu. - * @param {string} menuName - the name of the menu function to call. + * @param {string} menuItemFunctionName - the name of the menu function to call. * @returns {Array} menu items ready for scratch-blocks. * @private */ - _getExtensionMenuItems (extensionObject, menuName) { + _getExtensionMenuItems (extensionObject, menuItemFunctionName) { // Fetch the items appropriate for the target currently being edited. This assumes that menus only // collect items when opened by the user while editing a particular target. const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage(); @@ -338,7 +347,7 @@ class ExtensionManager { const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget); // TODO: Fix this to use dispatch.call when extensions are running in workers. - const menuFunc = extensionObject[menuName]; + const menuFunc = extensionObject[menuItemFunctionName]; const menuItems = menuFunc.call(extensionObject, editingTargetID).map( item => { item = maybeFormatMessage(item, extensionMessageContext); @@ -356,7 +365,7 @@ class ExtensionManager { }); if (!menuItems || menuItems.length < 1) { - throw new Error(`Extension menu returned no items: ${menuName}`); + throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`); } return menuItems; } @@ -389,27 +398,40 @@ class ExtensionManager { log.warn(`Ignoring opcode "${blockInfo.opcode}" for button with text: ${blockInfo.text}`); } break; - default: + default: { if (!blockInfo.opcode) { throw new Error('Missing opcode for block'); } - blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode; + const funcName = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode; - // Avoid promise overhead if possible - if (dispatch._isRemoteService(serviceName)) { - blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func); - } else { - const serviceObject = dispatch.services[serviceName]; - const func = serviceObject[blockInfo.func]; - if (func) { - blockInfo.func = func.bind(serviceObject); - } else { - throw new Error(`Could not find extension block function called ${blockInfo.func}`); + const getBlockInfo = blockInfo.isDynamic ? + args => args && args.mutation && args.mutation.blockInfo : + () => blockInfo; + const callBlockFunc = (() => { + if (dispatch._isRemoteService(serviceName)) { + return (args, util, realBlockInfo) => + dispatch.call(serviceName, funcName, args, util, realBlockInfo); } - } + + // avoid promise latency if we can call direct + const serviceObject = dispatch.services[serviceName]; + if (!serviceObject[funcName]) { + // The function might show up later as a dynamic property of the service object + log.warn(`Could not find extension block function called ${funcName}`); + } + return (args, util, realBlockInfo) => + serviceObject[funcName](args, util, realBlockInfo); + })(); + + blockInfo.func = (args, util) => { + const realBlockInfo = getBlockInfo(args); + // TODO: filter args using the keys of realBlockInfo.arguments? maybe only if sandboxed? + return callBlockFunc(args, util, realBlockInfo); + }; break; } + } return blockInfo; } diff --git a/src/extensions/scratch3_boost/index.js b/src/extensions/scratch3_boost/index.js index 53619656..5af1fc03 100644 --- a/src/extensions/scratch3_boost/index.js +++ b/src/extensions/scratch3_boost/index.js @@ -1424,206 +1424,227 @@ class Scratch3BoostBlocks { } ], menus: { - MOTOR_ID: [ - { - text: 'A', - value: BoostMotorLabel.A - }, - { - text: 'B', - value: BoostMotorLabel.B - }, - { - text: 'C', - value: BoostMotorLabel.C - }, - { - text: 'D', - value: BoostMotorLabel.D - }, - { - text: 'AB', - value: BoostMotorLabel.AB - }, - { - text: 'ABCD', - value: BoostMotorLabel.ALL - } - ], - MOTOR_REPORTER_ID: [ - { - text: 'A', - value: BoostMotorLabel.A - }, - { - text: 'B', - value: BoostMotorLabel.B - }, - { - text: 'C', - value: BoostMotorLabel.C - }, - { - text: 'D', - value: BoostMotorLabel.D - } - ], - MOTOR_DIRECTION: [ - { - text: formatMessage({ - id: 'boost.motorDirection.forward', - default: 'this way', - description: 'label for forward element in motor direction menu for LEGO Boost extension' - }), - value: BoostMotorDirection.FORWARD - }, - { - text: formatMessage({ - id: 'boost.motorDirection.backward', - default: 'that way', - description: 'label for backward element in motor direction menu for LEGO Boost extension' - }), - value: BoostMotorDirection.BACKWARD - }, - { - text: formatMessage({ - id: 'boost.motorDirection.reverse', - default: 'reverse', - description: 'label for reverse element in motor direction menu for LEGO Boost extension' - }), - value: BoostMotorDirection.REVERSE - } - ], - TILT_DIRECTION: [ - { - text: formatMessage({ - id: 'boost.tiltDirection.up', - default: 'up', - description: 'label for up element in tilt direction menu for LEGO Boost extension' - }), - value: BoostTiltDirection.UP - }, - { - text: formatMessage({ - id: 'boost.tiltDirection.down', - default: 'down', - description: 'label for down element in tilt direction menu for LEGO Boost extension' - }), - value: BoostTiltDirection.DOWN - }, - { - text: formatMessage({ - id: 'boost.tiltDirection.left', - default: 'left', - description: 'label for left element in tilt direction menu for LEGO Boost extension' - }), - value: BoostTiltDirection.LEFT - }, - { - text: formatMessage({ - id: 'boost.tiltDirection.right', - default: 'right', - description: 'label for right element in tilt direction menu for LEGO Boost extension' - }), - value: BoostTiltDirection.RIGHT - } - ], - TILT_DIRECTION_ANY: [ - { - text: formatMessage({ - id: 'boost.tiltDirection.up', - default: 'up' - }), - value: BoostTiltDirection.UP - }, - { - text: formatMessage({ - id: 'boost.tiltDirection.down', - default: 'down' - }), - value: BoostTiltDirection.DOWN - }, - { - text: formatMessage({ - id: 'boost.tiltDirection.left', - default: 'left' - }), - value: BoostTiltDirection.LEFT - }, - { - text: formatMessage({ - id: 'boost.tiltDirection.right', - default: 'right' - }), - value: BoostTiltDirection.RIGHT - }, - { - text: formatMessage({ - id: 'boost.tiltDirection.any', - default: 'any', - description: 'label for any element in tilt direction menu for LEGO Boost extension' - }), - value: BoostTiltDirection.ANY - } - ], - COLOR: [ - { - text: formatMessage({ - id: 'boost.color.red', - default: 'red', - description: 'the color red' - }), - value: BoostColor.RED - }, - { - text: formatMessage({ - id: 'boost.color.blue', - default: 'blue', - description: 'the color blue' - }), - value: BoostColor.BLUE - }, - { - text: formatMessage({ - id: 'boost.color.green', - default: 'green', - description: 'the color green' - }), - value: BoostColor.GREEN - }, - { - text: formatMessage({ - id: 'boost.color.yellow', - default: 'yellow', - description: 'the color yellow' - }), - value: BoostColor.YELLOW - }, - { - text: formatMessage({ - id: 'boost.color.white', - default: 'white', - desription: 'the color white' - }), - value: BoostColor.WHITE - }, - { - text: formatMessage({ - id: 'boost.color.black', - default: 'black', - description: 'the color black' - }), - value: BoostColor.BLACK - }, - { - text: formatMessage({ - id: 'boost.color.any', - default: 'any color', - description: 'any color' - }), - value: BoostColor.ANY - } - ] + MOTOR_ID: { + acceptReporters: true, + items: [ + { + text: 'A', + value: BoostMotorLabel.A + }, + { + text: 'B', + value: BoostMotorLabel.B + }, + { + text: 'C', + value: BoostMotorLabel.C + }, + { + text: 'D', + value: BoostMotorLabel.D + }, + { + text: 'AB', + value: BoostMotorLabel.AB + }, + { + text: 'ABCD', + value: BoostMotorLabel.ALL + } + ] + }, + MOTOR_REPORTER_ID: { + acceptReporters: true, + items: [ + { + text: 'A', + value: BoostMotorLabel.A + }, + { + text: 'B', + value: BoostMotorLabel.B + }, + { + text: 'C', + value: BoostMotorLabel.C + }, + { + text: 'D', + value: BoostMotorLabel.D + } + ] + }, + MOTOR_DIRECTION: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'boost.motorDirection.forward', + default: 'this way', + description: + 'label for forward element in motor direction menu for LEGO Boost extension' + }), + value: BoostMotorDirection.FORWARD + }, + { + text: formatMessage({ + id: 'boost.motorDirection.backward', + default: 'that way', + description: + 'label for backward element in motor direction menu for LEGO Boost extension' + }), + value: BoostMotorDirection.BACKWARD + }, + { + text: formatMessage({ + id: 'boost.motorDirection.reverse', + default: 'reverse', + description: + 'label for reverse element in motor direction menu for LEGO Boost extension' + }), + value: BoostMotorDirection.REVERSE + } + ] + }, + TILT_DIRECTION: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'boost.tiltDirection.up', + default: 'up', + description: 'label for up element in tilt direction menu for LEGO Boost extension' + }), + value: BoostTiltDirection.UP + }, + { + text: formatMessage({ + id: 'boost.tiltDirection.down', + default: 'down', + description: 'label for down element in tilt direction menu for LEGO Boost extension' + }), + value: BoostTiltDirection.DOWN + }, + { + text: formatMessage({ + id: 'boost.tiltDirection.left', + default: 'left', + description: 'label for left element in tilt direction menu for LEGO Boost extension' + }), + value: BoostTiltDirection.LEFT + }, + { + text: formatMessage({ + id: 'boost.tiltDirection.right', + default: 'right', + description: 'label for right element in tilt direction menu for LEGO Boost extension' + }), + value: BoostTiltDirection.RIGHT + } + ] + }, + TILT_DIRECTION_ANY: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'boost.tiltDirection.up', + default: 'up' + }), + value: BoostTiltDirection.UP + }, + { + text: formatMessage({ + id: 'boost.tiltDirection.down', + default: 'down' + }), + value: BoostTiltDirection.DOWN + }, + { + text: formatMessage({ + id: 'boost.tiltDirection.left', + default: 'left' + }), + value: BoostTiltDirection.LEFT + }, + { + text: formatMessage({ + id: 'boost.tiltDirection.right', + default: 'right' + }), + value: BoostTiltDirection.RIGHT + }, + { + text: formatMessage({ + id: 'boost.tiltDirection.any', + default: 'any', + description: 'label for any element in tilt direction menu for LEGO Boost extension' + }), + value: BoostTiltDirection.ANY + } + ] + }, + COLOR: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'boost.color.red', + default: 'red', + description: 'the color red' + }), + value: BoostColor.RED + }, + { + text: formatMessage({ + id: 'boost.color.blue', + default: 'blue', + description: 'the color blue' + }), + value: BoostColor.BLUE + }, + { + text: formatMessage({ + id: 'boost.color.green', + default: 'green', + description: 'the color green' + }), + value: BoostColor.GREEN + }, + { + text: formatMessage({ + id: 'boost.color.yellow', + default: 'yellow', + description: 'the color yellow' + }), + value: BoostColor.YELLOW + }, + { + text: formatMessage({ + id: 'boost.color.white', + default: 'white', + desription: 'the color white' + }), + value: BoostColor.WHITE + }, + { + text: formatMessage({ + id: 'boost.color.black', + default: 'black', + description: 'the color black' + }), + value: BoostColor.BLACK + }, + { + text: formatMessage({ + id: 'boost.color.any', + default: 'any color', + description: 'any color' + }), + value: BoostColor.ANY + } + ] + } } }; } diff --git a/src/extensions/scratch3_ev3/index.js b/src/extensions/scratch3_ev3/index.js index b77da591..481066ac 100644 --- a/src/extensions/scratch3_ev3/index.js +++ b/src/extensions/scratch3_ev3/index.js @@ -1142,8 +1142,14 @@ class Scratch3Ev3Blocks { } ], menus: { - motorPorts: this._formatMenu(Ev3MotorMenu), - sensorPorts: this._formatMenu(Ev3SensorMenu) + motorPorts: { + acceptReporters: true, + items: this._formatMenu(Ev3MotorMenu) + }, + sensorPorts: { + acceptReporters: true, + items: this._formatMenu(Ev3SensorMenu) + } } }; } diff --git a/src/extensions/scratch3_gdx_for/index.js b/src/extensions/scratch3_gdx_for/index.js index 6410afd1..e1e418dc 100644 --- a/src/extensions/scratch3_gdx_for/index.js +++ b/src/extensions/scratch3_gdx_for/index.js @@ -134,7 +134,7 @@ class GdxFor { * @type {BLE} * @private */ - this._scratchLinkSocket = null; + this._ble = null; /** * An @vernier/godirect Device @@ -181,11 +181,11 @@ class GdxFor { * Called by the runtime when user wants to scan for a peripheral. */ scan () { - if (this._scratchLinkSocket) { - this._scratchLinkSocket.disconnect(); + if (this._ble) { + this._ble.disconnect(); } - this._scratchLinkSocket = new BLE(this._runtime, this._extensionId, { + this._ble = new BLE(this._runtime, this._extensionId, { filters: [ {namePrefix: 'GDX-FOR'} ], @@ -200,8 +200,8 @@ class GdxFor { * @param {number} id - the id of the peripheral to connect to. */ connect (id) { - if (this._scratchLinkSocket) { - this._scratchLinkSocket.connectPeripheral(id); + if (this._ble) { + this._ble.connectPeripheral(id); } } @@ -247,8 +247,8 @@ class GdxFor { */ isConnected () { let connected = false; - if (this._scratchLinkSocket) { - connected = this._scratchLinkSocket.isConnected(); + if (this._ble) { + connected = this._ble.isConnected(); } return connected; } @@ -258,7 +258,7 @@ class GdxFor { * @private */ _onConnect () { - const adapter = new ScratchLinkDeviceAdapter(this._scratchLinkSocket, BLEUUID); + const adapter = new ScratchLinkDeviceAdapter(this._ble, BLEUUID); godirect.createDevice(adapter, {open: true, startMeasurements: false}).then(device => { // Setup device this._device = device; @@ -278,7 +278,7 @@ class GdxFor { }); }); this._timeoutID = window.setInterval( - () => this._scratchLinkSocket.handleDisconnectError(BLEDataStoppedError), + () => this._ble.handleDisconnectError(BLEDataStoppedError), BLETimeout ); }); @@ -322,7 +322,7 @@ class GdxFor { // cancel disconnect timeout and start a new one window.clearInterval(this._timeoutID); this._timeoutID = window.setInterval( - () => this._scratchLinkSocket.handleDisconnectError(BLEDataStoppedError), + () => this._ble.handleDisconnectError(BLEDataStoppedError), BLETimeout ); } @@ -773,11 +773,26 @@ class Scratch3GdxForBlocks { } ], menus: { - pushPullOptions: this.PUSH_PULL_MENU, - gestureOptions: this.GESTURE_MENU, - axisOptions: this.AXIS_MENU, - tiltOptions: this.TILT_MENU, - tiltAnyOptions: this.TILT_MENU_ANY + pushPullOptions: { + acceptReporters: true, + items: this.PUSH_PULL_MENU + }, + gestureOptions: { + acceptReporters: true, + items: this.GESTURE_MENU + }, + axisOptions: { + acceptReporters: true, + items: this.AXIS_MENU + }, + tiltOptions: { + acceptReporters: true, + items: this.TILT_MENU + }, + tiltAnyOptions: { + acceptReporters: true, + items: this.TILT_MENU_ANY + } } }; } diff --git a/src/extensions/scratch3_gdx_for/scratch-link-device-adapter.js b/src/extensions/scratch3_gdx_for/scratch-link-device-adapter.js index 47ea3172..bad68d8b 100644 --- a/src/extensions/scratch3_gdx_for/scratch-link-device-adapter.js +++ b/src/extensions/scratch3_gdx_for/scratch-link-device-adapter.js @@ -4,8 +4,8 @@ const Base64Util = require('../../util/base64-util'); * Adapter class */ class ScratchLinkDeviceAdapter { - constructor (scratchLinkSocket, {service, commandChar, responseChar}) { - this.scratchLinkSocket = scratchLinkSocket; + constructor (socket, {service, commandChar, responseChar}) { + this.socket = socket; this._service = service; this._commandChar = commandChar; @@ -21,13 +21,13 @@ class ScratchLinkDeviceAdapter { writeCommand (commandBuffer) { const data = Base64Util.uint8ArrayToBase64(commandBuffer); - return this.scratchLinkSocket + return this.socket .write(this._service, this._commandChar, data, 'base64'); } setup ({onResponse}) { this._deviceOnResponse = onResponse; - return this.scratchLinkSocket + return this.socket .startNotifications(this._service, this._responseChar, this._onResponse); // TODO: diff --git a/src/extensions/scratch3_makeymakey/index.js b/src/extensions/scratch3_makeymakey/index.js index ed925ac9..a83ca661 100644 --- a/src/extensions/scratch3_makeymakey/index.js +++ b/src/extensions/scratch3_makeymakey/index.js @@ -207,55 +207,61 @@ class Scratch3MakeyMakeyBlocks { } ], menus: { - KEY: [ - { - text: formatMessage({ - id: 'makeymakey.spaceKey', - default: 'space', - description: 'The space key on a computer keyboard.' - }), - value: KEY_ID_SPACE - }, - { - text: formatMessage({ - id: 'makeymakey.upArrow', - default: 'up arrow', - description: 'The up arrow key on a computer keyboard.' - }), - value: KEY_ID_UP - }, - { - text: formatMessage({ - id: 'makeymakey.downArrow', - default: 'down arrow', - description: 'The down arrow key on a computer keyboard.' - }), - value: KEY_ID_DOWN - }, - { - text: formatMessage({ - id: 'makeymakey.rightArrow', - default: 'right arrow', - description: 'The right arrow key on a computer keyboard.' - }), - value: KEY_ID_RIGHT - }, - { - text: formatMessage({ - id: 'makeymakey.leftArrow', - default: 'left arrow', - description: 'The left arrow key on a computer keyboard.' - }), - value: KEY_ID_LEFT - }, - {text: 'w', value: 'w'}, - {text: 'a', value: 'a'}, - {text: 's', value: 's'}, - {text: 'd', value: 'd'}, - {text: 'f', value: 'f'}, - {text: 'g', value: 'g'} - ], - SEQUENCE: this.buildSequenceMenu(this.DEFAULT_SEQUENCES) + KEY: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'makeymakey.spaceKey', + default: 'space', + description: 'The space key on a computer keyboard.' + }), + value: KEY_ID_SPACE + }, + { + text: formatMessage({ + id: 'makeymakey.upArrow', + default: 'up arrow', + description: 'The up arrow key on a computer keyboard.' + }), + value: KEY_ID_UP + }, + { + text: formatMessage({ + id: 'makeymakey.downArrow', + default: 'down arrow', + description: 'The down arrow key on a computer keyboard.' + }), + value: KEY_ID_DOWN + }, + { + text: formatMessage({ + id: 'makeymakey.rightArrow', + default: 'right arrow', + description: 'The right arrow key on a computer keyboard.' + }), + value: KEY_ID_RIGHT + }, + { + text: formatMessage({ + id: 'makeymakey.leftArrow', + default: 'left arrow', + description: 'The left arrow key on a computer keyboard.' + }), + value: KEY_ID_LEFT + }, + {text: 'w', value: 'w'}, + {text: 'a', value: 'a'}, + {text: 's', value: 's'}, + {text: 'd', value: 'd'}, + {text: 'f', value: 'f'}, + {text: 'g', value: 'g'} + ] + }, + SEQUENCE: { + acceptReporters: true, + items: this.buildSequenceMenu(this.DEFAULT_SEQUENCES) + } } }; } diff --git a/src/extensions/scratch3_microbit/index.js b/src/extensions/scratch3_microbit/index.js index 3a1ad2b6..a136d25d 100644 --- a/src/extensions/scratch3_microbit/index.js +++ b/src/extensions/scratch3_microbit/index.js @@ -761,12 +761,30 @@ class Scratch3MicroBitBlocks { } ], menus: { - buttons: this.BUTTONS_MENU, - gestures: this.GESTURES_MENU, - pinState: this.PIN_STATE_MENU, - tiltDirection: this.TILT_DIRECTION_MENU, - tiltDirectionAny: this.TILT_DIRECTION_ANY_MENU, - touchPins: ['0', '1', '2'] + buttons: { + acceptReporters: true, + items: this.BUTTONS_MENU + }, + gestures: { + acceptReporters: true, + items: this.GESTURES_MENU + }, + pinState: { + acceptReporters: true, + items: this.PIN_STATE_MENU + }, + tiltDirection: { + acceptReporters: true, + items: this.TILT_DIRECTION_MENU + }, + tiltDirectionAny: { + acceptReporters: true, + items: this.TILT_DIRECTION_ANY_MENU + }, + touchPins: { + acceptReporters: true, + items: ['0', '1', '2'] + } } }; } diff --git a/src/extensions/scratch3_music/index.js b/src/extensions/scratch3_music/index.js index e1693e40..cd43b87b 100644 --- a/src/extensions/scratch3_music/index.js +++ b/src/extensions/scratch3_music/index.js @@ -911,8 +911,14 @@ class Scratch3MusicBlocks { } ], menus: { - DRUM: this._buildMenu(this.DRUM_INFO), - INSTRUMENT: this._buildMenu(this.INSTRUMENT_INFO) + DRUM: { + acceptReporters: true, + items: this._buildMenu(this.DRUM_INFO) + }, + INSTRUMENT: { + acceptReporters: true, + items: this._buildMenu(this.INSTRUMENT_INFO) + } } }; } diff --git a/src/extensions/scratch3_pen/index.js b/src/extensions/scratch3_pen/index.js index 2132d10a..78705a5f 100644 --- a/src/extensions/scratch3_pen/index.js +++ b/src/extensions/scratch3_pen/index.js @@ -477,7 +477,10 @@ class Scratch3PenBlocks { } ], menus: { - colorParam: this._initColorParam() + colorParam: { + acceptReporters: true, + items: this._initColorParam() + } } }; } diff --git a/src/extensions/scratch3_text2speech/index.js b/src/extensions/scratch3_text2speech/index.js index c4aa0b42..74791232 100644 --- a/src/extensions/scratch3_text2speech/index.js +++ b/src/extensions/scratch3_text2speech/index.js @@ -469,8 +469,14 @@ class Scratch3Text2SpeechBlocks { } ], menus: { - voices: this.getVoiceMenu(), - languages: this.getLanguageMenu() + voices: { + acceptReporters: true, + items: this.getVoiceMenu() + }, + languages: { + acceptReporters: true, + items: this.getLanguageMenu() + } } }; } diff --git a/src/extensions/scratch3_translate/index.js b/src/extensions/scratch3_translate/index.js index 0a5bf614..3c1d4cd1 100644 --- a/src/extensions/scratch3_translate/index.js +++ b/src/extensions/scratch3_translate/index.js @@ -146,7 +146,10 @@ class Scratch3TranslateBlocks { } ], menus: { - languages: this._supportedLanguages + languages: { + acceptReporters: true, + items: this._supportedLanguages + } } }; } diff --git a/src/extensions/scratch3_video_sensing/index.js b/src/extensions/scratch3_video_sensing/index.js index 76d5a2c1..bbdf3c30 100644 --- a/src/extensions/scratch3_video_sensing/index.js +++ b/src/extensions/scratch3_video_sensing/index.js @@ -488,9 +488,18 @@ class Scratch3VideoSensingBlocks { } ], menus: { - ATTRIBUTE: this._buildMenu(this.ATTRIBUTE_INFO), - SUBJECT: this._buildMenu(this.SUBJECT_INFO), - VIDEO_STATE: this._buildMenu(this.VIDEO_STATE_INFO) + ATTRIBUTE: { + acceptReporters: true, + items: this._buildMenu(this.ATTRIBUTE_INFO) + }, + SUBJECT: { + acceptReporters: true, + items: this._buildMenu(this.SUBJECT_INFO) + }, + VIDEO_STATE: { + acceptReporters: true, + items: this._buildMenu(this.VIDEO_STATE_INFO) + } } }; } diff --git a/src/extensions/scratch3_wedo2/index.js b/src/extensions/scratch3_wedo2/index.js index 9bd222b5..0fc34cb3 100644 --- a/src/extensions/scratch3_wedo2/index.js +++ b/src/extensions/scratch3_wedo2/index.js @@ -297,7 +297,6 @@ class WeDo2Motor { /** * Start active braking on this motor. After a short time, the motor will turn off. - * // TODO: rename this to coastAfter? */ startBraking () { if (this._power === 0) return; @@ -1145,139 +1144,157 @@ class Scratch3WeDo2Blocks { } ], menus: { - MOTOR_ID: [ - { - text: formatMessage({ - id: 'wedo2.motorId.default', - default: 'motor', - description: 'label for motor element in motor menu for LEGO WeDo 2 extension' - }), - value: WeDo2MotorLabel.DEFAULT - }, - { - text: formatMessage({ - id: 'wedo2.motorId.a', - default: 'motor A', - description: 'label for motor A element in motor menu for LEGO WeDo 2 extension' - }), - value: WeDo2MotorLabel.A - }, - { - text: formatMessage({ - id: 'wedo2.motorId.b', - default: 'motor B', - description: 'label for motor B element in motor menu for LEGO WeDo 2 extension' - }), - value: WeDo2MotorLabel.B - }, - { - text: formatMessage({ - id: 'wedo2.motorId.all', - default: 'all motors', - description: 'label for all motors element in motor menu for LEGO WeDo 2 extension' - }), - value: WeDo2MotorLabel.ALL - } - ], - MOTOR_DIRECTION: [ - { - text: formatMessage({ - id: 'wedo2.motorDirection.forward', - default: 'this way', - description: 'label for forward element in motor direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2MotorDirection.FORWARD - }, - { - text: formatMessage({ - id: 'wedo2.motorDirection.backward', - default: 'that way', - description: 'label for backward element in motor direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2MotorDirection.BACKWARD - }, - { - text: formatMessage({ - id: 'wedo2.motorDirection.reverse', - default: 'reverse', - description: 'label for reverse element in motor direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2MotorDirection.REVERSE - } - ], - TILT_DIRECTION: [ - { - text: formatMessage({ - id: 'wedo2.tiltDirection.up', - default: 'up', - description: 'label for up element in tilt direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2TiltDirection.UP - }, - { - text: formatMessage({ - id: 'wedo2.tiltDirection.down', - default: 'down', - description: 'label for down element in tilt direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2TiltDirection.DOWN - }, - { - text: formatMessage({ - id: 'wedo2.tiltDirection.left', - default: 'left', - description: 'label for left element in tilt direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2TiltDirection.LEFT - }, - { - text: formatMessage({ - id: 'wedo2.tiltDirection.right', - default: 'right', - description: 'label for right element in tilt direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2TiltDirection.RIGHT - } - ], - TILT_DIRECTION_ANY: [ - { - text: formatMessage({ - id: 'wedo2.tiltDirection.up', - default: 'up' - }), - value: WeDo2TiltDirection.UP - }, - { - text: formatMessage({ - id: 'wedo2.tiltDirection.down', - default: 'down' - }), - value: WeDo2TiltDirection.DOWN - }, - { - text: formatMessage({ - id: 'wedo2.tiltDirection.left', - default: 'left' - }), - value: WeDo2TiltDirection.LEFT - }, - { - text: formatMessage({ - id: 'wedo2.tiltDirection.right', - default: 'right' - }), - value: WeDo2TiltDirection.RIGHT - }, - { - text: formatMessage({ - id: 'wedo2.tiltDirection.any', - default: 'any', - description: 'label for any element in tilt direction menu for LEGO WeDo 2 extension' - }), - value: WeDo2TiltDirection.ANY - } - ], - OP: ['<', '>'] + MOTOR_ID: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'wedo2.motorId.default', + default: 'motor', + description: 'label for motor element in motor menu for LEGO WeDo 2 extension' + }), + value: WeDo2MotorLabel.DEFAULT + }, + { + text: formatMessage({ + id: 'wedo2.motorId.a', + default: 'motor A', + description: 'label for motor A element in motor menu for LEGO WeDo 2 extension' + }), + value: WeDo2MotorLabel.A + }, + { + text: formatMessage({ + id: 'wedo2.motorId.b', + default: 'motor B', + description: 'label for motor B element in motor menu for LEGO WeDo 2 extension' + }), + value: WeDo2MotorLabel.B + }, + { + text: formatMessage({ + id: 'wedo2.motorId.all', + default: 'all motors', + description: 'label for all motors element in motor menu for LEGO WeDo 2 extension' + }), + value: WeDo2MotorLabel.ALL + } + ] + }, + MOTOR_DIRECTION: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'wedo2.motorDirection.forward', + default: 'this way', + description: + 'label for forward element in motor direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2MotorDirection.FORWARD + }, + { + text: formatMessage({ + id: 'wedo2.motorDirection.backward', + default: 'that way', + description: + 'label for backward element in motor direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2MotorDirection.BACKWARD + }, + { + text: formatMessage({ + id: 'wedo2.motorDirection.reverse', + default: 'reverse', + description: + 'label for reverse element in motor direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2MotorDirection.REVERSE + } + ] + }, + TILT_DIRECTION: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'wedo2.tiltDirection.up', + default: 'up', + description: 'label for up element in tilt direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2TiltDirection.UP + }, + { + text: formatMessage({ + id: 'wedo2.tiltDirection.down', + default: 'down', + description: 'label for down element in tilt direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2TiltDirection.DOWN + }, + { + text: formatMessage({ + id: 'wedo2.tiltDirection.left', + default: 'left', + description: 'label for left element in tilt direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2TiltDirection.LEFT + }, + { + text: formatMessage({ + id: 'wedo2.tiltDirection.right', + default: 'right', + description: 'label for right element in tilt direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2TiltDirection.RIGHT + } + ] + }, + TILT_DIRECTION_ANY: { + acceptReporters: true, + items: [ + { + text: formatMessage({ + id: 'wedo2.tiltDirection.up', + default: 'up' + }), + value: WeDo2TiltDirection.UP + }, + { + text: formatMessage({ + id: 'wedo2.tiltDirection.down', + default: 'down' + }), + value: WeDo2TiltDirection.DOWN + }, + { + text: formatMessage({ + id: 'wedo2.tiltDirection.left', + default: 'left' + }), + value: WeDo2TiltDirection.LEFT + }, + { + text: formatMessage({ + id: 'wedo2.tiltDirection.right', + default: 'right' + }), + value: WeDo2TiltDirection.RIGHT + }, + { + text: formatMessage({ + id: 'wedo2.tiltDirection.any', + default: 'any', + description: 'label for any element in tilt direction menu for LEGO WeDo 2 extension' + }), + value: WeDo2TiltDirection.ANY + } + ] + }, + OP: { + acceptReporters: true, + items: ['<', '>'] + } } }; } diff --git a/src/virtual-machine.js b/src/virtual-machine.js index c53be8a2..ea264767 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -109,18 +109,21 @@ class VirtualMachine extends EventEmitter { this.runtime.on(Runtime.BLOCK_DRAG_END, (blocks, topBlockId) => { this.emit(Runtime.BLOCK_DRAG_END, blocks, topBlockId); }); - this.runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => { - this.emit(Runtime.EXTENSION_ADDED, blocksInfo); + this.runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => { + this.emit(Runtime.EXTENSION_ADDED, categoryInfo); }); this.runtime.on(Runtime.EXTENSION_FIELD_ADDED, (fieldName, fieldImplementation) => { this.emit(Runtime.EXTENSION_FIELD_ADDED, fieldName, fieldImplementation); }); - this.runtime.on(Runtime.BLOCKSINFO_UPDATE, blocksInfo => { - this.emit(Runtime.BLOCKSINFO_UPDATE, blocksInfo); + this.runtime.on(Runtime.BLOCKSINFO_UPDATE, categoryInfo => { + this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo); }); this.runtime.on(Runtime.BLOCKS_NEED_UPDATE, () => { this.emitWorkspaceUpdate(); }); + this.runtime.on(Runtime.TOOLBOX_EXTENSIONS_NEED_UPDATE, () => { + this.extensionManager.refreshBlocks(); + }); this.runtime.on(Runtime.PERIPHERAL_LIST_UPDATE, info => { this.emit(Runtime.PERIPHERAL_LIST_UPDATE, info); }); diff --git a/test/integration/internal-extension.js b/test/integration/internal-extension.js index 9ad39940..970db809 100644 --- a/test/integration/internal-extension.js +++ b/test/integration/internal-extension.js @@ -1,6 +1,8 @@ const test = require('tap').test; const Worker = require('tiny-worker'); +const BlockType = require('../../src/extension-support/block-type'); + const dispatch = require('../../src/dispatch/central-dispatch'); const VirtualMachine = require('../../src/virtual-machine'); @@ -33,8 +35,9 @@ class TestInternalExtension { }; } - go () { + go (args, util, blockInfo) { this.status.goCalled = true; + return blockInfo; } _buildAMenu () { @@ -62,9 +65,22 @@ test('internal extension', t => { t.type(func, 'function'); t.notOk(extension.status.goCalled); - func(); + const goBlockInfo = func(); t.ok(extension.status.goCalled); + // The 'go' block returns its own blockInfo. Make sure it matches the expected info. + // Note that the extension parser fills in missing fields so there are more fields here than in `getInfo`. + const expectedBlockInfo = { + arguments: {}, + blockAllThreads: false, + blockType: BlockType.COMMAND, + func: goBlockInfo.func, // Cheat since we don't have a good way to ensure we generate the same function + opcode: 'go', + terminal: false, + text: 'go' + }; + t.deepEqual(goBlockInfo, expectedBlockInfo); + // There should be 2 menus - one is an array, one is the function to call. t.equal(vm.runtime._blockInfo[0].menus.length, 2); // First menu has 3 items. diff --git a/test/unit/engine_blocks.js b/test/unit/engine_blocks.js index 8ab483e2..7fdaafc7 100644 --- a/test/unit/engine_blocks.js +++ b/test/unit/engine_blocks.js @@ -25,7 +25,7 @@ test('spec', t => { t.type(b.getNextBlock, 'function'); t.type(b.getBranch, 'function'); t.type(b.getOpcode, 'function'); - + t.type(b.mutationToXML, 'function'); t.end(); }); @@ -239,6 +239,25 @@ test('getOpcode', t => { t.end(); }); +test('mutationToXML', t => { + const b = new Blocks(new Runtime()); + const testStringRaw = '"arbitrary" & \'complicated\' test string'; + const testStringEscaped = '\\"arbitrary\\" & 'complicated' test string'; + const mutation = { + tagName: 'mutation', + children: [], + blockInfo: { + text: testStringRaw + } + }; + const xml = b.mutationToXML(mutation); + t.equals( + xml, + `` + ); + t.end(); +}); + // Block events tests test('create', t => { const b = new Blocks(new Runtime()); diff --git a/test/unit/engine_mutation-adapter.js b/test/unit/engine_mutation-adapter.js new file mode 100644 index 00000000..30a62774 --- /dev/null +++ b/test/unit/engine_mutation-adapter.js @@ -0,0 +1,26 @@ +const test = require('tap').test; + +const mutationAdapter = require('../../src/engine/mutation-adapter'); + +test('spec', t => { + t.type(mutationAdapter, 'function'); + t.end(); +}); + +test('convert DOM to Scratch object', t => { + const testStringRaw = '"arbitrary" & \'complicated\' test string'; + const testStringEscaped = '\\"arbitrary\\" & 'complicated' test string'; + const xml = ``; + const expectedMutation = { + tagName: 'mutation', + children: [], + blockInfo: { + text: testStringRaw + } + }; + + // TODO: do we want to test passing a DOM node to `mutationAdapter`? Node.js doesn't have built-in DOM support... + const mutationFromString = mutationAdapter(xml); + t.deepEqual(mutationFromString, expectedMutation); + t.end(); +}); diff --git a/test/unit/extension_conversion.js b/test/unit/extension_conversion.js index 3a59c0a0..58f4ad59 100644 --- a/test/unit/extension_conversion.js +++ b/test/unit/extension_conversion.js @@ -11,6 +11,9 @@ const ScratchBlocksConstants = require('../../src/engine/scratch-blocks-constant const testExtensionInfo = { id: 'test', name: 'fake test extension', + color1: '#111111', + color2: '#222222', + color3: '#333333', blocks: [ { func: 'MAKE_A_VARIABLE', @@ -20,7 +23,8 @@ const testExtensionInfo = { { opcode: 'reporter', blockType: BlockType.REPORTER, - text: 'simple text' + text: 'simple text', + blockIconURI: 'invalid icon URI' // trigger the 'scratch_extension' path }, '---', // separator between groups of blocks in an extension { @@ -63,6 +67,14 @@ const testExtensionInfo = { ] }; +const testCategoryInfo = function (t, block) { + t.equal(block.json.category, 'fake test extension'); + t.equal(block.json.colour, '#111111'); + t.equal(block.json.colourSecondary, '#222222'); + t.equal(block.json.colourTertiary, '#333333'); + t.equal(block.json.inputsInline, true); +}; + const testButton = function (t, button) { t.same(button.json, null); // should be null or undefined t.equal(button.xml, ''); @@ -70,13 +82,28 @@ const testButton = function (t, button) { const testReporter = function (t, reporter) { t.equal(reporter.json.type, 'test_reporter'); + testCategoryInfo(t, reporter); + t.equal(reporter.json.checkboxInFlyout, true); t.equal(reporter.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_ROUND); t.equal(reporter.json.output, 'String'); t.notOk(reporter.json.hasOwnProperty('previousStatement')); t.notOk(reporter.json.hasOwnProperty('nextStatement')); - t.equal(reporter.json.message0, 'simple text'); + t.same(reporter.json.extensions, ['scratch_extension']); + t.equal(reporter.json.message0, '%1 %2simple text'); // "%1 %2" from the block icon t.notOk(reporter.json.hasOwnProperty('message1')); - t.notOk(reporter.json.hasOwnProperty('args0')); + t.same(reporter.json.args0, [ + // %1 in message0: the block icon + { + type: 'field_image', + src: 'invalid icon URI', + width: 40, + height: 40 + }, + // %2 in message0: separator between icon and text (only added when there's also an icon) + { + type: 'field_vertical_separator' + } + ]); t.notOk(reporter.json.hasOwnProperty('args1')); t.equal(reporter.xml, ''); }; @@ -88,9 +115,11 @@ const testSeparator = function (t, separator) { const testCommand = function (t, command) { t.equal(command.json.type, 'test_command'); + testCategoryInfo(t, command); t.equal(command.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE); t.assert(command.json.hasOwnProperty('previousStatement')); t.assert(command.json.hasOwnProperty('nextStatement')); + t.notOk(command.json.extensions && command.json.extensions.length); // OK if it's absent or empty t.equal(command.json.message0, 'text with %1'); t.notOk(command.json.hasOwnProperty('message1')); t.strictSame(command.json.args0[0], { @@ -105,9 +134,11 @@ const testCommand = function (t, command) { const testConditional = function (t, conditional) { t.equal(conditional.json.type, 'test_ifElse'); + testCategoryInfo(t, conditional); t.equal(conditional.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE); t.ok(conditional.json.hasOwnProperty('previousStatement')); t.ok(conditional.json.hasOwnProperty('nextStatement')); + t.notOk(conditional.json.extensions && conditional.json.extensions.length); // OK if it's absent or empty t.equal(conditional.json.message0, 'test if %1 is spiffy and if so then'); t.equal(conditional.json.message1, '%1'); // placeholder for substack #1 t.equal(conditional.json.message2, 'or elsewise'); @@ -133,9 +164,11 @@ const testConditional = function (t, conditional) { const testLoop = function (t, loop) { t.equal(loop.json.type, 'test_loop'); + testCategoryInfo(t, loop); t.equal(loop.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE); t.ok(loop.json.hasOwnProperty('previousStatement')); t.notOk(loop.json.hasOwnProperty('nextStatement')); // isTerminal is set on this block + t.notOk(loop.json.extensions && loop.json.extensions.length); // OK if it's absent or empty t.equal(loop.json.message0, 'loopty %1 loops'); t.equal(loop.json.message1, '%1'); // placeholder for substack t.equal(loop.json.message2, '%1'); // placeholder for loop arrow @@ -160,7 +193,8 @@ const testLoop = function (t, loop) { test('registerExtensionPrimitives', t => { const runtime = new Runtime(); - runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => { + runtime.on(Runtime.EXTENSION_ADDED, categoryInfo => { + const blocksInfo = categoryInfo.blocks; t.equal(blocksInfo.length, testExtensionInfo.blocks.length); blocksInfo.forEach(blockInfo => {