From ceaa3c7857b79459ccd1b14d548528e4511209e7 Mon Sep 17 00:00:00 2001 From: Erik Mejer Hansen Date: Sun, 9 Dec 2018 21:54:45 +0100 Subject: [PATCH] Add support extensions to define custom field types. This is done by adding a new element "customFieldTypes" to the extension info structure. Ex: ``` customFieldTypes: { angleField: { implementation: { fromJson: options => new AngleField(options) }, output: 'number', outputShape: 2, } } ``` Field types are defined by an implementation that has to match what is expected by ScratchBlocks.Field.register and its output and shape. src/engine/runtime.js has been updated to handle the new "customFieldTypes"-field: - Existing (global) field types cannot be overridden - New fields are "namespaced" to the extension in the same way as opcodes are. Once the custom field type has been picked up by scratch-vm a "EXTENSION_FIELD_ADDED" event is emitted. It is then up to the hosting app to call ScratchBlocks.Field.register to register the field type with ScratchBlocks. Ex: ``` vm.addListener('EXTENSION_FIELD_ADDED', fieldInfo => { this.ScratchBlocks.Field.register(fieldInfo.name, fieldInfo.implementation); }); ``` --- src/engine/runtime.js | 108 ++++++++++++++++++++++++++++++++++++++--- src/virtual-machine.js | 3 ++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 89beb4ace..89e9275fe 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -556,6 +556,14 @@ class Runtime extends EventEmitter { return 'EXTENSION_ADDED'; } + /** + * Event name for reporting that an extension as asked for a custom field to be added + * @const {string} + */ + static get EXTENSION_FIELD_ADDED () { + return 'EXTENSION_FIELD_ADDED'; + } + /** * Event name for updating the available set of peripheral devices. * This causes the peripheral connection modal to update a list of @@ -779,6 +787,7 @@ class Runtime extends EventEmitter { color1: extensionInfo.colour || '#0FBD8C', color2: extensionInfo.colourSecondary || '#0DA57A', color3: extensionInfo.colourTertiary || '#0B8E69', + customFieldTypes: {}, blocks: [], menus: [] }; @@ -787,7 +796,23 @@ class Runtime extends EventEmitter { this._fillExtensionCategory(categoryInfo, extensionInfo); - this.emit(Runtime.EXTENSION_ADDED, categoryInfo.blocks.concat(categoryInfo.menus)); + 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, { + name: `field_${fieldTypeInfo.extendedName}`, + implementation: fieldTypeInfo.fieldImplementation + }); + } + } + + const allBlocks = fieldTypeDefinitionsForScratch.concat(categoryInfo.blocks).concat(categoryInfo.menus); + + this.emit(Runtime.EXTENSION_ADDED, allBlocks); } /** @@ -811,7 +836,8 @@ class Runtime extends EventEmitter { } /** - * Read extension information, convert menus and blocks, and store the results in the provided category object. + * Read extension information, convert menus, blocks and custom field types + * 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 @@ -824,6 +850,19 @@ class Runtime extends EventEmitter { categoryInfo.menus.push(convertedMenu); } } + for (const fieldTypeName in extensionInfo.customFieldTypes) { + if (extensionInfo.customFieldTypes.hasOwnProperty(fieldTypeName)) { + const fieldType = extensionInfo.customFieldTypes[fieldTypeName]; + const fieldTypeInfo = this._buildCustomFieldInfo( + fieldTypeName, + fieldType, + extensionInfo.id, + categoryInfo + ); + + categoryInfo.customFieldTypes[fieldTypeName] = fieldTypeInfo; + } + } for (const blockInfo of extensionInfo.blocks) { if (blockInfo === '---') { @@ -897,6 +936,55 @@ class Runtime extends EventEmitter { }; } + _buildCustomFieldInfo (fieldName, fieldInfo, extensionId, categoryInfo) { + const extendedName = `${extensionId}_${fieldName}`; + return { + fieldName: fieldName, + extendedName: extendedName, + argumentTypeInfo: { + shadowType: extendedName, + fieldType: `field_${extendedName}` + }, + scratchBlocksDefinition: this._buildCustomFieldTypeForScratchBlocks( + extendedName, + fieldInfo.output, + fieldInfo.outputShape, + categoryInfo + ), + fieldImplementation: fieldInfo.implementation + }; + } + + /** + * Build the scratch-blocks JSON needed for a fieldType. + * Custom field types need to be namespaced to the extension so that extensions can't interfere with each other + * @param {string} fieldName - The name of the field + * @param {string} output - The output of the field + * @param {number} outputShape - Shape of the field (from ScratchBlocksConstants) + * @param {object} categoryInfo - The category the field belongs to (Used to set its colors) + * @returns {object} - Object to be inserted into scratch-blocks + */ + _buildCustomFieldTypeForScratchBlocks (fieldName, output, outputShape, categoryInfo) { + return { + json: { + type: fieldName, + message0: '%1', + inputsInline: true, + output: output, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colourTertiary: categoryInfo.color3, + outputShape: outputShape, + args0: [ + { + name: `field_${fieldName}`, + type: `field_${fieldName}` + } + ] + } + }; + } + /** * Convert ExtensionBlockMetadata into scratch-blocks JSON & XML, and generate a proxy function. * @param {ExtensionBlockMetadata} blockInfo - the block to convert @@ -1065,10 +1153,17 @@ class Runtime extends EventEmitter { }; const argInfo = context.blockInfo.arguments[placeholder] || {}; - const argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; - const defaultValue = (typeof argInfo.defaultValue === 'undefined' ? - '' : - escapeHtml(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString())); + let argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; + + // Field type not a standard field type, see if extension has registered custom field type + if (!ArgumentTypeMap[argInfo.type] && context.categoryInfo.customFieldTypes[argInfo.type]) { + argTypeInfo = context.categoryInfo.customFieldTypes[argInfo.type].argumentTypeInfo; + } + + const defaultValue = + typeof argInfo.defaultValue === 'undefined' + ? '' + : escapeHtml(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString()); if (argTypeInfo.check) { argJSON.check = argTypeInfo.check; @@ -1103,6 +1198,7 @@ class Runtime extends EventEmitter { blockArgs.push(argJSON); const argNum = blockArgs.length; context.argsMap[placeholder] = argNum; + return `%${argNum}`; } diff --git a/src/virtual-machine.js b/src/virtual-machine.js index a40d135e5..89bb08450 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -109,6 +109,9 @@ class VirtualMachine extends EventEmitter { this.runtime.on(Runtime.EXTENSION_ADDED, blocksInfo => { this.emit(Runtime.EXTENSION_ADDED, blocksInfo); }); + 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); });