Mutator with automatically inserted "default" blocks

41 views
Skip to first unread message

Taurus Rico

unread,
Jul 14, 2026, 5:27:17 AM (3 days ago) Jul 14
to Blockly
Hello everyone,

I am trying to make an or-block with a mutator. In the mutation I want to place blocks automatically into empty inputs. These inserted blocks shouldn't be shadowblocks because the user should insert own blocks into the inputs of these blocks or replace the blocks entirely. My default block is a compare block named "inlineIfCompare".

My block definition and the mutator looks like that:
import * as Blockly from 'blockly/core';
import { MyCustomLabelField } from '../../custom_fields/labelFieldTest';
import '../../code_generators/orBlockCode';

Blockly.defineBlocksWithJsonArray([
{
type: 'or_block',
tooltip: 'verodern',
helpUrl: '',
message0: '%1 %2 %3 %4',
args0: [
{
type: 'input_value',
name: 'OR1INP',
check: 'Boolean',
align: 'RIGHT',
},
{
type: 'input_end_row',
name: 'END',
},
{
type: 'field_custom_label',
name: 'OR0',
align: 'CENTRE',
text: 'or',
},
{
type: 'input_value',
name: 'OR2INP',
check: 'Boolean',
align: 'RIGHT',
},
],
output: 'Boolean',
mutator: 'custom_or_mutator',
style: 'custom_or_style',
inputsInline: true,
},
{
type: 'or_start',
align: 'LEFT',
message0: 'Condition 1 or 2 %1',
args0: [
{
type: 'input_dummy',
name: 'STACK',
},
],
nextStatement: null,
enableContextMenu: false,
style: 'custom_or_style',
tooltip: '',
},
// Block representing the item statement in the STATE mutator.
{
type: 'or_item',
align: 'LEFT',
message0: '+ Condition',
previousStatement: null,
nextStatement: null,
enableContextMenu: false,
style: 'custom_or_style',
tooltip: '',
},
]);

const OR_MUTATOR_MIXIN = {
itemCount_: 0,

saveExtraState: function () {
return {
itemCount: this.itemCount_,
};
},

loadExtraState: function (state) {
this.itemCount_ = state['itemCount'] || 0;
this.updateShape_();
},

decompose: function (workspace) {
const containerBlock = workspace.newBlock('or_start');
containerBlock.initSvg();
let connection = containerBlock.nextConnection;
for (let i = 0; i < this.itemCount_; i++) {
const itemBlock = workspace.newBlock('or_item');
itemBlock.initSvg();
connection.connect(itemBlock.previousConnection);
connection = itemBlock.nextConnection;
}
return containerBlock;
},

compose: function (containerBlock) {
let clauseBlock = containerBlock.nextConnection.targetBlock();
// Count number of inputs.
this.itemCount_ = 0;

const valueConnections = [null];

while (clauseBlock && !clauseBlock.isInsertionMarker()) {
switch (clauseBlock.type) {
case 'or_item':
this.itemCount_++;
valueConnections.push(clauseBlock.valueConnection_);
break;
default:
throw TypeError('Unknown block type: ' + clauseBlock.type);
}
clauseBlock =
clauseBlock.nextConnection && clauseBlock.nextConnection.targetBlock();
}
this.updateShape_();
// Reconnect any child blocks.
this.reconnectChildBlocks_(valueConnections);
},

saveConnections: function (containerBlock) {
let clauseBlock = containerBlock.nextConnection.targetBlock();
let i = 1;
while (clauseBlock) {
switch (clauseBlock.type) {
case 'or_item': {
const inputItem = this.getInput('ORINP' + i);
clauseBlock.valueConnection_ =
inputItem && inputItem.connection.targetConnection;
i++;
break;
}
default:
throw TypeError('Unknown block type: ' + clauseBlock.type);
}
clauseBlock =
clauseBlock.nextConnection && clauseBlock.nextConnection.targetBlock();
}
},

updateShape_: function () {
// Delete everything.
for (let i = 1; this.getInput('ORINP' + i); i++) {
const inpConn = this.getInput('ORINP' + i).connection.targetConnection;
//delete compare Blocks here, when it's a inlineIfCompare with no changes
if (
inpConn &&
inpConn?.sourceBlock_?.type === 'inlineIfCompare' &&
!inpConn?.sourceBlock_.getInput('VAR').connection.targetConnection
) {
inpConn.sourceBlock_.dispose();
}
this.removeInput('OR' + i);
this.removeInput('ORINP' + i);
this.removeInput('END' + i);
}
// Rebuild block.
for (let i = 1; i <= this.itemCount_; i++) {
this.appendEndRowInput('END' + i);
this.appendDummyInput('OR' + i).appendField(new MyCustomLabelField('or'));
this.appendValueInput('ORINP' + i).setAlign(Blockly.inputs.Align.RIGHT);
}
},

reconnectChildBlocks_: function (valueConnections) {
for (let i = 1; i <= this.itemCount_; i++) {
valueConnections[i]?.reconnect(this, 'ORINP' + i);
}
//set compare Blocks here, when the input is empty
//Blockly.Events.setGroup(true);
for (let i = 1; i <= this.itemCount_; i++) {
const inp = this.getInput('ORINP' + i);
if (inp.connection.targetConnection) {
continue;
}
const compBlock = this.workspace.newBlock('inlineIfCompare');
compBlock.outputConnection.connect(inp.connection);
compBlock.initSvg();
compBlock.render();
}
//Blockly.Events.setGroup(false);
},
};

Blockly.Extensions.registerMutator(
'custom_or_mutator',
OR_MUTATOR_MIXIN,
null,
['or_item']
);

And the look of the block is attached to this message, the block marked blue is a 'inlineIfCompare'

Generally it works but it always messes up the undo- und especially the redo-history. Sometimes when an undo tries to insert a block back into the filled inputs it jumps in and out of the input until I spam undo. And the redo chain is able to get cut which results in losing the remaining redos. The inserted compare-blocks also disappear in the inputs while redoing and sometimes they also appear somewhere else on the workspace.

Does someone know how to get the mutator right so that the undo and redo history does work?
I tried some Events.setGroup, Events.disable/enable und recordUndo combinations but with no breakthrough sadly.

I hope someone can help me with this issue.

Best Greetings, Rico
or_block.png

ewpa...@gmail.com

unread,
Jul 15, 2026, 8:38:53 AM (2 days ago) Jul 15
to Blockly
Hello Rico,

We have a similar feature in App Inventor with mutator blocks that create new blocks (we also have a mutatable OR operator, but it doesn't create blocks). However, I noticed in our latest update that it also seems to be broken in a way you mention. The undo stack looks much messier than I would expect, so it may be an interaction of different changes causing your issue. Have you had a look at the state of your workspace's undoStack_ and whether it makes sense to you? In our case, I see a bunch of extra move operations with different groupIds that are splitting things up.

Cheers,
Evan

Zoë Spriggs

unread,
Jul 15, 2026, 1:39:43 PM (2 days ago) Jul 15
to Blockly
Hi Rico and Evan,
Thanks for bringing this up, we’ll look into this!

I tested Rico’s code a bit and I am seeing a lot of move operations as well. However, I would expect to see many moves for the undo/redo operations, since the mutators are destroying and re-creating the blocks, which causes a lot of move events to fire.

Either way, it does seem that there’s a bug, especially if you’re seeing regressions in AppInventor. I just filed an issue to keep track of this. If either of you have the chance to add a comment with your thoughts and exactly what you’re seeing, that would be helpful!

Rico, for your code, I’ll just also note that Blockly’s default way of handling this type of situation is (as you suggested) with shadow blocks. With shadow blocks, users can insert their own blocks into inputs or replace the blocks entirely. But if you want to both be able to replace an input with a block *and* be able to replace the entire block, then you’re right that shadow blocks won’t fully suit your needs. If it’s helpful in your case, we also do have this plugin that converts shadow blocks to real blocks when a shadow block is edited. Unfortunately, connecting real blocks as children of shadow blocks isn’t part of this plugin.

Feel free to update the linked issue or this thread if you notice anything else or want to elaborate on what you’re seeing.

Best,
Zoë
Reply all
Reply to author
Forward
0 new messages