diff --git a/blocks/logic.js b/blocks/logic.js index a9d165314..ccc1c0335 100644 --- a/blocks/logic.js +++ b/blocks/logic.js @@ -86,7 +86,8 @@ Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT "nextStatement": null, "colour": "%{BKY_LOGIC_HUE}", "helpUrl": "%{BKY_CONTROLS_IF_HELPURL}", - "extensions": ["controls_if_mutator"] + "mutator": "controls_if_mutator", + "extensions": ["controls_if_tooltip"] }, // If/else block that does not use a mutator. { @@ -457,15 +458,16 @@ Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_MIXIN = { } }; +Blockly.Extensions.registerMutator('controls_if_mutator', + Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_MIXIN, null, + ['controls_if_elseif', 'controls_if_else']); /** * "controls_if" extension function. Adds mutator, shape updating methods, and * dynamic tooltip to "controls_if" blocks. * @this Blockly.Block * @package */ -Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_EXTENSION = function() { - this.setMutator(new Blockly.Mutator(['controls_if_elseif', 'controls_if_else'])); - this.mixin(Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_MIXIN); +Blockly.Constants.Logic.CONTROLS_IF_TOOLTIP_EXTENSION = function() { this.setTooltip(function() { if (!this.elseifCount_ && !this.elseCount_) { @@ -481,11 +483,11 @@ Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_EXTENSION = function() { }.bind(this)); }; -Blockly.Extensions.register('controls_if_mutator', - Blockly.Constants.Logic.CONTROLS_IF_MUTATOR_EXTENSION); +Blockly.Extensions.register('controls_if_tooltip', + Blockly.Constants.Logic.CONTROLS_IF_TOOLTIP_EXTENSION); /** - * Corrects the logic_compate dropdown label with respect to language direction. + * Corrects the logic_compare dropdown label with respect to language direction. * @this Blockly.Block * @package */ @@ -513,7 +515,7 @@ Blockly.Constants.Logic.fixLogicCompareRtlOpLabels = }; /** - * Adds dynamic type validation for the left and right sides of a logic_compate block. + * Adds dynamic type validation for the left and right sides of a logic_compare block. * @mixin * @augments Blockly.Block * @package diff --git a/blocks/math.js b/blocks/math.js index c8e6855b0..f88d19bdb 100644 --- a/blocks/math.js +++ b/blocks/math.js @@ -207,7 +207,7 @@ Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT "output": "Boolean", "colour": "%{BKY_MATH_HUE}", "tooltip": "%{BKY_MATH_IS_TOOLTIP}", - "extensions": ["math_is_divisibleby_mutator"] + "mutator": "math_is_divisibleby_mutator" }, // Block for adding to a variable in place. @@ -288,7 +288,8 @@ Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT "output": "Number", "colour": "%{BKY_MATH_HUE}", "helpUrl": "%{BKY_MATH_ONLIST_HELPURL}", - "extensions": ["math_op_tooltip", "math_modes_of_list_mutator"] + "mutator": "math_modes_of_list_mutator", + "extensions": ["math_op_tooltip"] }, // Block for remainder of a division. @@ -480,14 +481,14 @@ Blockly.Constants.Math.IS_DIVISIBLEBY_MUTATOR_MIXIN = { * @package */ Blockly.Constants.Math.IS_DIVISIBLE_MUTATOR_EXTENSION = function() { - this.mixin(Blockly.Constants.Math.IS_DIVISIBLEBY_MUTATOR_MIXIN); this.getField('PROPERTY').setValidator(function(option) { var divisorInput = (option == 'DIVISIBLE_BY'); this.sourceBlock_.updateShape_(divisorInput); }); }; -Blockly.Extensions.register('math_is_divisibleby_mutator', +Blockly.Extensions.registerMutator('math_is_divisibleby_mutator', + Blockly.Constants.Math.IS_DIVISIBLEBY_MUTATOR_MIXIN, Blockly.Constants.Math.IS_DIVISIBLE_MUTATOR_EXTENSION); /** @@ -555,11 +556,11 @@ Blockly.Constants.Math.LIST_MODES_MUTATOR_MIXIN = { * @package */ Blockly.Constants.Math.LIST_MODES_MUTATOR_EXTENSION = function() { - this.mixin(Blockly.Constants.Math.LIST_MODES_MUTATOR_MIXIN); this.getField('OP').setValidator(function(newOp) { this.updateType_(newOp); }.bind(this)); }; -Blockly.Extensions.register('math_modes_of_list_mutator', +Blockly.Extensions.registerMutator('math_modes_of_list_mutator', + Blockly.Constants.Math.LIST_MODES_MUTATOR_MIXIN, Blockly.Constants.Math.LIST_MODES_MUTATOR_EXTENSION); diff --git a/core/block.js b/core/block.js index b0985f000..c42322e1b 100644 --- a/core/block.js +++ b/core/block.js @@ -984,6 +984,7 @@ Blockly.Block.prototype.appendDummyInput = function(opt_name) { * @param {!Object} json Structured data describing the block. */ Blockly.Block.prototype.jsonInit = function(json) { + // Validate inputs. goog.asserts.assert(json['output'] == undefined || json['previousStatement'] == undefined, @@ -1037,11 +1038,17 @@ Blockly.Block.prototype.jsonInit = function(json) { 'strings. Found raw string in JSON for \'' + json['type'] + '\' block.'); json['extensions'] = [json['extensions']]; // Correct and continue. } + + // Add the mutator to the block + if (json['mutator'] !== undefined) { + Blockly.Extensions.apply(json['mutator'], this, true); + } + if (Array.isArray(json['extensions'])) { var extensionNames = json['extensions']; for (var i = 0; i < extensionNames.length; ++i) { var extensionName = extensionNames[i]; - Blockly.Extensions.apply(extensionName, this); + Blockly.Extensions.apply(extensionName, this, false); } } }; diff --git a/core/extensions.js b/core/extensions.js index ad9db0dc6..666026888 100644 --- a/core/extensions.js +++ b/core/extensions.js @@ -40,6 +40,15 @@ goog.provide('Blockly.Extensions'); */ Blockly.Extensions.ALL_ = {}; +/** + * The set of properties on a block that may only be set by a mutator. + * @type {!Array.} + * @private + * @constant + */ +Blockly.Extensions.MUTATOR_PROPERTIES_ = + ['domToMutation', 'mutationToDom', 'compose', 'decompose']; + /** * Registers a new extension function. Extensions are functions that help * initialize blocks, usually adding dynamic behavior such as onchange @@ -76,19 +85,213 @@ Blockly.Extensions.registerMixin = function(name, mixinObj) { }); }; +/** + * Registers a new extension function that adds a mutator to the block. + * At register time this performs some basic sanity checks on the mutator. + * The wrapper may also add a mutator dialog to the block, if both compose and + * decompose are defined on the mixin. + * @param {string} name The name of this mutator extension. + * @param {!Object} mixinObj The values to mix in. + * @param {function()=} opt_helperFn An optional function to apply after mixing + * in the object. + * @param {Array.=} opt_blockList A list of blocks to appear in the + * flyout of the mutator dialog. + * @throws {Error} if the mutation is invalid or can't be applied to the block. + */ +Blockly.Extensions.registerMutator = function(name, mixinObj, opt_helperFn, + opt_blockList) { + var errorPrefix = 'Error when registering mutator "' + name + '": '; + + // Sanity check the mixin object before registering it. + Blockly.Extensions.checkHasFunction_(errorPrefix, mixinObj, 'domToMutation'); + Blockly.Extensions.checkHasFunction_(errorPrefix, mixinObj, 'mutationToDom'); + + var hasMutatorDialog = Blockly.Extensions.checkMutatorDialog_(mixinObj, + errorPrefix); + + if (opt_helperFn && !goog.isFunction(opt_helperFn)) { + throw new Error('Extension "' + name + '" is not a function'); + } + + // Sanity checks passed. + Blockly.Extensions.register(name, function() { + if (hasMutatorDialog) { + this.setMutator(new Blockly.Mutator(opt_blockList)); + } + // Mixin the object. + this.mixin(mixinObj); + + if (opt_helperFn) { + opt_helperFn.apply(this); + } + }); +}; + /** * Applies an extension method to a block. This should only be called during * block construction. * @param {string} name The name of the extension. * @param {!Blockly.Block} block The block to apply the named extension to. + * @param {boolean} isMutator True if this extension defines a mutator. * @throws {Error} if the extension is not found. */ -Blockly.Extensions.apply = function(name, block) { +Blockly.Extensions.apply = function(name, block, isMutator) { var extensionFn = Blockly.Extensions.ALL_[name]; if (!goog.isFunction(extensionFn)) { throw new Error('Error: Extension "' + name + '" not found.'); } + if (isMutator) { + // Fail early if the block already has mutation properties. + Blockly.Extensions.checkNoMutatorProperties_(name, block); + } else { + // Record the old properties so we can make sure they don't change after + // applying the extension. + var mutatorProperties = Blockly.Extensions.getMutatorProperties_(block); + } extensionFn.apply(block); + + if (isMutator) { + var errorPrefix = 'Error after applying mutator "' + name + '": '; + Blockly.Extensions.checkBlockHasMutatorProperties_(name, block, errorPrefix); + } else { + if (!Blockly.Extensions.mutatorPropertiesMatch_(mutatorProperties, block)) { + throw new Error('Error when applying extension "' + name + + '": mutation properties changed when applying a non-mutator extension.'); + } + } +}; + +/** + * Check that the given object has a property with the given name, and that the + * property is a function. + * @param {string} errorPrefix The string to prepend to any error message. + * @param {!Object} object The object to check. + * @param {string} propertyName Which property to check. + * @throws {Error} if the property does not exist or is not a function. + * @private + */ +Blockly.Extensions.checkHasFunction_ = function(errorPrefix, object, + propertyName) { + if (!object.hasOwnProperty(propertyName)) { + throw new Error(errorPrefix + + 'missing required property "' + propertyName + '"'); + } else if (typeof object[propertyName] !== "function") { + throw new Error(errorPrefix + + '" required property "' + propertyName + '" must be a function'); + } +}; + +/** + * Check that the given block does not have any of the four mutator properties + * defined on it. This function should be called before applying a mutator + * extension to a block, to make sure we are not overwriting properties. + * @param {string} mutationName The name of the mutation to reference in error + * messages. + * @param {!Blockly.Block} block The block to check. + * @throws {Error} if any of the properties already exist on the block. + * @private + */ +Blockly.Extensions.checkNoMutatorProperties_ = function(mutationName, block) { + for (var i = 0; i < Blockly.Extensions.MUTATOR_PROPERTIES_.length; i++) { + var propertyName = Blockly.Extensions.MUTATOR_PROPERTIES_[i]; + if (block.hasOwnProperty(propertyName)) { + throw new Error('Error: tried to apply mutation "' + mutationName + + '" to a block that already has a "' + propertyName + + '" function. Block id: ' + block.id); + } + } +}; + +/** + * Check that the given object has both or neither of the functions required + * to have a mutator dialog. + * These functions are 'compose' and 'decompose'. If a block has one, it must + * have both. + * @param {!Object} object The object to check. + * @param {string} errorPrefix The string to prepend to any error message. + * @return {boolean} True if the object has both functions. False if it has + * neither function. + * @throws {Error} if the object has only one of the functions. + * @private + */ +Blockly.Extensions.checkMutatorDialog_ = function(object, errorPrefix) { + var hasCompose = object.hasOwnProperty('compose'); + var hasDecompose = object.hasOwnProperty('decompose'); + + if (hasCompose && hasDecompose) { + if (typeof object['compose'] !== "function") { + throw new Error(errorPrefix + 'compose must be a function.'); + } else if (typeof object['decompose'] !== "function") { + throw new Error(errorPrefix + 'decompose must be a function.'); + } + return true; + } else if (!hasCompose && !hasDecompose) { + return false; + } else { + throw new Error(errorPrefix + + 'Must have both or neither of "compose" and "decompose"'); + } +}; + +/** + * Check that a block has required mutator properties. This should be called + * after applying a mutation extension. + * @param {string} errorPrefix The string to prepend to any error message. + * @param {!Blockly.Block} block The block to inspect. + * @private + */ +Blockly.Extensions.checkBlockHasMutatorProperties_ = function(errorPrefix, + block) { + if (!block.hasOwnProperty('domToMutation')) { + throw new Error(errorPrefix + 'Applying a mutator didn\'t add "domToMutation"'); + } + if (!block.hasOwnProperty('mutationToDom')) { + throw new Error(errorPrefix + 'Applying a mutator didn\'t add "mutationToDom"'); + } + + // A block with a mutator isn't required to have a mutation dialog, but + // it should still have both or neither of compose and decompose. + Blockly.Extensions.checkMutatorDialog_(block, errorPrefix); +}; + +/** + * Get a list of values of mutator properties on the given block. + * @param {!Blockly.Block} block The block to inspect. + * @return {!Array.} a list with all of the properties, which should be + * functions or undefined, but are not guaranteed to be. + * @private + */ +Blockly.Extensions.getMutatorProperties_ = function(block) { + var result = []; + for (var i = 0; i < Blockly.Extensions.MUTATOR_PROPERTIES_.length; i++) { + result.push(block[Blockly.Extensions.MUTATOR_PROPERTIES_[i]]); + } + return result; +}; + +/** + * Check that the current mutator properties match a list of old mutator + * properties. This should be called after applying a non-mutator extension, + * to verify that the extension didn't change properties it shouldn't. + * @param {!Array.} oldProperties The old values to compare to. + * @param {!Blockly.Block} block The block to inspect for new values. + * @return {boolean} True if the property lists match. + * @private + */ +Blockly.Extensions.mutatorPropertiesMatch_ = function(oldProperties, block) { + var match = true; + var newProperties = Blockly.Extensions.getMutatorProperties_(block); + if (newProperties.length != oldProperties.length) { + match = false; + } else { + for (var i = 0; i < newProperties.length; i++) { + if (oldProperties[i] != newProperties[i]) { + match = false; + } + } + } + + return match; }; /** @@ -239,3 +442,5 @@ Blockly.Extensions.extensionParentTooltip_ = function() { }; Blockly.Extensions.register('parent_tooltip_when_inline', Blockly.Extensions.extensionParentTooltip_); + + diff --git a/tests/jsunit/extensions_test.js b/tests/jsunit/extensions_test.js index 5924f8e05..a72179113 100644 --- a/tests/jsunit/extensions_test.js +++ b/tests/jsunit/extensions_test.js @@ -304,3 +304,354 @@ function test_bad_mixin_overwrites_prototype() { delete Blockly.Blocks['test_block_bad_colour']; } } + +function test_mutator_mixin() { + var workspace = new Blockly.Workspace(); + var block; + + try { + Blockly.defineBlocksWithJsonArray([{ + "type": "mutator_test_block", + "message0": "mutator_test_block", + "mutator": "mutator_test" + }]); + + // Events code calls mutationToDom and expects it to give back a meaningful + // value. + Blockly.Events.disable(); + Blockly.Extensions.registerMutator('mutator_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + mutationToDom: function() { + return 'mutationToDomFn'; + }, + compose: function() { + return 'composeFn'; + }, + decompose: function() { + return 'decomposeFn'; + } + }); + + block = new Blockly.Block(workspace, 'mutator_test_block'); + + // Make sure all of the functions were installed correctly. + assertEquals(block.domToMutation(), 'domToMutationFn'); + assertEquals(block.mutationToDom(), 'mutationToDomFn'); + assertEquals(block.compose(), 'composeFn'); + assertEquals(block.decompose(), 'decomposeFn'); + } finally { + if (block) { + block.dispose(); + } + workspace.dispose(); + Blockly.Events.enable(); + delete Blockly.Extensions.ALL_['mutator_test']; + } +} + +function test_mutator_mixin_no_dialog() { + var workspace = new Blockly.Workspace(); + var block; + + try { + Blockly.defineBlocksWithJsonArray([{ + "type": "mutator_test_block", + "message0": "mutator_test_block", + "mutator": "mutator_test" + }]); + + // Events code calls mutationToDom and expects it to give back a meaningful + // value. + Blockly.Events.disable(); + assertUndefined(Blockly.Extensions.ALL_['mutator_test']); + Blockly.Extensions.registerMutator('mutator_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + mutationToDom: function() { + return 'mutationToDomFn'; + } + }); + + block = new Blockly.Block(workspace, 'mutator_test_block'); + + // Make sure all of the functions were installed correctly. + assertEquals(block.domToMutation(), 'domToMutationFn'); + assertEquals(block.mutationToDom(), 'mutationToDomFn'); + assertFalse(block.hasOwnProperty('compose')); + assertFalse(block.hasOwnProperty('decompose')); + } finally { + if (block) { + block.dispose(); + } + workspace.dispose(); + Blockly.Events.enable(); + delete Blockly.Extensions.ALL_['mutator_test']; + } +} + +// Explicitly check all four things that could be missing. +function test_mutator_mixin_no_decompose_fails() { + var exceptionWasThrown = false; + try { + Blockly.Extensions.registerMutator('mutator_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + mutationToDom: function() { + return 'mutationToDomFn'; + }, + compose: function() { + return 'composeFn'; + } + }); + } catch (e) { + // Expected. + exceptionWasThrown = true; + } finally { + delete Blockly.Extensions.ALL_['mutator_test']; + } + assertTrue(exceptionWasThrown); +} + +function test_mutator_mixin_no_compose_fails() { + var exceptionWasThrown = false; + try { + Blockly.Extensions.registerMutator('mutator_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + mutationToDom: function() { + return 'mutationToDomFn'; + }, + decompose: function() { + return 'decomposeFn'; + } + }); + } catch (e) { + // Expected. + exceptionWasThrown = true; + } finally { + delete Blockly.Extensions.ALL_['mutator_test']; + } + assertTrue(exceptionWasThrown); +} + +function test_mutator_mixin_no_domToMutation_fails() { + var exceptionWasThrown = false; + try { + Blockly.Extensions.registerMutator('mutator_test', + { + mutationToDom: function() { + return 'mutationToDomFn'; + }, + compose: function() { + return 'composeFn'; + }, + decompose: function() { + return 'decomposeFn'; + } + }); + } catch (e) { + // Expected. + exceptionWasThrown = true; + } finally { + delete Blockly.Extensions.ALL_['mutator_test']; + } + assertTrue(exceptionWasThrown); +} + +function test_mutator_mixin_no_mutationToDom_fails() { + var exceptionWasThrown = false; + try { + Blockly.Extensions.registerMutator('mutator_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + compose: function() { + return 'composeFn'; + }, + decompose: function() { + return 'decomposeFn'; + } + }); + } catch (e) { + // Expected. + exceptionWasThrown = true; + } finally { + delete Blockly.Extensions.ALL_['mutator_test']; + } + assertTrue(exceptionWasThrown); +} + +function test_use_mutator_as_extension_fails() { + var workspace = new Blockly.Workspace(); + var block; + var exceptionWasThrown = false; + + try { + Blockly.defineBlocksWithJsonArray([{ + "type": "mutator_test_block", + "message0": "mutator_test_block", + "extensions": ["mutator_test"] + }]); + + Blockly.Events.disable(); + assertUndefined(Blockly.Extensions.ALL_['mutator_test']); + Blockly.Extensions.registerMutator('mutator_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + mutationToDom: function() { + return 'mutationToDomFn'; + } + }); + + // Events code calls mutationToDom and expects it to give back a meaningful + // value. + block = new Blockly.Block(workspace, 'mutator_test_block'); + } catch (e) { + // Expected + exceptionWasThrown = true; + // Should have failed on apply, not on register. + assertNotNull(Blockly.Extensions.ALL_['mutator_test']); + } finally { + if (block) { + block.dispose(); + } + workspace.dispose(); + Blockly.Events.enable(); + delete Blockly.Extensions.ALL_['mutator_test']; + } + assertTrue(exceptionWasThrown); +} + +function test_use_mutator_mixin_as_extension_fails() { + var workspace = new Blockly.Workspace(); + var block; + var exceptionWasThrown = false; + + try { + Blockly.defineBlocksWithJsonArray([{ + "type": "mutator_test_block", + "message0": "mutator_test_block", + "extensions": ["mutator_test"] + }]); + + // Events code calls mutationToDom and expects it to give back a meaningful + // value. + Blockly.Events.disable(); + assertUndefined(Blockly.Extensions.ALL_['mutator_test']); + Blockly.Extensions.registerMixin('mutator_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + mutationToDom: function() { + return 'mutationToDomFn'; + } + }); + + block = new Blockly.Block(workspace, 'mutator_test_block'); + } catch (e) { + // Expected + exceptionWasThrown = true; + // Should have failed on apply, not on register. + assertNotNull(Blockly.Extensions.ALL_['mutator_test']); + } finally { + if (block) { + block.dispose(); + } + workspace.dispose(); + Blockly.Events.enable(); + delete Blockly.Extensions.ALL_['mutator_test']; + } + assertTrue(exceptionWasThrown); +} + +function test_use_extension_as_mutator_fails() { + var workspace = new Blockly.Workspace(); + var block; + var exceptionWasThrown = false; + + try { + Blockly.defineBlocksWithJsonArray([{ + "type": "mutator_test_block", + "message0": "mutator_test_block", + "mutator": ["extensions_test"] + }]); + + // Events code calls mutationToDom and expects it to give back a meaningful + // value. + Blockly.Events.disable(); + assertUndefined(Blockly.Extensions.ALL_['extensions_test']); + Blockly.Extensions.register('extensions_test', function() { + return 'extensions_test_fn'; + }); + + block = new Blockly.Block(workspace, 'mutator_test_block'); + } catch (e) { + // Expected + exceptionWasThrown = true; + // Should have failed on apply, not on register. + assertNotNull(Blockly.Extensions.ALL_['extensions_test']); + } finally { + if (block) { + block.dispose(); + } + workspace.dispose(); + Blockly.Events.enable(); + delete Blockly.Extensions.ALL_['extensions_test']; + } + assertTrue(exceptionWasThrown); +} + +function test_mutator_mixin_plus_function() { + var workspace = new Blockly.Workspace(); + var block; + var fnWasCalled = false; + + try { + Blockly.defineBlocksWithJsonArray([{ + "type": "mutator_test_block", + "message0": "mutator_test_block", + "mutator": ["extensions_test"] + }]); + + Blockly.Events.disable(); + assertUndefined(Blockly.Extensions.ALL_['extensions_test']); + Blockly.Extensions.registerMutator('extensions_test', + { + domToMutation: function() { + return 'domToMutationFn'; + }, + mutationToDom: function() { + return 'mutationToDomFn'; + } + }, + function() { + fnWasCalled = true; + } + ); + + // Events code calls mutationToDom and expects it to give back a meaningful + // value. + block = new Blockly.Block(workspace, 'mutator_test_block'); + } finally { + if (block) { + block.dispose(); + } + workspace.dispose(); + Blockly.Events.enable(); + delete Blockly.Extensions.ALL_['extensions_test']; + } + assertTrue(fnWasCalled); +}