Merge pull request #2161 from LLK/e16n

Supporting VM changes for extensionification
This commit is contained in:
Karishma Chadha 2019-06-18 18:53:20 -04:00 committed by GitHub
commit 9af2e4c086
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 209 additions and 57 deletions

View file

@ -152,6 +152,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

View file

@ -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 += '>';

View file

@ -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(

View file

@ -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.<ArgumentType, {shadowType: string, fieldType: string}>}
@ -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}
@ -777,23 +787,28 @@ class Runtime extends EventEmitter {
showStatusButton: extensionInfo.showStatusButton,
blockIconURI: extensionInfo.blockIconURI,
menuIconURI: extensionInfo.menuIconURI,
color1: extensionInfo.colour || '#0FBD8C',
color2: extensionInfo.colourSecondary || '#0DA57A',
color3: extensionInfo.colourTertiary || '#0B8E69',
customFieldTypes: {},
blocks: [],
menus: []
};
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 +818,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,18 +827,15 @@ class Runtime extends EventEmitter {
* @private
*/
_refreshExtensionPrimitives (extensionInfo) {
let extensionBlocks = [];
for (const categoryInfo of this._blockInfo) {
if (extensionInfo.id === categoryInfo.id) {
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);
extensionBlocks = extensionBlocks.concat(categoryInfo.blocks, categoryInfo.menus);
}
}
this.emit(Runtime.BLOCKSINFO_UPDATE, extensionBlocks);
this.emit(Runtime.BLOCKSINFO_UPDATE, categoryInfo);
}
}
/**
@ -1011,8 +1021,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 +1041,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 +1145,9 @@ class Runtime extends EventEmitter {
++outLineNum;
}
const blockXML = `<block type="${extendedOpcode}">${context.inputList.join('')}</block>`;
const mutation = blockInfo.isDynamic ? `<mutation blockInfo="${xmlEscape(JSON.stringify(blockInfo))}"/>` : '';
const inputs = context.inputList.join('');
const blockXML = `<block type="${extendedOpcode}">${mutation}${inputs}</block>`;
return {
info: context.blockInfo,
@ -1249,11 +1261,12 @@ class Runtime extends EventEmitter {
}
/**
* @returns {string} scratch-blocks XML description for all dynamic blocks, wrapped in <category> elements.
* @returns {Array.<object>} 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 `<category>` and ending with `</category>`
*/
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 +1287,12 @@ class Runtime extends EventEmitter {
statusButtonXML = 'showStatusButton="true"';
}
xmlParts.push(`<category name="${name}" id="${categoryInfo.id}"
${statusButtonXML} ${colorXML} ${menuIconXML}>`);
xmlParts.push.apply(xmlParts, paletteBlocks.map(block => block.xml));
xmlParts.push('</category>');
}
return xmlParts.join('\n');
return {
id: categoryInfo.id,
xml: `<category name="${name}" id="${categoryInfo.id}" ${statusButtonXML} ${colorXML} ${menuIconXML}>${
paletteBlocks.map(block => block.xml).join('')}</category>`
};
});
}
/**
@ -1981,10 +1994,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 +2420,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.
*/

View file

@ -389,27 +389,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
const getBlockInfo = blockInfo.isDynamic ?
args => args && args.mutation && args.mutation.blockInfo :
() => blockInfo;
const callBlockFunc = (() => {
if (dispatch._isRemoteService(serviceName)) {
blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func);
} else {
return (args, util, realBlockInfo) =>
dispatch.call(serviceName, funcName, args, util, realBlockInfo);
}
// avoid promise latency if we can call direct
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}`);
}
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;
}

View file

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

View file

@ -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.

View file

@ -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 = '\\&quot;arbitrary\\&quot; &amp; &apos;complicated&apos; test string';
const mutation = {
tagName: 'mutation',
children: [],
blockInfo: {
text: testStringRaw
}
};
const xml = b.mutationToXML(mutation);
t.equals(
xml,
`<mutation blockInfo="{&quot;text&quot;:&quot;${testStringEscaped}&quot;}"></mutation>`
);
t.end();
});
// Block events tests
test('create', t => {
const b = new Blocks(new Runtime());

View file

@ -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 = '\\&quot;arbitrary\\&quot; &amp; &apos;complicated&apos; test string';
const xml = `<mutation blockInfo="{&quot;text&quot;:&quot;${testStringEscaped}&quot;}"></mutation>`;
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();
});

View file

@ -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, '<button text="this is a button" callbackKey="MAKE_A_VARIABLE"></button>');
@ -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, '<block type="test_reporter"></block>');
};
@ -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 => {