diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 9039d5df5..afc482638 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -32,6 +32,10 @@ const defaultBlockPackages = { */ const ArgumentTypeMap = (() => { const map = {}; + map[ArgumentType.ANGLE] = { + shadowType: 'math_angle', + fieldType: 'NUM' + }; map[ArgumentType.COLOR] = { shadowType: 'colour_picker' }; @@ -43,9 +47,8 @@ const ArgumentTypeMap = (() => { shadowType: 'text', fieldType: 'TEXT' }; - // @TODO: talk to Rachel & co. to figure out what goes here. Make it OK to not have a field. Add `check` support. map[ArgumentType.BOOLEAN] = { - shadowType: '' + check: 'Boolean' }; return map; })(); @@ -398,6 +401,17 @@ class Runtime extends EventEmitter { } } + /** + * Generate an extension-specific menu ID. + * @param {string} menuName - the name of the menu. + * @param {string} extensionId - the ID of the extension hosting the menu. + * @returns {string} - the constructed ID. + * @private + */ + _makeExtensionMenuId (menuName, extensionId) { + return `${extensionId}.menu.${escapeHtml(menuName)}`; + } + /** * Register the primitives provided by an extension. * @param {ExtensionInfo} extensionInfo - information about the extension (id, blocks, etc.) @@ -410,11 +424,19 @@ class Runtime extends EventEmitter { color1: '#FF6680', color2: '#FF4D6A', color3: '#FF3355', - blocks: [] + blocks: [], + menus: [] }; this._blockInfo.push(categoryInfo); + for (const menuName in extensionInfo.menus) { + if (extensionInfo.menus.hasOwnProperty(menuName)) { + const menuItems = extensionInfo.menus[menuName]; + const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, extensionInfo); + categoryInfo.menus.push(convertedMenu); + } + } for (const blockInfo of extensionInfo.blocks) { const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo); const opcode = convertedBlock.json.type; @@ -422,7 +444,54 @@ class Runtime extends EventEmitter { this._primitives[opcode] = convertedBlock.info.func; } - this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks); + this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks.concat(categoryInfo.menus)); + } + + /** + * 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 + * @private + */ + _buildMenuForScratchBlocks (menuName, menuItems, categoryInfo) { + const menuId = this._makeExtensionMenuId(menuName, categoryInfo.id); + + /** @TODO: support dynamic menus when 'menuItems' is a method name string (see extension spec) */ + if (typeof menuItems === 'string') { + throw new Error(`Dynamic extension menus are not yet supported. Menu name: ${menuName}`); + } + const options = menuItems.map(item => { + switch (typeof item) { + case 'string': + return [item, item]; + case 'object': + return [item.text, item.value]; + default: + throw new Error(`Can't interpret menu item: ${item}`); + } + }); + + return { + json: { + message0: '%1', + type: menuId, + inputsInline: true, + output: 'String', + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + outputShape: ScratchBlocksConstants.OUTPUT_SHAPE_ROUND, + args0: [ + { + type: 'field_dropdown', + name: menuName, + options: options + } + ] + } + }; } /** @@ -457,14 +526,10 @@ class Runtime extends EventEmitter { // Sanitize the placeholder to ensure valid XML placeholder = placeholder.replace(/[<"&]/, '_'); - blockJSON.args0.push({ + const argJSON = { type: 'input_value', name: placeholder - }); - - // scratch-blocks uses 1-based argument indexing - const argNum = blockJSON.args0.length; - argsMap[placeholder] = argNum; + }; const argInfo = blockInfo.arguments[placeholder] || {}; const argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; @@ -472,18 +537,38 @@ class Runtime extends EventEmitter { '' : escapeHtml(argInfo.defaultValue.toString())); - // is the ScratchBlocks name for a block input. - // The is a placeholder for a reporter and is visible when there's no reporter in this input. - 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 (argTypeInfo.fieldType) { - inputList.push(`${defaultValue}`); + if (argTypeInfo.check) { + argJSON.check = argTypeInfo.check; } - inputList.push(''); + const shadowType = (argInfo.menu ? + this._makeExtensionMenuId(argInfo.menu, categoryInfo.id) : + argTypeInfo.shadowType); + const fieldType = argInfo.menu || argTypeInfo.fieldType; + // is the ScratchBlocks name for a block input. + 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) { + 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) { + inputList.push(`${defaultValue}`); + } + + inputList.push(''); + } + + inputList.push(''); + + // scratch-blocks uses 1-based argument indexing + blockJSON.args0.push(argJSON); + const argNum = blockJSON.args0.length; + argsMap[placeholder] = argNum; return `%${argNum}`; }); diff --git a/src/extension-support/argument-type.js b/src/extension-support/argument-type.js index 70381c84b..0589e9fb6 100644 --- a/src/extension-support/argument-type.js +++ b/src/extension-support/argument-type.js @@ -1,4 +1,5 @@ const ArgumentType = { + ANGLE: 'angle', BOOLEAN: 'Boolean', COLOR: 'color', NUMBER: 'number', diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index bdbc274f1..d7c5fab05 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -174,7 +174,7 @@ class ExtensionManager { result.push(this._prepareBlockInfo(serviceName, blockInfo)); } catch (e) { // TODO: more meaningful error reporting - log.error(`Skipping malformed block: ${JSON.stringify(e)}`); + log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`); } return result; }, []);