diff --git a/docs/extensions.md b/docs/extensions.md index 6cb632e1c..f2a8779ac 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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 diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 8f427ec44..189a595be 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 d1984d30e..6c3bd84de 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 15f59556a..792455c91 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} @@ -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) { - 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); + categoryInfo.blocks = []; + categoryInfo.menus = []; + this._fillExtensionCategory(categoryInfo, extensionInfo); - 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 = `${context.inputList.join('')}`; + const mutation = blockInfo.isDynamic ? `` : ''; + const inputs = context.inputList.join(''); + const blockXML = `${mutation}${inputs}`; return { info: context.blockInfo, @@ -1249,11 +1261,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 +1287,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 +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. */ diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index b27b0d642..71eb5f126 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -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 - 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/virtual-machine.js b/src/virtual-machine.js index c53be8a22..ea2647676 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 9ad399402..970db809a 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 8ab483e29..7fdaafc73 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 000000000..30a627740 --- /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 3a59c0a01..58f4ad59d 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 => {