@blockly/block-shareable-procedures
Version:
A plugin that adds procedure blocks which are backed by explicit data models.
1,396 lines (1,293 loc) • 41.2 kB
text/typescript
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
'use strict';
import * as Blockly from 'blockly/core';
import {ObservableProcedureModel} from './observable_procedure_model';
import {ObservableParameterModel} from './observable_parameter_model';
import {IProcedureBlock} from './i_procedure_block';
import {ProcedureCreate} from './events_procedure_create';
/* eslint-disable @typescript-eslint/naming-convention */
/**
* A dictionary of the block definitions provided by this module.
*
* @type {!Object<string, Object>}
*/
export const blocks = Blockly.common.createBlockDefinitionsFromJsonArray([
{
type: 'procedures_defnoreturn',
message0: '%{BKY_PROCEDURES_DEFNORETURN_TITLE} %1 %2 %3',
message1: '%{BKY_PROCEDURES_DEFNORETURN_DO} %1',
args0: [
{
type: 'field_input',
name: 'NAME',
text: '',
spellcheck: false,
},
{
type: 'field_label',
name: 'PARAMS',
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: [
'procedure_def_get_def_mixin',
'procedure_def_var_mixin',
'procedure_def_update_shape_mixin',
'procedure_def_context_menu_mixin',
'procedure_def_onchange_mixin',
'procedure_def_validator_helper',
'procedure_defnoreturn_get_caller_block_mixin',
'procedure_defnoreturn_set_comment_helper',
'procedure_def_set_no_return_helper',
],
mutator: 'procedure_def_mutator',
},
{
type: 'procedures_callnoreturn',
message0: '%1 %2',
args0: [
{type: 'field_label', name: 'NAME', text: '%{BKY_UNNAMED_KEY}'},
{
type: 'input_dummy',
name: 'TOPROW',
},
],
nextStatement: null,
previousStatement: null,
style: 'procedure_blocks',
helpUrl: '%{BKY_PROCEDURES_CALLNORETURN_HELPURL}',
extensions: [
'procedure_caller_get_def_mixin',
'procedure_caller_var_mixin',
'procedure_caller_update_shape_mixin',
'procedure_caller_context_menu_mixin',
'procedure_caller_onchange_mixin',
'procedure_callernoreturn_get_def_block_mixin',
],
mutator: 'procedure_caller_mutator',
},
{
type: 'procedures_defreturn',
message0: '%{BKY_PROCEDURES_DEFRETURN_TITLE} %1 %2 %3',
message1: '%{BKY_PROCEDURES_DEFRETURN_DO} %1',
message2: '%{BKY_PROCEDURES_DEFRETURN_RETURN} %1',
args0: [
{
type: 'field_input',
name: 'NAME',
text: '',
spellcheck: false,
},
{
type: 'field_label',
name: 'PARAMS',
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: [
'procedure_def_get_def_mixin',
'procedure_def_var_mixin',
'procedure_def_update_shape_mixin',
'procedure_def_context_menu_mixin',
'procedure_def_onchange_mixin',
'procedure_def_validator_helper',
'procedure_defreturn_get_caller_block_mixin',
'procedure_defreturn_set_comment_helper',
'procedure_def_set_return_helper',
],
mutator: 'procedure_def_mutator',
},
{
type: 'procedures_callreturn',
message0: '%1 %2',
args0: [
{type: 'field_label', name: 'NAME', text: '%{BKY_UNNAMED_KEY}'},
{
type: 'input_dummy',
name: 'TOPROW',
},
],
output: null,
style: 'procedure_blocks',
helpUrl: '%{BKY_PROCEDURES_CALLRETURN_HELPURL}',
extensions: [
'procedure_caller_get_def_mixin',
'procedure_caller_var_mixin',
'procedure_caller_update_shape_mixin',
'procedure_caller_context_menu_mixin',
'procedure_caller_onchange_mixin',
'procedure_callerreturn_get_def_block_mixin',
],
mutator: 'procedure_caller_mutator',
},
]);
const procedureDefGetDefMixin = function () {
const mixin = {
model_: null,
/**
* Returns the data model for this procedure block.
*
* @returns The data model for this procedure
* block.
*/
getProcedureModel() {
return this.model_;
},
/**
* True if this is a procedure definition block, false otherwise (i.e.
* it is a caller).
*
* @returns True because this is a procedure definition block.
*/
isProcedureDef() {
return true;
},
/**
* Return all variables referenced by this block.
*
* @returns List of variable names.
* @this {Blockly.Block}
*/
getVars: function () {
return this.getProcedureModel()
.getParameters()
.map((p) => p.getVariableModel().name);
},
/**
* Return all variables referenced by this block.
*
* @returns List of variable models.
* @this {Blockly.Block}
*/
getVarModels: function () {
return this.getProcedureModel()
.getParameters()
.map((p) => p.getVariableModel());
},
/**
* Disposes of the data model for this procedure block when the block is
* disposed.
*/
destroy: function () {
if (!this.isInsertionMarker()) {
this.workspace
.getProcedureMap()
.delete(this.getProcedureModel().getId());
}
},
};
mixin.model_ = new ObservableProcedureModel(
this.workspace,
Blockly.Procedures.findLegalName(this.getFieldValue('NAME'), this),
);
// Events cannot be fired from instantiation when deserializing or dragging
// from the flyout. So make this consistent and never fire from instantiation.
Blockly.Events.disable();
this.workspace.getProcedureMap().add(mixin.getProcedureModel());
Blockly.Events.enable();
this.mixin(mixin, true);
};
// Using register instead of registerMixin to avoid triggering warnings about
// overriding built-ins.
Blockly.Extensions.register(
'procedure_def_get_def_mixin',
procedureDefGetDefMixin,
);
const procedureDefVarMixin = function () {
const mixin = {
/**
* Notification that a variable is renaming.
* If the ID matches one of this block's variables, rename it.
*
* @param oldId ID of variable to rename.
* @param newId ID of new variable. May be the same as oldId, but
* with an updated name. Guaranteed to be the same type as the old
* variable.
* @override
* @this {Blockly.Block}
*/
renameVarById: function (oldId, newId) {
const oldVar = this.workspace.getVariableById(oldId);
const model = this.getProcedureModel();
const index = model
.getParameters()
.findIndex((p) => p.getVariableModel() === oldVar);
if (index === -1) return; // Not found.
const newVar = this.workspace.getVariableById(newId);
const oldParam = model.getParameter(index);
oldParam.setName(newVar.name);
},
/**
* 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 variable The variable being renamed.
* @package
* @override
* @this {Blockly.Block}
*/
updateVarName: function (variable) {
const containsVar = this.getProcedureModel()
.getParameters()
.some((p) => p.getVariableModel() === variable);
if (containsVar) {
this.doProcedureUpdate(); // Rerender.
}
},
};
this.mixin(mixin, true);
};
// Using register instead of registerMixin to avoid triggering warnings about
// overriding built-ins.
Blockly.Extensions.register('procedure_def_var_mixin', procedureDefVarMixin);
const procedureDefUpdateShapeMixin = {
/**
* Updates the block to reflect the state of the procedure model.
*/
doProcedureUpdate: function () {
this.setFieldValue(this.getProcedureModel().getName(), 'NAME');
this.setDisabledReason(!this.getProcedureModel().getEnabled());
this.updateParameters_();
this.updateMutator_();
},
/**
* Updates the parameters field to reflect the parameters in the procedure
* model.
*/
updateParameters_: function () {
const params = this.getProcedureModel()
.getParameters()
.map((p) => p.getName());
const paramString = params.length
? `${Blockly.Msg['PROCEDURES_BEFORE_PARAMS']} ${params.join(', ')}`
: '';
// The field is deterministic based on other events, no need to fire.
Blockly.Events.disable();
try {
this.setFieldValue(paramString, 'PARAMS');
} finally {
Blockly.Events.enable();
}
},
/**
* Updates the parameter blocks in the mutator (if it is open) to reflect
* the state of the procedure model.
*/
updateMutator_: function () {
const mutator = this.getIcon(Blockly.icons.MutatorIcon.TYPE);
if (!mutator?.bubbleIsVisible()) return;
const mutatorWorkspace = this.mutator.getWorkspace();
for (const p of this.getProcedureModel().getParameters()) {
const block = mutatorWorkspace.getBlockById(p.getId());
if (!block) continue; // Should not happen.
if (block.getFieldValue('NAME') !== p.getName()) {
block.setFieldValue(p.getName(), 'NAME');
}
}
},
/**
* Add or remove the statement block from this function definition.
*
* @param hasStatements True if a statement block is needed.
* @this {Blockly.Block}
*/
setStatements_: function (hasStatements) {
if (this.hasStatements_ === hasStatements) {
return;
}
if (hasStatements) {
this.appendStatementInput('STACK').appendField(
Blockly.Msg['PROCEDURES_DEFNORETURN_DO'],
);
if (this.getInput('RETURN')) {
this.moveInputBefore('STACK', 'RETURN');
}
// Restore the stack, if one was saved.
this.statementConnection_?.reconnect(this, 'STACK');
this.statementConnection_ = null;
} else {
// Save the stack, then disconnect it.
const stackConnection = this.getInput('STACK').connection;
this.statementConnection_ = stackConnection.targetConnection;
if (this.statementConnection_) {
const stackBlock = stackConnection.targetBlock();
stackBlock.unplug();
stackBlock.bumpNeighbours();
}
this.removeInput('STACK', true);
}
this.hasStatements_ = hasStatements;
},
};
Blockly.Extensions.registerMixin(
'procedure_def_update_shape_mixin',
procedureDefUpdateShapeMixin,
);
const procedureDefValidatorHelper = function () {
const nameField = this.getField('NAME');
nameField.setValue(Blockly.Procedures.findLegalName('', this));
nameField.setValidator(Blockly.Procedures.rename);
};
Blockly.Extensions.register(
'procedure_def_validator_helper',
procedureDefValidatorHelper,
);
const procedureDefMutator = {
hasStatements_: true,
/**
* Create XML to represent the argument inputs.
* Backwards compatible serialization implementation.
*
* @returns XML storage element.
* @this {Blockly.Block}
*/
mutationToDom: function () {
const container = Blockly.utils.xml.createElement('mutation');
const params = this.getProcedureModel().getParameters();
for (let i = 0; i < params.length; i++) {
const parameter = Blockly.utils.xml.createElement('arg');
const varModel = params[i].getVariableModel();
parameter.setAttribute('name', varModel.name);
parameter.setAttribute('varid', varModel.getId());
container.appendChild(parameter);
}
// Save whether the statement input is visible.
if (!this.hasStatements_) {
container.setAttribute('statements', 'false');
}
return container;
},
/**
* Parse XML to restore the argument inputs.
* Backwards compatible serialization implementation.
*
* @param xmlElement XML storage element.
* @this {Blockly.Block}
*/
domToMutation: function (xmlElement) {
for (let i = 0; i < xmlElement.childNodes.length; i++) {
const node = xmlElement.childNodes[i];
if (node.nodeName.toLowerCase() !== 'arg') continue;
const varId = node.getAttribute('varid');
this.getProcedureModel().insertParameter(
new ObservableParameterModel(
this.workspace,
node.getAttribute('name'),
undefined,
varId,
),
i,
);
}
this.setStatements_(xmlElement.getAttribute('statements') !== 'false');
},
/**
* Returns the state of this block as a JSON serializable object.
*
* @param doFullSerialization Tells the block if it should serialize
* its entire state (including data stored in the backing procedure
* model). Used for copy-paste.
* @returns The state of this block, eg the parameters and statements.
*/
saveExtraState: function (doFullSerialization) {
const state = Object.create(null);
state['procedureId'] = this.getProcedureModel().getId();
if (doFullSerialization) {
state['fullSerialization'] = true;
const params = this.getProcedureModel().getParameters();
if (params.length) {
state['params'] = params.map((p) => {
return {
name: p.getName(),
id: p.getVariableModel().getId(),
// Ideally this would be id, and the other would be varId,
// but backwards compatibility :/
paramId: p.getId(),
};
});
}
}
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) {
const map = this.workspace.getProcedureMap();
const procedureId = state['procedureId'];
if (map.has(procedureId) && !state['fullSerialization']) {
if (map.has(this.model_.getId())) {
map.delete(this.model_.getId());
}
this.model_ = map.get(procedureId);
}
const model = this.getProcedureModel();
const newParams = state['params'] ?? [];
const newIds = new Set(newParams.map((p) => p.id));
const currParams = model.getParameters();
if (state['fullSerialization']) {
for (let i = currParams.length - 1; i >= 0; i--) {
if (!newIds.has(currParams[i].getId)) {
model.deleteParameter(i);
}
}
}
for (let i = 0; i < newParams.length; i++) {
const {name, id, paramId} = state['params'][i];
this.getProcedureModel().insertParameter(
new ObservableParameterModel(this.workspace, name, paramId, id),
i,
);
}
this.doProcedureUpdate();
this.setStatements_(state['hasStatements'] === false ? false : true);
},
/**
* Populate the mutator's dialog with this block's components.
*
* @param workspace Blockly.Mutator's workspace.
* @returns Root block in mutator.
* @this {Blockly.Block}
*/
decompose: function (workspace) {
const containerBlockDef = {
type: 'procedures_mutatorcontainer',
inputs: {
STACK: {},
},
};
let connDef = containerBlockDef['inputs']['STACK'];
for (const param of this.getProcedureModel().getParameters()) {
connDef['block'] = {
type: 'procedures_mutatorarg',
id: param.getId(),
fields: {
NAME: param.getName(),
},
next: {},
};
connDef = connDef['block']['next'];
}
const containerBlock = Blockly.serialization.blocks.append(
containerBlockDef as unknown as Blockly.serialization.blocks.State,
workspace,
{recordUndo: false},
);
if (this.type === 'procedures_defreturn') {
containerBlock.setFieldValue(this.hasStatements_, 'STATEMENTS');
} else {
containerBlock.removeInput('STATEMENT_INPUT');
}
return containerBlock;
},
/**
* Reconfigure this block based on the mutator dialog's components.
*
* @param containerBlock Root block in mutator.
* @this {Blockly.Block}
*/
compose: function (containerBlock) {
// Note that only one of these four things can actually occur for any given
// composition, because the user can only drag blocks around so quickly.
// So we can use that when making assumptions inside the definitions of
// these sub procedures.
this.deleteParamsFromModel_(containerBlock);
this.renameParamsInModel_(containerBlock);
this.addParamsToModel_(containerBlock);
const hasStatements = containerBlock.getFieldValue('STATEMENTS');
if (hasStatements !== null) {
this.setStatements_(hasStatements === 'TRUE');
}
},
/**
* Deletes any parameters from the procedure model that do not have associated
* parameter blocks in the mutator.
*
* @param containerBlock Root block in the mutator.
*/
deleteParamsFromModel_: function (containerBlock) {
const ids = new Set(containerBlock.getDescendants().map((b) => b.id));
const model = this.getProcedureModel();
const count = model.getParameters().length;
for (let i = count - 1; i >= 0; i--) {
if (!ids.has(model.getParameter(i).getId())) {
model.deleteParameter(i);
}
}
},
/**
* Renames any parameters in the procedure model whose associated parameter
* blocks have been renamed.
*
* @param containerBlock Root block in the mutator.
*/
renameParamsInModel_: function (containerBlock) {
const model = this.getProcedureModel();
let i = 0;
let paramBlock = containerBlock.getInputTargetBlock('STACK');
while (paramBlock && !paramBlock.isInsertionMarker()) {
const param = model.getParameter(i);
if (
param &&
param.getId() === paramBlock.id &&
param.getName() !== paramBlock.getFieldValue('NAME')
) {
param.setName(paramBlock.getFieldValue('NAME'));
}
paramBlock =
paramBlock.nextConnection && paramBlock.nextConnection.targetBlock();
i++;
}
},
/**
* Adds new parameters to the procedure model for any new procedure parameter
* blocks.
*
* @param containerBlock Root block in the mutator.
*/
addParamsToModel_: function (containerBlock) {
const model = this.getProcedureModel();
let i = 0;
let paramBlock = containerBlock.getInputTargetBlock('STACK');
while (paramBlock && !paramBlock.isInsertionMarker()) {
if (
!model.getParameter(i) ||
model.getParameter(i).getId() !== paramBlock.id
) {
model.insertParameter(
new ObservableParameterModel(
this.workspace,
paramBlock.getFieldValue('NAME'),
paramBlock.id,
),
i,
);
}
paramBlock =
paramBlock.nextConnection && paramBlock.nextConnection.targetBlock();
i++;
}
},
};
Blockly.Extensions.registerMutator(
'procedure_def_mutator',
procedureDefMutator,
undefined,
['procedures_mutatorarg'],
);
const procedureDefContextMenuMixin = {
/**
* Add custom menu options to this block's context menu.
*
* @param options List of menu options to add to.
* @this {Blockly.Block}
*/
customContextMenu: function (
options: Array<
| Blockly.ContextMenuRegistry.ContextMenuOption
| Blockly.ContextMenuRegistry.LegacyContextMenuOption
>,
) {
if (this.isInFlyout) {
return;
}
const xmlMutation = Blockly.utils.xml.createElement('mutation');
xmlMutation.setAttribute('name', this.getFieldValue('NAME'));
const params = this.getProcedureModel().getParameters();
for (const param of params) {
const xmlArg = Blockly.utils.xml.createElement('arg');
xmlArg.setAttribute('name', param.getName());
xmlMutation.appendChild(xmlArg);
}
const xmlBlock = Blockly.utils.xml.createElement('block');
xmlBlock.setAttribute('type', this.callType_);
xmlBlock.appendChild(xmlMutation);
// Add option to create caller.
options.push({
enabled: true,
text: Blockly.Msg['PROCEDURES_CREATE_DO'].replace(
'%1',
this.getFieldValue('NAME'),
),
callback: Blockly.ContextMenu.callbackFactory(
this,
xmlBlock,
) as () => void,
});
// Add options to create getters for each parameter.
if (this.isCollapsed()) return;
for (const param of params) {
const argVar = param.getVariableModel();
const argXmlField = Blockly.Variables.generateVariableFieldDom(argVar);
const argXmlBlock = Blockly.utils.xml.createElement('block');
argXmlBlock.setAttribute('type', 'variables_get');
argXmlBlock.appendChild(argXmlField);
options.push({
enabled: true,
text: Blockly.Msg['VARIABLES_SET_CREATE_GET'].replace(
'%1',
argVar.name,
),
callback: Blockly.ContextMenu.callbackFactory(
this,
argXmlBlock,
) as () => void,
});
}
},
};
Blockly.Extensions.registerMixin(
'procedure_def_context_menu_mixin',
procedureDefContextMenuMixin,
);
const procedureDefOnChangeMixin = {
onchange: function (e) {
if (e.type === Blockly.Events.BLOCK_CREATE && e.blockId === this.id) {
Blockly.Events.fire(
new ProcedureCreate(this.workspace, this.getProcedureModel()),
);
}
if (
e.type === Blockly.Events.BLOCK_CHANGE &&
e.blockId === this.id &&
e.element === 'disabled'
) {
this.getProcedureModel().setEnabled(this.isEnabled());
}
},
};
Blockly.Extensions.registerMixin(
'procedure_def_onchange_mixin',
procedureDefOnChangeMixin,
);
const procedureDefNoReturnSetCommentHelper = function () {
if (
(this.workspace.options.comments ||
(this.workspace.options.parentWorkspace &&
this.workspace.options.parentWorkspace.options.comments)) &&
Blockly.Msg['PROCEDURES_DEFNORETURN_COMMENT']
) {
this.setCommentText(Blockly.Msg['PROCEDURES_DEFNORETURN_COMMENT']);
}
};
Blockly.Extensions.register(
'procedure_defnoreturn_set_comment_helper',
procedureDefNoReturnSetCommentHelper,
);
const procedureDefReturnSetCommentHelper = function () {
if (
(this.workspace.options.comments ||
(this.workspace.options.parentWorkspace &&
this.workspace.options.parentWorkspace.options.comments)) &&
Blockly.Msg['PROCEDURES_DEFRETURN_COMMENT']
) {
this.setCommentText(Blockly.Msg['PROCEDURES_DEFRETURN_COMMENT']);
}
};
Blockly.Extensions.register(
'procedure_defreturn_set_comment_helper',
procedureDefReturnSetCommentHelper,
);
const procedureDefNoReturnGetCallerBlockMixin = {
callType_: 'procedures_callnoreturn',
};
Blockly.Extensions.registerMixin(
'procedure_defnoreturn_get_caller_block_mixin',
procedureDefNoReturnGetCallerBlockMixin,
);
const procedureDefReturnGetCallerBlockMixin = {
callType_: 'procedures_callreturn',
};
Blockly.Extensions.registerMixin(
'procedure_defreturn_get_caller_block_mixin',
procedureDefReturnGetCallerBlockMixin,
);
const procedureDefSetNoReturnHelper = function () {
this.getProcedureModel().setReturnTypes(null);
};
Blockly.Extensions.register(
'procedure_def_set_no_return_helper',
procedureDefSetNoReturnHelper,
);
const procedureDefSetReturnHelper = function () {
this.getProcedureModel().setReturnTypes([]);
};
Blockly.Extensions.register(
'procedure_def_set_return_helper',
procedureDefSetReturnHelper,
);
const procedureCallerGetDefMixin = function () {
const mixin = {
model_: null,
prevParams_: [],
argsMap_: new Map(),
/**
* Returns the procedure model associated with this block.
*
* @returns The procedure model associated with this block.
*/
getProcedureModel() {
return this.model_;
},
/**
* Returns the procedure model tha was found.
*
* @param name The name of the procedure model to find.
* @param params The param names of the procedure model
* to find.
* @returns The procedure model that was found.
*/
findProcedureModel_(name, params = []) {
const workspace = this.getTargetWorkspace_();
const model = workspace
.getProcedureMap()
.getProcedures()
.find((proc) => proc.getName() === name);
if (!model) return null;
const returnTypes = model.getReturnTypes();
const hasMatchingReturn = this.hasReturn_ ? returnTypes : !returnTypes;
if (!hasMatchingReturn) return null;
const hasMatchingParams = model
.getParameters()
.every((p, i) => p.getName() === params[i]);
if (!hasMatchingParams) return null;
return model;
},
/**
* Returns the main workspace (i.e. not the flyout workspace) associated
* with this block.
*
* @returns The main workspace (i.e. not the flyout workspace) associated
* with this block.
*/
getTargetWorkspace_() {
return this.workspace.isFlyout
? this.workspace.targetWorkspace
: this.workspace;
},
/**
* True if this is a procedure definition block, false otherwise (i.e.
* it is a caller).
*
* @returns False because this is not a procedure definition block.
*/
isProcedureDef() {
return false;
},
/**
* Return all variables referenced by this block.
*
* @returns List of variable names.
* @this {Blockly.Block}
*/
getVars: function () {
return this.getProcedureModel()
.getParameters()
.map((p) => p.getVariableModel().name);
},
/**
* Return all variables referenced by this block.
*
* @returns List of variable models.
* @this {Blockly.Block}
*/
getVarModels: function () {
return this.getProcedureModel()
.getParameters()
.map((p) => p.getVariableModel());
},
};
this.mixin(mixin, true);
};
// Using register instead of registerMixin to avoid triggering warnings about
// overriding built-ins.
Blockly.Extensions.register(
'procedure_caller_get_def_mixin',
procedureCallerGetDefMixin,
);
const procedureCallerVarMixin = function () {
const mixin = {
/**
* 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 variable The variable being renamed.
* @package
* @override
* @this {Blockly.Block}
*/
updateVarName: function (variable) {
const containsVar = this.getProcedureModel()
.getParameters()
.some((p) => p.getVariableModel() === variable);
if (containsVar) {
this.doProcedureUpdate(); // Rerender.
}
},
};
this.mixin(mixin, true);
};
// Using register instead of registerMixin to avoid triggering warnings about
// overriding built-ins.
Blockly.Extensions.register(
'procedure_caller_var_mixin',
procedureCallerVarMixin,
);
const procedureCallerMutator = {
paramsFromSerializedState_: [],
/**
* Create XML to represent the (non-editable) name and arguments.
* Backwards compatible serialization implementation.
*
* @returns XML storage element.
* @this {Blockly.Block}
*/
mutationToDom: function () {
const container = Blockly.utils.xml.createElement('mutation');
const model = this.getProcedureModel();
if (!model) return container;
container.setAttribute('name', model.getName());
for (const param of model.getParameters()) {
const arg = Blockly.utils.xml.createElement('arg');
arg.setAttribute('name', param.getName());
container.appendChild(arg);
}
return container;
},
/**
* Parse XML to restore the (non-editable) name and parameters.
* Backwards compatible serialization implementation.
*
* @param xmlElement XML storage element.
* @this {Blockly.Block}
*/
domToMutation: function (xmlElement) {
const name = xmlElement.getAttribute('name');
const params = [];
for (const n of xmlElement.childNodes) {
if (n.nodeName.toLowerCase() === 'arg') {
params.push(n.getAttribute('name'));
}
}
this.deserialize_(name, params);
},
/**
* Returns the state of this block as a JSON serializable object.
*
* @returns The state of
* this block, ie the params and procedure name.
*/
saveExtraState: function () {
const state = Object.create(null);
const model = this.getProcedureModel();
if (!model) {
// We reached here because we've deserialized a caller into a workspace
// where its model did not already exist (no procedures array in the json,
// and deserialized before any definition block), and are reserializing
// it before the event delay has elapsed and change listeners have run.
// (If they had run, we would have found or created a model).
// Just reserialize any deserialized state. Nothing should have happened
// in-between to change it.
state['name'] = this.getFieldValue('NAME');
state['params'] = this.paramsFromSerializedState_;
return state;
}
state['name'] = model.getName();
if (model.getParameters().length) {
state['params'] = model.getParameters().map((p) => p.getName());
}
return state;
},
/**
* Applies the given state to this block.
*
* @param state The state to apply to this block, ie the params and
* procedure name.
*/
loadExtraState: function (state) {
this.deserialize_(state['name'], state['params'] || []);
},
/**
* Applies the given name and params from the serialized state to the block.
*
* @param name The name to apply to the block.
* @param params The parameters to apply to the block.
*/
deserialize_: function (name, params) {
this.setFieldValue(name, 'NAME');
if (!this.model_) this.model_ = this.findProcedureModel_(name, params);
if (this.getProcedureModel()) {
this.initBlockWithProcedureModel_();
} else {
// Create inputs based on the mutation so that children can be connected.
this.createArgInputs_(params);
}
this.paramsFromSerializedState_ = params;
},
};
Blockly.Extensions.registerMutator(
'procedure_caller_mutator',
procedureCallerMutator,
);
const PROCEDURE_MODEL_DISABLED_REASON = 'PROCEDURE_MODEL_DISABLED';
const procedureCallerUpdateShapeMixin = {
/**
* Renders the block for the first time based on the procedure model.
*/
initBlockWithProcedureModel_() {
this.prevParams_ = [...this.getProcedureModel().getParameters()];
this.doProcedureUpdate();
},
/**
* Updates the shape of this block to reflect the state of the data model.
*/
doProcedureUpdate: function () {
if (!this.getProcedureModel() || this.isDeadOrDying()) return;
const id = this.getProcedureModel().getId();
if (
!this.getTargetWorkspace_().getProcedureMap().has(id) &&
!this.isInFlyout
) {
this.dispose(true);
return;
}
this.updateName_();
this.updateEnabled_();
this.updateParameters_();
},
/**
* Updates the name field of this block to match the state of the data model.
*/
updateName_: function () {
const name = this.getProcedureModel().getName();
this.setFieldValue(name, 'NAME');
const baseMsg = this.outputConnection
? Blockly.Msg['PROCEDURES_CALLRETURN_TOOLTIP']
: Blockly.Msg['PROCEDURES_CALLNORETURN_TOOLTIP'];
this.setTooltip(baseMsg.replace('%1', name));
},
/**
* Updates the enabled state of this block to match the state of the data
* model.
*/
updateEnabled_: function () {
this.setDisabledReason(
!this.getProcedureModel().getEnabled(),
PROCEDURE_MODEL_DISABLED_REASON,
);
},
/**
* Updates the parameter fields/inputs of this block to match the state of the
* data model.
*/
updateParameters_: function () {
this.syncArgsMap_();
this.deleteAllArgInputs_();
this.addParametersLabel__();
this.createArgInputs_();
this.reattachBlocks_();
this.prevParams_ = [...this.getProcedureModel().getParameters()];
},
/**
* Makes sure that if we are updating the parameters before any move events
* have happened, the args map records the current state of the block. Does
* not remove entries from the array, since blocks can be disconnected
* temporarily during mutation (which triggers this method).
*/
syncArgsMap_: function () {
// We look at the prevParams array because the current state of the block
// matches the old params, not the new params state.
for (const [i, p] of this.prevParams_.entries()) {
const target = this.getInputTargetBlock(`ARG${i}`);
if (target) this.argsMap_.set(p.getId(), target);
}
},
/**
* Saves a map of parameter IDs to target blocks attached to the inputs
* of this caller block.
*/
updateArgsMap_: function () {
for (const [i, p] of this.getProcedureModel().getParameters().entries()) {
const target = this.getInputTargetBlock(`ARG${i}`);
if (target) {
this.argsMap_.set(p.getId(), target);
} else {
this.argsMap_.delete(p.getId());
}
}
},
/**
* Deletes all the parameter inputs on this block.
*/
deleteAllArgInputs_: function () {
let i = 0;
while (this.getInput(`ARG${i}`)) {
this.removeInput(`ARG${i}`);
i++;
}
},
/**
* Adds or removes the parameter label to match the state of the data model.
*/
addParametersLabel__: function () {
const topRow = this.getInput('TOPROW');
if (this.getProcedureModel().getParameters().length) {
if (!this.getField('WITH')) {
topRow.appendField(
Blockly.Msg['PROCEDURES_CALL_BEFORE_PARAMS'],
'WITH',
);
topRow.init();
}
} else if (this.getField('WITH')) {
topRow.removeField('WITH');
}
},
/**
* Creates all of the parameter inputs to match the state of the data model.
*
* @param params The params to add to the block, or null to
* use the params defined in the procedure model.
*/
createArgInputs_: function (params = null) {
if (!params) {
params = this.getProcedureModel()
.getParameters()
.map((p) => p.getName());
}
for (const [i, p] of params.entries()) {
this.appendValueInput(`ARG${i}`)
.appendField(new Blockly.FieldLabel(p), `ARGNAME${i}`)
.setAlign(Blockly.inputs.Align.RIGHT);
}
},
/**
* Reattaches blocks to this blocks' inputs based on the data saved in the
* argsMap_.
*/
reattachBlocks_: function () {
const params = this.getProcedureModel().getParameters();
for (const [i, p] of params.entries()) {
if (!this.argsMap_.has(p.getId())) continue;
this.getInput(`ARG${i}`).connection.connect(
this.argsMap_.get(p.getId()).outputConnection,
);
}
},
/**
* Notification that a procedure is renaming.
* If the name matches this block's procedure, rename it.
*
* @param oldName Previous name of procedure.
* @param newName Renamed procedure.
* @this {Blockly.Block}
*/
renameProcedure: function (oldName, newName) {
if (Blockly.Names.equals(oldName, this.getFieldValue('NAME'))) {
this.setFieldValue(newName, 'NAME');
const baseMsg = this.outputConnection
? Blockly.Msg['PROCEDURES_CALLRETURN_TOOLTIP']
: Blockly.Msg['PROCEDURES_CALLNORETURN_TOOLTIP'];
this.setTooltip(baseMsg.replace('%1', newName));
}
},
};
Blockly.Extensions.registerMixin(
'procedure_caller_update_shape_mixin',
procedureCallerUpdateShapeMixin,
);
const procedureCallerOnChangeMixin = {
/**
* Procedure calls cannot exist without the corresponding procedure
* definition. Enforce this link whenever an event is fired.
*
* @param event Change event.
* @this {Blockly.Block}
*/
onchange: function (event) {
if (this.disposed || this.workspace.isFlyout) return;
if (event.type === Blockly.Events.BLOCK_MOVE) this.updateArgsMap_(true);
if (
event.type !== Blockly.Events.FINISHED_LOADING &&
!this.eventIsCreatingThisBlockDuringPaste_(event)
)
return;
// We already found our model, which means we don't need to create a block.
if (this.getProcedureModel()) return;
// Look for the case where a procedure call was created (usually through
// paste) and there is no matching definition. In this case, create
// an empty definition block with the correct signature.
const name = this.getFieldValue('NAME');
let def = Blockly.Procedures.getDefinition(name, this.workspace);
if (!this.defMatches_(def)) def = null;
if (!def) {
// We have no def nor procedure model.
Blockly.Events.setGroup(event.group);
this.model_ = this.createDef_(
this.getFieldValue('NAME'),
this.paramsFromSerializedState_,
);
Blockly.Events.setGroup(false);
}
if (!this.getProcedureModel()) {
// We have a def, but no reference to its model.
this.model_ = this.findProcedureModel_(
this.getFieldValue('NAME'),
this.paramsFromSerializedState_,
);
}
this.initBlockWithProcedureModel_();
},
/**
* @param event The event to check.
* @returns True if the given event is a paste event for this block.
*/
eventIsCreatingThisBlockDuringPaste_(event) {
return (
event.type === Blockly.Events.BLOCK_CREATE &&
(event.blockId === this.id || event.ids.indexOf(this.id) !== -1) &&
// Record undo makes sure this is during paste.
event.recordUndo
);
},
/**
* Returns true if the given def block matches the definition of this caller
* block.
*
* @param defBlock The definition block to check against.
* @returns Whether the def block matches or not.
*/
defMatches_(defBlock) {
return (
defBlock &&
defBlock.type === this.defType_ &&
JSON.stringify(defBlock.getVars()) ===
JSON.stringify(this.paramsFromSerializedState_)
);
},
/**
* Creates a procedure definition block with the given name and params,
* and returns the procedure model associated with it.
*
* @param name The name of the procedure to create.
* @param params The names of the parameters to create.
* @returns The procedure model associated with the new
* procedure definition block.
*/
createDef_(name, params = []) {
const xy = this.getRelativeToSurfaceXY();
const newName = Blockly.Procedures.findLegalName(name, this);
this.renameProcedure(name, newName);
const blockDef = {
type: this.defType_,
x: xy.x + Blockly.config.snapRadius * (this.RTL ? -1 : 1),
y: xy.y + Blockly.config.snapRadius * 2,
extraState: {
params: params.map((p) => ({name: p})),
},
fields: {NAME: newName},
};
const block = Blockly.serialization.blocks.append(
blockDef,
this.getTargetWorkspace_(),
{recordUndo: true},
);
return (block as unknown as IProcedureBlock).getProcedureModel();
},
};
Blockly.Extensions.registerMixin(
'procedure_caller_onchange_mixin',
procedureCallerOnChangeMixin,
);
const procedureCallerContextMenuMixin = {
/**
* Add menu option to find the definition block for this call.
*
* @param options List of menu options to add to.
* @this {Blockly.Block}
*/
customContextMenu: function (options) {
if (!this.workspace.isMovable()) {
// If we center on the block and the workspace isn't movable we could
// lose blocks at the edges of the workspace.
return;
}
const name = this.getFieldValue('NAME');
const workspace = this.workspace;
const callback = function () {
const def = Blockly.Procedures.getDefinition(name, workspace);
if (def && def instanceof Blockly.BlockSvg) {
workspace.centerOnBlock(def.id);
def.select();
}
};
options.push({
enabled: true,
text: Blockly.Msg['PROCEDURES_HIGHLIGHT_DEF'],
callback: callback,
});
},
};
Blockly.Extensions.registerMixin(
'procedure_caller_context_menu_mixin',
procedureCallerContextMenuMixin,
);
const procedureCallerNoReturnGetDefBlockMixin = {
hasReturn_: false,
defType_: 'procedures_defnoreturn',
};
Blockly.Extensions.registerMixin(
'procedure_callernoreturn_get_def_block_mixin',
procedureCallerNoReturnGetDefBlockMixin,
);
const procedureCallerReturnGetDefBlockMixin = {
hasReturn_: true,
defType_: 'procedures_defreturn',
};
Blockly.Extensions.registerMixin(
'procedure_callerreturn_get_def_block_mixin',
procedureCallerReturnGetDefBlockMixin,
);