@blockly/block-plus-minus
Version:
A group of blocks that replace the built-in mutator UI with a +/- based UI.
684 lines (624 loc) • 20.7 kB
JavaScript
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Changes the procedure blocks to use a +/- mutator UI.
*/
import * as Blockly from 'blockly/core';
import {createMinusField} from './field_minus';
import {createPlusField} from './field_plus';
Blockly.Msg['PROCEDURE_VARIABLE'] = 'variable:';
// Delete original blocks because there's no way to unregister them:
// https://github.com/RaspberryPiFoundation/blockly-samples/issues/768#issuecomment-885663394
delete Blockly.Blocks['procedures_defnoreturn'];
delete Blockly.Blocks['procedures_defreturn'];
Blockly.defineBlocksWithJsonArray([
{
type: 'procedures_defnoreturn',
message0: '%{BKY_PROCEDURES_DEFNORETURN_TITLE} %1 %2',
message1: '%{BKY_PROCEDURES_DEFNORETURN_DO} %1',
args0: [
{
type: 'field_input',
name: 'NAME',
text: '',
},
{
type: 'input_dummy',
name: 'TOP',
},
],
args1: [
{
type: 'input_statement',
name: 'STACK',
},
],
style: 'procedure_blocks',
helpUrl: '%{BKY_PROCEDURES_DEFNORETURN_HELPURL}',
tooltip: '%{BKY_PROCEDURES_DEFNORETURN_TOOLTIP}',
extensions: [
'get_procedure_def_no_return',
'procedure_context_menu',
'procedure_rename',
'procedure_vars',
],
mutator: 'procedure_def_mutator',
},
{
type: 'procedures_defreturn',
message0: '%{BKY_PROCEDURES_DEFRETURN_TITLE} %1 %2',
message1: '%{BKY_PROCEDURES_DEFRETURN_DO} %1',
message2: '%{BKY_PROCEDURES_DEFRETURN_RETURN} %1',
args0: [
{
type: 'field_input',
name: 'NAME',
text: '',
},
{
type: 'input_dummy',
name: 'TOP',
},
],
args1: [
{
type: 'input_statement',
name: 'STACK',
},
],
args2: [
{
type: 'input_value',
align: 'right',
name: 'RETURN',
},
],
style: 'procedure_blocks',
helpUrl: '%{BKY_PROCEDURES_DEFRETURN_HELPURL}',
tooltip: '%{BKY_PROCEDURES_DEFRETURN_TOOLTIP}',
extensions: [
'get_procedure_def_return',
'procedure_context_menu',
'procedure_rename',
'procedure_vars',
],
mutator: 'procedure_def_mutator',
},
]);
/**
* Defines the what are essentially info-getters for the procedures_defnoreturn
* block.
* @type {{callType_: string, getProcedureDef: (function(): Array)}}
*/
const getDefNoReturn = {
/**
* Returns info about this block to be used by the Blockly.Procedures.
* @returns {Array} An array of info.
* @this {Blockly.Block}
*/
getProcedureDef: function () {
const argNames = this.argData_.map((elem) => elem.model.name);
return [this.getFieldValue('NAME'), argNames, false];
},
/**
* Used by the context menu to create a caller block.
* @type {string}
*/
callType_: 'procedures_callnoreturn',
};
Blockly.Extensions.registerMixin('get_procedure_def_no_return', getDefNoReturn);
/**
* Defines what are essentially info-getters for the procedures_def_return
* block.
* @type {{callType_: string, getProcedureDef: (function(): Array)}}
*/
const getDefReturn = {
/**
* Returns info about this block to be used by the Blockly.Procedures.
* @returns {Array} An array of info.
* @this {Blockly.Block}
*/
getProcedureDef: function () {
const argNames = this.argData_.map((elem) => elem.model.name);
return [this.getFieldValue('NAME'), argNames, true];
},
/**
* Used by the context menu to create a caller block.
* @type {string}
*/
callType_: 'procedures_callreturn',
};
Blockly.Extensions.registerMixin('get_procedure_def_return', getDefReturn);
const procedureContextMenu = {
/**
* Adds an option to create a caller block.
* Adds an option to create a variable getter for each variable included in
* the procedure definition.
* @this {Blockly.Block}
* @param {!Array} options The current options for the context menu.
*/
customContextMenu: function (options) {
if (this.isInFlyout) {
return;
}
// Add option to create caller.
const name = this.getFieldValue('NAME');
const text = Blockly.Msg['PROCEDURES_CREATE_DO'].replace('%1', name);
const xml = Blockly.utils.xml.createElement('block');
xml.setAttribute('type', this.callType_);
xml.appendChild(this.mutationToDom(true));
const callback = Blockly.ContextMenu.callbackFactory(this, xml);
options.push({
enabled: true,
text: text,
callback: callback,
});
if (this.isCollapsed()) {
return;
}
// Add options to create getters for each parameter.
const varModels = this.getVarModels();
for (const model of varModels) {
const text = Blockly.Msg['VARIABLES_SET_CREATE_GET'].replace(
'%1',
model.name,
);
const xml = Blockly.utils.xml.createElement('block');
xml.setAttribute('type', 'variables_get');
xml.appendChild(Blockly.Variables.generateVariableFieldDom(model));
const callback = Blockly.ContextMenu.callbackFactory(this, xml);
options.push({
enabled: true,
text: text,
callback: callback,
});
}
},
};
Blockly.Extensions.registerMixin(
'procedure_context_menu',
procedureContextMenu,
);
const procedureDefMutator = {
/**
* Create XML to represent the argument inputs.
* @param {boolean=} isForCaller If true include the procedure name and
* argument IDs. Used by Blockly.Procedures.mutateCallers for
* reconnection.
* @returns {!Element} XML storage element.
* @this {Blockly.Block}
*/
mutationToDom: function (isForCaller = false) {
const container = Blockly.utils.xml.createElement('mutation');
if (isForCaller) {
container.setAttribute('name', this.getFieldValue('NAME'));
}
this.argData_.forEach((element) => {
const argument = Blockly.utils.xml.createElement('arg');
const argModel = element.model;
argument.setAttribute('name', argModel.name);
argument.setAttribute('varid', argModel.getId());
argument.setAttribute('argid', element.argId);
if (isForCaller) {
argument.setAttribute('paramid', element.argId);
}
container.appendChild(argument);
});
// Not used by this block, but necessary if switching back and forth
// between this mutator UI and the default UI.
if (!this.hasStatements_) {
container.setAttribute('statements', 'false');
}
return container;
},
/**
* Parse XML to restore the argument inputs.
* @param {!Element} xmlElement XML storage element.
* @this {Blockly.Block}
*/
domToMutation: function (xmlElement) {
// We have to handle this so that the user doesn't add blocks to the stack,
// in which case it would be impossible to return to the old mutators.
this.hasStatements_ = xmlElement.getAttribute('statements') !== 'false';
if (!this.hasStatements_) {
this.removeInput('STACK');
}
const names = [];
const varIds = [];
const argIds = [];
for (const childNode of xmlElement.childNodes) {
if (childNode.nodeName.toLowerCase() == 'arg') {
names.push(childNode.getAttribute('name'));
varIds.push(
childNode.getAttribute('varid') || childNode.getAttribute('varId'),
);
argIds.push(childNode.getAttribute('argid'));
}
}
this.updateShape_(names, varIds, argIds);
},
/**
* Returns the state of this block as a JSON serializable object.
* @returns {{params: (!Array<{name: string, id: string}>|undefined),
* hasStatements: (boolean|undefined)}} The state of this block, eg the
* parameters and statements.
*/
saveExtraState: function () {
if (!this.argData_.length && this.hasStatements_) {
return null;
}
const state = Object.create(null);
if (this.argData_.length) {
state['params'] = [];
this.argData_.forEach((arg) => {
const model = arg.model;
state['params'].push({
name: model.name,
id: model.getId(),
argId: arg.argId,
});
});
}
if (!this.hasStatements_) {
state['hasStatements'] = false;
}
return state;
},
/**
* Applies the given state to this block.
* @param {*} state The state to apply to this block, eg the parameters and
* statements.
*/
loadExtraState: function (state) {
// We have to handle this so that the user doesn't add blocks to the stack,
// in which case it would be impossible to return to the old mutators.
this.hasStatements_ = state['hasStatements'] !== false;
if (!this.hasStatements_) {
this.removeInput('STACK');
}
const names = [];
const varIds = [];
const argIds = [];
if (state['params']) {
for (let i = 0; i < state['params'].length; i++) {
const param = state['params'][i];
names.push(param['name']);
varIds.push(param['id']);
argIds.push(param['argId']);
}
}
this.updateShape_(names, varIds, argIds);
},
/**
* Adds arguments to the block until it matches the targets.
* @param {!Array<string>} names An array of argument names to display.
* @param {!Array<string>} varIds An array of variable IDs associated with
* those names.
* @param {!Array<?string>} argIds An array of argument IDs associated with
* those names.
* @this {Blockly.Block}
* @private
*/
updateShape_: function (names, varIds, argIds) {
if (names.length != varIds.length) {
throw Error('names and varIds must have the same length.');
}
// Usually it's more efficient to modify the block, rather than tearing it
// down and rebuilding (less render calls), but in this case it's easier
// to just work from scratch.
// We need to remove args in reverse order so that it doesn't mess up
// as removeArg_ modifies our array.
for (let i = this.argData_.length - 1; i >= 0; i--) {
this.removeArg_(this.argData_[i].argId);
}
this.argData_ = [];
const length = varIds.length;
for (let i = 0; i < length; i++) {
this.addArg_(names[i], varIds[i], argIds[i]);
}
Blockly.Procedures.mutateCallers(this);
},
/**
* Callback for the plus image. Adds an argument to the block and mutates
* callers to match.
*/
plus: function () {
this.addArg_();
Blockly.Procedures.mutateCallers(this);
},
/**
* Callback for the minus image. Removes the argument associated with the
* given argument ID and mutates the callers to match.
* @param {string} argId The argId of the argument to remove.
* @this {Blockly.Block}
*/
minus: function (argId) {
if (!this.argData_.length) {
return;
}
this.removeArg_(argId);
Blockly.Procedures.mutateCallers(this);
},
/**
* Adds an argument to the block and updates the block's parallel tracking
* arrays as appropriate.
* @param {?string=} name An optional name for the argument.
* @param {?string=} varId An optional variable ID for the argument.
* @param {?string=} argId An optional argument ID for the argument
* (used to identify the argument across variable merges).
* @this {Blockly.Block}
* @private
*/
addArg_: function (name = null, varId = null, argId = null) {
if (!this.argData_.length) {
const withField = new Blockly.FieldLabel(
Blockly.Msg['PROCEDURES_BEFORE_PARAMS'],
);
this.getInput('TOP').appendField(withField, 'WITH');
}
const argNames = this.argData_.map((elem) => elem.model.name);
name =
name ||
Blockly.Variables.generateUniqueNameFromOptions(
Blockly.Procedures.DEFAULT_ARG,
argNames,
);
const variable = Blockly.Variables.getOrCreateVariablePackage(
this.workspace,
varId,
name,
'',
);
argId = argId || Blockly.utils.idGenerator.genUid();
this.addVarInput_(name, argId);
if (this.getInput('STACK')) {
this.moveInputBefore(argId, 'STACK');
} else {
this.moveInputBefore(argId, 'RETURN');
}
this.argData_.push({
model: variable,
argId: argId,
});
},
/**
* Removes the argument associated with the given argument ID from the block.
* @param {string} argId An ID used to track arguments on the block.
* @private
*/
removeArg_: function (argId) {
if (this.removeInput(argId, true)) {
if (this.argData_.length == 1) {
// Becoming argumentless.
this.getInput('TOP').removeField('WITH');
}
this.argData_ = this.argData_.filter((element) => element.argId != argId);
}
},
/**
* Appends the actual inputs and fields associated with an argument to the
* block.
* @param {string} name The name of the argument.
* @param {string} argId The UUID of the argument (different from var ID).
* @this {Blockly.Block}
* @private
*/
addVarInput_: function (name, argId) {
const nameField = new Blockly.FieldTextInput(name, this.validator_);
nameField.onFinishEditing_ = this.finishEditing_.bind(nameField);
nameField.varIdsToDelete_ = [];
nameField.preEditVarModel_ = null;
this.appendDummyInput(argId)
.setAlign(Blockly.inputs.Align.RIGHT)
.appendField(createMinusField(argId))
.appendField(Blockly.Msg['PROCEDURE_VARIABLE']) // Untranslated!
.appendField(nameField, argId); // The name of the field is the arg id.
},
/**
* Validates text entered into the argument name field.
* @param {string} newName The new text entered into the field.
* @returns {?string} The field's new value.
* @this {Blockly.FieldTextInput}
*/
validator_: function (newName) {
const sourceBlock = this.getSourceBlock();
const workspace = sourceBlock.workspace;
const argData = sourceBlock.argData_;
const argDatum = sourceBlock.argData_.find(
(element) => element.argId == this.name,
);
const currId = argDatum.model.getId();
// Replace all whitespace with normal spaces, then trim.
newName = newName.replace(/[\s\xa0]+/g, ' ').trim();
const caselessName = newName.toLowerCase();
/**
* Returns true if the given argDatum is associated with this field, or has
* a different caseless name than the argDatum associated with this field.
* @param {{model: Blockly.IVariableModel, argId:string}} argDatum The
* argDatum we want to make sure does not conflict with the argDatum
* associated with this field.
* @returns {boolean} True if the given datum does not conflict with the
* datum associated with this field.
* @this {Blockly.FieldTextInput}
*/
const hasDifName = (argDatum) => {
// The field name (aka id) is always equal to the arg id.
return (
argDatum.argId == this.name ||
caselessName != argDatum.model.name.toLowerCase()
);
};
/**
* Returns true if the variable associated with this field is only used
* by this block, or callers of this procedure.
* @returns {boolean} True if the variable associated with this field is
* only used by this block, or callers of this procedure.
*/
const varOnlyUsedHere = () => {
return workspace.getVariableUsesById(currId).every((block) => {
return (
block.id == sourceBlock.id ||
(block.getProcedureCall &&
block.getProcedureCall() == sourceBlock.getProcedureDef()[0])
);
});
};
if (!newName || !argData.every(hasDifName)) {
if (this.preEditVarModel_) {
argDatum.model = this.preEditVarModel_;
this.preEditVarModel_ = null;
}
Blockly.Procedures.mutateCallers(sourceBlock);
return null;
}
if (!this.varIdsToDelete_.length) {
this.preEditVarModel_ = argDatum.model;
if (varOnlyUsedHere()) {
this.varIdsToDelete_.push(currId);
}
}
// Create new vars instead of renaming the old ones, so users can't
// accidentally rename/coalesce vars.
let model = workspace.getVariable(newName, '');
if (!model) {
model = workspace.createVariable(newName, '');
this.varIdsToDelete_.push(model.getId());
} else if (model.name != newName) {
// Blockly is case-insensitive so we have to update the var instead of
// creating a new one.
workspace.renameVariableById(model.getId(), newName);
}
if (model.getId() != currId) {
argDatum.model = model;
}
Blockly.Procedures.mutateCallers(sourceBlock);
return newName;
},
/**
* Removes any unused vars that were created as a result of editing.
* @param {string} _finalName The final value of the field.
* @this {Blockly.FieldTextInput}
*/
finishEditing_: function (_finalName) {
const source = this.getSourceBlock();
const argDatum = source.argData_.find(
(element) => element.argId == this.name,
);
const currentVarId = argDatum.model.getId();
this.varIdsToDelete_.forEach((id) => {
if (id != currentVarId) {
source.workspace.deleteVariableById(id);
}
});
this.varIdsToDelete_.length = 0;
this.preEditVarModel_ = null;
},
};
/**
* Initializes some private variables for procedure blocks.
* @this {Blockly.Block}
*/
const procedureDefHelper = function () {
/**
* An array of objects containing data about the args belonging to the
* procedure definition.
* @type {!Array<{
* model:Blockly.IVariableModel,
* argId: string
* }>}
* @private
*/
this.argData_ = [];
/**
* Does this block have a 'STACK' input for statements?
* @type {boolean}
* @private
*/
this.hasStatements_ = true;
this.getInput('TOP').insertFieldAt(0, createPlusField(), 'PLUS');
};
Blockly.Extensions.registerMutator(
'procedure_def_mutator',
procedureDefMutator,
procedureDefHelper,
);
/**
* Sets the validator for the procedure's name field.
* @this {Blockly.Block}
*/
const procedureRename = function () {
this.getField('NAME').setValidator(Blockly.Procedures.rename);
};
Blockly.Extensions.register('procedure_rename', procedureRename);
/**
* Defines functions for dealing with variables and renaming variables.
* @this {Blockly.Block}
*/
const procedureVars = function () {
// This is a hack to get around the don't-override-builtins check.
const mixin = {
/**
* Return all variables referenced by this block.
* @returns {!Array.<string>} List of variable names.
* @this {Blockly.Block}
*/
getVars: function () {
return this.argData_.map((elem) => elem.model.name);
},
/**
* Return all variables referenced by this block.
* @returns {!Array.<!Blockly.IVariableModel>} List of variable models.
* @this {Blockly.Block}
*/
getVarModels: function () {
return this.argData_.map((elem) => elem.model);
},
/**
* Notification that a variable was renamed to the same name as an existing
* variable. These variables are coalescing into a single variable with the
* ID of the variable that was already using the name.
* @param {string} oldId The ID of the variable that was renamed.
* @param {string} newId The ID of the variable that was already using
* the name.
*/
renameVarById: function (oldId, newId) {
const argData = this.argData_.find(
(element) => element.model.getId() == oldId,
);
if (!argData) {
return; // Not on this block.
}
const newVar = this.workspace.getVariableById(newId);
const newName = newVar.name;
this.addVarInput_(newName, newId);
this.moveInputBefore(newId, oldId);
this.removeInput(oldId);
argData.model = newVar;
Blockly.Procedures.mutateCallers(this);
},
/**
* Notification that a variable is renaming but keeping the same ID. If the
* variable is in use on this block, rerender to show the new name.
* @param {!Blockly.IVariableModel} variable The variable being renamed.
* @package
* @override
* @this {Blockly.Block}
*/
updateVarName: function (variable) {
const id = variable.getId();
const argData = this.argData_.find(
(element) => element.model.getId() == id,
);
if (!argData) {
return; // Not on this block.
}
this.setFieldValue(variable.name, argData.argId);
argData.model = variable;
},
};
this.mixin(mixin, true);
};
Blockly.Extensions.register('procedure_vars', procedureVars);