Extensions and Mutators

Extensions are functions that run on each block of a given type as the block is created. These often add some custom configuration or behavior to a block.

A mutator is a special kind of extension that adds custom serialization, and sometimes UI, to a block.

Extensions

Extensions are functions that run on each block of a given type as the block is created. They may add custom configuration (e.g. setting the block's tooltip) or custom behavior (e.g. adding an event listener to the block).

// This extension sets the block's tooltip to be a function which displays
// the parent block's tooltip (if it exists).
Blockly.Extensions.register(
    'parent_tooltip_extension',
    function() { // this refers to the block that the extension is being run on
      var thisBlock = this;
      this.setTooltip(function() {
        var parent = thisBlock.getParent();
        return (parent && parent.getInputsInline() && parent.tooltip) ||
            Blockly.Msg.MATH_NUMBER_TOOLTIP;
      });
    });

Extensions have to be "registered" so that they can be associated with a string key. Then you can assign this string key to the extensions property of your block type's JSON definition to apply the extension to the block.

{
 //...,
 "extensions": ["parent_tooltip_extension",]
}

You can also add multiple extensions at once. Note that the extensions property must be an array, even if you are only applying one extension.

{
  //...,
  "extensions": ["parent_tooltip_extension", "break_warning_extension"],
}

Mixins

Blockly also provides a convenience method for situations where you want to add some properties/helper functions to a block, but not run them immediately. This works by allowing you to register a mixin object that contains all of your additional properties/methods. The mixin object is then wrapped in a function which applies the mixin every time an instance of the given block type is created.

Blockly.Extensions.registerMixin('my_mixin', {
  someProperty: 'a cool value',

  someMethod: function() {
    // Do something cool!
  }
))`

String keys associated with mixins can be referenced in JSON just like any other extension.

{
 //...,
 "extensions": ["my_mixin"],
}

Mutators

A mutator is a special type of extension that adds extra serialization (extra state that gets saved and loaded) to a block. For example, the built-in controls_if and list_create_with blocks need extra serialization so that they can save how many inputs they have.

Note that changing the shape of your block does not necessarily mean you need extra serialization. For example, the math_number_property block changes shape, but it does that based on a dropdown field, whose value already gets serialized. As such, it can just use a field validator, and doesn't need a mutator.

See the serialization page for more information about when you need a mutator and when you don't.

Mutators also provide a built-in UI for users to change the shapes of blocks if you provide some optional methods.

Serialization hooks

Mutators have two pairs of serialization hooks they work with. One pair of hooks works with the new JSON serialization system, and the other pair works with the old XML serialization system. You have to provide at least one of these pairs.

saveExtraState and loadExtraState

saveExtraState and loadExtraState are serialization hooks that work with the new JSON serialization system. saveExtraState returns a JSON serializable value which represents the extra state of the block, and loadExtraState accepts that same JSON serializable value, and applies it to the block.

// These are the serialization hooks for the lists_create_with block.
saveExtraState: function() {
  return {
    'itemCount': this.itemCount_,
  };
},

loadExtraState: function(state) {
  this.itemCount_ = state['itemCount'];
  // This is a helper function which adds or removes inputs from the block.
  this.updateShape_();
},

The resulting JSON will look like:

{
  "type": "lists_create_with",
  "extraState": {
    "itemCount": 3 // or whatever the count is
  }
}
No state

If your block is in its default state when it is serialized, then your saveExtraState method can return null to indicate this. If your saveExtraState method returns null then no extraState property is added to the JSON. This keeps your save file size small.

Full serialization and backing data

saveExtraState also receives an optional doFullSerialization parameter. This is used by blocks that reference state serialized by a different serializer (like backing data models). The parameter signals that the referenced state won't be available when the block is deserialized, so the block should serialize all of the backing state itself. For example, this is true when an individual block is serialized, or when a block is copy-pasted.

Two common use cases for this are:

  • When an individual block is loaded into a workspace where the backing data model doesn't exist, it has enough information in its own state to create a new data model.
  • When a block is copy-pasted, it always creates a new backing data model instead of referencing an existing one.

Some blocks that use this are the @blockly/block-shareable-procedures blocks. Normally they serialize a reference to a backing data model, which stores their state. But if the doFullSerialization parameter is true, then they serialize all of their state. The shareable procedure blocks use this to make sure that when they are copy-pasted they create a new backing data model, instead of referencing an existing model.

mutationToDom and domToMutation

mutationToDom and domToMutation are serialization hooks that work with the old XML serialization system. Only use these hooks if you have to (e.g. you're working on an old code-base that hasn't migrated yet), otherwise use saveExtraState and loadExtraState.

mutationToDom returns an XML node which represents the extra state of the block, and domToMutation accepts that same XML node and applies the state to the block.

// These are the old XML serialization hooks for the lists_create_with block.
mutationToDom: function() {
  // You *must* create a <mutation></mutation> element.
  // This element can have children.
  var container = Blockly.utils.xml.createElement('mutation');
  container.setAttribute('items', this.itemCount_);
  return container;
},

domToMutation: function(xmlElement) {
  this.itemCount_ = parseInt(xmlElement.getAttribute('items'), 10);
  // This is a helper function which adds or removes inputs from the block.
  this.updateShape_();
},

The resulting XML will look like:

<block type="lists_create_with">
  <mutation items="3"></mutation>
</block>

If your mutationToDom function returns null, then no extra element will be added to the XML.

UI Hooks

If you provide certain functions as part of your mutator, Blockly will add a default "mutator" UI to your block.

You don't have to use this UI if you want to add extra serialization. You could use a custom UI, like the blocks-plus-minus plugin provides, or you could use no UI at all!

compose and decompose

The default UI relies on the compose and decompose functions.

decompose "explodes" the block into smaller sub-blocks which can be moved around, added and deleted. This function should return a "top block" which is the main block in the mutator workspace that sub-blocks connect to.

compose then interprets the configuration of the sub-blocks and uses them to modify the main block. This function should accept the "top block" which was returned by decompose as a parameter.

Note that these functions get "mixed in" to the block being "mutated" so this can be used to refer to that block.

// These are the decompose and compose functions for the lists_create_with block.
decompose: function(workspace) {
  // This is a special sub-block that only gets created in the mutator UI.
  // It acts as our "top block"
  var topBlock = workspace.newBlock('lists_create_with_container');
  topBlock.initSvg();

  // Then we add one sub-block for each item in the list.
  var connection = topBlock.getInput('STACK').connection;
  for (var i = 0; i < this.itemCount_; i++) {
    var itemBlock = workspace.newBlock('lists_create_with_item');
    itemBlock.initSvg();
    connection.connect(itemBlock.previousConnection);
    connection = itemBlock.nextConnection;
  }

  // And finally we have to return the top-block.
  return topBlock;
},

// The container block is the top-block returned by decompose.
compose: function(topBlock) {
  // First we get the first sub-block (which represents an input on our main block).
  var itemBlock = topBlock.getInputTargetBlock('STACK');

  // Then we collect up all of the connections of on our main block that are
  // referenced by our sub-blocks.
  // This relates to the saveConnections hook (explained below).
  var connections = [];
  while (itemBlock && !itemBlock.isInsertionMarker()) {  // Ignore insertion markers!
    connections.push(itemBlock.valueConnection_);
    itemBlock = itemBlock.nextConnection &&
        itemBlock.nextConnection.targetBlock();
  }

  // Then we disconnect any children where the sub-block associated with that
  // child has been deleted/removed from the stack.
  for (var i = 0; i < this.itemCount_; i++) {
    var connection = this.getInput('ADD' + i).connection.targetConnection;
    if (connection && connections.indexOf(connection) == -1) {
      connection.disconnect();
    }
  }

  // Then we update the shape of our block (removing or adding iputs as necessary).
  // `this` refers to the main block.
  this.itemCount_ = connections.length;
  this.updateShape_();

  // And finally we reconnect any child blocks.
  for (var i = 0; i < this.itemCount_; i++) {
    connections[i].reconnect(this, 'ADD' + i);
  }
},

saveConnections

Optionally, you can also define a saveConnections function which works with the default UI. This function gives you a chance to associate children of your main block (which exists on the main workspace) with sub-blocks that exist in your mutator workspace. You can then use this data to make sure your compose function properly re-connects the children of your main block when your sub-blocks are reorganized.

saveConnections should accept the "top block" returned by your decompose function as a parameter. If the saveConnections function is defined, Blockly will call it before calling compose.

saveConnections: function(topBlock) {
  // First we get the first sub-block (which represents an input on our main block).
  var itemBlock = topBlock.getInputTargetBlock('STACK');

  // Then we go through and assign references to connections on our main block
  // (input.connection.targetConnection) to properties on our sub blocks
  // (itemBlock.valueConnection_).
  var i = 0;
  while (itemBlock) {
    // `this` refers to the main block (which is being "mutated").
    var input = this.getInput('ADD' + i);
    // This is the important line of this function!
    itemBlock.valueConnection_ = input && input.connection.targetConnection;
    i++;
    itemBlock = itemBlock.nextConnection &&
        itemBlock.nextConnection.targetBlock();
  }
},

Registering

Mutators are just a special kind of extension, so they also have to be registered before you can use them in your block type's JSON definition.

// Function signature.
Blockly.Extensions.registerMutator(name, mixinObj, opt_helperFn, opt_blockList);

// Example call.
Blockly.Extensions.registerMutator(
    'controls_if_mutator',
    { /* mutator methods */ },
    undefined,
    ['controls_if_elseif', 'controls_if_else']);
  • name: A string to associate with the mutator so you can use it in JSON.
  • mixinObj: An object containing the various mutation methods. E.g. saveExtraState and loadExtraState.
  • opt_helperFn: An optional helper function that will run on the block after the mixin is mixed in.
  • opt_blockList: An optional array of block types (as strings) that will be added to the flyout in the default mutator UI, if the UI methods are also defined.

Note that unlike extensions, each block type may only have one mutator.

{
  //...
  "mutator": "controls_if_mutator"
}

Helper function

Along with the mixin, a mutator may register a helper function. This function is run on each block of the given type after it is created and the mixinObj is added. It can be used to add additional triggers or effects to a mutation.

For example, you could add a helper to your list-like block that sets the initial number of items:

var helper = function() {
  this.itemCount_ = 5;
  this.updateShape();
}