Merge pull request #2143 from cwillisf/non-droppable-extension-menus

support non-droppable menus in extensions
This commit is contained in:
Chris Willis-Ford 2019-06-18 21:18:10 -07:00 committed by GitHub
commit c6b63a8f09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 734 additions and 452 deletions

View file

@ -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
@ -311,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)

View file

@ -786,10 +786,7 @@ class Runtime extends EventEmitter {
name: maybeFormatMessage(extensionInfo.name),
showStatusButton: extensionInfo.showStatusButton,
blockIconURI: extensionInfo.blockIconURI,
menuIconURI: extensionInfo.menuIconURI,
customFieldTypes: {},
blocks: [],
menus: []
menuIconURI: extensionInfo.menuIconURI
};
if (extensionInfo.color1) {
@ -830,8 +827,6 @@ class Runtime extends EventEmitter {
const categoryInfo = this._blockInfo.find(info => info.id === extensionInfo.id);
if (categoryInfo) {
categoryInfo.name = maybeFormatMessage(extensionInfo.name);
categoryInfo.blocks = [];
categoryInfo.menus = [];
this._fillExtensionCategory(categoryInfo, extensionInfo);
this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo);
@ -846,11 +841,17 @@ class Runtime extends EventEmitter {
* @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) {
@ -890,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':
@ -916,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',
@ -925,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
}
]
}
@ -1227,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;
}
// <value> is the ScratchBlocks name for a block input.
if (valueName) {
context.inputList.push(`<value name="${placeholder}">`);
}
// The <shadow> 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(`<shadow type="${shadowType}">`);
// <field> 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(`<field name="${fieldType}">${defaultValue}</field>`);
}
// A <field> displays a dynamic value: a user-editable text field, a drop-down menu, etc.
if (fieldName) {
context.inputList.push(`<field name="${fieldName}">${defaultValue}</field>`);
}
if (shadowType) {
context.inputList.push('</shadow>');
}
if (valueName) {
context.inputList.push('</value>');
}
const argsName = `args${context.outLineNum}`;
const blockArgs = (context.blockJSON[argsName] = context.blockJSON[argsName] || []);

View file

@ -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;
}

View file

@ -1413,7 +1413,9 @@ class Scratch3BoostBlocks {
}
],
menus: {
MOTOR_ID: [
MOTOR_ID: {
acceptReporters: true,
items: [
{
text: 'A',
value: BoostMotorLabel.A
@ -1438,8 +1440,11 @@ class Scratch3BoostBlocks {
text: 'ABCD',
value: BoostMotorLabel.ALL
}
],
MOTOR_REPORTER_ID: [
]
},
MOTOR_REPORTER_ID: {
acceptReporters: true,
items: [
{
text: 'A',
value: BoostMotorLabel.A
@ -1456,13 +1461,17 @@ class Scratch3BoostBlocks {
text: 'D',
value: BoostMotorLabel.D
}
],
MOTOR_DIRECTION: [
]
},
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'
description:
'label for forward element in motor direction menu for LEGO Boost extension'
}),
value: BoostMotorDirection.FORWARD
},
@ -1470,7 +1479,8 @@ class Scratch3BoostBlocks {
text: formatMessage({
id: 'boost.motorDirection.backward',
default: 'that way',
description: 'label for backward element in motor direction menu for LEGO Boost extension'
description:
'label for backward element in motor direction menu for LEGO Boost extension'
}),
value: BoostMotorDirection.BACKWARD
},
@ -1478,12 +1488,16 @@ class Scratch3BoostBlocks {
text: formatMessage({
id: 'boost.motorDirection.reverse',
default: 'reverse',
description: 'label for reverse element in motor direction menu for LEGO Boost extension'
description:
'label for reverse element in motor direction menu for LEGO Boost extension'
}),
value: BoostMotorDirection.REVERSE
}
],
TILT_DIRECTION: [
]
},
TILT_DIRECTION: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'boost.tiltDirection.up',
@ -1516,8 +1530,11 @@ class Scratch3BoostBlocks {
}),
value: BoostTiltDirection.RIGHT
}
],
TILT_DIRECTION_ANY: [
]
},
TILT_DIRECTION_ANY: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'boost.tiltDirection.up',
@ -1554,8 +1571,11 @@ class Scratch3BoostBlocks {
}),
value: BoostTiltDirection.ANY
}
],
COLOR: [
]
},
COLOR: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'boost.color.red',
@ -1614,6 +1634,7 @@ class Scratch3BoostBlocks {
}
]
}
}
};
}

View file

@ -1137,8 +1137,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)
}
}
};
}

View file

@ -757,11 +757,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
}
}
};
}

View file

@ -207,7 +207,9 @@ class Scratch3MakeyMakeyBlocks {
}
],
menus: {
KEY: [
KEY: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'makeymakey.spaceKey',
@ -254,8 +256,12 @@ class Scratch3MakeyMakeyBlocks {
{text: 'd', value: 'd'},
{text: 'f', value: 'f'},
{text: 'g', value: 'g'}
],
SEQUENCE: this.buildSequenceMenu(this.DEFAULT_SEQUENCES)
]
},
SEQUENCE: {
acceptReporters: true,
items: this.buildSequenceMenu(this.DEFAULT_SEQUENCES)
}
}
};
}

View file

@ -746,12 +746,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']
}
}
};
}

View file

@ -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)
}
}
};
}

View file

@ -477,7 +477,10 @@ class Scratch3PenBlocks {
}
],
menus: {
colorParam: this._initColorParam()
colorParam: {
acceptReporters: true,
items: this._initColorParam()
}
}
};
}

View file

@ -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()
}
}
};
}

View file

@ -146,7 +146,10 @@ class Scratch3TranslateBlocks {
}
],
menus: {
languages: this._supportedLanguages
languages: {
acceptReporters: true,
items: this._supportedLanguages
}
}
};
}

View file

@ -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)
}
}
};
}

View file

@ -1133,7 +1133,9 @@ class Scratch3WeDo2Blocks {
}
],
menus: {
MOTOR_ID: [
MOTOR_ID: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'wedo2.motorId.default',
@ -1166,13 +1168,17 @@ class Scratch3WeDo2Blocks {
}),
value: WeDo2MotorLabel.ALL
}
],
MOTOR_DIRECTION: [
]
},
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'
description:
'label for forward element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.FORWARD
},
@ -1180,7 +1186,8 @@ class Scratch3WeDo2Blocks {
text: formatMessage({
id: 'wedo2.motorDirection.backward',
default: 'that way',
description: 'label for backward element in motor direction menu for LEGO WeDo 2 extension'
description:
'label for backward element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.BACKWARD
},
@ -1188,12 +1195,16 @@ class Scratch3WeDo2Blocks {
text: formatMessage({
id: 'wedo2.motorDirection.reverse',
default: 'reverse',
description: 'label for reverse element in motor direction menu for LEGO WeDo 2 extension'
description:
'label for reverse element in motor direction menu for LEGO WeDo 2 extension'
}),
value: WeDo2MotorDirection.REVERSE
}
],
TILT_DIRECTION: [
]
},
TILT_DIRECTION: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'wedo2.tiltDirection.up',
@ -1226,8 +1237,11 @@ class Scratch3WeDo2Blocks {
}),
value: WeDo2TiltDirection.RIGHT
}
],
TILT_DIRECTION_ANY: [
]
},
TILT_DIRECTION_ANY: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'wedo2.tiltDirection.up',
@ -1264,8 +1278,12 @@ class Scratch3WeDo2Blocks {
}),
value: WeDo2TiltDirection.ANY
}
],
OP: ['<', '>']
]
},
OP: {
acceptReporters: true,
items: ['<', '>']
}
}
};
}