mit-cml/blockly-plugins

What is the minimum requirement to make a block lexical-variable compatible?

ysfchn opened this issue ยท 8 comments

My blocks are auto-generated in backend (Python web-server, also I don't use Blockly's own built-in blocks), and I pass the generated block definitions as JSON to defineBlocksWithJsonArray method in web page, so I only prefer defining blocks in JavaScript when it is the only option (like mutator blocks etc.).

Now I want to use this plugin for my event listener blocks, similar to App Inventor's events. My current goal is to include a local parameter, it doesn't need to be renamed nor edited.

However, looks like blocks with lexical variable fields must be defined from JavaScript (and it relies on other Block properties/methods, as far I see). I don't want to build a JavaScript definition for each block (because it is not-portable like JSON definitions, and requires a lot of line of code).

So I wanted to define a new extension Blockly.Extensions.register so I thought I can patch the block by pulling its statement inputs and variable inputs without needing to hard-coding the field names, it doesn't repeat the code and I can simply use it in multiple blocks.

My extension:

Blockly.Extensions.register(
    'lexical_setup', 
    function() {
        var block = this;
        // I can't directly pull the class from module, so I'm getting the class from registry.
        var parameter_flydown = Blockly.registry.getClass("FIELD", "field_parameter_flydown");
        var var_names = [];
        var statement_names = [];
        // Loop through all block fields and get a list of lexical variables.
        for (const input of block.inputList) {
            for (const field of input.fieldRow) {
                if (field instanceof parameter_flydown) {
                    var_names.push(field.name);
                }
            }
            if (input.type == 3) {
                statement_names.push(input.name);
            }
        }
        block.cp_LexicalVarNames = var_names;
        block.cp_StatementNames = statement_names;
        // Patch the block to support lexical variables,
        // without defining different functions for each block.
        block.getVars = function() {
            var x = [];
            for (const v of this.cp_LexicalVarNames) {
                x.push(this.getFieldValue(v));
            }
            return x;
        }
        block.declaredNames = block.getVars;
        block.blocksInScope = function() {
            var x = [];
            for (const v of this.cp_StatementNames) {
                var z = this.getInputTargetBlock(v);
                if (z)
                    x.push(z);
            }
            return x;
        }
    }
);

And the block definition:

{
    "type": "MY_EVENT",
    "message0": "when %1 happens %2 %3",
    "args0": [
      { "type": "field_parameter_flydown", "name": "thing", "is_editable": false },
      { "type": "input_dummy" },
      { "type": "input_statement", "name": "value", "align": "RIGHT" }
   ]
    "extensions": ["lexical_setup"]
}

However, looks like my "patching the block" approach is not working because when I add a getter/setter block to workspace, I get this even if it is added under a block:


And since there is no documentation, I don't know what the minimum methods/properties required for this to work properly. I looked example blocks in source code and as far you can see above, I added blocksInScope and getVars functions, but I don't know which ones are required.

Additional information:

  • Blockly version: 7.20211209.1
  • Plugin version: v0.0.10
    • I'm using a skypack.dev module (and it is the latest version on skypack), because my Blockly is loaded from Unpkg, and to prevent Skypack to override my imported Blockly with its own bundled one, I downloaded the JS file and replaced the first line with: const __commonjs_module0 = Blockly;

@ysfchn You are on the right path. The problem you are having has to do with some stuff that is currently baked into the FieldParameterFlydown. Luckily, I have been needing to do something very similar to what you want to do and have a good idea for what needs to be done to the package to make that reasonably easy. That will take a little time, but I also have a workaround that you can use in the meantime if you can't wait. I need to clean it up a bit, but I should be able to post it in a day or two.

Glad to hear! I'm not currently in a hurry, so I can probably wait (and I can experiment more with Blockly in the meanwhile). But it can be helpful for me if there is a current workaround for that. ๐Ÿ˜„

Glad to hear! I'm not currently in a hurry, so I can probably wait (and I can experiment more with Blockly in the meanwhile). But it can be helpful for me if there is a current workaround for that. ๐Ÿ˜„

Good to know!

@ysfchn
Something like the following should work as a workaround. Then you ought to be able to use field_event_parameter_flydown where you are using field_parameter_flydown above:

const FieldParameterFlydown = Blockly.registry.getClass("FIELD", "field_parameter_flydown");

const makeEventParameterGetter = (paramName, opt_helpUrl) => {
  const blockType = paramName + '_event_param_get';
  Blockly.Blocks[blockType] = {
    init: function() {
      this.appendDummyInput()
          .appendField('get')
          .appendField(paramName);
      this.setOutput(true);
      this.setTooltip(`Get parameter: ${paramName}.`);
      opt_helpUrl && this.setHelpUrl(opt_helpUrl);
      this.setStyle('variable_blocks');
    },
  }
  return blockType;
}

const makeEventParameterSetter = (paramName, opt_helpUrl) => {
  const blockType = paramName + '_event_param_set';
  Blockly.Blocks[blockType] = {
    init: function() {
      this.appendValueInput('PARAM')
          .appendField('set')
          .appendField(paramName)
          .appendField('to');
      this.setNextStatement(true);
      this.setPreviousStatement(true);
      this.setTooltip(`Set param: ${paramName}`);
      opt_helpUrl && this.setHelpUrl(opt_helpUrl);
      this.setStyle('variable_blocks');
    },
  }
  return blockType;
}

class FieldEventParameterFlydown extends FieldParameterFlydown {
  constructor(paramName) {
    super(paramName, false);
    this.getterBlockName = makeEventParameterGetter(procedureName);
    this.setterBlockName = makeEventParameterSetter(procedureName);
  }

  flydownBlocksXML_() {
    return `
      <xml>
        <block type="${this.getterBlockName}" />
        <block type="${this.setterBlockName}" />
      </xml>`;
  }
}

FieldEventParameterFlydown.fromJson = function(options) {
  const name = Blockly.utils.replaceMessageReferences(options['name']);
  return new FieldEventParameterFlydown(name, options['is_editable']);
};

Blockly.fieldRegistry.register('field_event_parameter_flydown',
    FieldEventParameterFlydown);

Note that it's possible that I made some errors in modifying my workaround to fit your case, but hopefully there is enough there for you to figure out what to do. Feel free to ask for help, though, if you need it.

@ysfchn, Note that after posting the comment above I edited it to add the following:
const FieldParameterFlydown = Blockly.registry.getClass("FIELD", "field_parameter_flydown");

Thank you so much! I edited the code a bit to support parameter switching and add an option to disable setters/getters.

And here is the current code:

const getOtherParameters = (block, fieldName) => {
    var parameter_flydown = Blockly.registry.getClass("FIELD", "field_event_parameter_flydown");
    var var_names = [[fieldName, fieldName]];
    for (const input of block.inputList) {
        for (const field of input.fieldRow) {
            if (field instanceof parameter_flydown) {
                if (fieldName != field.name)
                    var_names.push([field.name, field.name]);
            }
        }
    }
    return var_names;
}

const makeEventParameterGetter = (paramName, opt_helpUrl, sourceBlock) => {
    const blockType = paramName + '_event_param_get';
    Blockly.Blocks[blockType] = {
        init: function() {
            this.appendDummyInput()
                .appendField('get')
                .appendField(new Blockly.FieldDropdown(() => getOtherParameters(sourceBlock, paramName)), 'parameter')
            this.setOutput(true);
            this.setTooltip(`Get parameter: ${paramName}.`);
            opt_helpUrl && this.setHelpUrl(opt_helpUrl);
            this.setStyle('variable_blocks');
        }
    }
    return blockType;
}

const makeEventParameterSetter = (paramName, opt_helpUrl, sourceBlock) => {
    const blockType = paramName + '_event_param_set';
    Blockly.Blocks[blockType] = {
        init: function() {
            this.appendValueInput('value')
                .appendField('set')
                .appendField(new Blockly.FieldDropdown(() => getOtherParameters(sourceBlock, paramName)), 'parameter')
                .appendField('to');
            this.setNextStatement(true);
            this.setPreviousStatement(true);
            this.setTooltip(`Set param: ${paramName}`);
            opt_helpUrl && this.setHelpUrl(opt_helpUrl);
            this.setStyle('variable_blocks');
        }
    }
    return blockType;
}

const FieldParameterFlydown = Blockly.registry.getObject("FIELD", "field_parameter_flydown");
class FieldEventParameterFlydown extends FieldParameterFlydown {
    constructor(paramName, isEditable, disableSetter, disableGetter) {
        super(paramName, isEditable);
        this.paramName = paramName;
        this.disableGetter = !!disableGetter;
        this.disableSetter = !!disableSetter;
    }

    flydownBlocksXML_() {
        this.getterBlockName = makeEventParameterGetter(this.paramName, null, this.sourceBlock_);
        this.setterBlockName = makeEventParameterSetter(this.paramName, null, this.sourceBlock_);
        var xml = "";
        if (!this.disableGetter)
            xml += `<block type="${this.getterBlockName}" />`
        if (!this.disableSetter)
            xml += `<block type="${this.setterBlockName}" />`
        return "<xml>" + xml + "</xml>";
    }
}
FieldEventParameterFlydown.fromJson = function(options) {
    const name = Blockly.utils.replaceMessageReferences(options['name']);
    return new FieldEventParameterFlydown(
        name, options['is_editable'], options['disable_setter'], options['disable_getter']
    );
};
Blockly.fieldRegistry.register('field_event_parameter_flydown', FieldEventParameterFlydown);

@ysfchn When I woke up this morning I realized that my workaround assumed that you didn't have multiple parameters, so I'm glad that you were able to adapt it to handle that!

BTW, my code also doesn't handle adding global variables to the getter and setter dropdowns. If you want/need that, let me know and I can probably give you the code to handle that.

My initial purpose was just adding the event parameters, but adding global variables to dropdowns would be great for later. However, I'll try to do it myself first, then get back to you if I need help. ๐Ÿ˜… Thanks for your help again!