Merge pull request #1021 from cwillisf/features-for-control-extension

Features for control extension
This commit is contained in:
Chris Willis-Ford 2018-04-10 12:17:43 -07:00 committed by GitHub
commit 631d7fb4de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 647 additions and 193 deletions

View file

@ -5,9 +5,13 @@ const escapeHtml = require('escape-html');
const ArgumentType = require('../extension-support/argument-type');
const Blocks = require('./blocks');
const BlockType = require('../extension-support/block-type');
const Sequencer = require('./sequencer');
const Thread = require('./thread');
const Profiler = require('./profiler');
const Sequencer = require('./sequencer');
const ScratchBlocksConstants = require('./scratch-blocks-constants');
const TargetType = require('../extension-support/target-type');
const Thread = require('./thread');
const log = require('../util/log');
const maybeFormatMessage = require('../util/maybe-format-message');
// Virtual I/O devices.
const Clock = require('../io/clock');
@ -56,29 +60,13 @@ const ArgumentTypeMap = (() => {
})();
/**
* These constants are copied from scratch-blocks/core/constants.js
* @TODO find a way to require() these... maybe make a scratch-blocks/dist/constants.js or something like that?
* @readonly
* @enum {int}
* Predefined "Converted block info" for a separator between blocks in a block category
* @type {ConvertedBlockInfo}
*/
const ScratchBlocksConstants = {
/**
* ENUM for output shape: hexagonal (booleans/predicates).
* @const
*/
OUTPUT_SHAPE_HEXAGONAL: 1,
/**
* ENUM for output shape: rounded (numbers).
* @const
*/
OUTPUT_SHAPE_ROUND: 2,
/**
* ENUM for output shape: squared (any/all values; strings).
* @const
*/
OUTPUT_SHAPE_SQUARE: 3
const ConvertedSeparator = {
info: {},
json: null,
xml: '<sep gap="36"/>'
};
/**
@ -484,15 +472,28 @@ class Runtime extends EventEmitter {
return `${extensionId}.menu.${escapeHtml(menuName)}`;
}
/**
* Create a context ("args") object for use with `formatMessage` on messages which might be target-specific.
* @param {Target} [target] - the target to use as context. If a target is not provided, default to the current
* editing target or the stage.
*/
makeMessageContextForTarget (target) {
const context = {};
target = target || this.getEditingTarget() || this.getTargetForStage();
if (target) {
context.targetType = (target.isStage ? TargetType.STAGE : TargetType.SPRITE);
}
}
/**
* Register the primitives provided by an extension.
* @param {ExtensionInfo} extensionInfo - information about the extension (id, blocks, etc.)
* @param {ExtensionMetadata} extensionInfo - information about the extension (id, blocks, etc.)
* @private
*/
_registerExtensionPrimitives (extensionInfo) {
const categoryInfo = {
id: extensionInfo.id,
name: extensionInfo.name,
name: maybeFormatMessage(extensionInfo.name),
blockIconURI: extensionInfo.blockIconURI,
menuIconURI: extensionInfo.menuIconURI,
color1: '#FF6680',
@ -504,30 +505,14 @@ class Runtime extends EventEmitter {
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, categoryInfo);
categoryInfo.menus.push(convertedMenu);
}
}
for (const blockInfo of extensionInfo.blocks) {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
const opcode = convertedBlock.json.type;
categoryInfo.blocks.push(convertedBlock);
this._primitives[opcode] = convertedBlock.info.func;
if (blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */
}
}
this._fillExtensionCategory(categoryInfo, extensionInfo);
this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks.concat(categoryInfo.menus));
}
/**
* Reregister the primitives for an extension
* @param {ExtensionInfo} extensionInfo - new info (results of running getInfo)
* for an extension
* @param {ExtensionMetadata} extensionInfo - new info (results of running getInfo) for an extension
* @private
*/
_refreshExtensionPrimitives (extensionInfo) {
@ -536,22 +521,7 @@ class Runtime extends EventEmitter {
if (extensionInfo.id === categoryInfo.id) {
categoryInfo.blocks = [];
categoryInfo.menus = [];
for (const menuName in extensionInfo.menus) {
if (extensionInfo.menus.hasOwnProperty(menuName)) {
const menuItems = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
categoryInfo.menus.push(convertedMenu);
}
}
for (const blockInfo of extensionInfo.blocks) {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
const opcode = convertedBlock.json.type;
categoryInfo.blocks.push(convertedBlock);
this._primitives[opcode] = convertedBlock.info.func;
if (blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {edgeActivated: true}; /** @TODO let extension specify this */
}
}
this._fillExtensionCategory(categoryInfo, extensionInfo);
extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus);
}
}
@ -559,6 +529,44 @@ class Runtime extends EventEmitter {
this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks);
}
/**
* Read extension information, convert menus and blocks, 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) {
for (const menuName in extensionInfo.menus) {
if (extensionInfo.menus.hasOwnProperty(menuName)) {
const menuItems = extensionInfo.menus[menuName];
const convertedMenu = this._buildMenuForScratchBlocks(menuName, menuItems, categoryInfo);
categoryInfo.menus.push(convertedMenu);
}
}
for (const blockInfo of extensionInfo.blocks) {
if (blockInfo === '---') {
categoryInfo.blocks.push(ConvertedSeparator);
continue;
}
try {
const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo);
const opcode = convertedBlock.json.type;
categoryInfo.blocks.push(convertedBlock);
if (blockInfo.blockType !== BlockType.EVENT) {
this._primitives[opcode] = convertedBlock.info.func;
}
if (blockInfo.blockType === BlockType.EVENT || blockInfo.blockType === BlockType.HAT) {
this._hats[opcode] = {
edgeActivated: blockInfo.isEdgeActivated,
restartExistingThreads: blockInfo.shouldRestartExistingThreads
};
}
} catch (e) {
log.error('Error parsing block: ', {block: blockInfo, error: e});
}
}
}
/**
* 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
@ -573,14 +581,16 @@ class Runtime extends EventEmitter {
if (typeof menuItems === 'function') {
options = menuItems;
} else {
const extensionMessageContext = this.makeMessageContextForTarget();
options = menuItems.map(item => {
switch (typeof item) {
const formattedItem = maybeFormatMessage(item, extensionMessageContext);
switch (typeof formattedItem) {
case 'string':
return [item, item];
return [formattedItem, formattedItem];
case 'object':
return [item.text, item.value];
return [maybeFormatMessage(item.text, extensionMessageContext), item.value];
default:
throw new Error(`Can't interpret menu item: ${item}`);
throw new Error(`Can't interpret menu item: ${JSON.stringify(item)}`);
}
});
}
@ -606,14 +616,15 @@ class Runtime extends EventEmitter {
}
/**
* Convert BlockInfo into scratch-blocks JSON & XML, and generate a proxy function.
* @param {BlockInfo} blockInfo - the block to convert
* Convert ExtensionBlockMetadata into scratch-blocks JSON & XML, and generate a proxy function.
* @param {ExtensionBlockMetadata} blockInfo - the block to convert
* @param {CategoryInfo} categoryInfo - the category for this block
* @returns {{info: BlockInfo, json: object, xml: string}} - the converted & original block information
* @returns {ConvertedBlockInfo} - the converted & original block information
* @private
*/
_convertForScratchBlocks (blockInfo, categoryInfo) {
const extendedOpcode = `${categoryInfo.id}.${blockInfo.opcode}`;
const blockJSON = {
type: extendedOpcode,
inputsInline: true,
@ -621,19 +632,19 @@ class Runtime extends EventEmitter {
colour: categoryInfo.color1,
colourSecondary: categoryInfo.color2,
colourTertiary: categoryInfo.color3,
args0: [],
extensions: ['scratch_extension']
};
const inputList = [];
// TODO: store this somewhere so that we can map args appropriately after translation.
// This maps an arg name to its relative position in the original (usually English) block text.
// When displaying a block in another language we'll need to run a `replace` action similar to the one below,
// but each `[ARG]` will need to be replaced with the number in this map instead of `args0.length`.
const argsMap = {};
blockJSON.message0 = '';
const context = {
// TODO: store this somewhere so that we can map args appropriately after translation.
// This maps an arg name to its relative position in the original (usually English) block text.
// When displaying a block in another language we'll need to run a `replace` action similar to the one
// below, but each `[ARG]` will need to be replaced with the number in this map.
argsMap: {},
blockJSON,
categoryInfo,
blockInfo,
inputList: []
};
// If an icon for the extension exists, prepend it to each block, with a vertical separator.
if (categoryInfo.blockIconURI) {
@ -647,60 +658,12 @@ class Runtime extends EventEmitter {
const separatorJSON = {
type: 'field_vertical_separator'
};
blockJSON.args0.push(iconJSON);
blockJSON.args0.push(separatorJSON);
blockJSON.args0 = [
iconJSON,
separatorJSON
];
}
blockJSON.message0 += blockInfo.text.replace(/\[(.+?)]/g, (match, placeholder) => {
// Sanitize the placeholder to ensure valid XML
placeholder = placeholder.replace(/[<"&]/, '_');
const argJSON = {
type: 'input_value',
name: placeholder
};
const argInfo = blockInfo.arguments[placeholder] || {};
const argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ?
'' :
escapeHtml(argInfo.defaultValue.toString()));
if (argTypeInfo.check) {
argJSON.check = argTypeInfo.check;
}
const shadowType = (argInfo.menu ?
this._makeExtensionMenuId(argInfo.menu, categoryInfo.id) :
argTypeInfo.shadowType);
const fieldType = argInfo.menu || argTypeInfo.fieldType;
// <value> is the ScratchBlocks name for a block input.
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) {
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) {
inputList.push(`<field name="${fieldType}">${defaultValue}</field>`);
}
inputList.push('</shadow>');
}
inputList.push('</value>');
// scratch-blocks uses 1-based argument indexing
blockJSON.args0.push(argJSON);
const argNum = blockJSON.args0.length;
argsMap[placeholder] = argNum;
return `%${argNum}`;
});
switch (blockInfo.blockType) {
case BlockType.COMMAND:
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
@ -718,37 +681,141 @@ class Runtime extends EventEmitter {
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_HEXAGONAL;
break;
case BlockType.HAT:
case BlockType.EVENT:
if (!blockInfo.hasOwnProperty('isEdgeActivated')) {
// if absent, this property defaults to true
blockInfo.isEdgeActivated = true;
}
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
break;
case BlockType.CONDITIONAL:
// Statement inputs get names like 'SUBSTACK', 'SUBSTACK2', 'SUBSTACK3', ...
for (let branchNum = 1; branchNum <= blockInfo.branchCount; ++branchNum) {
blockJSON[`message${branchNum}`] = '%1';
blockJSON[`args${branchNum}`] = [{
type: 'input_statement',
name: `SUBSTACK${branchNum > 1 ? branchNum : ''}`
}];
}
case BlockType.LOOP:
blockInfo.branchCount = blockInfo.branchCount || 1;
blockJSON.outputShape = ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE;
blockJSON.previousStatement = null; // null = available connection; undefined = hat
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
if (!blockInfo.isTerminal) {
blockJSON.nextStatement = null; // null = available connection; undefined = terminal
}
break;
}
if (blockInfo.isTerminal) {
delete blockJSON.nextStatement;
const blockText = Array.isArray(blockInfo.text) ? blockInfo.text : [blockInfo.text];
let inTextNum = 0; // text for the next block "arm" is blockText[inTextNum]
let inBranchNum = 0; // how many branches have we placed into the JSON so far?
let outLineNum = 0; // used for scratch-blocks `message${outLineNum}` and `args${outLineNum}`
const convertPlaceholders = this._convertPlaceholders.bind(this, context);
const extensionMessageContext = this.makeMessageContextForTarget();
// alternate between a block "arm" with text on it and an open slot for a substack
while (inTextNum < blockText.length || inBranchNum < blockInfo.branchCount) {
if (inTextNum < blockText.length) {
context.outLineNum = outLineNum;
const lineText = maybeFormatMessage(blockText[inTextNum], extensionMessageContext);
const convertedText = lineText.replace(/\[(.+?)]/g, convertPlaceholders);
if (blockJSON[`message${outLineNum}`]) {
blockJSON[`message${outLineNum}`] += convertedText;
} else {
blockJSON[`message${outLineNum}`] = convertedText;
}
++inTextNum;
++outLineNum;
}
if (inBranchNum < blockInfo.branchCount) {
blockJSON[`message${outLineNum}`] = '%1';
blockJSON[`args${outLineNum}`] = [{
type: 'input_statement',
name: `SUBSTACK${inBranchNum > 0 ? inBranchNum + 1 : ''}`
}];
++inBranchNum;
++outLineNum;
}
}
const blockXML = `<block type="${extendedOpcode}">${inputList.join('')}</block>`;
// Add icon to the bottom right of a loop block
if (blockInfo.blockType === BlockType.LOOP) {
blockJSON[`lastDummyAlign${outLineNum}`] = 'RIGHT';
blockJSON[`message${outLineNum}`] = '%1';
blockJSON[`args${outLineNum}`] = [{
type: 'field_image',
src: './static/blocks-media/repeat.svg', // TODO: use a constant or make this configurable?
width: 24,
height: 24,
alt: '*',
flip_rtl: true
}];
++outLineNum;
}
const blockXML = `<block type="${extendedOpcode}">${context.inputList.join('')}</block>`;
return {
info: blockInfo,
json: blockJSON,
info: context.blockInfo,
json: context.blockJSON,
xml: blockXML
};
}
/**
* Helper for _convertForScratchBlocks which handles linearization of argument placeholders. Called as a callback
* from string#replace. In addition to the return value the JSON and XML items in the context will be filled.
* @param {object} context - information shared with _convertForScratchBlocks about the block, etc.
* @param {string} match - the overall string matched by the placeholder regex, including brackets: '[FOO]'.
* @param {string} placeholder - the name of the placeholder being matched: 'FOO'.
* @return {string} scratch-blocks placeholder for the argument: '%1'.
* @private
*/
_convertPlaceholders (context, match, placeholder) {
// Sanitize the placeholder to ensure valid XML
placeholder = placeholder.replace(/[<"&]/, '_');
const argJSON = {
type: 'input_value',
name: placeholder
};
const argInfo = context.blockInfo.arguments[placeholder] || {};
const argTypeInfo = ArgumentTypeMap[argInfo.type] || {};
const defaultValue = (typeof argInfo.defaultValue === 'undefined' ?
'' :
escapeHtml(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString()));
if (argTypeInfo.check) {
argJSON.check = argTypeInfo.check;
}
const shadowType = (argInfo.menu ?
this._makeExtensionMenuId(argInfo.menu, context.categoryInfo.id) :
argTypeInfo.shadowType);
const fieldType = argInfo.menu || argTypeInfo.fieldType;
// <value> is the ScratchBlocks name for a block input.
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>`);
}
context.inputList.push('</shadow>');
}
context.inputList.push('</value>');
const argsName = `args${context.outLineNum}`;
const blockArgs = (context.blockJSON[argsName] = context.blockJSON[argsName] || []);
blockArgs.push(argJSON);
const argNum = blockArgs.length;
context.argsMap[placeholder] = argNum;
return `%${argNum}`;
}
/**
* @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in <category> elements.
*/

View file

@ -0,0 +1,27 @@
/**
* These constants are copied from scratch-blocks/core/constants.js
* @TODO find a way to require() these straight from scratch-blocks... maybe make a scratch-blocks/dist/constants.js?
* @readonly
* @enum {int}
*/
const ScratchBlocksConstants = {
/**
* ENUM for output shape: hexagonal (booleans/predicates).
* @const
*/
OUTPUT_SHAPE_HEXAGONAL: 1,
/**
* ENUM for output shape: rounded (numbers).
* @const
*/
OUTPUT_SHAPE_ROUND: 2,
/**
* ENUM for output shape: squared (any/all values; strings).
* @const
*/
OUTPUT_SHAPE_SQUARE: 3
};
module.exports = ScratchBlocksConstants;

View file

@ -15,14 +15,27 @@ const BlockType = {
/**
* Specialized command block which may or may not run a child branch
* The thread continues with the next block whether or not a child branch ran.
*/
CONDITIONAL: 'conditional',
/**
* Specialized hat block with no implementation function
* This stack only runs if the corresponding event is emitted by other code.
*/
EVENT: 'event',
/**
* Hat block which conditionally starts a block stack
*/
HAT: 'hat',
/**
* Specialized command block which may or may not run a child branch
* If a child branch runs, the thread evaluates the loop block again.
*/
LOOP: 'loop',
/**
* General reporter with numeric or string value
*/

View file

@ -0,0 +1,18 @@
/**
* @typedef {object} MessageDescriptor
* @property {string} id - the translator-friendly unique ID of this message.
* @property {string} default - the message text in the default language (English).
* @property {string} [description] - a description of this message to help translators understand the context.
*/
/**
* This is a hook for extracting messages from extension source files.
* This function simply returns the message descriptor map object that's passed in.
* @param {object.<MessageDescriptor>} messages - the messages to be defined
* @return {object.<MessageDescriptor>} - the input, unprocessed
*/
const defineMessages = function (messages) {
return messages;
};
module.exports = defineMessages;

View file

@ -1,5 +1,6 @@
const dispatch = require('../dispatch/central-dispatch');
const log = require('../util/log');
const maybeFormatMessage = require('../util/maybe-format-message');
const BlockType = require('./block-type');
@ -25,26 +26,22 @@ const builtinExtensions = {
*/
/**
* @typedef {object} BlockInfo - Information about an extension block
* @property {string} opcode - the block opcode
* @property {string|object} text - the human-readable text on this block
* @property {BlockType|undefined} blockType - the type of block (default: BlockType.COMMAND)
* @property {int|undefined} branchCount - the number of branches this block controls, if conditional (default: 0)
* @property {Boolean|undefined} isTerminal - true if this block ends a stack (default: false)
* @property {Boolean|undefined} blockAllThreads - true if all threads must wait for this block to run (default: false)
* @property {object.<string,ArgumentInfo>|undefined} arguments - information about this block's arguments, if any
* @property {string|Function|undefined} func - the method for this block on the extension service (default: opcode)
* @property {Array.<string>|undefined} filter - the list of targets for which this block should appear (default: all)
* @property {Boolean|undefined} hideFromPalette - true if should not be appear in the palette. (default false)
* @typedef {object} ConvertedBlockInfo - Raw extension block data paired with processed data ready for scratch-blocks
* @property {ExtensionBlockMetadata} info - the raw block info
* @property {object} json - the scratch-blocks JSON definition for this block
* @property {string} xml - the scratch-blocks XML definition for this block
*/
/**
* @typedef {object} CategoryInfo - Information about a block category
* @property {string} id - the unique ID of this category
* @property {string} name - the human-readable name of this category
* @property {string|undefined} blockIconURI - optional URI for the block icon image
* @property {string} color1 - the primary color for this category, in '#rrggbb' format
* @property {string} color2 - the secondary color for this category, in '#rrggbb' format
* @property {string} color3 - the tertiary color for this category, in '#rrggbb' format
* @property {Array.<BlockInfo>} block - the blocks in this category
* @property {Array.<ConvertedBlockInfo>} blocks - the blocks, separators, etc. in this category
* @property {Array.<object>} menus - the menus provided by this category
*/
/**
@ -208,7 +205,7 @@ class ExtensionManager {
_registerExtensionInfo (serviceName, extensionInfo) {
extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo);
dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => {
log.error(`Failed to register primitives for extension on service ${serviceName}: ${JSON.stringify(e)}`);
log.error(`Failed to register primitives for extension on service ${serviceName}:`, e);
});
}
@ -236,14 +233,23 @@ class ExtensionManager {
extensionInfo.name = extensionInfo.name || extensionInfo.id;
extensionInfo.blocks = extensionInfo.blocks || [];
extensionInfo.targetTypes = extensionInfo.targetTypes || [];
extensionInfo.blocks = extensionInfo.blocks.reduce((result, blockInfo) => {
extensionInfo.blocks = extensionInfo.blocks.reduce((results, blockInfo) => {
try {
result.push(this._prepareBlockInfo(serviceName, blockInfo));
let result;
switch (blockInfo) {
case '---': // separator
result = '---';
break;
default: // an ExtensionBlockMetadata object
result = this._prepareBlockInfo(serviceName, blockInfo);
break;
}
results.push(result);
} catch (e) {
// TODO: more meaningful error reporting
log.error(`Error processing block: ${e.message}, Block:\n${JSON.stringify(blockInfo)}`);
}
return result;
return results;
}, []);
extensionInfo.menus = extensionInfo.menus || [];
extensionInfo.menus = this._prepareMenuInfo(serviceName, extensionInfo.menus);
@ -284,12 +290,23 @@ class ExtensionManager {
_getExtensionMenuItems (extensionObject, menuName) {
// 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();
const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage();
const editingTargetID = editingTarget ? editingTarget.id : null;
const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget);
// TODO: Fix this to use dispatch.call when extensions are running in workers.
const menuFunc = extensionObject[menuName];
const menuItems = menuFunc.call(extensionObject, editingTargetID);
const menuItems = menuFunc.call(extensionObject, editingTargetID).map(
item => {
item = maybeFormatMessage(item, extensionMessageContext);
if (typeof item === 'object') {
return [
maybeFormatMessage(item.text, extensionMessageContext),
item.value
];
}
return item;
});
if (!menuItems || menuItems.length < 1) {
throw new Error(`Extension menu returned no items: ${menuName}`);
@ -300,8 +317,8 @@ class ExtensionManager {
/**
* Apply defaults for optional block fields.
* @param {string} serviceName - the name of the service hosting this extension block
* @param {BlockInfo} blockInfo - the block info from the extension
* @returns {BlockInfo} - a new block info object which has values for all relevant optional fields.
* @param {ExtensionBlockMetadata} blockInfo - the block info from the extension
* @returns {ExtensionBlockMetadata} - a new block info object which has values for all relevant optional fields.
* @private
*/
_prepareBlockInfo (serviceName, blockInfo) {
@ -312,24 +329,30 @@ class ExtensionManager {
arguments: {}
}, blockInfo);
blockInfo.opcode = this._sanitizeID(blockInfo.opcode);
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
blockInfo.text = blockInfo.text || blockInfo.opcode;
/**
* This is only here because the VM performs poorly when blocks return promises.
* @TODO make it possible for the VM to resolve a promise and continue during the same frame.
*/
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);
if (blockInfo.blockType !== BlockType.EVENT) {
blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode;
/**
* This is only here because the VM performs poorly when blocks return promises.
* @TODO make it possible for the VM to resolve a promise and continue during the same Scratch "tick"
*/
if (dispatch._isRemoteService(serviceName)) {
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
} else {
throw new Error(`Could not find extension block function called ${blockInfo.func}`);
const serviceObject = dispatch.services[serviceName];
const func = serviceObject[blockInfo.func];
if (func) {
blockInfo.func = func.bind(serviceObject);
} else if (blockInfo.blockType !== BlockType.EVENT) {
throw new Error(`Could not find extension block function called ${blockInfo.func}`);
}
}
} else if (blockInfo.func) {
log.warn(`Ignoring function "${blockInfo.func}" for event block ${blockInfo.opcode}`);
}
return blockInfo;
}
}

View file

@ -2,33 +2,36 @@
* @typedef {object} ExtensionMetadata
* All the metadata needed to register an extension.
* @property {string} id - a unique alphanumeric identifier for this extension. No special characters allowed.
* @property {string} name - the human-readable name of this extension.
* @property {string} blockIconURI - URI for an image to be placed on each block in this extension. Data URI ok.
* @property {string} menuIconURI - URI for an image to be placed on this extension's category menu entry. Data URI ok.
* @property {string} docsURI - link to documentation content for this extension.
* @property {Array.<ExtensionBlockMetadata|string>} - the blocks provided by this extension, with optional separators.
* @property {Object.<ExtensionMenuMetadata>} - map of menu name to metadata about each of this extension's menus.
* @property {string} [name] - the human-readable name of this extension.
* @property {string} [blockIconURI] - URI for an image to be placed on each block in this extension. Data URI ok.
* @property {string} [menuIconURI] - URI for an image to be placed on this extension's category menu item. Data URI ok.
* @property {string} [docsURI] - link to documentation content for this extension.
* @property {Array.<ExtensionBlockMetadata|string>} blocks - the blocks provided by this extension, plus separators.
* @property {Object.<ExtensionMenuMetadata>} [menus] - map of menu name to metadata for each of this extension's menus.
*/
/**
* @typedef {object} ExtensionBlockMetadata
* All the metadata needed to register an extension block.
* @property {string} opcode - a unique alphanumeric identifier for this block. No special characters allowed.
* @property {string} [func] - the name of the function implementing this block. Can be shared by other blocks/opcodes.
* @property {BlockType} blockType - the type of block (command, reporter, etc.) being described.
* @property {string} func - the name of the function implementing this block. Can be shared with other blocks/opcodes.
* @property {Boolean} hideFromPalette - true if this block should not appear in the block palette.
* @property {ReporterScope} reporterScope - if this block is a reporter, this is the scope/context for its value.
* @property {Boolean} terminal - true if the block ends a stack - no blocks can be connected after it.
* @property {string} text - the text on the block, with [PLACEHOLDERS] for arguments.
* @property {Object.<ExtensionArgumentMetadata>} arguments - map of argument placeholder to metadata about each arg.
* @property {Boolean} [hideFromPalette] - true if this block should not appear in the block palette.
* @property {Boolean} [isTerminal] - true if the block ends a stack - no blocks can be connected after it.
* @property {ReporterScope} [reporterScope] - if this block is a reporter, this is the scope/context for its value.
* @property {Boolean} [isEdgeActivated] - sets whether a hat block is edge-activated.
* @property {Boolean} [shouldRestartExistingThreads] - sets whether a hat/event block should restart existing threads.
* @property {int} [branchCount] - for flow control blocks, the number of branches/substacks for this block.
* @property {Object.<ExtensionArgumentMetadata>} [arguments] - map of argument placeholder to metadata about each arg.
*/
/**
* @typedef {object} ExtensionArgumentMetadata
* All the metadata needed to register an argument for an extension block.
* @property {ArgumentType} type - the type of the argument (number, string, etc.)
* @property {*} defaultValue - the default value of this argument.
* @property {string} menu - the name of the menu to use for this argument, if any.
* @property {*} [defaultValue] - the default value of this argument.
* @property {string} [menu] - the name of the menu to use for this argument, if any.
*/
/**

View file

@ -3,6 +3,7 @@
const ArgumentType = require('../extension-support/argument-type');
const BlockType = require('../extension-support/block-type');
const dispatch = require('../dispatch/worker-dispatch');
const TargetType = require('../extension-support/target-type');
class ExtensionWorker {
constructor () {
@ -47,6 +48,7 @@ class ExtensionWorker {
global.Scratch = global.Scratch || {};
global.Scratch.ArgumentType = ArgumentType;
global.Scratch.BlockType = BlockType;
global.Scratch.TargetType = TargetType;
/**
* Expose only specific parts of the worker to extensions.

View file

@ -0,0 +1,17 @@
/**
* Default types of Target supported by the VM
* @enum {string}
*/
const TargetType = {
/**
* Rendered target which can move, change costumes, etc.
*/
SPRITE: 'sprite',
/**
* Rendered target which cannot move but can change backdrops
*/
STAGE: 'stage'
};
module.exports = TargetType;

View file

@ -0,0 +1,18 @@
const formatMessage = require('format-message');
/**
* Check if `maybeMessage` looks like a message object, and if so pass it to `formatMessage`.
* Otherwise, return `maybeMessage` as-is.
* @param {*} maybeMessage - something that might be a message descriptor object.
* @param {object} [args] - the arguments to pass to `formatMessage` if it gets called.
* @param {string} [locale] - the locale to pass to `formatMessage` if it gets called.
* @return {string|*} - the formatted message OR the original `maybeMessage` input.
*/
const maybeFormatMessage = function (maybeMessage, args, locale) {
if (maybeMessage && maybeMessage.id && maybeMessage.default) {
return formatMessage(maybeMessage, args, locale);
}
return maybeMessage;
};
module.exports = maybeFormatMessage;

View file

@ -20,6 +20,18 @@ const {serializeSounds, serializeCostumes} = require('./serialization/serialize-
const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_'];
const CORE_EXTENSIONS = [
// 'motion',
// 'looks',
// 'sound',
// 'events',
// 'control',
// 'sensing',
// 'operators',
// 'variables',
// 'myBlocks'
];
/**
* Handles connections between blocks, stage, and extensions.
* @constructor
@ -314,6 +326,17 @@ class VirtualMachine extends EventEmitter {
*/
installTargets (targets, extensions, wholeProject) {
const extensionPromises = [];
if (wholeProject) {
this.clear();
CORE_EXTENSIONS.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
extensionPromises.push(this.extensionManager.loadExtensionURL(extensionID));
}
});
}
extensions.extensionIDs.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID;
@ -324,9 +347,6 @@ class VirtualMachine extends EventEmitter {
targets = targets.filter(target => !!target);
return Promise.all(extensionPromises).then(() => {
if (wholeProject) {
this.clear();
}
targets.forEach(target => {
this.runtime.targets.push(target);
(/** @type RenderedTarget */ target).updateAllDrawableProperties();

View file

@ -0,0 +1,169 @@
const test = require('tap').test;
const ArgumentType = require('../../src/extension-support/argument-type');
const BlockType = require('../../src/extension-support/block-type');
const Runtime = require('../../src/engine/runtime');
const ScratchBlocksConstants = require('../../src/engine/scratch-blocks-constants');
/**
* @type {ExtensionMetadata}
*/
const testExtensionInfo = {
id: 'test',
name: 'fake test extension',
blocks: [
{
opcode: 'reporter',
blockType: BlockType.REPORTER,
text: 'simple text'
},
'---', // separator between groups of blocks in an extension
{
opcode: 'command',
blockType: BlockType.COMMAND,
text: 'text with [ARG]',
arguments: {
ARG: {
type: ArgumentType.STRING
}
}
},
{
opcode: 'ifElse',
blockType: BlockType.CONDITIONAL,
branchCount: 2,
text: [
'test if [THING] is spiffy and if so then',
'or elsewise'
],
arguments: {
THING: {
type: ArgumentType.BOOLEAN
}
}
},
{
opcode: 'loop',
blockType: BlockType.LOOP, // implied branchCount of 1 unless otherwise stated
isTerminal: true,
text: [
'loopty [MANY] loops'
],
arguments: {
MANY: {
type: ArgumentType.NUMBER
}
}
}
]
};
const testReporter = function (t, reporter) {
t.equal(reporter.json.type, 'test.reporter');
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.notOk(reporter.json.hasOwnProperty('message1'));
t.notOk(reporter.json.hasOwnProperty('args0'));
t.notOk(reporter.json.hasOwnProperty('args1'));
t.equal(reporter.xml, '<block type="test.reporter"></block>');
};
const testSeparator = function (t, separator) {
t.equal(separator.json, null);
t.equal(separator.xml, '<sep gap="36"/>');
};
const testCommand = function (t, command) {
t.equal(command.json.type, 'test.command');
t.equal(command.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.assert(command.json.hasOwnProperty('previousStatement'));
t.assert(command.json.hasOwnProperty('nextStatement'));
t.equal(command.json.message0, 'text with %1');
t.notOk(command.json.hasOwnProperty('message1'));
t.strictSame(command.json.args0[0], {
type: 'input_value',
name: 'ARG'
});
t.notOk(command.json.hasOwnProperty('args1'));
t.equal(command.xml,
'<block type="test.command"><value name="ARG"><shadow type="text"><field name="TEXT">' +
'</field></shadow></value></block>');
};
const testConditional = function (t, conditional) {
t.equal(conditional.json.type, 'test.ifElse');
t.equal(conditional.json.outputShape, ScratchBlocksConstants.OUTPUT_SHAPE_SQUARE);
t.ok(conditional.json.hasOwnProperty('previousStatement'));
t.ok(conditional.json.hasOwnProperty('nextStatement'));
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');
t.equal(conditional.json.message3, '%1'); // placeholder for substack #2
t.notOk(conditional.json.hasOwnProperty('message4'));
t.strictSame(conditional.json.args0[0], {
type: 'input_value',
name: 'THING',
check: 'Boolean'
});
t.strictSame(conditional.json.args1[0], {
type: 'input_statement',
name: 'SUBSTACK'
});
t.notOk(conditional.json.hasOwnProperty(conditional.json.args2));
t.strictSame(conditional.json.args3[0], {
type: 'input_statement',
name: 'SUBSTACK2'
});
t.notOk(conditional.json.hasOwnProperty('args4'));
t.equal(conditional.xml, '<block type="test.ifElse"><value name="THING"></value></block>');
};
const testLoop = function (t, loop) {
t.equal(loop.json.type, 'test.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.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
t.notOk(loop.json.hasOwnProperty('message3'));
t.strictSame(loop.json.args0[0], {
type: 'input_value',
name: 'MANY'
});
t.strictSame(loop.json.args1[0], {
type: 'input_statement',
name: 'SUBSTACK'
});
t.equal(loop.json.lastDummyAlign2, 'RIGHT'); // move loop arrow to right side
t.equal(loop.json.args2[0].type, 'field_image');
t.equal(loop.json.args2[0].flip_rtl, true);
t.notOk(loop.json.hasOwnProperty('args3'));
t.equal(loop.xml,
'<block type="test.loop"><value name="MANY"><shadow type="math_number"><field name="NUM">' +
'</field></shadow></value></block>');
};
test('registerExtensionPrimitives', t => {
const runtime = new Runtime();
runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => {
t.equal(blocksInfo.length, testExtensionInfo.blocks.length);
// Note that this also implicitly tests that block order is preserved
const [reporter, separator, command, conditional, loop] = blocksInfo;
testReporter(t, reporter);
testSeparator(t, separator);
testCommand(t, command);
testConditional(t, conditional);
testLoop(t, loop);
t.end();
});
runtime._registerExtensionPrimitives(testExtensionInfo);
});

View file

@ -0,0 +1,77 @@
const test = require('tap').test;
const maybeFormatMessage = require('../../src/util/maybe-format-message');
const nonMessages = [
'hi',
42,
true,
function () {
return 'unused';
},
{
a: 1,
b: 2
},
{
id: 'almost a message',
notDefault: 'but missing the "default" property'
},
{
notId: 'this one is missing the "id" property',
default: 'but has "default"'
}
];
const argsQuick = {
speed: 'quick'
};
const argsOther = {
speed: 'slow'
};
const argsEmpty = {};
const simpleMessage = {
id: 'test.simpleMessage',
default: 'The quick brown fox jumped over the lazy dog.'
};
const complexMessage = {
id: 'test.complexMessage',
default: '{speed, select, quick {The quick brown fox jumped over the lazy dog.} other {Too slow, Gobo!}}'
};
const quickExpectedResult = 'The quick brown fox jumped over the lazy dog.';
const otherExpectedResult = 'Too slow, Gobo!';
test('preserve non-messages', t => {
t.plan(nonMessages.length);
for (const x of nonMessages) {
const result = maybeFormatMessage(x);
t.strictSame(x, result);
}
t.end();
});
test('format messages', t => {
const quickResult1 = maybeFormatMessage(simpleMessage);
t.strictNotSame(quickResult1, simpleMessage);
t.same(quickResult1, quickExpectedResult);
const quickResult2 = maybeFormatMessage(complexMessage, argsQuick);
t.strictNotSame(quickResult2, complexMessage);
t.same(quickResult2, quickExpectedResult);
const otherResult1 = maybeFormatMessage(complexMessage, argsOther);
t.strictNotSame(otherResult1, complexMessage);
t.same(otherResult1, otherExpectedResult);
const otherResult2 = maybeFormatMessage(complexMessage, argsEmpty);
t.strictNotSame(otherResult2, complexMessage);
t.same(otherResult2, otherExpectedResult);
t.end();
});