diff --git a/package.json b/package.json index 905e371b9dbec7ffbe04970c91393d1682a2487f..b0564c305de9ac9bdcdd2100c794a7e97dbdb30b 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "scratch-render": "0.1.0-prerelease.20190605151415", "scratch-storage": "1.3.2", "scratch-svg-renderer": "0.2.0-prerelease.20190523193400", - "scratch-vm": "0.2.0-prerelease.20190610152034", + "scratch-vm": "0.2.0-prerelease.20190619042313", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index d79c7e9c72bc867b96d77c42f3a2382c4a95c421..fb01ce15d5dd321908d8fc38960e7b3ff1d65244 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -17,6 +17,7 @@ import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; import DropAreaHOC from '../lib/drop-area-hoc.jsx'; import DragConstants from '../lib/drag-constants'; +import defineDynamicBlock from '../lib/define-dynamic-block'; import {connect} from 'react-redux'; import {updateToolbox} from '../reducers/toolbox'; @@ -393,20 +394,50 @@ class Blocks extends React.Component { // workspace to be 'undone' here. this.workspace.clearUndo(); } - handleExtensionAdded (blocksInfo) { - // select JSON from each block info object then reject the pseudo-blocks which don't have JSON, like separators - // this actually defines blocks and MUST run regardless of the UI state - this.ScratchBlocks.defineBlocksWithJsonArray(blocksInfo.map(blockInfo => blockInfo.json).filter(x => x)); + handleExtensionAdded (categoryInfo) { + const defineBlocks = blockInfoArray => { + if (blockInfoArray && blockInfoArray.length > 0) { + const staticBlocksJson = []; + const dynamicBlocksInfo = []; + blockInfoArray.forEach(blockInfo => { + if (blockInfo.info && blockInfo.info.isDynamic) { + dynamicBlocksInfo.push(blockInfo); + } else if (blockInfo.json) { + staticBlocksJson.push(blockInfo.json); + } + // otherwise it's a non-block entry such as '---' + }); + + this.ScratchBlocks.defineBlocksWithJsonArray(staticBlocksJson); + dynamicBlocksInfo.forEach(blockInfo => { + // This is creating the block factory / constructor -- NOT a specific instance of the block. + // The factory should only know static info about the block: the category info and the opcode. + // Anything else will be picked up from the XML attached to the block instance. + const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`; + const blockDefinition = + defineDynamicBlock(this.ScratchBlocks, categoryInfo, blockInfo, extendedOpcode); + this.ScratchBlocks.Blocks[extendedOpcode] = blockDefinition; + }); + } + }; + + // scratch-blocks implements a menu or custom field as a special kind of block ("shadow" block) + // these actually define blocks and MUST run regardless of the UI state + defineBlocks( + Object.getOwnPropertyNames(categoryInfo.customFieldTypes) + .map(fieldTypeName => categoryInfo.customFieldTypes[fieldTypeName].scratchBlocksDefinition)); + defineBlocks(categoryInfo.menus); + defineBlocks(categoryInfo.blocks); - // Update the toolbox with new blocks + // Update the toolbox with new blocks if possible const toolboxXML = this.getToolboxXML(); if (toolboxXML) { this.props.updateToolboxState(toolboxXML); } } - handleBlocksInfoUpdate (blocksInfo) { + handleBlocksInfoUpdate (categoryInfo) { // @todo Later we should replace this to avoid all the warnings from redefining blocks. - this.handleExtensionAdded(blocksInfo); + this.handleExtensionAdded(categoryInfo); } handleCategorySelected (categoryId) { const extension = extensionData.find(ext => ext.extensionId === categoryId); diff --git a/src/lib/define-dynamic-block.js b/src/lib/define-dynamic-block.js new file mode 100644 index 0000000000000000000000000000000000000000..33f1c8732cda4fdb88ae31a366dfcadf51ef3eca --- /dev/null +++ b/src/lib/define-dynamic-block.js @@ -0,0 +1,108 @@ +// TODO: access `BlockType` and `ArgumentType` without reaching into VM +// Should we move these into a new extension support module or something? +import ArgumentType from 'scratch-vm/src/extension-support/argument-type'; +import BlockType from 'scratch-vm/src/extension-support/block-type'; + +/** + * Define a block using extension info which has the ability to dynamically determine (and update) its layout. + * This functionality is used for extension blocks which can change its properties based on different state + * information. For example, the `control_stop` block changes its shape based on which menu item is selected + * and a variable block changes its text to reflect the variable name without using an editable field. + * @param {object} ScratchBlocks - The ScratchBlocks name space. + * @param {object} categoryInfo - Information about this block's extension category, including any menus and icons. + * @param {object} staticBlockInfo - The base block information before any dynamic changes. + * @param {string} extendedOpcode - The opcode for the block (including the extension ID). + */ +// TODO: grow this until it can fully replace `_convertForScratchBlocks` in the VM runtime +const defineDynamicBlock = (ScratchBlocks, categoryInfo, staticBlockInfo, extendedOpcode) => ({ + init: function () { + const blockJson = { + type: extendedOpcode, + inputsInline: true, + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3 + }; + // There is a scratch-blocks / Blockly extension called "scratch_extension" which adjusts the styling of + // blocks to allow for an icon, a feature of Scratch extension blocks. However, Scratch "core" extension + // blocks don't have icons and so they should not use 'scratch_extension'. Adding a scratch-blocks / Blockly + // extension after `jsonInit` isn't fully supported (?), so we decide now whether there will be an icon. + if (staticBlockInfo.blockIconURI || categoryInfo.blockIconURI) { + blockJson.extensions = ['scratch_extension']; + } + // initialize the basics of the block, to be overridden & extended later by `domToMutation` + this.jsonInit(blockJson); + // initialize the cached block info used to carry block info from `domToMutation` to `mutationToDom` + this.blockInfoText = '{}'; + // we need a block info update (through `domToMutation`) before we have a completely initialized block + this.needsBlockInfoUpdate = true; + }, + mutationToDom: function () { + const container = document.createElement('mutation'); + container.setAttribute('blockInfo', this.blockInfoText); + return container; + }, + domToMutation: function (xmlElement) { + const blockInfoText = xmlElement.getAttribute('blockInfo'); + if (!blockInfoText) return; + if (!this.needsBlockInfoUpdate) { + throw new Error('Attempted to update block info twice'); + } + delete this.needsBlockInfoUpdate; + this.blockInfoText = blockInfoText; + const blockInfo = JSON.parse(blockInfoText); + + switch (blockInfo.blockType) { + case BlockType.COMMAND: + case BlockType.CONDITIONAL: + case BlockType.LOOP: + this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_SQUARE); + this.setPreviousStatement(true); + this.setNextStatement(!blockInfo.isTerminal); + break; + case BlockType.REPORTER: + this.setOutput(true); + this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_ROUND); + if (!blockInfo.disableMonitor) { + this.setCheckboxInFlyout(true); + } + break; + case BlockType.BOOLEAN: + this.setOutput(true); + this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_HEXAGONAL); + break; + case BlockType.HAT: + case BlockType.EVENT: + this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_SQUARE); + this.setNextStatement(true); + break; + } + + if (blockInfo.color1 || blockInfo.color2 || blockInfo.color3) { + // `setColour` handles undefined parameters by adjusting defined colors + this.setColour(blockInfo.color1, blockInfo.color2, blockInfo.color3); + } + + // Layout block arguments + // TODO handle E/C Blocks + const blockText = blockInfo.text; + const args = []; + let argCount = 0; + const scratchBlocksStyleText = blockText.replace(/\[(.+?)]/g, (match, argName) => { + const arg = blockInfo.arguments[argName]; + switch (arg.type) { + case ArgumentType.STRING: + args.push({type: 'input_value', name: argName}); + break; + case ArgumentType.BOOLEAN: + args.push({type: 'input_value', name: argName, check: 'Boolean'}); + break; + } + return `%${++argCount}`; + }); + this.interpolate_(scratchBlocksStyleText, args); + } +}); + +export default defineDynamicBlock; diff --git a/src/lib/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js index 3443e05d2c0b5f5b763f28a5a146c10940251c5b..79cf35cc25e872931383a3dc3d0ceb955664812a 100644 --- a/src/lib/make-toolbox-xml.js +++ b/src/lib/make-toolbox-xml.js @@ -716,13 +716,16 @@ const xmlClose = '</xml>'; /** * @param {!boolean} isStage - Whether the toolbox is for a stage-type target. * @param {?string} targetId - The current editing target - * @param {?string} categoriesXML - null for default toolbox, or an XML string with <category> elements. + * @param {?Array.<object>} categoriesXML - optional array of `{id,xml}` for categories. This can include both core + * and other extensions: core extensions will be placed in the normal Scratch order; others will go at the bottom. + * @property {string} id - the extension / category ID. + * @property {string} xml - the `<category>...</category>` XML for this extension / category. * @param {?string} costumeName - The name of the default selected costume dropdown. * @param {?string} backdropName - The name of the default selected backdrop dropdown. * @param {?string} soundName - The name of the default selected sound dropdown. * @returns {string} - a ScratchBlocks-style XML document for the contents of the toolbox. */ -const makeToolboxXML = function (isStage, targetId, categoriesXML, +const makeToolboxXML = function (isStage, targetId, categoriesXML = [], costumeName = '', backdropName = '', soundName = '') { const gap = [categorySeparator]; @@ -730,21 +733,41 @@ const makeToolboxXML = function (isStage, targetId, categoriesXML, backdropName = xmlEscape(backdropName); soundName = xmlEscape(soundName); + categoriesXML = categoriesXML.slice(); + const moveCategory = categoryId => { + const index = categoriesXML.findIndex(categoryInfo => categoryInfo.id === categoryId); + if (index >= 0) { + // remove the category from categoriesXML and return its XML + const [categoryInfo] = categoriesXML.splice(index, 1); + return categoryInfo.xml; + } + // return `undefined` + }; + const motionXML = moveCategory('motion') || motion(isStage, targetId); + const looksXML = moveCategory('looks') || looks(isStage, targetId, costumeName, backdropName); + const soundXML = moveCategory('sound') || sound(isStage, targetId, soundName); + const eventsXML = moveCategory('event') || events(isStage, targetId); + const controlXML = moveCategory('control') || control(isStage, targetId); + const sensingXML = moveCategory('sensing') || sensing(isStage, targetId); + const operatorsXML = moveCategory('operators') || operators(isStage, targetId); + const variablesXML = moveCategory('data') || variables(isStage, targetId); + const myBlocksXML = moveCategory('procedures') || myBlocks(isStage, targetId); + const everything = [ xmlOpen, - motion(isStage, targetId), gap, - looks(isStage, targetId, costumeName, backdropName), gap, - sound(isStage, targetId, soundName), gap, - events(isStage, targetId), gap, - control(isStage, targetId), gap, - sensing(isStage, targetId), gap, - operators(isStage, targetId), gap, - variables(isStage, targetId), gap, - myBlocks(isStage, targetId) + motionXML, gap, + looksXML, gap, + soundXML, gap, + eventsXML, gap, + controlXML, gap, + sensingXML, gap, + operatorsXML, gap, + variablesXML, gap, + myBlocksXML ]; - if (categoriesXML) { - everything.push(gap, categoriesXML); + for (const extensionCategory of categoriesXML) { + everything.push(gap, extensionCategory.xml); } everything.push(xmlClose); diff --git a/test/unit/util/define-dynamic-block.test.js b/test/unit/util/define-dynamic-block.test.js new file mode 100644 index 0000000000000000000000000000000000000000..257e5529befa67b3163edfefcea230ec72c346b2 --- /dev/null +++ b/test/unit/util/define-dynamic-block.test.js @@ -0,0 +1,199 @@ +import defineDynamicBlock from '../../../src/lib/define-dynamic-block'; + +import BlockType from 'scratch-vm/src/extension-support/block-type'; + +const MockScratchBlocks = { + OUTPUT_SHAPE_HEXAGONAL: 1, + OUTPUT_SHAPE_ROUND: 2, + OUTPUT_SHAPE_SQUARE: 3 +}; + +const categoryInfo = { + name: 'test category', + color1: '#111', + color2: '#222', + color3: '#333' +}; + +const penIconURI = 'data:image/svg+xml;base64,fake_pen_icon_svg_base64_data'; + +const testBlockInfo = { + commandWithIcon: { + blockType: BlockType.COMMAND, + blockIconURI: penIconURI, + text: 'command with icon' + }, + commandWithoutIcon: { + blockType: BlockType.COMMAND, + text: 'command without icon' + }, + terminalCommand: { + blockType: BlockType.COMMAND, + isTerminal: true, + text: 'terminal command' + }, + reporter: { + blockType: BlockType.REPORTER, + text: 'reporter' + }, + boolean: { + blockType: BlockType.BOOLEAN, + text: 'Boolean' + }, + hat: { + blockType: BlockType.HAT, + text: 'hat' + } +}; + +// similar to goog.mixin from the Closure library +const mixin = function (target, source) { + for (const x in source) { + target[x] = source[x]; + } +}; + +class MockBlock { + constructor (blockInfo, extendedOpcode) { + // mimic Closure-style inheritance by mixing in `defineDynamicBlock` output as this instance's prototype + // see also the `Blockly.Block` constructor + const prototype = defineDynamicBlock(MockScratchBlocks, categoryInfo, blockInfo, extendedOpcode); + mixin(this, prototype); + this.init(); + + // bootstrap the mutation<->DOM cycle + this.blockInfoText = JSON.stringify(blockInfo); + const xmlElement = this.mutationToDom(); + + // parse blockInfo from XML to fill dynamic properties + this.domToMutation(xmlElement); + } + + jsonInit (json) { + this.result = Object.assign({}, json); + } + interpolate_ () { + // TODO: add tests for this? + } + setCheckboxInFlyout (isEnabled) { + this.result.checkboxInFlyout_ = isEnabled; + } + setOutput (isEnabled) { + this.result.outputConnection = isEnabled; // Blockly calls `makeConnection_` here + } + setOutputShape (outputShape) { + this.result.outputShape_ = outputShape; + } + setNextStatement (isEnabled) { + this.result.nextConnection = isEnabled; // Blockly calls `makeConnection_` here + } + setPreviousStatement (isEnabled) { + this.result.previousConnection = isEnabled; // Blockly calls `makeConnection_` here + } +} + +describe('defineDynamicBlock', () => { + test('is a function', () => { + expect(typeof defineDynamicBlock).toBe('function'); + }); + test('can define a command block with an icon', () => { + const extendedOpcode = 'test.commandWithIcon'; + const block = new MockBlock(testBlockInfo.commandWithIcon, extendedOpcode); + expect(block.result).toEqual({ + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + extensions: ['scratch_extension'], + inputsInline: true, + nextConnection: true, + outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE, + previousConnection: true, + type: extendedOpcode + }); + }); + test('can define a command block without an icon', () => { + const extendedOpcode = 'test.commandWithoutIcon'; + const block = new MockBlock(testBlockInfo.commandWithoutIcon, extendedOpcode); + expect(block.result).toEqual({ + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + // extensions: undefined, // no icon means no extension + inputsInline: true, + nextConnection: true, + outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE, + previousConnection: true, + type: extendedOpcode + }); + }); + test('can define a terminal command', () => { + const extendedOpcode = 'test.terminal'; + const block = new MockBlock(testBlockInfo.terminalCommand, extendedOpcode); + expect(block.result).toEqual({ + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + // extensions: undefined, // no icon means no extension + inputsInline: true, + nextConnection: false, // terminal + outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE, + previousConnection: true, + type: extendedOpcode + }); + }); + test('can define a reporter', () => { + const extendedOpcode = 'test.reporter'; + const block = new MockBlock(testBlockInfo.reporter, extendedOpcode); + expect(block.result).toEqual({ + category: categoryInfo.name, + checkboxInFlyout_: true, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + // extensions: undefined, // no icon means no extension + inputsInline: true, + // nextConnection: undefined, // reporter + outputConnection: true, // reporter + outputShape_: MockScratchBlocks.OUTPUT_SHAPE_ROUND, // reporter + // previousConnection: undefined, // reporter + type: extendedOpcode + }); + }); + test('can define a Boolean', () => { + const extendedOpcode = 'test.boolean'; + const block = new MockBlock(testBlockInfo.boolean, extendedOpcode); + expect(block.result).toEqual({ + category: categoryInfo.name, + // checkboxInFlyout_: undefined, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + // extensions: undefined, // no icon means no extension + inputsInline: true, + // nextConnection: undefined, // reporter + outputConnection: true, // reporter + outputShape_: MockScratchBlocks.OUTPUT_SHAPE_HEXAGONAL, // Boolean + // previousConnection: undefined, // reporter + type: extendedOpcode + }); + }); + test('can define a hat', () => { + const extendedOpcode = 'test.hat'; + const block = new MockBlock(testBlockInfo.hat, extendedOpcode); + expect(block.result).toEqual({ + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + // extensions: undefined, // no icon means no extension + inputsInline: true, + nextConnection: true, + outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE, + // previousConnection: undefined, // hat + type: extendedOpcode + }); + }); +});