diff --git a/.eslintignore b/.eslintignore
index 38c86f3d1..45e036d9c 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,10 +1,7 @@
*_compressed*.js
-blockly_uncompressed.js
-gulpfile.js
/msg/*
/build/*
/dist/*
-/core/utils/global.js
/tests/blocks/*
/tests/themes/*
/tests/compile/*
diff --git a/blockly_uncompressed.js b/blockly_uncompressed.js
index 435954011..35d025f37 100644
--- a/blockly_uncompressed.js
+++ b/blockly_uncompressed.js
@@ -10,13 +10,15 @@
'use strict';
+/* eslint-disable no-var */
+
/**
* Blockly uncompiled-mode startup code. If running in a browser
* loads closure/goog/base.js and tests/deps.js, then (in any case)
* requires Blockly.requires.
*/
(function(globalThis) {
- /* eslint-disable no-undef */
+ /* eslint-disable-next-line no-undef */
var IS_NODE_JS = !!(typeof module !== 'undefined' && module.exports);
if (IS_NODE_JS) {
@@ -52,4 +54,5 @@
// Load the rest of Blockly.
document.write('');
}
+ /* eslint-disable-next-line no-invalid-this */
})(this);
diff --git a/blocks/all.js b/blocks/all.js
deleted file mode 100644
index 17000f8c9..000000000
--- a/blocks/all.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright 2021 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/**
- * @fileoverview All the blocks. (Entry point for blocks_compressed.js.)
- * @suppress {extraRequire}
- */
-'use strict';
-
-goog.module('Blockly.blocks.all');
-
-goog.require('Blockly.blocks.colour');
-goog.require('Blockly.blocks.lists');
-goog.require('Blockly.blocks.logic');
-goog.require('Blockly.blocks.loops');
-goog.require('Blockly.blocks.math');
-goog.require('Blockly.blocks.procedures');
-goog.require('Blockly.blocks.texts');
-goog.require('Blockly.blocks.variables');
-goog.require('Blockly.blocks.variablesDynamic');
diff --git a/blocks/blocks.js b/blocks/blocks.js
new file mode 100644
index 000000000..f6758c2c5
--- /dev/null
+++ b/blocks/blocks.js
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview All the blocks. (Entry point for blocks_compressed.js.)
+ * @suppress {extraRequire}
+ */
+'use strict';
+
+goog.module('Blockly.libraryBlocks');
+goog.module.declareLegacyNamespace();
+
+const colour = goog.require('Blockly.libraryBlocks.colour');
+const lists = goog.require('Blockly.libraryBlocks.lists');
+const logic = goog.require('Blockly.libraryBlocks.logic');
+const loops = goog.require('Blockly.libraryBlocks.loops');
+const math = goog.require('Blockly.libraryBlocks.math');
+const procedures = goog.require('Blockly.libraryBlocks.procedures');
+const texts = goog.require('Blockly.libraryBlocks.texts');
+const variables = goog.require('Blockly.libraryBlocks.variables');
+const variablesDynamic = goog.require('Blockly.libraryBlocks.variablesDynamic');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
+
+
+exports.colour = colour;
+exports.lists = lists;
+exports.logic = logic;
+exports.loops = loops;
+exports.math = math;
+exports.procedures = procedures;
+exports.texts = texts;
+exports.variables = variables;
+exports.variablesDynamic = variablesDynamic;
+
+/**
+ * A dictionary of the block definitions provided by all the
+ * Blockly.libraryBlocks.* modules.
+ * @type {!Object}
+ */
+const blocks = Object.assign(
+ {}, colour.blocks, lists.blocks, logic.blocks, loops.blocks, math.blocks,
+ procedures.blocks, variables.blocks, variablesDynamic.blocks);
+exports.blocks = blocks;
diff --git a/blocks/colour.js b/blocks/colour.js
index c2a8b6b4b..91b82a282 100644
--- a/blocks/colour.js
+++ b/blocks/colour.js
@@ -9,14 +9,20 @@
*/
'use strict';
-goog.module('Blockly.blocks.colour');
+goog.module('Blockly.libraryBlocks.colour');
-const {defineBlocksWithJsonArray} = goog.require('Blockly.common');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldColour');
-defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for colour picker.
{
'type': 'colour_picker',
@@ -107,3 +113,7 @@ defineBlocksWithJsonArray([
'tooltip': '%{BKY_COLOUR_BLEND_TOOLTIP}',
},
]);
+exports.blocks = blocks;
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/lists.js b/blocks/lists.js
index 80400536d..46975d6cd 100644
--- a/blocks/lists.js
+++ b/blocks/lists.js
@@ -10,25 +10,30 @@
*/
'use strict';
-goog.module('Blockly.blocks.lists');
+goog.module('Blockly.libraryBlocks.lists');
const xmlUtils = goog.require('Blockly.utils.xml');
const {Align} = goog.require('Blockly.Input');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
-const {Blocks} = goog.require('Blockly.blocks');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
const {ConnectionType} = goog.require('Blockly.ConnectionType');
const {FieldDropdown} = goog.require('Blockly.FieldDropdown');
const {Msg} = goog.require('Blockly.Msg');
const {Mutator} = goog.require('Blockly.Mutator');
/* eslint-disable-next-line no-unused-vars */
const {Workspace} = goog.requireType('Blockly.Workspace');
-const {defineBlocksWithJsonArray} = goog.require('Blockly.common');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldDropdown');
-defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for creating an empty list
// The 'list_create_with' block is preferred as it is more flexible.
//
@@ -112,8 +117,9 @@ defineBlocksWithJsonArray([
'helpUrl': '%{BKY_LISTS_LENGTH_HELPURL}',
},
]);
+exports.blocks = blocks;
-Blocks['lists_create_with'] = {
+blocks['lists_create_with'] = {
/**
* Block for creating a list with any number of elements of any type.
* @this {Block}
@@ -255,7 +261,7 @@ Blocks['lists_create_with'] = {
},
};
-Blocks['lists_create_with_container'] = {
+blocks['lists_create_with_container'] = {
/**
* Mutator block for list container.
* @this {Block}
@@ -270,7 +276,7 @@ Blocks['lists_create_with_container'] = {
},
};
-Blocks['lists_create_with_item'] = {
+blocks['lists_create_with_item'] = {
/**
* Mutator block for adding items.
* @this {Block}
@@ -285,7 +291,7 @@ Blocks['lists_create_with_item'] = {
},
};
-Blocks['lists_indexOf'] = {
+blocks['lists_indexOf'] = {
/**
* Block for finding an item in the list.
* @this {Block}
@@ -312,7 +318,7 @@ Blocks['lists_indexOf'] = {
},
};
-Blocks['lists_getIndex'] = {
+blocks['lists_getIndex'] = {
/**
* Block for getting element at index.
* @this {Block}
@@ -516,7 +522,7 @@ Blocks['lists_getIndex'] = {
},
};
-Blocks['lists_setIndex'] = {
+blocks['lists_setIndex'] = {
/**
* Block for setting the element at index.
* @this {Block}
@@ -668,7 +674,7 @@ Blocks['lists_setIndex'] = {
},
};
-Blocks['lists_getSublist'] = {
+blocks['lists_getSublist'] = {
/**
* Block for getting sublist.
* @this {Block}
@@ -786,7 +792,7 @@ Blocks['lists_getSublist'] = {
},
};
-Blocks['lists_sort'] = {
+blocks['lists_sort'] = {
/**
* Block for sorting a list.
* @this {Block}
@@ -826,7 +832,7 @@ Blocks['lists_sort'] = {
},
};
-Blocks['lists_split'] = {
+blocks['lists_split'] = {
/**
* Block for splitting text into a list, or joining a list into text.
* @this {Block}
@@ -913,3 +919,6 @@ Blocks['lists_split'] = {
// dropdown values.
// XML hooks are kept for backwards compatibility.
};
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/logic.js b/blocks/logic.js
index 8a79d7f94..04e1572be 100644
--- a/blocks/logic.js
+++ b/blocks/logic.js
@@ -10,7 +10,7 @@
*/
'use strict';
-goog.module('Blockly.blocks.logic');
+goog.module('Blockly.libraryBlocks.logic');
/* eslint-disable-next-line no-unused-vars */
const AbstractEvent = goog.requireType('Blockly.Events.Abstract');
@@ -19,20 +19,26 @@ const Extensions = goog.require('Blockly.Extensions');
const xmlUtils = goog.require('Blockly.utils.xml');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
const {Msg} = goog.require('Blockly.Msg');
const {Mutator} = goog.require('Blockly.Mutator');
/* eslint-disable-next-line no-unused-vars */
const {RenderedConnection} = goog.requireType('Blockly.RenderedConnection');
/* eslint-disable-next-line no-unused-vars */
const {Workspace} = goog.requireType('Blockly.Workspace');
-const {defineBlocksWithJsonArray} = goog.require('Blockly.common');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldDropdown');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldLabel');
-defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for boolean data type: true and false.
{
'type': 'logic_boolean',
@@ -258,6 +264,7 @@ defineBlocksWithJsonArray([
'tooltip': '%{BKY_CONTROLS_IF_ELSE_TOOLTIP}',
},
]);
+exports.blocks = blocks;
/**
* Tooltip text, keyed by block OP value. Used by logic_compare and
@@ -645,3 +652,6 @@ const LOGIC_TERNARY_ONCHANGE_MIXIN = {
};
Extensions.registerMixin('logic_ternary', LOGIC_TERNARY_ONCHANGE_MIXIN);
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/loops.js b/blocks/loops.js
index cc3c27d37..4578fc46f 100644
--- a/blocks/loops.js
+++ b/blocks/loops.js
@@ -10,7 +10,7 @@
*/
'use strict';
-goog.module('Blockly.blocks.loops');
+goog.module('Blockly.libraryBlocks.loops');
/* eslint-disable-next-line no-unused-vars */
const AbstractEvent = goog.requireType('Blockly.Events.Abstract');
@@ -18,11 +18,13 @@ const ContextMenu = goog.require('Blockly.ContextMenu');
const Events = goog.require('Blockly.Events');
const Extensions = goog.require('Blockly.Extensions');
const Variables = goog.require('Blockly.Variables');
-const common = goog.require('Blockly.common');
const xmlUtils = goog.require('Blockly.utils.xml');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
const {Msg} = goog.require('Blockly.Msg');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldDropdown');
/** @suppress {extraRequire} */
@@ -35,7 +37,11 @@ goog.require('Blockly.FieldVariable');
goog.require('Blockly.Warning');
-common.defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for repeat n times (external number).
{
'type': 'controls_repeat_ext',
@@ -205,6 +211,7 @@ common.defineBlocksWithJsonArray([
],
},
]);
+exports.blocks = blocks;
/**
* Tooltips for the 'controls_whileUntil' block, keyed by MODE value.
@@ -287,21 +294,24 @@ Extensions.register(
*
* // If using the Blockly npm package and es6 import syntax:
* import {loopTypes} from 'blockly/blocks';
- * loopTypes.push('custom_loop');
+ * loopTypes.add('custom_loop');
*
* // Else if using Closure Compiler and goog.modules:
- * const {loopTypes} = goog.require('Blockly.blocks.loops');
- * loopTypes.push('custom_loop');
+ * const {loopTypes} = goog.require('Blockly.libraryBlocks.loops');
+ * loopTypes.add('custom_loop');
*
- * @type {!Array}
+ * // Else if using blockly_compressed + blockss_compressed.js in browser:
+ * Blockly.libraryBlocks.loopTypes.add('custom_loop');
+ *
+ * @type {!Set}
*/
-const loopTypes = [
+const loopTypes = new Set([
'controls_repeat',
'controls_repeat_ext',
'controls_forEach',
'controls_for',
'controls_whileUntil',
-];
+]);
exports.loopTypes = loopTypes;
/**
@@ -321,7 +331,7 @@ const CONTROL_FLOW_IN_LOOP_CHECK_MIXIN = {
getSurroundLoop: function() {
let block = this;
do {
- if (loopTypes.includes(block.type)) {
+ if (loopTypes.has(block.type)) {
return block;
}
block = block.getSurroundParent();
@@ -332,7 +342,7 @@ const CONTROL_FLOW_IN_LOOP_CHECK_MIXIN = {
/**
* Called whenever anything on the workspace changes.
* Add warning if this flow block is not nested inside a loop.
- * @param {!AbstractEvent} e Change event.
+ * @param {!AbstractEvent} e Move event.
* @this {Block}
*/
onchange: function(e) {
@@ -358,3 +368,6 @@ const CONTROL_FLOW_IN_LOOP_CHECK_MIXIN = {
Extensions.registerMixin(
'controls_flow_in_loop_check', CONTROL_FLOW_IN_LOOP_CHECK_MIXIN);
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/math.js b/blocks/math.js
index 003ff5bdc..bd5458fdd 100644
--- a/blocks/math.js
+++ b/blocks/math.js
@@ -10,7 +10,7 @@
*/
'use strict';
-goog.module('Blockly.blocks.math');
+goog.module('Blockly.libraryBlocks.math');
const Extensions = goog.require('Blockly.Extensions');
// N.B.: Blockly.FieldDropdown needed for type AND side-effects.
@@ -20,7 +20,8 @@ const xmlUtils = goog.require('Blockly.utils.xml');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
/* eslint-disable-next-line no-unused-vars */
-const {defineBlocksWithJsonArray} = goog.require('Blockly.common');
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldLabel');
/** @suppress {extraRequire} */
@@ -29,7 +30,11 @@ goog.require('Blockly.FieldNumber');
goog.require('Blockly.FieldVariable');
-defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for numeric value.
{
'type': 'math_number',
@@ -384,6 +389,7 @@ defineBlocksWithJsonArray([
'helpUrl': '%{BKY_MATH_ATAN2_HELPURL}',
},
]);
+exports.blocks = blocks;
/**
* Mapping of math block OP value to tooltip message for blocks
@@ -581,3 +587,6 @@ const LIST_MODES_MUTATOR_EXTENSION = function() {
Extensions.registerMutator(
'math_modes_of_list_mutator', LIST_MODES_MUTATOR_MIXIN,
LIST_MODES_MUTATOR_EXTENSION);
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/procedures.js b/blocks/procedures.js
index c41eb92db..8e652ed39 100644
--- a/blocks/procedures.js
+++ b/blocks/procedures.js
@@ -10,7 +10,7 @@
*/
'use strict';
-goog.module('Blockly.blocks.procedures');
+goog.module('Blockly.libraryBlocks.procedures');
/* eslint-disable-next-line no-unused-vars */
const AbstractEvent = goog.requireType('Blockly.Events.Abstract');
@@ -19,12 +19,13 @@ const Events = goog.require('Blockly.Events');
const Procedures = goog.require('Blockly.Procedures');
const Variables = goog.require('Blockly.Variables');
const Xml = goog.require('Blockly.Xml');
-const internalConstants = goog.require('Blockly.internalConstants');
const xmlUtils = goog.require('Blockly.utils.xml');
const {Align} = goog.require('Blockly.Input');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
-const {Blocks} = goog.require('Blockly.blocks');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
+const {config} = goog.require('Blockly.config');
/* eslint-disable-next-line no-unused-vars */
const {FieldCheckbox} = goog.require('Blockly.FieldCheckbox');
const {FieldLabel} = goog.require('Blockly.FieldLabel');
@@ -36,12 +37,20 @@ const {Names} = goog.require('Blockly.Names');
const {VariableModel} = goog.requireType('Blockly.VariableModel');
/* eslint-disable-next-line no-unused-vars */
const {Workspace} = goog.requireType('Blockly.Workspace');
+const {defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.Comment');
/** @suppress {extraRequire} */
goog.require('Blockly.Warning');
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = {};
+exports.blocks = blocks;
+
/**
* Common properties for the procedure_defnoreturn and
* procedure_defreturn blocks.
@@ -434,10 +443,9 @@ const PROCEDURE_DEF_COMMON = {
}
}
},
- callType_: 'procedures_callnoreturn',
};
-Blocks['procedures_defnoreturn'] = {
+blocks['procedures_defnoreturn'] = {
...PROCEDURE_DEF_COMMON,
/**
* Block for defining a procedure with no return value.
@@ -477,9 +485,10 @@ Blocks['procedures_defnoreturn'] = {
getProcedureDef: function() {
return [this.getFieldValue('NAME'), this.arguments_, false];
},
+ callType_: 'procedures_callnoreturn',
};
-Blocks['procedures_defreturn'] = {
+blocks['procedures_defreturn'] = {
...PROCEDURE_DEF_COMMON,
/**
* Block for defining a procedure with a return value.
@@ -522,9 +531,10 @@ Blocks['procedures_defreturn'] = {
getProcedureDef: function() {
return [this.getFieldValue('NAME'), this.arguments_, true];
},
+ callType_: 'procedures_callreturn',
};
-Blocks['procedures_mutatorcontainer'] = {
+blocks['procedures_mutatorcontainer'] = {
/**
* Mutator block for procedure container.
* @this {Block}
@@ -542,7 +552,7 @@ Blocks['procedures_mutatorcontainer'] = {
},
};
-Blocks['procedures_mutatorarg'] = {
+blocks['procedures_mutatorarg'] = {
/**
* Mutator block for procedure argument.
* @this {Block}
@@ -950,19 +960,18 @@ const PROCEDURE_CALL_COMMON = {
const block = xmlUtils.createElement('block');
block.setAttribute('type', this.defType_);
const xy = this.getRelativeToSurfaceXY();
- const x = xy.x + internalConstants.SNAP_RADIUS * (this.RTL ? -1 : 1);
- const y = xy.y + internalConstants.SNAP_RADIUS * 2;
+ const x = xy.x + config.snapRadius * (this.RTL ? -1 : 1);
+ const y = xy.y + config.snapRadius * 2;
block.setAttribute('x', x);
block.setAttribute('y', y);
const mutation = this.mutationToDom();
block.appendChild(mutation);
const field = xmlUtils.createElement('field');
field.setAttribute('name', 'NAME');
- let callName = this.getProcedureCall();
- if (!callName) {
- // Rename if name is empty string.
- callName = Procedures.findLegalName('', this);
- this.renameProcedure('', callName);
+ const callName = this.getProcedureCall();
+ const newName = Procedures.findLegalName(callName, this);
+ if (callName !== newName) {
+ this.renameProcedure(callName, newName);
}
field.appendChild(xmlUtils.createTextNode(callName));
block.appendChild(field);
@@ -1033,7 +1042,7 @@ const PROCEDURE_CALL_COMMON = {
},
};
-Blocks['procedures_callnoreturn'] = {
+blocks['procedures_callnoreturn'] = {
...PROCEDURE_CALL_COMMON,
/**
* Block for calling a procedure with no return value.
@@ -1056,7 +1065,7 @@ Blocks['procedures_callnoreturn'] = {
defType_: 'procedures_defnoreturn',
};
-Blocks['procedures_callreturn'] = {
+blocks['procedures_callreturn'] = {
...PROCEDURE_CALL_COMMON,
/**
* Block for calling a procedure with a return value.
@@ -1078,7 +1087,7 @@ Blocks['procedures_callreturn'] = {
defType_: 'procedures_defreturn',
};
-Blocks['procedures_ifreturn'] = {
+blocks['procedures_ifreturn'] = {
/**
* Block for conditionally returning a value from a procedure.
* @this {Block}
@@ -1130,11 +1139,12 @@ Blocks['procedures_ifreturn'] = {
/**
* Called whenever anything on the workspace changes.
* Add warning if this flow block is not nested inside a loop.
- * @param {!AbstractEvent} _e Change event.
+ * @param {!AbstractEvent} e Move event.
* @this {Block}
*/
- onchange: function(_e) {
- if (this.workspace.isDragging && this.workspace.isDragging()) {
+ onchange: function(e) {
+ if (this.workspace.isDragging && this.workspace.isDragging() ||
+ e.type !== Events.BLOCK_MOVE) {
return; // Don't change state at the start of a drag.
}
let legal = false;
@@ -1162,14 +1172,15 @@ Blocks['procedures_ifreturn'] = {
this.hasReturnValue_ = true;
}
this.setWarningText(null);
- if (!this.isInFlyout) {
- this.setEnabled(true);
- }
} else {
this.setWarningText(Msg['PROCEDURES_IFRETURN_WARNING']);
- if (!this.isInFlyout && !this.getInheritedDisabled()) {
- this.setEnabled(false);
- }
+ }
+ if (!this.isInFlyout) {
+ const group = Events.getGroup();
+ // Makes it so the move and the disable event get undone together.
+ Events.setGroup(e.group);
+ this.setEnabled(legal);
+ Events.setGroup(group);
}
},
/**
@@ -1179,3 +1190,6 @@ Blocks['procedures_ifreturn'] = {
*/
FUNCTION_TYPES: ['procedures_defnoreturn', 'procedures_defreturn'],
};
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/text.js b/blocks/text.js
index 9072c7fa1..efaebb500 100644
--- a/blocks/text.js
+++ b/blocks/text.js
@@ -10,7 +10,7 @@
*/
'use strict';
-goog.module('Blockly.blocks.texts');
+goog.module('Blockly.libraryBlocks.texts');
const Extensions = goog.require('Blockly.Extensions');
const {Msg} = goog.require('Blockly.Msg');
@@ -19,7 +19,8 @@ const xmlUtils = goog.require('Blockly.utils.xml');
const {Align} = goog.require('Blockly.Input');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
-const {Blocks} = goog.require('Blockly.blocks');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
const {ConnectionType} = goog.require('Blockly.ConnectionType');
const {FieldDropdown} = goog.require('Blockly.FieldDropdown');
const {FieldImage} = goog.require('Blockly.FieldImage');
@@ -27,14 +28,18 @@ const {FieldTextInput} = goog.require('Blockly.FieldTextInput');
const {Mutator} = goog.require('Blockly.Mutator');
/* eslint-disable-next-line no-unused-vars */
const {Workspace} = goog.requireType('Blockly.Workspace');
-const {defineBlocksWithJsonArray} = goog.require('Blockly.common');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldMultilineInput');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldVariable');
-defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for text value
{
'type': 'text',
@@ -238,8 +243,9 @@ defineBlocksWithJsonArray([
'mutator': 'text_charAt_mutator',
},
]);
+exports.blocks = blocks;
-Blocks['text_getSubstring'] = {
+blocks['text_getSubstring'] = {
/**
* Block for getting substring.
* @this {Block}
@@ -363,7 +369,7 @@ Blocks['text_getSubstring'] = {
},
};
-Blocks['text_changeCase'] = {
+blocks['text_changeCase'] = {
/**
* Block for changing capitalization.
* @this {Block}
@@ -383,7 +389,7 @@ Blocks['text_changeCase'] = {
},
};
-Blocks['text_trim'] = {
+blocks['text_trim'] = {
/**
* Block for trimming spaces.
* @this {Block}
@@ -403,7 +409,7 @@ Blocks['text_trim'] = {
},
};
-Blocks['text_print'] = {
+blocks['text_print'] = {
/**
* Block for print statement.
* @this {Block}
@@ -463,7 +469,7 @@ const TEXT_PROMPT_COMMON = {
},
};
-Blocks['text_prompt_ext'] = {
+blocks['text_prompt_ext'] = {
...TEXT_PROMPT_COMMON,
/**
* Block for prompt function (external message).
@@ -496,7 +502,7 @@ Blocks['text_prompt_ext'] = {
// XML hooks are kept for backwards compatibility.
};
-Blocks['text_prompt'] = {
+blocks['text_prompt'] = {
...TEXT_PROMPT_COMMON,
/**
* Block for prompt function (internal message).
@@ -531,7 +537,7 @@ Blocks['text_prompt'] = {
},
};
-Blocks['text_count'] = {
+blocks['text_count'] = {
/**
* Block for counting how many times one string appears within another string.
* @this {Block}
@@ -560,7 +566,7 @@ Blocks['text_count'] = {
},
};
-Blocks['text_replace'] = {
+blocks['text_replace'] = {
/**
* Block for replacing one string with another in the text.
* @this {Block}
@@ -594,7 +600,7 @@ Blocks['text_replace'] = {
},
};
-Blocks['text_reverse'] = {
+blocks['text_reverse'] = {
/**
* Block for reversing a string.
* @this {Block}
@@ -981,3 +987,6 @@ Extensions.registerMutator(
Extensions.registerMutator(
'text_charAt_mutator', TEXT_CHARAT_MUTATOR_MIXIN, TEXT_CHARAT_EXTENSION);
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/variables.js b/blocks/variables.js
index c9e4720fa..56a7b5eb8 100644
--- a/blocks/variables.js
+++ b/blocks/variables.js
@@ -10,7 +10,7 @@
*/
'use strict';
-goog.module('Blockly.blocks.variables');
+goog.module('Blockly.libraryBlocks.variables');
const ContextMenu = goog.require('Blockly.ContextMenu');
const Extensions = goog.require('Blockly.Extensions');
@@ -18,15 +18,21 @@ const Variables = goog.require('Blockly.Variables');
const xmlUtils = goog.require('Blockly.utils.xml');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
const {Msg} = goog.require('Blockly.Msg');
-const {defineBlocksWithJsonArray} = goog.require('Blockly.common');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldLabel');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldVariable');
-defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for variable getter.
{
'type': 'variables_get',
@@ -67,6 +73,8 @@ defineBlocksWithJsonArray([
'extensions': ['contextMenu_variableSetterGetter'],
},
]);
+exports.blocks = blocks;
+
/**
* Mixin to add context menu items to create getter/setter blocks for this
@@ -161,3 +169,6 @@ const deleteOptionCallbackFactory = function(block) {
Extensions.registerMixin(
'contextMenu_variableSetterGetter',
CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN);
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/blocks/variables_dynamic.js b/blocks/variables_dynamic.js
index 26a91af1e..6020c7859 100644
--- a/blocks/variables_dynamic.js
+++ b/blocks/variables_dynamic.js
@@ -10,7 +10,7 @@
*/
'use strict';
-goog.module('Blockly.blocks.variablesDynamic');
+goog.module('Blockly.libraryBlocks.variablesDynamic');
/* eslint-disable-next-line no-unused-vars */
const AbstractEvent = goog.requireType('Blockly.Events.Abstract');
@@ -20,15 +20,21 @@ const Variables = goog.require('Blockly.Variables');
const xml = goog.require('Blockly.utils.xml');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition} = goog.requireType('Blockly.blocks');
const {Msg} = goog.require('Blockly.Msg');
-const {defineBlocksWithJsonArray} = goog.require('Blockly.common');
+const {createBlockDefinitionsFromJsonArray, defineBlocks} = goog.require('Blockly.common');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldLabel');
/** @suppress {extraRequire} */
goog.require('Blockly.FieldVariable');
-defineBlocksWithJsonArray([
+/**
+ * A dictionary of the block definitions provided by this module.
+ * @type {!Object}
+ */
+const blocks = createBlockDefinitionsFromJsonArray([
// Block for variable getter.
{
'type': 'variables_get_dynamic',
@@ -67,6 +73,7 @@ defineBlocksWithJsonArray([
'extensions': ['contextMenu_variableDynamicSetterGetter'],
},
]);
+exports.blocks = blocks;
/**
* Mixin to add context menu items to create getter/setter blocks for this
@@ -178,3 +185,6 @@ const deleteOptionCallbackFactory = function(block) {
Extensions.registerMixin(
'contextMenu_variableDynamicSetterGetter',
CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN);
+
+// Register provided blocks.
+defineBlocks(blocks);
diff --git a/closure/goog/base.js b/closure/goog/base.js
index 310db20f7..03d5ec690 100644
--- a/closure/goog/base.js
+++ b/closure/goog/base.js
@@ -91,7 +91,14 @@ goog.global.CLOSURE_UNCOMPILED_DEFINES;
* var CLOSURE_DEFINES = {'goog.DEBUG': false} ;
*
*
- * @type {Object|undefined}
+ * Currently the Closure Compiler will only recognize very simple definitions of
+ * this value when looking for values to apply to compiled code and ignore all
+ * other references. Specifically, it looks the value defined at the variable
+ * declaration, as with the example above.
+ *
+ * TODO(user): Improve the recognized definitions.
+ *
+ * @type {!Object|null|undefined}
*/
goog.global.CLOSURE_DEFINES;
@@ -3175,23 +3182,10 @@ if (!COMPILED && goog.DEPENDENCIES_ENABLED) {
scriptEl.nonce = nonce;
}
- if (goog.DebugLoader_.IS_OLD_IE_) {
- // Execution order is not guaranteed on old IE, halt loading and write
- // these scripts one at a time, after each loads.
- controller.pause();
- scriptEl.onreadystatechange = function() {
- if (scriptEl.readyState == 'loaded' ||
- scriptEl.readyState == 'complete') {
- controller.loaded();
- controller.resume();
- }
- };
- } else {
- scriptEl.onload = function() {
- scriptEl.onload = null;
- controller.loaded();
- };
- }
+ scriptEl.onload = function() {
+ scriptEl.onload = null;
+ controller.loaded();
+ };
scriptEl.src = goog.TRUSTED_TYPES_POLICY_ ?
goog.TRUSTED_TYPES_POLICY_.createScriptURL(this.path) :
@@ -3502,13 +3496,6 @@ if (!COMPILED && goog.DEPENDENCIES_ENABLED) {
// If one thing is pending it is this.
var anythingElsePending = controller.pending().length > 1;
- // If anything else is loading we need to lazy load due to bugs in old IE.
- // Specifically script tags with src and script tags with contents could
- // execute out of order if document.write is used, so we cannot use
- // document.write. Do not pause here; it breaks old IE as well.
- var useOldIeWorkAround =
- anythingElsePending && goog.DebugLoader_.IS_OLD_IE_;
-
// Additionally if we are meant to defer scripts but the page is still
// loading (e.g. an ES6 module is loading) then also defer. Or if we are
// meant to defer and anything else is pending then defer (those may be
@@ -3517,7 +3504,7 @@ if (!COMPILED && goog.DEPENDENCIES_ENABLED) {
var needsAsyncLoading = goog.Dependency.defer_ &&
(anythingElsePending || goog.isDocumentLoading_());
- if (useOldIeWorkAround || needsAsyncLoading) {
+ if (needsAsyncLoading) {
// Note that we only defer when we have to rather than 100% of the time.
// Always defering would work, but then in theory the order of
// goog.require calls would then matter. We want to enforce that most of
@@ -3561,8 +3548,7 @@ if (!COMPILED && goog.DEPENDENCIES_ENABLED) {
};
} else {
// Always eval on old IE.
- if (goog.DebugLoader_.IS_OLD_IE_ || !goog.inHtmlDocument_() ||
- !goog.isDocumentLoading_()) {
+ if (!goog.inHtmlDocument_() || !goog.isDocumentLoading_()) {
load();
} else {
fetchInOwnScriptThenLoad();
@@ -3706,15 +3692,6 @@ if (!COMPILED && goog.DEPENDENCIES_ENABLED) {
};
- /**
- * Whether the browser is IE9 or earlier, which needs special handling
- * for deferred modules.
- * @const @private {boolean}
- */
- goog.DebugLoader_.IS_OLD_IE_ = !!(
- !goog.global.atob && goog.global.document && goog.global.document['all']);
-
-
/**
* @param {string} relPath
* @param {!Array|undefined} provides
diff --git a/closure/goog/goog.js b/closure/goog/goog.js
new file mode 100644
index 000000000..9c8d53e88
--- /dev/null
+++ b/closure/goog/goog.js
@@ -0,0 +1,99 @@
+// Copyright 2018 The Closure Library Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview ES6 module that exports symbols from base.js so that ES6
+ * modules do not need to use globals and so that is clear if a project is using
+ * Closure's base.js file. It is also a subset of properties in base.js, meaning
+ * it should be clearer what should not be used in ES6 modules
+ * (goog.module/provide are not exported here, for example). Though that is not
+ * to say that everything in this file should be used in an ES6 module; some
+ * depreciated functions are exported to make migration easier (e.g.
+ * goog.scope).
+ *
+ * Note that this does not load Closure's base.js file, it is still up to the
+ * programmer to include it. Nor does the fact that this is an ES6 module mean
+ * that projects no longer require deps.js files for debug loading - they do.
+ * Closure will need to load your ES6 modules for you if you have any Closure
+ * file (goog.provide/goog.module) dependencies, as they need to be available
+ * before the ES6 module evaluates.
+ *
+ * Also note that this file has special compiler handling! It is okay to export
+ * anything from this file, but the name also needs to exist on the global goog.
+ * This special compiler pass enforces that you always import this file as
+ * `import * as goog`, as many tools use regex based parsing to find
+ * goog.require calls.
+ */
+
+export const global = goog.global;
+export const require = goog.require;
+export const define = goog.define;
+export const DEBUG = goog.DEBUG;
+export const LOCALE = goog.LOCALE;
+export const TRUSTED_SITE = goog.TRUSTED_SITE;
+export const DISALLOW_TEST_ONLY_CODE = goog.DISALLOW_TEST_ONLY_CODE;
+export const getGoogModule = goog.module.get;
+export const setTestOnly = goog.setTestOnly;
+export const forwardDeclare = goog.forwardDeclare;
+export const getObjectByName = goog.getObjectByName;
+export const basePath = goog.basePath;
+export const addSingletonGetter = goog.addSingletonGetter;
+export const typeOf = goog.typeOf;
+export const isArrayLike = goog.isArrayLike;
+export const isDateLike = goog.isDateLike;
+export const isObject = goog.isObject;
+export const getUid = goog.getUid;
+export const hasUid = goog.hasUid;
+export const removeUid = goog.removeUid;
+export const mixin = goog.mixin;
+export const now = Date.now;
+export const globalEval = goog.globalEval;
+export const getCssName = goog.getCssName;
+export const setCssNameMapping = goog.setCssNameMapping;
+export const getMsg = goog.getMsg;
+export const getMsgWithFallback = goog.getMsgWithFallback;
+export const exportSymbol = goog.exportSymbol;
+export const exportProperty = goog.exportProperty;
+export const nullFunction = goog.nullFunction;
+export const abstractMethod = goog.abstractMethod;
+export const cloneObject = goog.cloneObject;
+export const bind = goog.bind;
+export const partial = goog.partial;
+export const inherits = goog.inherits;
+export const scope = goog.scope;
+export const defineClass = goog.defineClass;
+export const declareModuleId = goog.declareModuleId;
+
+// Export select properties of module. Do not export the function itself or
+// goog.module.declareLegacyNamespace.
+export const module = {
+ get: goog.module.get,
+};
+
+// Omissions include:
+// goog.ENABLE_DEBUG_LOADER - define only used in base.
+// goog.ENABLE_CHROME_APP_SAFE_SCRIPT_LOADING - define only used in base.
+// goog.provide - ES6 modules do not provide anything.
+// goog.module - ES6 modules cannot be goog.modules.
+// goog.module.declareLegacyNamespace - ES6 modules cannot declare namespaces.
+// goog.addDependency - meant to only be used by dependency files.
+// goog.DEPENDENCIES_ENABLED - constant only used in base.
+// goog.TRANSPILE - define only used in base.
+// goog.TRANSPILER - define only used in base.
+// goog.loadModule - should not be called by any ES6 module; exists for
+// generated bundles.
+// goog.LOAD_MODULE_USING_EVAL - define only used in base.
+// goog.SEAL_MODULE_EXPORTS - define only used in base.
+// goog.DebugLoader - used rarely, only outside of compiled code.
+// goog.Transpiler - used rarely, only outside of compiled code.
diff --git a/core/block.js b/core/block.js
index cda2e39a7..ce8f274b6 100644
--- a/core/block.js
+++ b/core/block.js
@@ -15,8 +15,6 @@
*/
goog.module('Blockly.Block');
-/* eslint-disable-next-line no-unused-vars */
-const Abstract = goog.requireType('Blockly.Events.Abstract');
const Extensions = goog.require('Blockly.Extensions');
const Tooltip = goog.require('Blockly.Tooltip');
const arrayUtils = goog.require('Blockly.utils.array');
@@ -27,8 +25,12 @@ const fieldRegistry = goog.require('Blockly.fieldRegistry');
const idGenerator = goog.require('Blockly.utils.idGenerator');
const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
+/* eslint-disable-next-line no-unused-vars */
+const {Abstract} = goog.requireType('Blockly.Events.Abstract');
const {Align, Input} = goog.require('Blockly.Input');
const {ASTNode} = goog.require('Blockly.ASTNode');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockMove} = goog.requireType('Blockly.Events.BlockMove');
const {Blocks} = goog.require('Blockly.blocks');
/* eslint-disable-next-line no-unused-vars */
const {Comment} = goog.requireType('Blockly.Comment');
@@ -62,205 +64,2120 @@ goog.require('Blockly.Events.BlockMove');
/**
* Class for one block.
* Not normally called directly, workspace.newBlock() is preferred.
- * @param {!Workspace} workspace The block's workspace.
- * @param {!string} prototypeName Name of the language object containing
- * type-specific functions for this block.
- * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise
- * create a new ID.
- * @constructor
* @implements {IASTNodeLocation}
* @implements {IDeletable}
- * @throws When the prototypeName is not valid or not allowed.
+ * @unrestricted
* @alias Blockly.Block
*/
-const Block = function(workspace, prototypeName, opt_id) {
- const {Generator} = goog.module.get('Blockly.Generator');
- if (Generator && typeof Generator.prototype[prototypeName] !== 'undefined') {
- // Occluding Generator class members is not allowed.
- throw Error(
- 'Block prototypeName "' + prototypeName +
- '" conflicts with Blockly.Generator members.');
- }
-
- /** @type {string} */
- this.id = (opt_id && !workspace.getBlockById(opt_id)) ? opt_id :
- idGenerator.genUid();
- workspace.setBlockById(this.id, this);
- /** @type {Connection} */
- this.outputConnection = null;
- /** @type {Connection} */
- this.nextConnection = null;
- /** @type {Connection} */
- this.previousConnection = null;
- /** @type {!Array} */
- this.inputList = [];
- /** @type {boolean|undefined} */
- this.inputsInline = undefined;
+class Block {
/**
- * @type {boolean}
- * @private
+ * @param {!Workspace} workspace The block's workspace.
+ * @param {!string} prototypeName Name of the language object containing
+ * type-specific functions for this block.
+ * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise
+ * create a new ID.
+ * @throws When the prototypeName is not valid or not allowed.
*/
- this.disabled = false;
- /** @type {!Tooltip.TipInfo} */
- this.tooltip = '';
- /** @type {boolean} */
- this.contextMenu = true;
-
- /**
- * @type {Block}
- * @protected
- */
- this.parentBlock_ = null;
-
- /**
- * @type {!Array}
- * @protected
- */
- this.childBlocks_ = [];
-
- /**
- * @type {boolean}
- * @private
- */
- this.deletable_ = true;
-
- /**
- * @type {boolean}
- * @private
- */
- this.movable_ = true;
-
- /**
- * @type {boolean}
- * @private
- */
- this.editable_ = true;
-
- /**
- * @type {boolean}
- * @private
- */
- this.isShadow_ = false;
-
- /**
- * @type {boolean}
- * @protected
- */
- this.collapsed_ = false;
-
- /**
- * @type {?number}
- * @protected
- */
- this.outputShape_ = null;
-
- /**
- * A string representing the comment attached to this block.
- * @type {string|Comment}
- * @deprecated August 2019. Use getCommentText instead.
- */
- this.comment = null;
-
- /**
- * A model of the comment attached to this block.
- * @type {!Block.CommentModel}
- * @package
- */
- this.commentModel = {text: null, pinned: false, size: new Size(160, 80)};
-
- /**
- * The block's position in workspace units. (0, 0) is at the workspace's
- * origin; scale does not change this value.
- * @type {!Coordinate}
- * @private
- */
- this.xy_ = new Coordinate(0, 0);
-
- /** @type {!Workspace} */
- this.workspace = workspace;
- /** @type {boolean} */
- this.isInFlyout = workspace.isFlyout;
- /** @type {boolean} */
- this.isInMutator = workspace.isMutator;
-
- /** @type {boolean} */
- this.RTL = workspace.RTL;
-
- /**
- * True if this block is an insertion marker.
- * @type {boolean}
- * @protected
- */
- this.isInsertionMarker_ = false;
-
- /**
- * Name of the type of hat.
- * @type {string|undefined}
- */
- this.hat = undefined;
-
- /** @type {?boolean} */
- this.rendered = null;
-
- /**
- * A count of statement inputs on the block.
- * @type {number}
- * @package
- */
- this.statementInputCount = 0;
-
- // Copy the type-specific functions and data from the prototype.
- if (prototypeName) {
- /** @type {string} */
- this.type = prototypeName;
- const prototype = Blocks[prototypeName];
- if (!prototype || typeof prototype !== 'object') {
- throw TypeError('Unknown block type: ' + prototypeName);
+ constructor(workspace, prototypeName, opt_id) {
+ const {Generator} = goog.module.get('Blockly.Generator');
+ if (Generator &&
+ typeof Generator.prototype[prototypeName] !== 'undefined') {
+ // Occluding Generator class members is not allowed.
+ throw Error(
+ 'Block prototypeName "' + prototypeName +
+ '" conflicts with Blockly.Generator members.');
}
- object.mixin(this, prototype);
+
+ /**
+ * Optional text data that round-trips between blocks and XML.
+ * Has no effect. May be used by 3rd parties for meta information.
+ * @type {?string}
+ */
+ this.data = null;
+
+ /**
+ * Has this block been disposed of?
+ * @type {boolean}
+ * @package
+ */
+ this.disposed = false;
+
+ /**
+ * Colour of the block as HSV hue value (0-360)
+ * This may be null if the block colour was not set via a hue number.
+ * @type {?number}
+ * @private
+ */
+ this.hue_ = null;
+
+ /**
+ * Colour of the block in '#RRGGBB' format.
+ * @type {string}
+ * @protected
+ */
+ this.colour_ = '#000000';
+
+ /**
+ * Name of the block style.
+ * @type {string}
+ * @protected
+ */
+ this.styleName_ = '';
+
+ /**
+ * An optional method called during initialization.
+ * @type {undefined|?function()}
+ */
+ this.init = undefined;
+
+ /**
+ * An optional serialization method for defining how to serialize the
+ * mutation state to XML. This must be coupled with defining
+ * `domToMutation`.
+ * @type {undefined|?function(...):!Element}
+ */
+ this.mutationToDom = undefined;
+
+ /**
+ * An optional deserialization method for defining how to deserialize the
+ * mutation state from XML. This must be coupled with defining
+ * `mutationToDom`.
+ * @type {undefined|?function(!Element)}
+ */
+ this.domToMutation = undefined;
+
+ /**
+ * An optional serialization method for defining how to serialize the
+ * block's extra state (eg mutation state) to something JSON compatible.
+ * This must be coupled with defining `loadExtraState`.
+ * @type {undefined|?function(): *}
+ */
+ this.saveExtraState = undefined;
+
+ /**
+ * An optional serialization method for defining how to deserialize the
+ * block's extra state (eg mutation state) from something JSON compatible.
+ * This must be coupled with defining `saveExtraState`.
+ * @type {undefined|?function(*)}
+ */
+ this.loadExtraState = undefined;
+
+
+ /**
+ * An optional property for suppressing adding STATEMENT_PREFIX and
+ * STATEMENT_SUFFIX to generated code.
+ * @type {?boolean}
+ */
+ this.suppressPrefixSuffix = false;
+
+ /**
+ * An optional property for declaring developer variables. Return a list of
+ * variable names for use by generators. Developer variables are never
+ * shown to the user, but are declared as global variables in the generated
+ * code.
+ * @type {undefined|?function():!Array}
+ */
+ this.getDeveloperVariables = undefined;
+
+ /** @type {string} */
+ this.id = (opt_id && !workspace.getBlockById(opt_id)) ?
+ opt_id :
+ idGenerator.genUid();
+ workspace.setBlockById(this.id, this);
+ /** @type {Connection} */
+ this.outputConnection = null;
+ /** @type {Connection} */
+ this.nextConnection = null;
+ /** @type {Connection} */
+ this.previousConnection = null;
+ /** @type {!Array} */
+ this.inputList = [];
+ /** @type {boolean|undefined} */
+ this.inputsInline = undefined;
+ /**
+ * @type {boolean}
+ * @private
+ */
+ this.disabled = false;
+ /** @type {!Tooltip.TipInfo} */
+ this.tooltip = '';
+ /** @type {boolean} */
+ this.contextMenu = true;
+
+ /**
+ * @type {Block}
+ * @protected
+ */
+ this.parentBlock_ = null;
+
+ /**
+ * @type {!Array}
+ * @protected
+ */
+ this.childBlocks_ = [];
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ this.deletable_ = true;
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ this.movable_ = true;
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ this.editable_ = true;
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ this.isShadow_ = false;
+
+ /**
+ * @type {boolean}
+ * @protected
+ */
+ this.collapsed_ = false;
+
+ /**
+ * @type {?number}
+ * @protected
+ */
+ this.outputShape_ = null;
+
+ /**
+ * A string representing the comment attached to this block.
+ * @type {string|Comment}
+ * @deprecated August 2019. Use getCommentText instead.
+ */
+ this.comment = null;
+
+ /**
+ * A model of the comment attached to this block.
+ * @type {!Block.CommentModel}
+ * @package
+ */
+ this.commentModel = {text: null, pinned: false, size: new Size(160, 80)};
+
+ /**
+ * The block's position in workspace units. (0, 0) is at the workspace's
+ * origin; scale does not change this value.
+ * @type {!Coordinate}
+ * @private
+ */
+ this.xy_ = new Coordinate(0, 0);
+
+ /** @type {!Workspace} */
+ this.workspace = workspace;
+ /** @type {boolean} */
+ this.isInFlyout = workspace.isFlyout;
+ /** @type {boolean} */
+ this.isInMutator = workspace.isMutator;
+
+ /** @type {boolean} */
+ this.RTL = workspace.RTL;
+
+ /**
+ * True if this block is an insertion marker.
+ * @type {boolean}
+ * @protected
+ */
+ this.isInsertionMarker_ = false;
+
+ /**
+ * Name of the type of hat.
+ * @type {string|undefined}
+ */
+ this.hat = undefined;
+
+ /** @type {?boolean} */
+ this.rendered = null;
+
+ /**
+ * String for block help, or function that returns a URL. Null for no help.
+ * @type {string|Function}
+ */
+ this.helpUrl = null;
+
+ /**
+ * A bound callback function to use when the parent workspace changes.
+ * @type {?function(Abstract)}
+ * @private
+ */
+ this.onchangeWrapper_ = null;
+
+ /**
+ * A count of statement inputs on the block.
+ * @type {number}
+ * @package
+ */
+ this.statementInputCount = 0;
+
+ // Copy the type-specific functions and data from the prototype.
+ if (prototypeName) {
+ /** @type {string} */
+ this.type = prototypeName;
+ const prototype = Blocks[prototypeName];
+ if (!prototype || typeof prototype !== 'object') {
+ throw TypeError('Invalid block definition for type: ' + prototypeName);
+ }
+ object.mixin(this, prototype);
+ }
+
+ workspace.addTopBlock(this);
+ workspace.addTypedBlock(this);
+
+ if (new.target === Block) this.doInit_();
}
- workspace.addTopBlock(this);
- workspace.addTypedBlock(this);
+ /**
+ * Calls the init() function and handles associated event firing, etc.
+ * @protected
+ */
+ doInit_() {
+ // All events fired should be part of the same group.
+ // Any events fired during init should not be undoable,
+ // so that block creation is atomic.
+ const existingGroup = eventUtils.getGroup();
+ if (!existingGroup) {
+ eventUtils.setGroup(true);
+ }
+ const initialUndoFlag = eventUtils.getRecordUndo();
- // All events fired should be part of the same group.
- // Any events fired during init should not be undoable,
- // so that block creation is atomic.
- const existingGroup = eventUtils.getGroup();
- if (!existingGroup) {
- eventUtils.setGroup(true);
- }
- const initialUndoFlag = eventUtils.getRecordUndo();
+ try {
+ // Call an initialization function, if it exists.
+ if (typeof this.init === 'function') {
+ eventUtils.setRecordUndo(false);
+ this.init();
+ eventUtils.setRecordUndo(initialUndoFlag);
+ }
- try {
- // Call an initialization function, if it exists.
- if (typeof this.init === 'function') {
- eventUtils.setRecordUndo(false);
- this.init();
+ // Fire a create event.
+ if (eventUtils.isEnabled()) {
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(this));
+ }
+ } finally {
+ if (!existingGroup) {
+ eventUtils.setGroup(false);
+ }
+ // In case init threw, recordUndo flag should still be reset.
eventUtils.setRecordUndo(initialUndoFlag);
}
- // Fire a create event.
+ // Record initial inline state.
+ /** @type {boolean|undefined} */
+ this.inputsInlineDefault = this.inputsInline;
+
+ // Bind an onchange function, if it exists.
+ if (typeof this.onchange === 'function') {
+ this.setOnChange(this.onchange);
+ }
+ }
+
+ /**
+ * Dispose of this block.
+ * @param {boolean} healStack If true, then try to heal any gap by connecting
+ * the next statement with the previous statement. Otherwise, dispose of
+ * all children of this block.
+ * @suppress {checkTypes}
+ */
+ dispose(healStack) {
+ if (!this.workspace) {
+ // Already deleted.
+ return;
+ }
+ // Terminate onchange event calls.
+ if (this.onchangeWrapper_) {
+ this.workspace.removeChangeListener(this.onchangeWrapper_);
+ }
+
+ this.unplug(healStack);
if (eventUtils.isEnabled()) {
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(this));
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_DELETE))(this));
}
- } finally {
- if (!existingGroup) {
- eventUtils.setGroup(false);
+ eventUtils.disable();
+
+ try {
+ // This block is now at the top of the workspace.
+ // Remove this block from the workspace's list of top-most blocks.
+ if (this.workspace) {
+ this.workspace.removeTopBlock(this);
+ this.workspace.removeTypedBlock(this);
+ // Remove from block database.
+ this.workspace.removeBlockById(this.id);
+ this.workspace = null;
+ }
+
+ // Just deleting this block from the DOM would result in a memory leak as
+ // well as corruption of the connection database. Therefore we must
+ // methodically step through the blocks and carefully disassemble them.
+
+ if (common.getSelected() === this) {
+ common.setSelected(null);
+ }
+
+ // First, dispose of all my children.
+ for (let i = this.childBlocks_.length - 1; i >= 0; i--) {
+ this.childBlocks_[i].dispose(false);
+ }
+ // Then dispose of myself.
+ // Dispose of all inputs and their fields.
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ input.dispose();
+ }
+ this.inputList.length = 0;
+ // Dispose of any remaining connections (next/previous/output).
+ const connections = this.getConnections_(true);
+ for (let i = 0, connection; (connection = connections[i]); i++) {
+ connection.dispose();
+ }
+ } finally {
+ eventUtils.enable();
+ this.disposed = true;
}
- // In case init threw, recordUndo flag should still be reset.
- eventUtils.setRecordUndo(initialUndoFlag);
}
- // Record initial inline state.
- /** @type {boolean|undefined} */
- this.inputsInlineDefault = this.inputsInline;
-
- // Bind an onchange function, if it exists.
- if (typeof this.onchange === 'function') {
- this.setOnChange(this.onchange);
+ /**
+ * Call initModel on all fields on the block.
+ * May be called more than once.
+ * Either initModel or initSvg must be called after creating a block and
+ * before the first interaction with it. Interactions include UI actions
+ * (e.g. clicking and dragging) and firing events (e.g. create, delete, and
+ * change).
+ * @public
+ */
+ initModel() {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ for (let j = 0, field; (field = input.fieldRow[j]); j++) {
+ if (field.initModel) {
+ field.initModel();
+ }
+ }
+ }
}
-};
+
+ /**
+ * Unplug this block from its superior block. If this block is a statement,
+ * optionally reconnect the block underneath with the block on top.
+ * @param {boolean=} opt_healStack Disconnect child statement and reconnect
+ * stack. Defaults to false.
+ */
+ unplug(opt_healStack) {
+ if (this.outputConnection) {
+ this.unplugFromRow_(opt_healStack);
+ }
+ if (this.previousConnection) {
+ this.unplugFromStack_(opt_healStack);
+ }
+ }
+
+ /**
+ * Unplug this block's output from an input on another block. Optionally
+ * reconnect the block's parent to the only child block, if possible.
+ * @param {boolean=} opt_healStack Disconnect right-side block and connect to
+ * left-side block. Defaults to false.
+ * @private
+ */
+ unplugFromRow_(opt_healStack) {
+ let parentConnection = null;
+ if (this.outputConnection.isConnected()) {
+ parentConnection = this.outputConnection.targetConnection;
+ // Disconnect from any superior block.
+ this.outputConnection.disconnect();
+ }
+
+ // Return early in obvious cases.
+ if (!parentConnection || !opt_healStack) {
+ return;
+ }
+
+ const thisConnection = this.getOnlyValueConnection_();
+ if (!thisConnection || !thisConnection.isConnected() ||
+ thisConnection.targetBlock().isShadow()) {
+ // Too many or too few possible connections on this block, or there's
+ // nothing on the other side of this connection.
+ return;
+ }
+
+ const childConnection = thisConnection.targetConnection;
+ // Disconnect the child block.
+ childConnection.disconnect();
+ // Connect child to the parent if possible, otherwise bump away.
+ if (this.workspace.connectionChecker.canConnect(
+ childConnection, parentConnection, false)) {
+ parentConnection.connect(childConnection);
+ } else {
+ childConnection.onFailedConnect(parentConnection);
+ }
+ }
+
+ /**
+ * Returns the connection on the value input that is connected to another
+ * block. When an insertion marker is connected to a connection with a block
+ * already attached, the connected block is attached to the insertion marker.
+ * Since only one block can be displaced and attached to the insertion marker
+ * this should only ever return one connection.
+ *
+ * @return {?Connection} The connection on the value input, or null.
+ * @private
+ */
+ getOnlyValueConnection_() {
+ let connection = null;
+ for (let i = 0; i < this.inputList.length; i++) {
+ const thisConnection = this.inputList[i].connection;
+ if (thisConnection &&
+ thisConnection.type === ConnectionType.INPUT_VALUE &&
+ thisConnection.targetConnection) {
+ if (connection) {
+ return null; // More than one value input found.
+ }
+ connection = thisConnection;
+ }
+ }
+ return connection;
+ }
+
+ /**
+ * Unplug this statement block from its superior block. Optionally reconnect
+ * the block underneath with the block on top.
+ * @param {boolean=} opt_healStack Disconnect child statement and reconnect
+ * stack. Defaults to false.
+ * @private
+ */
+ unplugFromStack_(opt_healStack) {
+ let previousTarget = null;
+ if (this.previousConnection.isConnected()) {
+ // Remember the connection that any next statements need to connect to.
+ previousTarget = this.previousConnection.targetConnection;
+ // Detach this block from the parent's tree.
+ this.previousConnection.disconnect();
+ }
+ const nextBlock = this.getNextBlock();
+ if (opt_healStack && nextBlock && !nextBlock.isShadow()) {
+ // Disconnect the next statement.
+ const nextTarget = this.nextConnection.targetConnection;
+ nextTarget.disconnect();
+ if (previousTarget &&
+ this.workspace.connectionChecker.canConnect(
+ previousTarget, nextTarget, false)) {
+ // Attach the next statement to the previous statement.
+ previousTarget.connect(nextTarget);
+ }
+ }
+ }
+
+ /**
+ * Returns all connections originating from this block.
+ * @param {boolean} _all If true, return all connections even hidden ones.
+ * @return {!Array} Array of connections.
+ * @package
+ */
+ getConnections_(_all) {
+ const myConnections = [];
+ if (this.outputConnection) {
+ myConnections.push(this.outputConnection);
+ }
+ if (this.previousConnection) {
+ myConnections.push(this.previousConnection);
+ }
+ if (this.nextConnection) {
+ myConnections.push(this.nextConnection);
+ }
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.connection) {
+ myConnections.push(input.connection);
+ }
+ }
+ return myConnections;
+ }
+
+ /**
+ * Walks down a stack of blocks and finds the last next connection on the
+ * stack.
+ * @param {boolean} ignoreShadows If true,the last connection on a non-shadow
+ * block will be returned. If false, this will follow shadows to find the
+ * last connection.
+ * @return {?Connection} The last next connection on the stack, or null.
+ * @package
+ */
+ lastConnectionInStack(ignoreShadows) {
+ let nextConnection = this.nextConnection;
+ while (nextConnection) {
+ const nextBlock = nextConnection.targetBlock();
+ if (!nextBlock || (ignoreShadows && nextBlock.isShadow())) {
+ return nextConnection;
+ }
+ nextConnection = nextBlock.nextConnection;
+ }
+ return null;
+ }
+
+ /**
+ * Bump unconnected blocks out of alignment. Two blocks which aren't actually
+ * connected should not coincidentally line up on screen.
+ */
+ bumpNeighbours() {
+ // noop.
+ }
+
+ /**
+ * Return the parent block or null if this block is at the top level. The
+ * parent block is either the block connected to the previous connection (for
+ * a statement block) or the block connected to the output connection (for a
+ * value block).
+ * @return {?Block} The block (if any) that holds the current block.
+ */
+ getParent() {
+ return this.parentBlock_;
+ }
+
+ /**
+ * Return the input that connects to the specified block.
+ * @param {!Block} block A block connected to an input on this block.
+ * @return {?Input} The input (if any) that connects to the specified
+ * block.
+ */
+ getInputWithBlock(block) {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.connection && input.connection.targetBlock() === block) {
+ return input;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the parent block that surrounds the current block, or null if this
+ * block has no surrounding block. A parent block might just be the previous
+ * statement, whereas the surrounding block is an if statement, while loop,
+ * etc.
+ * @return {?Block} The block (if any) that surrounds the current block.
+ */
+ getSurroundParent() {
+ let block = this;
+ let prevBlock;
+ do {
+ prevBlock = block;
+ block = block.getParent();
+ if (!block) {
+ // Ran off the top.
+ return null;
+ }
+ } while (block.getNextBlock() === prevBlock);
+ // This block is an enclosing parent, not just a statement in a stack.
+ return block;
+ }
+
+ /**
+ * Return the next statement block directly connected to this block.
+ * @return {?Block} The next statement block or null.
+ */
+ getNextBlock() {
+ return this.nextConnection && this.nextConnection.targetBlock();
+ }
+
+ /**
+ * Returns the block connected to the previous connection.
+ * @return {?Block} The previous statement block or null.
+ */
+ getPreviousBlock() {
+ return this.previousConnection && this.previousConnection.targetBlock();
+ }
+
+ /**
+ * Return the connection on the first statement input on this block, or null
+ * if there are none.
+ * @return {?Connection} The first statement connection or null.
+ * @package
+ */
+ getFirstStatementConnection() {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.connection &&
+ input.connection.type === ConnectionType.NEXT_STATEMENT) {
+ return input.connection;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the top-most block in this block's tree.
+ * This will return itself if this block is at the top level.
+ * @return {!Block} The root block.
+ */
+ getRootBlock() {
+ let rootBlock;
+ let block = this;
+ do {
+ rootBlock = block;
+ block = rootBlock.parentBlock_;
+ } while (block);
+ return rootBlock;
+ }
+
+ /**
+ * Walk up from the given block up through the stack of blocks to find
+ * the top block of the sub stack. If we are nested in a statement input only
+ * find the top-most nested block. Do not go all the way to the root block.
+ * @return {!Block} The top block in a stack.
+ * @package
+ */
+ getTopStackBlock() {
+ let block = this;
+ let previous;
+ do {
+ previous = block.getPreviousBlock();
+ } while (previous && previous.getNextBlock() === block &&
+ (block = previous));
+ return block;
+ }
+
+ /**
+ * Find all the blocks that are directly nested inside this one.
+ * Includes value and statement inputs, as well as any following statement.
+ * Excludes any connection on an output tab or any preceding statement.
+ * Blocks are optionally sorted by position; top to bottom.
+ * @param {boolean} ordered Sort the list if true.
+ * @return {!Array} Array of blocks.
+ */
+ getChildren(ordered) {
+ if (!ordered) {
+ return this.childBlocks_;
+ }
+ const blocks = [];
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.connection) {
+ const child = input.connection.targetBlock();
+ if (child) {
+ blocks.push(child);
+ }
+ }
+ }
+ const next = this.getNextBlock();
+ if (next) {
+ blocks.push(next);
+ }
+ return blocks;
+ }
+
+ /**
+ * Set parent of this block to be a new block or null.
+ * @param {Block} newParent New parent block.
+ * @package
+ */
+ setParent(newParent) {
+ if (newParent === this.parentBlock_) {
+ return;
+ }
+
+ // Check that block is connected to new parent if new parent is not null and
+ // that block is not connected to superior one if new parent is null.
+ const targetBlock =
+ (this.previousConnection && this.previousConnection.targetBlock()) ||
+ (this.outputConnection && this.outputConnection.targetBlock());
+ const isConnected = !!targetBlock;
+
+ if (isConnected && newParent && targetBlock !== newParent) {
+ throw Error('Block connected to superior one that is not new parent.');
+ } else if (!isConnected && newParent) {
+ throw Error('Block not connected to new parent.');
+ } else if (isConnected && !newParent) {
+ throw Error(
+ 'Cannot set parent to null while block is still connected to' +
+ ' superior block.');
+ }
+
+ if (this.parentBlock_) {
+ // Remove this block from the old parent's child list.
+ arrayUtils.removeElem(this.parentBlock_.childBlocks_, this);
+
+ // This block hasn't actually moved on-screen, so there's no need to
+ // update
+ // its connection locations.
+ } else {
+ // New parent must be non-null so remove this block from the workspace's
+ // list of top-most blocks.
+ this.workspace.removeTopBlock(this);
+ }
+
+ this.parentBlock_ = newParent;
+ if (newParent) {
+ // Add this block to the new parent's child list.
+ newParent.childBlocks_.push(this);
+ } else {
+ this.workspace.addTopBlock(this);
+ }
+ }
+
+ /**
+ * Find all the blocks that are directly or indirectly nested inside this one.
+ * Includes this block in the list.
+ * Includes value and statement inputs, as well as any following statements.
+ * Excludes any connection on an output tab or any preceding statements.
+ * Blocks are optionally sorted by position; top to bottom.
+ * @param {boolean} ordered Sort the list if true.
+ * @return {!Array} Flattened array of blocks.
+ */
+ getDescendants(ordered) {
+ const blocks = [this];
+ const childBlocks = this.getChildren(ordered);
+ for (let child, i = 0; (child = childBlocks[i]); i++) {
+ blocks.push.apply(blocks, child.getDescendants(ordered));
+ }
+ return blocks;
+ }
+
+ /**
+ * Get whether this block is deletable or not.
+ * @return {boolean} True if deletable.
+ */
+ isDeletable() {
+ return this.deletable_ && !this.isShadow_ &&
+ !(this.workspace && this.workspace.options.readOnly);
+ }
+
+ /**
+ * Set whether this block is deletable or not.
+ * @param {boolean} deletable True if deletable.
+ */
+ setDeletable(deletable) {
+ this.deletable_ = deletable;
+ }
+
+ /**
+ * Get whether this block is movable or not.
+ * @return {boolean} True if movable.
+ */
+ isMovable() {
+ return this.movable_ && !this.isShadow_ &&
+ !(this.workspace && this.workspace.options.readOnly);
+ }
+
+ /**
+ * Set whether this block is movable or not.
+ * @param {boolean} movable True if movable.
+ */
+ setMovable(movable) {
+ this.movable_ = movable;
+ }
+
+ /**
+ * Get whether is block is duplicatable or not. If duplicating this block and
+ * descendants will put this block over the workspace's capacity this block is
+ * not duplicatable. If duplicating this block and descendants will put any
+ * type over their maxInstances this block is not duplicatable.
+ * @return {boolean} True if duplicatable.
+ */
+ isDuplicatable() {
+ if (!this.workspace.hasBlockLimits()) {
+ return true;
+ }
+ return this.workspace.isCapacityAvailable(
+ common.getBlockTypeCounts(this, true));
+ }
+
+ /**
+ * Get whether this block is a shadow block or not.
+ * @return {boolean} True if a shadow.
+ */
+ isShadow() {
+ return this.isShadow_;
+ }
+
+ /**
+ * Set whether this block is a shadow block or not.
+ * @param {boolean} shadow True if a shadow.
+ * @package
+ */
+ setShadow(shadow) {
+ this.isShadow_ = shadow;
+ }
+
+ /**
+ * Get whether this block is an insertion marker block or not.
+ * @return {boolean} True if an insertion marker.
+ */
+ isInsertionMarker() {
+ return this.isInsertionMarker_;
+ }
+
+ /**
+ * Set whether this block is an insertion marker block or not.
+ * Once set this cannot be unset.
+ * @param {boolean} insertionMarker True if an insertion marker.
+ * @package
+ */
+ setInsertionMarker(insertionMarker) {
+ this.isInsertionMarker_ = insertionMarker;
+ }
+
+ /**
+ * Get whether this block is editable or not.
+ * @return {boolean} True if editable.
+ */
+ isEditable() {
+ return this.editable_ &&
+ !(this.workspace && this.workspace.options.readOnly);
+ }
+
+ /**
+ * Set whether this block is editable or not.
+ * @param {boolean} editable True if editable.
+ */
+ setEditable(editable) {
+ this.editable_ = editable;
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ for (let j = 0, field; (field = input.fieldRow[j]); j++) {
+ field.updateEditable();
+ }
+ }
+ }
+
+ /**
+ * Returns if this block has been disposed of / deleted.
+ * @return {boolean} True if this block has been disposed of / deleted.
+ */
+ isDisposed() {
+ return this.disposed;
+ }
+
+ /**
+ * Find the connection on this block that corresponds to the given connection
+ * on the other block.
+ * Used to match connections between a block and its insertion marker.
+ * @param {!Block} otherBlock The other block to match against.
+ * @param {!Connection} conn The other connection to match.
+ * @return {?Connection} The matching connection on this block, or null.
+ * @package
+ */
+ getMatchingConnection(otherBlock, conn) {
+ const connections = this.getConnections_(true);
+ const otherConnections = otherBlock.getConnections_(true);
+ if (connections.length !== otherConnections.length) {
+ throw Error('Connection lists did not match in length.');
+ }
+ for (let i = 0; i < otherConnections.length; i++) {
+ if (otherConnections[i] === conn) {
+ return connections[i];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Set the URL of this block's help page.
+ * @param {string|Function} url URL string for block help, or function that
+ * returns a URL. Null for no help.
+ */
+ setHelpUrl(url) {
+ this.helpUrl = url;
+ }
+
+ /**
+ * Sets the tooltip for this block.
+ * @param {!Tooltip.TipInfo} newTip The text for the tooltip, a function
+ * that returns the text for the tooltip, or a parent object whose tooltip
+ * will be used. To not display a tooltip pass the empty string.
+ */
+ setTooltip(newTip) {
+ this.tooltip = newTip;
+ }
+
+ /**
+ * Returns the tooltip text for this block.
+ * @return {!string} The tooltip text for this block.
+ */
+ getTooltip() {
+ return Tooltip.getTooltipOfObject(this);
+ }
+
+ /**
+ * Get the colour of a block.
+ * @return {string} #RRGGBB string.
+ */
+ getColour() {
+ return this.colour_;
+ }
+
+ /**
+ * Get the name of the block style.
+ * @return {string} Name of the block style.
+ */
+ getStyleName() {
+ return this.styleName_;
+ }
+
+ /**
+ * Get the HSV hue value of a block. Null if hue not set.
+ * @return {?number} Hue value (0-360).
+ */
+ getHue() {
+ return this.hue_;
+ }
+
+ /**
+ * Change the colour of a block.
+ * @param {number|string} colour HSV hue value (0 to 360), #RRGGBB string,
+ * or a message reference string pointing to one of those two values.
+ */
+ setColour(colour) {
+ const parsed = parsing.parseBlockColour(colour);
+ this.hue_ = parsed.hue;
+ this.colour_ = parsed.hex;
+ }
+
+ /**
+ * Set the style and colour values of a block.
+ * @param {string} blockStyleName Name of the block style.
+ */
+ setStyle(blockStyleName) {
+ this.styleName_ = blockStyleName;
+ }
+
+ /**
+ * Sets a callback function to use whenever the block's parent workspace
+ * changes, replacing any prior onchange handler. This is usually only called
+ * from the constructor, the block type initializer function, or an extension
+ * initializer function.
+ * @param {function(Abstract)} onchangeFn The callback to call
+ * when the block's workspace changes.
+ * @throws {Error} if onchangeFn is not falsey and not a function.
+ */
+ setOnChange(onchangeFn) {
+ if (onchangeFn && typeof onchangeFn !== 'function') {
+ throw Error('onchange must be a function.');
+ }
+ if (this.onchangeWrapper_) {
+ this.workspace.removeChangeListener(this.onchangeWrapper_);
+ }
+ this.onchange = onchangeFn;
+ if (this.onchange) {
+ this.onchangeWrapper_ = onchangeFn.bind(this);
+ this.workspace.addChangeListener(this.onchangeWrapper_);
+ }
+ }
+
+ /**
+ * Returns the named field from a block.
+ * @param {string} name The name of the field.
+ * @return {?Field} Named field, or null if field does not exist.
+ */
+ getField(name) {
+ if (typeof name !== 'string') {
+ throw TypeError(
+ 'Block.prototype.getField expects a string ' +
+ 'with the field name but received ' +
+ (name === undefined ? 'nothing' : name + ' of type ' + typeof name) +
+ ' instead');
+ }
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ for (let j = 0, field; (field = input.fieldRow[j]); j++) {
+ if (field.name === name) {
+ return field;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return all variables referenced by this block.
+ * @return {!Array} List of variable ids.
+ */
+ getVars() {
+ const vars = [];
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ for (let j = 0, field; (field = input.fieldRow[j]); j++) {
+ if (field.referencesVariables()) {
+ vars.push(field.getValue());
+ }
+ }
+ }
+ return vars;
+ }
+
+ /**
+ * Return all variables referenced by this block.
+ * @return {!Array} List of variable models.
+ * @package
+ */
+ getVarModels() {
+ const vars = [];
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ for (let j = 0, field; (field = input.fieldRow[j]); j++) {
+ if (field.referencesVariables()) {
+ const model = this.workspace.getVariableById(
+ /** @type {string} */ (field.getValue()));
+ // Check if the variable actually exists (and isn't just a potential
+ // variable).
+ if (model) {
+ vars.push(model);
+ }
+ }
+ }
+ }
+ return vars;
+ }
+
+ /**
+ * 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 {!VariableModel} variable The variable being renamed.
+ * @package
+ */
+ updateVarName(variable) {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ for (let j = 0, field; (field = input.fieldRow[j]); j++) {
+ if (field.referencesVariables() &&
+ variable.getId() === field.getValue()) {
+ field.refreshVariableName();
+ }
+ }
+ }
+ }
+
+ /**
+ * Notification that a variable is renaming.
+ * If the ID matches one of this block's variables, rename it.
+ * @param {string} oldId ID of variable to rename.
+ * @param {string} newId ID of new variable. May be the same as oldId, but
+ * with an updated name.
+ */
+ renameVarById(oldId, newId) {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ for (let j = 0, field; (field = input.fieldRow[j]); j++) {
+ if (field.referencesVariables() && oldId === field.getValue()) {
+ field.setValue(newId);
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns the language-neutral value of the given field.
+ * @param {string} name The name of the field.
+ * @return {*} Value of the field or null if field does not exist.
+ */
+ getFieldValue(name) {
+ const field = this.getField(name);
+ if (field) {
+ return field.getValue();
+ }
+ return null;
+ }
+
+ /**
+ * Sets the value of the given field for this block.
+ * @param {*} newValue The value to set.
+ * @param {string} name The name of the field to set the value of.
+ */
+ setFieldValue(newValue, name) {
+ const field = this.getField(name);
+ if (!field) {
+ throw Error('Field "' + name + '" not found.');
+ }
+ field.setValue(newValue);
+ }
+
+ /**
+ * Set whether this block can chain onto the bottom of another block.
+ * @param {boolean} newBoolean True if there can be a previous statement.
+ * @param {(string|Array|null)=} opt_check Statement type or
+ * list of statement types. Null/undefined if any type could be
+ * connected.
+ */
+ setPreviousStatement(newBoolean, opt_check) {
+ if (newBoolean) {
+ if (opt_check === undefined) {
+ opt_check = null;
+ }
+ if (!this.previousConnection) {
+ this.previousConnection =
+ this.makeConnection_(ConnectionType.PREVIOUS_STATEMENT);
+ }
+ this.previousConnection.setCheck(opt_check);
+ } else {
+ if (this.previousConnection) {
+ if (this.previousConnection.isConnected()) {
+ throw Error(
+ 'Must disconnect previous statement before removing ' +
+ 'connection.');
+ }
+ this.previousConnection.dispose();
+ this.previousConnection = null;
+ }
+ }
+ }
+
+ /**
+ * Set whether another block can chain onto the bottom of this block.
+ * @param {boolean} newBoolean True if there can be a next statement.
+ * @param {(string|Array|null)=} opt_check Statement type or
+ * list of statement types. Null/undefined if any type could be
+ * connected.
+ */
+ setNextStatement(newBoolean, opt_check) {
+ if (newBoolean) {
+ if (opt_check === undefined) {
+ opt_check = null;
+ }
+ if (!this.nextConnection) {
+ this.nextConnection =
+ this.makeConnection_(ConnectionType.NEXT_STATEMENT);
+ }
+ this.nextConnection.setCheck(opt_check);
+ } else {
+ if (this.nextConnection) {
+ if (this.nextConnection.isConnected()) {
+ throw Error(
+ 'Must disconnect next statement before removing ' +
+ 'connection.');
+ }
+ this.nextConnection.dispose();
+ this.nextConnection = null;
+ }
+ }
+ }
+
+ /**
+ * Set whether this block returns a value.
+ * @param {boolean} newBoolean True if there is an output.
+ * @param {(string|Array|null)=} opt_check Returned type or list
+ * of returned types. Null or undefined if any type could be returned
+ * (e.g. variable get).
+ */
+ setOutput(newBoolean, opt_check) {
+ if (newBoolean) {
+ if (opt_check === undefined) {
+ opt_check = null;
+ }
+ if (!this.outputConnection) {
+ this.outputConnection =
+ this.makeConnection_(ConnectionType.OUTPUT_VALUE);
+ }
+ this.outputConnection.setCheck(opt_check);
+ } else {
+ if (this.outputConnection) {
+ if (this.outputConnection.isConnected()) {
+ throw Error(
+ 'Must disconnect output value before removing connection.');
+ }
+ this.outputConnection.dispose();
+ this.outputConnection = null;
+ }
+ }
+ }
+
+ /**
+ * Set whether value inputs are arranged horizontally or vertically.
+ * @param {boolean} newBoolean True if inputs are horizontal.
+ */
+ setInputsInline(newBoolean) {
+ if (this.inputsInline !== newBoolean) {
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
+ this, 'inline', null, this.inputsInline, newBoolean));
+ this.inputsInline = newBoolean;
+ }
+ }
+
+ /**
+ * Get whether value inputs are arranged horizontally or vertically.
+ * @return {boolean} True if inputs are horizontal.
+ */
+ getInputsInline() {
+ if (this.inputsInline !== undefined) {
+ // Set explicitly.
+ return this.inputsInline;
+ }
+ // Not defined explicitly. Figure out what would look best.
+ for (let i = 1; i < this.inputList.length; i++) {
+ if (this.inputList[i - 1].type === inputTypes.DUMMY &&
+ this.inputList[i].type === inputTypes.DUMMY) {
+ // Two dummy inputs in a row. Don't inline them.
+ return false;
+ }
+ }
+ for (let i = 1; i < this.inputList.length; i++) {
+ if (this.inputList[i - 1].type === inputTypes.VALUE &&
+ this.inputList[i].type === inputTypes.DUMMY) {
+ // Dummy input after a value input. Inline them.
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Set the block's output shape.
+ * @param {?number} outputShape Value representing an output shape.
+ */
+ setOutputShape(outputShape) {
+ this.outputShape_ = outputShape;
+ }
+
+ /**
+ * Get the block's output shape.
+ * @return {?number} Value representing output shape if one exists.
+ */
+ getOutputShape() {
+ return this.outputShape_;
+ }
+
+ /**
+ * Get whether this block is enabled or not.
+ * @return {boolean} True if enabled.
+ */
+ isEnabled() {
+ return !this.disabled;
+ }
+
+ /**
+ * Set whether the block is enabled or not.
+ * @param {boolean} enabled True if enabled.
+ */
+ setEnabled(enabled) {
+ if (this.isEnabled() !== enabled) {
+ const oldValue = this.disabled;
+ this.disabled = !enabled;
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
+ this, 'disabled', null, oldValue, !enabled));
+ }
+ }
+
+ /**
+ * Get whether the block is disabled or not due to parents.
+ * The block's own disabled property is not considered.
+ * @return {boolean} True if disabled.
+ */
+ getInheritedDisabled() {
+ let ancestor = this.getSurroundParent();
+ while (ancestor) {
+ if (ancestor.disabled) {
+ return true;
+ }
+ ancestor = ancestor.getSurroundParent();
+ }
+ // Ran off the top.
+ return false;
+ }
+
+ /**
+ * Get whether the block is collapsed or not.
+ * @return {boolean} True if collapsed.
+ */
+ isCollapsed() {
+ return this.collapsed_;
+ }
+
+ /**
+ * Set whether the block is collapsed or not.
+ * @param {boolean} collapsed True if collapsed.
+ */
+ setCollapsed(collapsed) {
+ if (this.collapsed_ !== collapsed) {
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
+ this, 'collapsed', null, this.collapsed_, collapsed));
+ this.collapsed_ = collapsed;
+ }
+ }
+
+ /**
+ * Create a human-readable text representation of this block and any children.
+ * @param {number=} opt_maxLength Truncate the string to this length.
+ * @param {string=} opt_emptyToken The placeholder string used to denote an
+ * empty field. If not specified, '?' is used.
+ * @return {string} Text of block.
+ */
+ toString(opt_maxLength, opt_emptyToken) {
+ let text = [];
+ const emptyFieldPlaceholder = opt_emptyToken || '?';
+
+ // Temporarily set flag to navigate to all fields.
+ const prevNavigateFields = ASTNode.NAVIGATE_ALL_FIELDS;
+ ASTNode.NAVIGATE_ALL_FIELDS = true;
+
+ let node = ASTNode.createBlockNode(this);
+ const rootNode = node;
+
+ /**
+ * Whether or not to add parentheses around an input.
+ * @param {!Connection} connection The connection.
+ * @return {boolean} True if we should add parentheses around the input.
+ */
+ function shouldAddParentheses(connection) {
+ let checks = connection.getCheck();
+ if (!checks && connection.targetConnection) {
+ checks = connection.targetConnection.getCheck();
+ }
+ return !!checks &&
+ (checks.indexOf('Boolean') !== -1 || checks.indexOf('Number') !== -1);
+ }
+
+ /**
+ * Check that we haven't circled back to the original root node.
+ */
+ function checkRoot() {
+ if (node && node.getType() === rootNode.getType() &&
+ node.getLocation() === rootNode.getLocation()) {
+ node = null;
+ }
+ }
+
+ // Traverse the AST building up our text string.
+ while (node) {
+ switch (node.getType()) {
+ case ASTNode.types.INPUT: {
+ const connection = /** @type {!Connection} */ (node.getLocation());
+ if (!node.in()) {
+ text.push(emptyFieldPlaceholder);
+ } else if (shouldAddParentheses(connection)) {
+ text.push('(');
+ }
+ break;
+ }
+ case ASTNode.types.FIELD: {
+ const field = /** @type {Field} */ (node.getLocation());
+ if (field.name !== constants.COLLAPSED_FIELD_NAME) {
+ text.push(field.getText());
+ }
+ break;
+ }
+ }
+
+ const current = node;
+ node = current.in() || current.next();
+ if (!node) {
+ // Can't go in or next, keep going out until we can go next.
+ node = current.out();
+ checkRoot();
+ while (node && !node.next()) {
+ node = node.out();
+ checkRoot();
+ // If we hit an input on the way up, possibly close out parentheses.
+ if (node && node.getType() === ASTNode.types.INPUT &&
+ shouldAddParentheses(
+ /** @type {!Connection} */ (node.getLocation()))) {
+ text.push(')');
+ }
+ }
+ if (node) {
+ node = node.next();
+ }
+ }
+ }
+
+ // Restore state of NAVIGATE_ALL_FIELDS.
+ ASTNode.NAVIGATE_ALL_FIELDS = prevNavigateFields;
+
+ // Run through our text array and simplify expression to remove parentheses
+ // around single field blocks.
+ // E.g. ['repeat', '(', '10', ')', 'times', 'do', '?']
+ for (let i = 2; i < text.length; i++) {
+ if (text[i - 2] === '(' && text[i] === ')') {
+ text[i - 2] = text[i - 1];
+ text.splice(i - 1, 2);
+ }
+ }
+
+ // Join the text array, removing spaces around added parentheses.
+ text = text.reduce(function(acc, value) {
+ return acc + ((acc.substr(-1) === '(' || value === ')') ? '' : ' ') +
+ value;
+ }, '');
+ text = text.trim() || '???';
+ if (opt_maxLength) {
+ // TODO: Improve truncation so that text from this block is given
+ // priority. E.g. "1+2+3+4+5+6+7+8+9=0" should be "...6+7+8+9=0", not
+ // "1+2+3+4+5...". E.g. "1+2+3+4+5=6+7+8+9+0" should be "...4+5=6+7...".
+ if (text.length > opt_maxLength) {
+ text = text.substring(0, opt_maxLength - 3) + '...';
+ }
+ }
+ return text;
+ }
+
+ /**
+ * Shortcut for appending a value input row.
+ * @param {string} name Language-neutral identifier which may used to find
+ * this input again. Should be unique to this block.
+ * @return {!Input} The input object created.
+ */
+ appendValueInput(name) {
+ return this.appendInput_(inputTypes.VALUE, name);
+ }
+
+ /**
+ * Shortcut for appending a statement input row.
+ * @param {string} name Language-neutral identifier which may used to find
+ * this input again. Should be unique to this block.
+ * @return {!Input} The input object created.
+ */
+ appendStatementInput(name) {
+ return this.appendInput_(inputTypes.STATEMENT, name);
+ }
+
+ /**
+ * Shortcut for appending a dummy input row.
+ * @param {string=} opt_name Language-neutral identifier which may used to
+ * find this input again. Should be unique to this block.
+ * @return {!Input} The input object created.
+ */
+ appendDummyInput(opt_name) {
+ return this.appendInput_(inputTypes.DUMMY, opt_name || '');
+ }
+
+ /**
+ * Initialize this block using a cross-platform, internationalization-friendly
+ * JSON description.
+ * @param {!Object} json Structured data describing the block.
+ */
+ jsonInit(json) {
+ const warningPrefix = json['type'] ? 'Block "' + json['type'] + '": ' : '';
+
+ // Validate inputs.
+ if (json['output'] && json['previousStatement']) {
+ throw Error(
+ warningPrefix +
+ 'Must not have both an output and a previousStatement.');
+ }
+
+ // Set basic properties of block.
+ // Makes styles backward compatible with old way of defining hat style.
+ if (json['style'] && json['style'].hat) {
+ this.hat = json['style'].hat;
+ // Must set to null so it doesn't error when checking for style and
+ // colour.
+ json['style'] = null;
+ }
+
+ if (json['style'] && json['colour']) {
+ throw Error(warningPrefix + 'Must not have both a colour and a style.');
+ } else if (json['style']) {
+ this.jsonInitStyle_(json, warningPrefix);
+ } else {
+ this.jsonInitColour_(json, warningPrefix);
+ }
+
+ // Interpolate the message blocks.
+ let i = 0;
+ while (json['message' + i] !== undefined) {
+ this.interpolate_(
+ json['message' + i], json['args' + i] || [],
+ json['lastDummyAlign' + i], warningPrefix);
+ i++;
+ }
+
+ if (json['inputsInline'] !== undefined) {
+ this.setInputsInline(json['inputsInline']);
+ }
+ // Set output and previous/next connections.
+ if (json['output'] !== undefined) {
+ this.setOutput(true, json['output']);
+ }
+ if (json['outputShape'] !== undefined) {
+ this.setOutputShape(json['outputShape']);
+ }
+ if (json['previousStatement'] !== undefined) {
+ this.setPreviousStatement(true, json['previousStatement']);
+ }
+ if (json['nextStatement'] !== undefined) {
+ this.setNextStatement(true, json['nextStatement']);
+ }
+ if (json['tooltip'] !== undefined) {
+ const rawValue = json['tooltip'];
+ const localizedText = parsing.replaceMessageReferences(rawValue);
+ this.setTooltip(localizedText);
+ }
+ if (json['enableContextMenu'] !== undefined) {
+ this.contextMenu = !!json['enableContextMenu'];
+ }
+ if (json['suppressPrefixSuffix'] !== undefined) {
+ this.suppressPrefixSuffix = !!json['suppressPrefixSuffix'];
+ }
+ if (json['helpUrl'] !== undefined) {
+ const rawValue = json['helpUrl'];
+ const localizedValue = parsing.replaceMessageReferences(rawValue);
+ this.setHelpUrl(localizedValue);
+ }
+ if (typeof json['extensions'] === 'string') {
+ console.warn(
+ warningPrefix +
+ 'JSON attribute \'extensions\' should be an array of' +
+ ' strings. Found raw string in JSON for \'' + json['type'] +
+ '\' block.');
+ json['extensions'] = [json['extensions']]; // Correct and continue.
+ }
+
+ // Add the mutator to the block.
+ if (json['mutator'] !== undefined) {
+ Extensions.apply(json['mutator'], this, true);
+ }
+
+ const extensionNames = json['extensions'];
+ if (Array.isArray(extensionNames)) {
+ for (let j = 0; j < extensionNames.length; j++) {
+ Extensions.apply(extensionNames[j], this, false);
+ }
+ }
+ }
+
+ /**
+ * Initialize the colour of this block from the JSON description.
+ * @param {!Object} json Structured data describing the block.
+ * @param {string} warningPrefix Warning prefix string identifying block.
+ * @private
+ */
+ jsonInitColour_(json, warningPrefix) {
+ if ('colour' in json) {
+ if (json['colour'] === undefined) {
+ console.warn(warningPrefix + 'Undefined colour value.');
+ } else {
+ const rawValue = json['colour'];
+ try {
+ this.setColour(rawValue);
+ } catch (e) {
+ console.warn(warningPrefix + 'Illegal colour value: ', rawValue);
+ }
+ }
+ }
+ }
+
+ /**
+ * Initialize the style of this block from the JSON description.
+ * @param {!Object} json Structured data describing the block.
+ * @param {string} warningPrefix Warning prefix string identifying block.
+ * @private
+ */
+ jsonInitStyle_(json, warningPrefix) {
+ const blockStyleName = json['style'];
+ try {
+ this.setStyle(blockStyleName);
+ } catch (styleError) {
+ console.warn(warningPrefix + 'Style does not exist: ', blockStyleName);
+ }
+ }
+
+ /**
+ * Add key/values from mixinObj to this block object. By default, this method
+ * will check that the keys in mixinObj will not overwrite existing values in
+ * the block, including prototype values. This provides some insurance against
+ * mixin / extension incompatibilities with future block features. This check
+ * can be disabled by passing true as the second argument.
+ * @param {!Object} mixinObj The key/values pairs to add to this block object.
+ * @param {boolean=} opt_disableCheck Option flag to disable overwrite checks.
+ */
+ mixin(mixinObj, opt_disableCheck) {
+ if (opt_disableCheck !== undefined &&
+ typeof opt_disableCheck !== 'boolean') {
+ throw Error('opt_disableCheck must be a boolean if provided');
+ }
+ if (!opt_disableCheck) {
+ const overwrites = [];
+ for (const key in mixinObj) {
+ if (this[key] !== undefined) {
+ overwrites.push(key);
+ }
+ }
+ if (overwrites.length) {
+ throw Error(
+ 'Mixin will overwrite block members: ' +
+ JSON.stringify(overwrites));
+ }
+ }
+ object.mixin(this, mixinObj);
+ }
+
+ /**
+ * Interpolate a message description onto the block.
+ * @param {string} message Text contains interpolation tokens (%1, %2, ...)
+ * that match with fields or inputs defined in the args array.
+ * @param {!Array} args Array of arguments to be interpolated.
+ * @param {string|undefined} lastDummyAlign If a dummy input is added at the
+ * end, how should it be aligned?
+ * @param {string} warningPrefix Warning prefix string identifying block.
+ * @private
+ */
+ interpolate_(message, args, lastDummyAlign, warningPrefix) {
+ const tokens = parsing.tokenizeInterpolation(message);
+ this.validateTokens_(tokens, args.length);
+ const elements = this.interpolateArguments_(tokens, args, lastDummyAlign);
+
+ // An array of [field, fieldName] tuples.
+ const fieldStack = [];
+ for (let i = 0, element; (element = elements[i]); i++) {
+ if (this.isInputKeyword_(element['type'])) {
+ const input = this.inputFromJson_(element, warningPrefix);
+ // Should never be null, but just in case.
+ if (input) {
+ for (let j = 0, tuple; (tuple = fieldStack[j]); j++) {
+ input.appendField(tuple[0], tuple[1]);
+ }
+ fieldStack.length = 0;
+ }
+ } else {
+ // All other types, including ones starting with 'input_' get routed
+ // here.
+ const field = this.fieldFromJson_(element);
+ if (field) {
+ fieldStack.push([field, element['name']]);
+ }
+ }
+ }
+ }
+
+ /**
+ * Validates that the tokens are within the correct bounds, with no
+ * duplicates, and that all of the arguments are referred to. Throws errors if
+ * any of these things are not true.
+ * @param {!Array} tokens An array of tokens to validate
+ * @param {number} argsCount The number of args that need to be referred to.
+ * @private
+ */
+ validateTokens_(tokens, argsCount) {
+ const visitedArgsHash = [];
+ let visitedArgsCount = 0;
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i];
+ if (typeof token !== 'number') {
+ continue;
+ }
+ if (token < 1 || token > argsCount) {
+ throw Error(
+ 'Block "' + this.type + '": ' +
+ 'Message index %' + token + ' out of range.');
+ }
+ if (visitedArgsHash[token]) {
+ throw Error(
+ 'Block "' + this.type + '": ' +
+ 'Message index %' + token + ' duplicated.');
+ }
+ visitedArgsHash[token] = true;
+ visitedArgsCount++;
+ }
+ if (visitedArgsCount !== argsCount) {
+ throw Error(
+ 'Block "' + this.type + '": ' +
+ 'Message does not reference all ' + argsCount + ' arg(s).');
+ }
+ }
+
+ /**
+ * Inserts args in place of numerical tokens. String args are converted to
+ * JSON that defines a label field. If necessary an extra dummy input is added
+ * to the end of the elements.
+ * @param {!Array} tokens The tokens to interpolate
+ * @param {!Array} args The arguments to insert.
+ * @param {string|undefined} lastDummyAlign The alignment the added dummy
+ * input should have, if we are required to add one.
+ * @return {!Array} The JSON definitions of field and inputs to add
+ * to the block.
+ * @private
+ */
+ interpolateArguments_(tokens, args, lastDummyAlign) {
+ const elements = [];
+ for (let i = 0; i < tokens.length; i++) {
+ let element = tokens[i];
+ if (typeof element === 'number') {
+ element = args[element - 1];
+ }
+ // Args can be strings, which is why this isn't elseif.
+ if (typeof element === 'string') {
+ element = this.stringToFieldJson_(element);
+ if (!element) {
+ continue;
+ }
+ }
+ elements.push(element);
+ }
+
+ const length = elements.length;
+ if (length && !this.isInputKeyword_(elements[length - 1]['type'])) {
+ const dummyInput = {'type': 'input_dummy'};
+ if (lastDummyAlign) {
+ dummyInput['align'] = lastDummyAlign;
+ }
+ elements.push(dummyInput);
+ }
+
+ return elements;
+ }
+
+ /**
+ * Creates a field from the JSON definition of a field. If a field with the
+ * given type cannot be found, this attempts to create a different field using
+ * the 'alt' property of the JSON definition (if it exists).
+ * @param {{alt:(string|undefined)}} element The element to try to turn into a
+ * field.
+ * @return {?Field} The field defined by the JSON, or null if one
+ * couldn't be created.
+ * @private
+ */
+ fieldFromJson_(element) {
+ const field = fieldRegistry.fromJson(element);
+ if (!field && element['alt']) {
+ if (typeof element['alt'] === 'string') {
+ const json = this.stringToFieldJson_(element['alt']);
+ return json ? this.fieldFromJson_(json) : null;
+ }
+ return this.fieldFromJson_(element['alt']);
+ }
+ return field;
+ }
+
+ /**
+ * Creates an input from the JSON definition of an input. Sets the input's
+ * check and alignment if they are provided.
+ * @param {!Object} element The JSON to turn into an input.
+ * @param {string} warningPrefix The prefix to add to warnings to help the
+ * developer debug.
+ * @return {?Input} The input that has been created, or null if one
+ * could not be created for some reason (should never happen).
+ * @private
+ */
+ inputFromJson_(element, warningPrefix) {
+ const alignmentLookup = {
+ 'LEFT': Align.LEFT,
+ 'RIGHT': Align.RIGHT,
+ 'CENTRE': Align.CENTRE,
+ 'CENTER': Align.CENTRE,
+ };
+
+ let input = null;
+ switch (element['type']) {
+ case 'input_value':
+ input = this.appendValueInput(element['name']);
+ break;
+ case 'input_statement':
+ input = this.appendStatementInput(element['name']);
+ break;
+ case 'input_dummy':
+ input = this.appendDummyInput(element['name']);
+ break;
+ }
+ // Should never be hit because of interpolate_'s checks, but just in case.
+ if (!input) {
+ return null;
+ }
+
+ if (element['check']) {
+ input.setCheck(element['check']);
+ }
+ if (element['align']) {
+ const alignment = alignmentLookup[element['align'].toUpperCase()];
+ if (alignment === undefined) {
+ console.warn(warningPrefix + 'Illegal align value: ', element['align']);
+ } else {
+ input.setAlign(alignment);
+ }
+ }
+ return input;
+ }
+
+ /**
+ * Returns true if the given string matches one of the input keywords.
+ * @param {string} str The string to check.
+ * @return {boolean} True if the given string matches one of the input
+ * keywords, false otherwise.
+ * @private
+ */
+ isInputKeyword_(str) {
+ return str === 'input_value' || str === 'input_statement' ||
+ str === 'input_dummy';
+ }
+
+ /**
+ * Turns a string into the JSON definition of a label field. If the string
+ * becomes an empty string when trimmed, this returns null.
+ * @param {string} str String to turn into the JSON definition of a label
+ * field.
+ * @return {?{text: string, type: string}} The JSON definition or null.
+ * @private
+ */
+ stringToFieldJson_(str) {
+ str = str.trim();
+ if (str) {
+ return {
+ 'type': 'field_label',
+ 'text': str,
+ };
+ }
+ return null;
+ }
+
+ /**
+ * Add a value input, statement input or local variable to this block.
+ * @param {number} type One of Blockly.inputTypes.
+ * @param {string} name Language-neutral identifier which may used to find
+ * this input again. Should be unique to this block.
+ * @return {!Input} The input object created.
+ * @protected
+ */
+ appendInput_(type, name) {
+ let connection = null;
+ if (type === inputTypes.VALUE || type === inputTypes.STATEMENT) {
+ connection = this.makeConnection_(type);
+ }
+ if (type === inputTypes.STATEMENT) {
+ this.statementInputCount++;
+ }
+ const input = new Input(type, name, this, connection);
+ // Append input to list.
+ this.inputList.push(input);
+ return input;
+ }
+
+ /**
+ * Move a named input to a different location on this block.
+ * @param {string} name The name of the input to move.
+ * @param {?string} refName Name of input that should be after the moved
+ * input,
+ * or null to be the input at the end.
+ */
+ moveInputBefore(name, refName) {
+ if (name === refName) {
+ return;
+ }
+ // Find both inputs.
+ let inputIndex = -1;
+ let refIndex = refName ? -1 : this.inputList.length;
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.name === name) {
+ inputIndex = i;
+ if (refIndex !== -1) {
+ break;
+ }
+ } else if (refName && input.name === refName) {
+ refIndex = i;
+ if (inputIndex !== -1) {
+ break;
+ }
+ }
+ }
+ if (inputIndex === -1) {
+ throw Error('Named input "' + name + '" not found.');
+ }
+ if (refIndex === -1) {
+ throw Error('Reference input "' + refName + '" not found.');
+ }
+ this.moveNumberedInputBefore(inputIndex, refIndex);
+ }
+
+ /**
+ * Move a numbered input to a different location on this block.
+ * @param {number} inputIndex Index of the input to move.
+ * @param {number} refIndex Index of input that should be after the moved
+ * input.
+ */
+ moveNumberedInputBefore(inputIndex, refIndex) {
+ // Validate arguments.
+ if (inputIndex === refIndex) {
+ throw Error('Can\'t move input to itself.');
+ }
+ if (inputIndex >= this.inputList.length) {
+ throw RangeError('Input index ' + inputIndex + ' out of bounds.');
+ }
+ if (refIndex > this.inputList.length) {
+ throw RangeError('Reference input ' + refIndex + ' out of bounds.');
+ }
+ // Remove input.
+ const input = this.inputList[inputIndex];
+ this.inputList.splice(inputIndex, 1);
+ if (inputIndex < refIndex) {
+ refIndex--;
+ }
+ // Reinsert input.
+ this.inputList.splice(refIndex, 0, input);
+ }
+
+ /**
+ * Remove an input from this block.
+ * @param {string} name The name of the input.
+ * @param {boolean=} opt_quiet True to prevent an error if input is not
+ * present.
+ * @return {boolean} True if operation succeeds, false if input is not present
+ * and opt_quiet is true.
+ * @throws {Error} if the input is not present and opt_quiet is not true.
+ */
+ removeInput(name, opt_quiet) {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.name === name) {
+ if (input.type === inputTypes.STATEMENT) {
+ this.statementInputCount--;
+ }
+ input.dispose();
+ this.inputList.splice(i, 1);
+ return true;
+ }
+ }
+ if (opt_quiet) {
+ return false;
+ }
+ throw Error('Input not found: ' + name);
+ }
+
+ /**
+ * Fetches the named input object.
+ * @param {string} name The name of the input.
+ * @return {?Input} The input object, or null if input does not exist.
+ */
+ getInput(name) {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.name === name) {
+ return input;
+ }
+ }
+ // This input does not exist.
+ return null;
+ }
+
+ /**
+ * Fetches the block attached to the named input.
+ * @param {string} name The name of the input.
+ * @return {?Block} The attached value block, or null if the input is
+ * either disconnected or if the input does not exist.
+ */
+ getInputTargetBlock(name) {
+ const input = this.getInput(name);
+ return input && input.connection && input.connection.targetBlock();
+ }
+
+ /**
+ * Returns the comment on this block (or null if there is no comment).
+ * @return {?string} Block's comment.
+ */
+ getCommentText() {
+ return this.commentModel.text;
+ }
+
+ /**
+ * Set this block's comment text.
+ * @param {?string} text The text, or null to delete.
+ */
+ setCommentText(text) {
+ if (this.commentModel.text === text) {
+ return;
+ }
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
+ this, 'comment', null, this.commentModel.text, text));
+ this.commentModel.text = text;
+ this.comment = text; // For backwards compatibility.
+ }
+
+ /**
+ * Set this block's warning text.
+ * @param {?string} _text The text, or null to delete.
+ * @param {string=} _opt_id An optional ID for the warning text to be able to
+ * maintain multiple warnings.
+ */
+ setWarningText(_text, _opt_id) {
+ // NOP.
+ }
+
+ /**
+ * Give this block a mutator dialog.
+ * @param {Mutator} _mutator A mutator dialog instance or null to
+ * remove.
+ */
+ setMutator(_mutator) {
+ // NOP.
+ }
+
+ /**
+ * Return the coordinates of the top-left corner of this block relative to the
+ * drawing surface's origin (0,0), in workspace units.
+ * @return {!Coordinate} Object with .x and .y properties.
+ */
+ getRelativeToSurfaceXY() {
+ return this.xy_;
+ }
+
+ /**
+ * Move a block by a relative offset.
+ * @param {number} dx Horizontal offset, in workspace units.
+ * @param {number} dy Vertical offset, in workspace units.
+ */
+ moveBy(dx, dy) {
+ if (this.parentBlock_) {
+ throw Error('Block has parent.');
+ }
+ const event = /** @type {!BlockMove} */ (
+ new (eventUtils.get(eventUtils.BLOCK_MOVE))(this));
+ this.xy_.translate(dx, dy);
+ event.recordNew();
+ eventUtils.fire(event);
+ }
+
+ /**
+ * Create a connection of the specified type.
+ * @param {number} type The type of the connection to create.
+ * @return {!Connection} A new connection of the specified type.
+ * @protected
+ */
+ makeConnection_(type) {
+ return new Connection(this, type);
+ }
+
+ /**
+ * Recursively checks whether all statement and value inputs are filled with
+ * blocks. Also checks all following statement blocks in this stack.
+ * @param {boolean=} opt_shadowBlocksAreFilled An optional argument
+ * controlling whether shadow blocks are counted as filled. Defaults to
+ * true.
+ * @return {boolean} True if all inputs are filled, false otherwise.
+ */
+ allInputsFilled(opt_shadowBlocksAreFilled) {
+ // Account for the shadow block filledness toggle.
+ if (opt_shadowBlocksAreFilled === undefined) {
+ opt_shadowBlocksAreFilled = true;
+ }
+ if (!opt_shadowBlocksAreFilled && this.isShadow()) {
+ return false;
+ }
+
+ // Recursively check each input block of the current block.
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (!input.connection) {
+ continue;
+ }
+ const target = input.connection.targetBlock();
+ if (!target || !target.allInputsFilled(opt_shadowBlocksAreFilled)) {
+ return false;
+ }
+ }
+
+ // Recursively check the next block after the current block.
+ const next = this.getNextBlock();
+ if (next) {
+ return next.allInputsFilled(opt_shadowBlocksAreFilled);
+ }
+
+ return true;
+ }
+
+ /**
+ * This method returns a string describing this Block in developer terms (type
+ * name and ID; English only).
+ *
+ * Intended to on be used in console logs and errors. If you need a string
+ * that uses the user's native language (including block text, field values,
+ * and child blocks), use [toString()]{@link Block#toString}.
+ * @return {string} The description.
+ */
+ toDevString() {
+ let msg = this.type ? '"' + this.type + '" block' : 'Block';
+ if (this.id) {
+ msg += ' (id="' + this.id + '")';
+ }
+ return msg;
+ }
+}
/**
* @typedef {{
@@ -271,6 +2188,14 @@ const Block = function(workspace, prototypeName, opt_id) {
*/
Block.CommentModel;
+/**
+ * An optional callback method to use whenever the block's parent workspace
+ * changes. This is usually only called from the constructor, the block type
+ * initializer function, or an extension initializer function.
+ * @type {undefined|?function(Abstract)}
+ */
+Block.prototype.onchange;
+
/**
* The language-neutral ID given to the collapsed input.
* @const {string}
@@ -283,1874 +2208,4 @@ Block.COLLAPSED_INPUT_NAME = constants.COLLAPSED_INPUT_NAME;
*/
Block.COLLAPSED_FIELD_NAME = constants.COLLAPSED_FIELD_NAME;
-/**
- * Optional text data that round-trips between blocks and XML.
- * Has no effect. May be used by 3rd parties for meta information.
- * @type {?string}
- */
-Block.prototype.data = null;
-
-/**
- * Has this block been disposed of?
- * @type {boolean}
- * @package
- */
-Block.prototype.disposed = false;
-
-/**
- * Colour of the block as HSV hue value (0-360)
- * This may be null if the block colour was not set via a hue number.
- * @type {?number}
- * @private
- */
-Block.prototype.hue_ = null;
-
-/**
- * Colour of the block in '#RRGGBB' format.
- * @type {string}
- * @protected
- */
-Block.prototype.colour_ = '#000000';
-
-/**
- * Name of the block style.
- * @type {string}
- * @protected
- */
-Block.prototype.styleName_ = '';
-
-/**
- * An optional method called during initialization.
- * @type {?function()}
- */
-Block.prototype.init;
-
-/**
- * An optional callback method to use whenever the block's parent workspace
- * changes. This is usually only called from the constructor, the block type
- * initializer function, or an extension initializer function.
- * @type {?function(Abstract)}
- */
-Block.prototype.onchange;
-
-/**
- * An optional serialization method for defining how to serialize the
- * mutation state to XML. This must be coupled with defining `domToMutation`.
- * @type {?function(...):!Element}
- */
-Block.prototype.mutationToDom;
-
-/**
- * An optional deserialization method for defining how to deserialize the
- * mutation state from XML. This must be coupled with defining `mutationToDom`.
- * @type {?function(!Element)}
- */
-Block.prototype.domToMutation;
-
-/**
- * An optional serialization method for defining how to serialize the block's
- * extra state (eg mutation state) to something JSON compatible. This must be
- * coupled with defining `loadExtraState`.
- * @type {?function(): *}
- */
-Block.prototype.saveExtraState;
-
-/**
- * An optional serialization method for defining how to deserialize the block's
- * extra state (eg mutation state) from something JSON compatible. This must be
- * coupled with defining `saveExtraState`.
- * @type {?function(*)}
- */
-Block.prototype.loadExtraState;
-
-/**
- * An optional property for suppressing adding STATEMENT_PREFIX and
- * STATEMENT_SUFFIX to generated code.
- * @type {?boolean}
- */
-Block.prototype.suppressPrefixSuffix;
-
-/**
- * An optional property for declaring developer variables. Return a list of
- * variable names for use by generators. Developer variables are never shown to
- * the user, but are declared as global variables in the generated code.
- * @type {?function():!Array}
- */
-Block.prototype.getDeveloperVariables;
-
-/**
- * Dispose of this block.
- * @param {boolean} healStack If true, then try to heal any gap by connecting
- * the next statement with the previous statement. Otherwise, dispose of
- * all children of this block.
- * @suppress {checkTypes}
- */
-Block.prototype.dispose = function(healStack) {
- if (!this.workspace) {
- // Already deleted.
- return;
- }
- // Terminate onchange event calls.
- if (this.onchangeWrapper_) {
- this.workspace.removeChangeListener(this.onchangeWrapper_);
- }
-
- this.unplug(healStack);
- if (eventUtils.isEnabled()) {
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_DELETE))(this));
- }
- eventUtils.disable();
-
- try {
- // This block is now at the top of the workspace.
- // Remove this block from the workspace's list of top-most blocks.
- if (this.workspace) {
- this.workspace.removeTopBlock(this);
- this.workspace.removeTypedBlock(this);
- // Remove from block database.
- this.workspace.removeBlockById(this.id);
- this.workspace = null;
- }
-
- // Just deleting this block from the DOM would result in a memory leak as
- // well as corruption of the connection database. Therefore we must
- // methodically step through the blocks and carefully disassemble them.
-
- if (common.getSelected() === this) {
- common.setSelected(null);
- }
-
- // First, dispose of all my children.
- for (let i = this.childBlocks_.length - 1; i >= 0; i--) {
- this.childBlocks_[i].dispose(false);
- }
- // Then dispose of myself.
- // Dispose of all inputs and their fields.
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- input.dispose();
- }
- this.inputList.length = 0;
- // Dispose of any remaining connections (next/previous/output).
- const connections = this.getConnections_(true);
- for (let i = 0, connection; (connection = connections[i]); i++) {
- connection.dispose();
- }
- } finally {
- eventUtils.enable();
- this.disposed = true;
- }
-};
-
-/**
- * Call initModel on all fields on the block.
- * May be called more than once.
- * Either initModel or initSvg must be called after creating a block and before
- * the first interaction with it. Interactions include UI actions
- * (e.g. clicking and dragging) and firing events (e.g. create, delete, and
- * change).
- * @public
- */
-Block.prototype.initModel = function() {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- for (let j = 0, field; (field = input.fieldRow[j]); j++) {
- if (field.initModel) {
- field.initModel();
- }
- }
- }
-};
-
-/**
- * Unplug this block from its superior block. If this block is a statement,
- * optionally reconnect the block underneath with the block on top.
- * @param {boolean=} opt_healStack Disconnect child statement and reconnect
- * stack. Defaults to false.
- */
-Block.prototype.unplug = function(opt_healStack) {
- if (this.outputConnection) {
- this.unplugFromRow_(opt_healStack);
- }
- if (this.previousConnection) {
- this.unplugFromStack_(opt_healStack);
- }
-};
-
-/**
- * Unplug this block's output from an input on another block. Optionally
- * reconnect the block's parent to the only child block, if possible.
- * @param {boolean=} opt_healStack Disconnect right-side block and connect to
- * left-side block. Defaults to false.
- * @private
- */
-Block.prototype.unplugFromRow_ = function(opt_healStack) {
- let parentConnection = null;
- if (this.outputConnection.isConnected()) {
- parentConnection = this.outputConnection.targetConnection;
- // Disconnect from any superior block.
- this.outputConnection.disconnect();
- }
-
- // Return early in obvious cases.
- if (!parentConnection || !opt_healStack) {
- return;
- }
-
- const thisConnection = this.getOnlyValueConnection_();
- if (!thisConnection || !thisConnection.isConnected() ||
- thisConnection.targetBlock().isShadow()) {
- // Too many or too few possible connections on this block, or there's
- // nothing on the other side of this connection.
- return;
- }
-
- const childConnection = thisConnection.targetConnection;
- // Disconnect the child block.
- childConnection.disconnect();
- // Connect child to the parent if possible, otherwise bump away.
- if (this.workspace.connectionChecker.canConnect(
- childConnection, parentConnection, false)) {
- parentConnection.connect(childConnection);
- } else {
- childConnection.onFailedConnect(parentConnection);
- }
-};
-
-/**
- * Returns the connection on the value input that is connected to another block.
- * When an insertion marker is connected to a connection with a block already
- * attached, the connected block is attached to the insertion marker.
- * Since only one block can be displaced and attached to the insertion marker
- * this should only ever return one connection.
- *
- * @return {?Connection} The connection on the value input, or null.
- * @private
- */
-Block.prototype.getOnlyValueConnection_ = function() {
- let connection = null;
- for (let i = 0; i < this.inputList.length; i++) {
- const thisConnection = this.inputList[i].connection;
- if (thisConnection && thisConnection.type === ConnectionType.INPUT_VALUE &&
- thisConnection.targetConnection) {
- if (connection) {
- return null; // More than one value input found.
- }
- connection = thisConnection;
- }
- }
- return connection;
-};
-
-/**
- * Unplug this statement block from its superior block. Optionally reconnect
- * the block underneath with the block on top.
- * @param {boolean=} opt_healStack Disconnect child statement and reconnect
- * stack. Defaults to false.
- * @private
- */
-Block.prototype.unplugFromStack_ = function(opt_healStack) {
- let previousTarget = null;
- if (this.previousConnection.isConnected()) {
- // Remember the connection that any next statements need to connect to.
- previousTarget = this.previousConnection.targetConnection;
- // Detach this block from the parent's tree.
- this.previousConnection.disconnect();
- }
- const nextBlock = this.getNextBlock();
- if (opt_healStack && nextBlock && !nextBlock.isShadow()) {
- // Disconnect the next statement.
- const nextTarget = this.nextConnection.targetConnection;
- nextTarget.disconnect();
- if (previousTarget &&
- this.workspace.connectionChecker.canConnect(
- previousTarget, nextTarget, false)) {
- // Attach the next statement to the previous statement.
- previousTarget.connect(nextTarget);
- }
- }
-};
-
-/**
- * Returns all connections originating from this block.
- * @param {boolean} _all If true, return all connections even hidden ones.
- * @return {!Array} Array of connections.
- * @package
- */
-Block.prototype.getConnections_ = function(_all) {
- const myConnections = [];
- if (this.outputConnection) {
- myConnections.push(this.outputConnection);
- }
- if (this.previousConnection) {
- myConnections.push(this.previousConnection);
- }
- if (this.nextConnection) {
- myConnections.push(this.nextConnection);
- }
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.connection) {
- myConnections.push(input.connection);
- }
- }
- return myConnections;
-};
-
-/**
- * Walks down a stack of blocks and finds the last next connection on the stack.
- * @param {boolean} ignoreShadows If true,the last connection on a non-shadow
- * block will be returned. If false, this will follow shadows to find the
- * last connection.
- * @return {?Connection} The last next connection on the stack, or null.
- * @package
- */
-Block.prototype.lastConnectionInStack = function(ignoreShadows) {
- let nextConnection = this.nextConnection;
- while (nextConnection) {
- const nextBlock = nextConnection.targetBlock();
- if (!nextBlock || (ignoreShadows && nextBlock.isShadow())) {
- return nextConnection;
- }
- nextConnection = nextBlock.nextConnection;
- }
- return null;
-};
-
-/**
- * Bump unconnected blocks out of alignment. Two blocks which aren't actually
- * connected should not coincidentally line up on screen.
- */
-Block.prototype.bumpNeighbours = function() {
- // noop.
-};
-
-/**
- * Return the parent block or null if this block is at the top level. The parent
- * block is either the block connected to the previous connection (for a
- * statement block) or the block connected to the output connection (for a value
- * block).
- * @return {?Block} The block (if any) that holds the current block.
- */
-Block.prototype.getParent = function() {
- return this.parentBlock_;
-};
-
-/**
- * Return the input that connects to the specified block.
- * @param {!Block} block A block connected to an input on this block.
- * @return {?Input} The input (if any) that connects to the specified
- * block.
- */
-Block.prototype.getInputWithBlock = function(block) {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.connection && input.connection.targetBlock() === block) {
- return input;
- }
- }
- return null;
-};
-
-/**
- * Return the parent block that surrounds the current block, or null if this
- * block has no surrounding block. A parent block might just be the previous
- * statement, whereas the surrounding block is an if statement, while loop, etc.
- * @return {?Block} The block (if any) that surrounds the current block.
- */
-Block.prototype.getSurroundParent = function() {
- let block = this;
- let prevBlock;
- do {
- prevBlock = block;
- block = block.getParent();
- if (!block) {
- // Ran off the top.
- return null;
- }
- } while (block.getNextBlock() === prevBlock);
- // This block is an enclosing parent, not just a statement in a stack.
- return block;
-};
-
-/**
- * Return the next statement block directly connected to this block.
- * @return {?Block} The next statement block or null.
- */
-Block.prototype.getNextBlock = function() {
- return this.nextConnection && this.nextConnection.targetBlock();
-};
-
-/**
- * Returns the block connected to the previous connection.
- * @return {?Block} The previous statement block or null.
- */
-Block.prototype.getPreviousBlock = function() {
- return this.previousConnection && this.previousConnection.targetBlock();
-};
-
-/**
- * Return the connection on the first statement input on this block, or null if
- * there are none.
- * @return {?Connection} The first statement connection or null.
- * @package
- */
-Block.prototype.getFirstStatementConnection = function() {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.connection &&
- input.connection.type === ConnectionType.NEXT_STATEMENT) {
- return input.connection;
- }
- }
- return null;
-};
-
-/**
- * Return the top-most block in this block's tree.
- * This will return itself if this block is at the top level.
- * @return {!Block} The root block.
- */
-Block.prototype.getRootBlock = function() {
- let rootBlock;
- let block = this;
- do {
- rootBlock = block;
- block = rootBlock.parentBlock_;
- } while (block);
- return rootBlock;
-};
-
-/**
- * Walk up from the given block up through the stack of blocks to find
- * the top block of the sub stack. If we are nested in a statement input only
- * find the top-most nested block. Do not go all the way to the root block.
- * @return {!Block} The top block in a stack.
- * @package
- */
-Block.prototype.getTopStackBlock = function() {
- let block = this;
- let previous;
- do {
- previous = block.getPreviousBlock();
- } while (previous && previous.getNextBlock() === block && (block = previous));
- return block;
-};
-
-/**
- * Find all the blocks that are directly nested inside this one.
- * Includes value and statement inputs, as well as any following statement.
- * Excludes any connection on an output tab or any preceding statement.
- * Blocks are optionally sorted by position; top to bottom.
- * @param {boolean} ordered Sort the list if true.
- * @return {!Array} Array of blocks.
- */
-Block.prototype.getChildren = function(ordered) {
- if (!ordered) {
- return this.childBlocks_;
- }
- const blocks = [];
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.connection) {
- const child = input.connection.targetBlock();
- if (child) {
- blocks.push(child);
- }
- }
- }
- const next = this.getNextBlock();
- if (next) {
- blocks.push(next);
- }
- return blocks;
-};
-
-/**
- * Set parent of this block to be a new block or null.
- * @param {Block} newParent New parent block.
- * @package
- */
-Block.prototype.setParent = function(newParent) {
- if (newParent === this.parentBlock_) {
- return;
- }
-
- // Check that block is connected to new parent if new parent is not null and
- // that block is not connected to superior one if new parent is null.
- const targetBlock =
- (this.previousConnection && this.previousConnection.targetBlock()) ||
- (this.outputConnection && this.outputConnection.targetBlock());
- const isConnected = !!targetBlock;
-
- if (isConnected && newParent && targetBlock !== newParent) {
- throw Error('Block connected to superior one that is not new parent.');
- } else if (!isConnected && newParent) {
- throw Error('Block not connected to new parent.');
- } else if (isConnected && !newParent) {
- throw Error(
- 'Cannot set parent to null while block is still connected to' +
- ' superior block.');
- }
-
- if (this.parentBlock_) {
- // Remove this block from the old parent's child list.
- arrayUtils.removeElem(this.parentBlock_.childBlocks_, this);
-
- // This block hasn't actually moved on-screen, so there's no need to update
- // its connection locations.
- } else {
- // New parent must be non-null so remove this block from the workspace's
- // list of top-most blocks.
- this.workspace.removeTopBlock(this);
- }
-
- this.parentBlock_ = newParent;
- if (newParent) {
- // Add this block to the new parent's child list.
- newParent.childBlocks_.push(this);
- } else {
- this.workspace.addTopBlock(this);
- }
-};
-
-/**
- * Find all the blocks that are directly or indirectly nested inside this one.
- * Includes this block in the list.
- * Includes value and statement inputs, as well as any following statements.
- * Excludes any connection on an output tab or any preceding statements.
- * Blocks are optionally sorted by position; top to bottom.
- * @param {boolean} ordered Sort the list if true.
- * @return {!Array} Flattened array of blocks.
- */
-Block.prototype.getDescendants = function(ordered) {
- const blocks = [this];
- const childBlocks = this.getChildren(ordered);
- for (let child, i = 0; (child = childBlocks[i]); i++) {
- blocks.push.apply(blocks, child.getDescendants(ordered));
- }
- return blocks;
-};
-
-/**
- * Get whether this block is deletable or not.
- * @return {boolean} True if deletable.
- */
-Block.prototype.isDeletable = function() {
- return this.deletable_ && !this.isShadow_ &&
- !(this.workspace && this.workspace.options.readOnly);
-};
-
-/**
- * Set whether this block is deletable or not.
- * @param {boolean} deletable True if deletable.
- */
-Block.prototype.setDeletable = function(deletable) {
- this.deletable_ = deletable;
-};
-
-/**
- * Get whether this block is movable or not.
- * @return {boolean} True if movable.
- */
-Block.prototype.isMovable = function() {
- return this.movable_ && !this.isShadow_ &&
- !(this.workspace && this.workspace.options.readOnly);
-};
-
-/**
- * Set whether this block is movable or not.
- * @param {boolean} movable True if movable.
- */
-Block.prototype.setMovable = function(movable) {
- this.movable_ = movable;
-};
-
-/**
- * Get whether is block is duplicatable or not. If duplicating this block and
- * descendants will put this block over the workspace's capacity this block is
- * not duplicatable. If duplicating this block and descendants will put any
- * type over their maxInstances this block is not duplicatable.
- * @return {boolean} True if duplicatable.
- */
-Block.prototype.isDuplicatable = function() {
- if (!this.workspace.hasBlockLimits()) {
- return true;
- }
- return this.workspace.isCapacityAvailable(
- common.getBlockTypeCounts(this, true));
-};
-
-/**
- * Get whether this block is a shadow block or not.
- * @return {boolean} True if a shadow.
- */
-Block.prototype.isShadow = function() {
- return this.isShadow_;
-};
-
-/**
- * Set whether this block is a shadow block or not.
- * @param {boolean} shadow True if a shadow.
- * @package
- */
-Block.prototype.setShadow = function(shadow) {
- this.isShadow_ = shadow;
-};
-
-/**
- * Get whether this block is an insertion marker block or not.
- * @return {boolean} True if an insertion marker.
- */
-Block.prototype.isInsertionMarker = function() {
- return this.isInsertionMarker_;
-};
-
-/**
- * Set whether this block is an insertion marker block or not.
- * Once set this cannot be unset.
- * @param {boolean} insertionMarker True if an insertion marker.
- * @package
- */
-Block.prototype.setInsertionMarker = function(insertionMarker) {
- this.isInsertionMarker_ = insertionMarker;
-};
-
-/**
- * Get whether this block is editable or not.
- * @return {boolean} True if editable.
- */
-Block.prototype.isEditable = function() {
- return this.editable_ && !(this.workspace && this.workspace.options.readOnly);
-};
-
-/**
- * Set whether this block is editable or not.
- * @param {boolean} editable True if editable.
- */
-Block.prototype.setEditable = function(editable) {
- this.editable_ = editable;
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- for (let j = 0, field; (field = input.fieldRow[j]); j++) {
- field.updateEditable();
- }
- }
-};
-
-/**
- * Returns if this block has been disposed of / deleted.
- * @return {boolean} True if this block has been disposed of / deleted.
- */
-Block.prototype.isDisposed = function() {
- return this.disposed;
-};
-
-/**
- * Find the connection on this block that corresponds to the given connection
- * on the other block.
- * Used to match connections between a block and its insertion marker.
- * @param {!Block} otherBlock The other block to match against.
- * @param {!Connection} conn The other connection to match.
- * @return {?Connection} The matching connection on this block, or null.
- * @package
- */
-Block.prototype.getMatchingConnection = function(otherBlock, conn) {
- const connections = this.getConnections_(true);
- const otherConnections = otherBlock.getConnections_(true);
- if (connections.length !== otherConnections.length) {
- throw Error('Connection lists did not match in length.');
- }
- for (let i = 0; i < otherConnections.length; i++) {
- if (otherConnections[i] === conn) {
- return connections[i];
- }
- }
- return null;
-};
-
-/**
- * Set the URL of this block's help page.
- * @param {string|Function} url URL string for block help, or function that
- * returns a URL. Null for no help.
- */
-Block.prototype.setHelpUrl = function(url) {
- this.helpUrl = url;
-};
-
-/**
- * Sets the tooltip for this block.
- * @param {!Tooltip.TipInfo} newTip The text for the tooltip, a function
- * that returns the text for the tooltip, or a parent object whose tooltip
- * will be used. To not display a tooltip pass the empty string.
- */
-Block.prototype.setTooltip = function(newTip) {
- this.tooltip = newTip;
-};
-
-/**
- * Returns the tooltip text for this block.
- * @return {!string} The tooltip text for this block.
- */
-Block.prototype.getTooltip = function() {
- return Tooltip.getTooltipOfObject(this);
-};
-
-/**
- * Get the colour of a block.
- * @return {string} #RRGGBB string.
- */
-Block.prototype.getColour = function() {
- return this.colour_;
-};
-
-/**
- * Get the name of the block style.
- * @return {string} Name of the block style.
- */
-Block.prototype.getStyleName = function() {
- return this.styleName_;
-};
-
-/**
- * Get the HSV hue value of a block. Null if hue not set.
- * @return {?number} Hue value (0-360).
- */
-Block.prototype.getHue = function() {
- return this.hue_;
-};
-
-/**
- * Change the colour of a block.
- * @param {number|string} colour HSV hue value (0 to 360), #RRGGBB string,
- * or a message reference string pointing to one of those two values.
- */
-Block.prototype.setColour = function(colour) {
- const parsed = parsing.parseBlockColour(colour);
- this.hue_ = parsed.hue;
- this.colour_ = parsed.hex;
-};
-
-/**
- * Set the style and colour values of a block.
- * @param {string} blockStyleName Name of the block style.
- */
-Block.prototype.setStyle = function(blockStyleName) {
- this.styleName_ = blockStyleName;
-};
-
-/**
- * Sets a callback function to use whenever the block's parent workspace
- * changes, replacing any prior onchange handler. This is usually only called
- * from the constructor, the block type initializer function, or an extension
- * initializer function.
- * @param {function(Abstract)} onchangeFn The callback to call
- * when the block's workspace changes.
- * @throws {Error} if onchangeFn is not falsey and not a function.
- */
-Block.prototype.setOnChange = function(onchangeFn) {
- if (onchangeFn && typeof onchangeFn !== 'function') {
- throw Error('onchange must be a function.');
- }
- if (this.onchangeWrapper_) {
- this.workspace.removeChangeListener(this.onchangeWrapper_);
- }
- this.onchange = onchangeFn;
- if (this.onchange) {
- this.onchangeWrapper_ = onchangeFn.bind(this);
- this.workspace.addChangeListener(this.onchangeWrapper_);
- }
-};
-
-/**
- * Returns the named field from a block.
- * @param {string} name The name of the field.
- * @return {?Field} Named field, or null if field does not exist.
- */
-Block.prototype.getField = function(name) {
- if (typeof name !== 'string') {
- throw TypeError(
- 'Block.prototype.getField expects a string ' +
- 'with the field name but received ' +
- (name === undefined ? 'nothing' : name + ' of type ' + typeof name) +
- ' instead');
- }
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- for (let j = 0, field; (field = input.fieldRow[j]); j++) {
- if (field.name === name) {
- return field;
- }
- }
- }
- return null;
-};
-
-/**
- * Return all variables referenced by this block.
- * @return {!Array} List of variable ids.
- */
-Block.prototype.getVars = function() {
- const vars = [];
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- for (let j = 0, field; (field = input.fieldRow[j]); j++) {
- if (field.referencesVariables()) {
- vars.push(field.getValue());
- }
- }
- }
- return vars;
-};
-
-/**
- * Return all variables referenced by this block.
- * @return {!Array} List of variable models.
- * @package
- */
-Block.prototype.getVarModels = function() {
- const vars = [];
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- for (let j = 0, field; (field = input.fieldRow[j]); j++) {
- if (field.referencesVariables()) {
- const model = this.workspace.getVariableById(
- /** @type {string} */ (field.getValue()));
- // Check if the variable actually exists (and isn't just a potential
- // variable).
- if (model) {
- vars.push(model);
- }
- }
- }
- }
- return vars;
-};
-
-/**
- * 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 {!VariableModel} variable The variable being renamed.
- * @package
- */
-Block.prototype.updateVarName = function(variable) {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- for (let j = 0, field; (field = input.fieldRow[j]); j++) {
- if (field.referencesVariables() &&
- variable.getId() === field.getValue()) {
- field.refreshVariableName();
- }
- }
- }
-};
-
-/**
- * Notification that a variable is renaming.
- * If the ID matches one of this block's variables, rename it.
- * @param {string} oldId ID of variable to rename.
- * @param {string} newId ID of new variable. May be the same as oldId, but with
- * an updated name.
- */
-Block.prototype.renameVarById = function(oldId, newId) {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- for (let j = 0, field; (field = input.fieldRow[j]); j++) {
- if (field.referencesVariables() && oldId === field.getValue()) {
- field.setValue(newId);
- }
- }
- }
-};
-
-/**
- * Returns the language-neutral value of the given field.
- * @param {string} name The name of the field.
- * @return {*} Value of the field or null if field does not exist.
- */
-Block.prototype.getFieldValue = function(name) {
- const field = this.getField(name);
- if (field) {
- return field.getValue();
- }
- return null;
-};
-
-/**
- * Sets the value of the given field for this block.
- * @param {*} newValue The value to set.
- * @param {string} name The name of the field to set the value of.
- */
-Block.prototype.setFieldValue = function(newValue, name) {
- const field = this.getField(name);
- if (!field) {
- throw Error('Field "' + name + '" not found.');
- }
- field.setValue(newValue);
-};
-
-/**
- * Set whether this block can chain onto the bottom of another block.
- * @param {boolean} newBoolean True if there can be a previous statement.
- * @param {(string|Array|null)=} opt_check Statement type or
- * list of statement types. Null/undefined if any type could be connected.
- */
-Block.prototype.setPreviousStatement = function(newBoolean, opt_check) {
- if (newBoolean) {
- if (opt_check === undefined) {
- opt_check = null;
- }
- if (!this.previousConnection) {
- this.previousConnection =
- this.makeConnection_(ConnectionType.PREVIOUS_STATEMENT);
- }
- this.previousConnection.setCheck(opt_check);
- } else {
- if (this.previousConnection) {
- if (this.previousConnection.isConnected()) {
- throw Error(
- 'Must disconnect previous statement before removing ' +
- 'connection.');
- }
- this.previousConnection.dispose();
- this.previousConnection = null;
- }
- }
-};
-
-/**
- * Set whether another block can chain onto the bottom of this block.
- * @param {boolean} newBoolean True if there can be a next statement.
- * @param {(string|Array|null)=} opt_check Statement type or
- * list of statement types. Null/undefined if any type could be connected.
- */
-Block.prototype.setNextStatement = function(newBoolean, opt_check) {
- if (newBoolean) {
- if (opt_check === undefined) {
- opt_check = null;
- }
- if (!this.nextConnection) {
- this.nextConnection = this.makeConnection_(ConnectionType.NEXT_STATEMENT);
- }
- this.nextConnection.setCheck(opt_check);
- } else {
- if (this.nextConnection) {
- if (this.nextConnection.isConnected()) {
- throw Error(
- 'Must disconnect next statement before removing ' +
- 'connection.');
- }
- this.nextConnection.dispose();
- this.nextConnection = null;
- }
- }
-};
-
-/**
- * Set whether this block returns a value.
- * @param {boolean} newBoolean True if there is an output.
- * @param {(string|Array|null)=} opt_check Returned type or list
- * of returned types. Null or undefined if any type could be returned
- * (e.g. variable get).
- */
-Block.prototype.setOutput = function(newBoolean, opt_check) {
- if (newBoolean) {
- if (opt_check === undefined) {
- opt_check = null;
- }
- if (!this.outputConnection) {
- this.outputConnection = this.makeConnection_(ConnectionType.OUTPUT_VALUE);
- }
- this.outputConnection.setCheck(opt_check);
- } else {
- if (this.outputConnection) {
- if (this.outputConnection.isConnected()) {
- throw Error('Must disconnect output value before removing connection.');
- }
- this.outputConnection.dispose();
- this.outputConnection = null;
- }
- }
-};
-
-/**
- * Set whether value inputs are arranged horizontally or vertically.
- * @param {boolean} newBoolean True if inputs are horizontal.
- */
-Block.prototype.setInputsInline = function(newBoolean) {
- if (this.inputsInline !== newBoolean) {
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
- this, 'inline', null, this.inputsInline, newBoolean));
- this.inputsInline = newBoolean;
- }
-};
-
-/**
- * Get whether value inputs are arranged horizontally or vertically.
- * @return {boolean} True if inputs are horizontal.
- */
-Block.prototype.getInputsInline = function() {
- if (this.inputsInline !== undefined) {
- // Set explicitly.
- return this.inputsInline;
- }
- // Not defined explicitly. Figure out what would look best.
- for (let i = 1; i < this.inputList.length; i++) {
- if (this.inputList[i - 1].type === inputTypes.DUMMY &&
- this.inputList[i].type === inputTypes.DUMMY) {
- // Two dummy inputs in a row. Don't inline them.
- return false;
- }
- }
- for (let i = 1; i < this.inputList.length; i++) {
- if (this.inputList[i - 1].type === inputTypes.VALUE &&
- this.inputList[i].type === inputTypes.DUMMY) {
- // Dummy input after a value input. Inline them.
- return true;
- }
- }
- return false;
-};
-
-/**
- * Set the block's output shape.
- * @param {?number} outputShape Value representing an output shape.
- */
-Block.prototype.setOutputShape = function(outputShape) {
- this.outputShape_ = outputShape;
-};
-
-/**
- * Get the block's output shape.
- * @return {?number} Value representing output shape if one exists.
- */
-Block.prototype.getOutputShape = function() {
- return this.outputShape_;
-};
-
-/**
- * Get whether this block is enabled or not.
- * @return {boolean} True if enabled.
- */
-Block.prototype.isEnabled = function() {
- return !this.disabled;
-};
-
-/**
- * Set whether the block is enabled or not.
- * @param {boolean} enabled True if enabled.
- */
-Block.prototype.setEnabled = function(enabled) {
- if (this.isEnabled() !== enabled) {
- const oldValue = this.disabled;
- this.disabled = !enabled;
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
- this, 'disabled', null, oldValue, !enabled));
- }
-};
-
-/**
- * Get whether the block is disabled or not due to parents.
- * The block's own disabled property is not considered.
- * @return {boolean} True if disabled.
- */
-Block.prototype.getInheritedDisabled = function() {
- let ancestor = this.getSurroundParent();
- while (ancestor) {
- if (ancestor.disabled) {
- return true;
- }
- ancestor = ancestor.getSurroundParent();
- }
- // Ran off the top.
- return false;
-};
-
-/**
- * Get whether the block is collapsed or not.
- * @return {boolean} True if collapsed.
- */
-Block.prototype.isCollapsed = function() {
- return this.collapsed_;
-};
-
-/**
- * Set whether the block is collapsed or not.
- * @param {boolean} collapsed True if collapsed.
- */
-Block.prototype.setCollapsed = function(collapsed) {
- if (this.collapsed_ !== collapsed) {
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
- this, 'collapsed', null, this.collapsed_, collapsed));
- this.collapsed_ = collapsed;
- }
-};
-
-/**
- * Create a human-readable text representation of this block and any children.
- * @param {number=} opt_maxLength Truncate the string to this length.
- * @param {string=} opt_emptyToken The placeholder string used to denote an
- * empty field. If not specified, '?' is used.
- * @return {string} Text of block.
- */
-Block.prototype.toString = function(opt_maxLength, opt_emptyToken) {
- let text = [];
- const emptyFieldPlaceholder = opt_emptyToken || '?';
-
- // Temporarily set flag to navigate to all fields.
- const prevNavigateFields = ASTNode.NAVIGATE_ALL_FIELDS;
- ASTNode.NAVIGATE_ALL_FIELDS = true;
-
- let node = ASTNode.createBlockNode(this);
- const rootNode = node;
-
- /**
- * Whether or not to add parentheses around an input.
- * @param {!Connection} connection The connection.
- * @return {boolean} True if we should add parentheses around the input.
- */
- function shouldAddParentheses(connection) {
- let checks = connection.getCheck();
- if (!checks && connection.targetConnection) {
- checks = connection.targetConnection.getCheck();
- }
- return !!checks &&
- (checks.indexOf('Boolean') !== -1 || checks.indexOf('Number') !== -1);
- }
-
- /**
- * Check that we haven't circled back to the original root node.
- */
- function checkRoot() {
- if (node && node.getType() === rootNode.getType() &&
- node.getLocation() === rootNode.getLocation()) {
- node = null;
- }
- }
-
- // Traverse the AST building up our text string.
- while (node) {
- switch (node.getType()) {
- case ASTNode.types.INPUT: {
- const connection = /** @type {!Connection} */ (node.getLocation());
- if (!node.in()) {
- text.push(emptyFieldPlaceholder);
- } else if (shouldAddParentheses(connection)) {
- text.push('(');
- }
- break;
- }
- case ASTNode.types.FIELD: {
- const field = /** @type {Field} */ (node.getLocation());
- if (field.name !== constants.COLLAPSED_FIELD_NAME) {
- text.push(field.getText());
- }
- break;
- }
- }
-
- const current = node;
- node = current.in() || current.next();
- if (!node) {
- // Can't go in or next, keep going out until we can go next.
- node = current.out();
- checkRoot();
- while (node && !node.next()) {
- node = node.out();
- checkRoot();
- // If we hit an input on the way up, possibly close out parentheses.
- if (node && node.getType() === ASTNode.types.INPUT &&
- shouldAddParentheses(
- /** @type {!Connection} */ (node.getLocation()))) {
- text.push(')');
- }
- }
- if (node) {
- node = node.next();
- }
- }
- }
-
- // Restore state of NAVIGATE_ALL_FIELDS.
- ASTNode.NAVIGATE_ALL_FIELDS = prevNavigateFields;
-
- // Run through our text array and simplify expression to remove parentheses
- // around single field blocks.
- // E.g. ['repeat', '(', '10', ')', 'times', 'do', '?']
- for (let i = 2; i < text.length; i++) {
- if (text[i - 2] === '(' && text[i] === ')') {
- text[i - 2] = text[i - 1];
- text.splice(i - 1, 2);
- }
- }
-
- // Join the text array, removing spaces around added parentheses.
- text = text.reduce(function(acc, value) {
- return acc + ((acc.substr(-1) === '(' || value === ')') ? '' : ' ') + value;
- }, '');
- text = text.trim() || '???';
- if (opt_maxLength) {
- // TODO: Improve truncation so that text from this block is given priority.
- // E.g. "1+2+3+4+5+6+7+8+9=0" should be "...6+7+8+9=0", not "1+2+3+4+5...".
- // E.g. "1+2+3+4+5=6+7+8+9+0" should be "...4+5=6+7...".
- if (text.length > opt_maxLength) {
- text = text.substring(0, opt_maxLength - 3) + '...';
- }
- }
- return text;
-};
-
-/**
- * Shortcut for appending a value input row.
- * @param {string} name Language-neutral identifier which may used to find this
- * input again. Should be unique to this block.
- * @return {!Input} The input object created.
- */
-Block.prototype.appendValueInput = function(name) {
- return this.appendInput_(inputTypes.VALUE, name);
-};
-
-/**
- * Shortcut for appending a statement input row.
- * @param {string} name Language-neutral identifier which may used to find this
- * input again. Should be unique to this block.
- * @return {!Input} The input object created.
- */
-Block.prototype.appendStatementInput = function(name) {
- return this.appendInput_(inputTypes.STATEMENT, name);
-};
-
-/**
- * Shortcut for appending a dummy input row.
- * @param {string=} opt_name Language-neutral identifier which may used to find
- * this input again. Should be unique to this block.
- * @return {!Input} The input object created.
- */
-Block.prototype.appendDummyInput = function(opt_name) {
- return this.appendInput_(inputTypes.DUMMY, opt_name || '');
-};
-
-/**
- * Initialize this block using a cross-platform, internationalization-friendly
- * JSON description.
- * @param {!Object} json Structured data describing the block.
- */
-Block.prototype.jsonInit = function(json) {
- const warningPrefix = json['type'] ? 'Block "' + json['type'] + '": ' : '';
-
- // Validate inputs.
- if (json['output'] && json['previousStatement']) {
- throw Error(
- warningPrefix +
- 'Must not have both an output and a previousStatement.');
- }
-
- // Set basic properties of block.
- // Makes styles backward compatible with old way of defining hat style.
- if (json['style'] && json['style'].hat) {
- this.hat = json['style'].hat;
- // Must set to null so it doesn't error when checking for style and colour.
- json['style'] = null;
- }
-
- if (json['style'] && json['colour']) {
- throw Error(warningPrefix + 'Must not have both a colour and a style.');
- } else if (json['style']) {
- this.jsonInitStyle_(json, warningPrefix);
- } else {
- this.jsonInitColour_(json, warningPrefix);
- }
-
- // Interpolate the message blocks.
- let i = 0;
- while (json['message' + i] !== undefined) {
- this.interpolate_(
- json['message' + i], json['args' + i] || [], json['lastDummyAlign' + i],
- warningPrefix);
- i++;
- }
-
- if (json['inputsInline'] !== undefined) {
- this.setInputsInline(json['inputsInline']);
- }
- // Set output and previous/next connections.
- if (json['output'] !== undefined) {
- this.setOutput(true, json['output']);
- }
- if (json['outputShape'] !== undefined) {
- this.setOutputShape(json['outputShape']);
- }
- if (json['previousStatement'] !== undefined) {
- this.setPreviousStatement(true, json['previousStatement']);
- }
- if (json['nextStatement'] !== undefined) {
- this.setNextStatement(true, json['nextStatement']);
- }
- if (json['tooltip'] !== undefined) {
- const rawValue = json['tooltip'];
- const localizedText = parsing.replaceMessageReferences(rawValue);
- this.setTooltip(localizedText);
- }
- if (json['enableContextMenu'] !== undefined) {
- this.contextMenu = !!json['enableContextMenu'];
- }
- if (json['suppressPrefixSuffix'] !== undefined) {
- this.suppressPrefixSuffix = !!json['suppressPrefixSuffix'];
- }
- if (json['helpUrl'] !== undefined) {
- const rawValue = json['helpUrl'];
- const localizedValue = parsing.replaceMessageReferences(rawValue);
- this.setHelpUrl(localizedValue);
- }
- if (typeof json['extensions'] === 'string') {
- console.warn(
- warningPrefix + 'JSON attribute \'extensions\' should be an array of' +
- ' strings. Found raw string in JSON for \'' + json['type'] +
- '\' block.');
- json['extensions'] = [json['extensions']]; // Correct and continue.
- }
-
- // Add the mutator to the block.
- if (json['mutator'] !== undefined) {
- Extensions.apply(json['mutator'], this, true);
- }
-
- const extensionNames = json['extensions'];
- if (Array.isArray(extensionNames)) {
- for (let j = 0; j < extensionNames.length; j++) {
- Extensions.apply(extensionNames[j], this, false);
- }
- }
-};
-
-/**
- * Initialize the colour of this block from the JSON description.
- * @param {!Object} json Structured data describing the block.
- * @param {string} warningPrefix Warning prefix string identifying block.
- * @private
- */
-Block.prototype.jsonInitColour_ = function(json, warningPrefix) {
- if ('colour' in json) {
- if (json['colour'] === undefined) {
- console.warn(warningPrefix + 'Undefined colour value.');
- } else {
- const rawValue = json['colour'];
- try {
- this.setColour(rawValue);
- } catch (e) {
- console.warn(warningPrefix + 'Illegal colour value: ', rawValue);
- }
- }
- }
-};
-
-/**
- * Initialize the style of this block from the JSON description.
- * @param {!Object} json Structured data describing the block.
- * @param {string} warningPrefix Warning prefix string identifying block.
- * @private
- */
-Block.prototype.jsonInitStyle_ = function(json, warningPrefix) {
- const blockStyleName = json['style'];
- try {
- this.setStyle(blockStyleName);
- } catch (styleError) {
- console.warn(warningPrefix + 'Style does not exist: ', blockStyleName);
- }
-};
-
-/**
- * Add key/values from mixinObj to this block object. By default, this method
- * will check that the keys in mixinObj will not overwrite existing values in
- * the block, including prototype values. This provides some insurance against
- * mixin / extension incompatibilities with future block features. This check
- * can be disabled by passing true as the second argument.
- * @param {!Object} mixinObj The key/values pairs to add to this block object.
- * @param {boolean=} opt_disableCheck Option flag to disable overwrite checks.
- */
-Block.prototype.mixin = function(mixinObj, opt_disableCheck) {
- if (opt_disableCheck !== undefined && typeof opt_disableCheck !== 'boolean') {
- throw Error('opt_disableCheck must be a boolean if provided');
- }
- if (!opt_disableCheck) {
- const overwrites = [];
- for (const key in mixinObj) {
- if (this[key] !== undefined) {
- overwrites.push(key);
- }
- }
- if (overwrites.length) {
- throw Error(
- 'Mixin will overwrite block members: ' + JSON.stringify(overwrites));
- }
- }
- object.mixin(this, mixinObj);
-};
-
-/**
- * Interpolate a message description onto the block.
- * @param {string} message Text contains interpolation tokens (%1, %2, ...)
- * that match with fields or inputs defined in the args array.
- * @param {!Array} args Array of arguments to be interpolated.
- * @param {string|undefined} lastDummyAlign If a dummy input is added at the
- * end, how should it be aligned?
- * @param {string} warningPrefix Warning prefix string identifying block.
- * @private
- */
-Block.prototype.interpolate_ = function(
- message, args, lastDummyAlign, warningPrefix) {
- const tokens = parsing.tokenizeInterpolation(message);
- this.validateTokens_(tokens, args.length);
- const elements = this.interpolateArguments_(tokens, args, lastDummyAlign);
-
- // An array of [field, fieldName] tuples.
- const fieldStack = [];
- for (let i = 0, element; (element = elements[i]); i++) {
- if (this.isInputKeyword_(element['type'])) {
- const input = this.inputFromJson_(element, warningPrefix);
- // Should never be null, but just in case.
- if (input) {
- for (let j = 0, tuple; (tuple = fieldStack[j]); j++) {
- input.appendField(tuple[0], tuple[1]);
- }
- fieldStack.length = 0;
- }
- } else {
- // All other types, including ones starting with 'input_' get routed here.
- const field = this.fieldFromJson_(element);
- if (field) {
- fieldStack.push([field, element['name']]);
- }
- }
- }
-};
-
-/**
- * Validates that the tokens are within the correct bounds, with no duplicates,
- * and that all of the arguments are referred to. Throws errors if any of these
- * things are not true.
- * @param {!Array} tokens An array of tokens to validate
- * @param {number} argsCount The number of args that need to be referred to.
- * @private
- */
-Block.prototype.validateTokens_ = function(tokens, argsCount) {
- const visitedArgsHash = [];
- let visitedArgsCount = 0;
- for (let i = 0; i < tokens.length; i++) {
- const token = tokens[i];
- if (typeof token !== 'number') {
- continue;
- }
- if (token < 1 || token > argsCount) {
- throw Error(
- 'Block "' + this.type + '": ' +
- 'Message index %' + token + ' out of range.');
- }
- if (visitedArgsHash[token]) {
- throw Error(
- 'Block "' + this.type + '": ' +
- 'Message index %' + token + ' duplicated.');
- }
- visitedArgsHash[token] = true;
- visitedArgsCount++;
- }
- if (visitedArgsCount !== argsCount) {
- throw Error(
- 'Block "' + this.type + '": ' +
- 'Message does not reference all ' + argsCount + ' arg(s).');
- }
-};
-
-/**
- * Inserts args in place of numerical tokens. String args are converted to JSON
- * that defines a label field. If necessary an extra dummy input is added to
- * the end of the elements.
- * @param {!Array} tokens The tokens to interpolate
- * @param {!Array} args The arguments to insert.
- * @param {string|undefined} lastDummyAlign The alignment the added dummy input
- * should have, if we are required to add one.
- * @return {!Array} The JSON definitions of field and inputs to add
- * to the block.
- * @private
- */
-Block.prototype.interpolateArguments_ = function(tokens, args, lastDummyAlign) {
- const elements = [];
- for (let i = 0; i < tokens.length; i++) {
- let element = tokens[i];
- if (typeof element === 'number') {
- element = args[element - 1];
- }
- // Args can be strings, which is why this isn't elseif.
- if (typeof element === 'string') {
- element = this.stringToFieldJson_(element);
- if (!element) {
- continue;
- }
- }
- elements.push(element);
- }
-
- const length = elements.length;
- if (length && !this.isInputKeyword_(elements[length - 1]['type'])) {
- const dummyInput = {'type': 'input_dummy'};
- if (lastDummyAlign) {
- dummyInput['align'] = lastDummyAlign;
- }
- elements.push(dummyInput);
- }
-
- return elements;
-};
-
-/**
- * Creates a field from the JSON definition of a field. If a field with the
- * given type cannot be found, this attempts to create a different field using
- * the 'alt' property of the JSON definition (if it exists).
- * @param {{alt:(string|undefined)}} element The element to try to turn into a
- * field.
- * @return {?Field} The field defined by the JSON, or null if one
- * couldn't be created.
- * @private
- */
-Block.prototype.fieldFromJson_ = function(element) {
- const field = fieldRegistry.fromJson(element);
- if (!field && element['alt']) {
- if (typeof element['alt'] === 'string') {
- const json = this.stringToFieldJson_(element['alt']);
- return json ? this.fieldFromJson_(json) : null;
- }
- return this.fieldFromJson_(element['alt']);
- }
- return field;
-};
-
-/**
- * Creates an input from the JSON definition of an input. Sets the input's check
- * and alignment if they are provided.
- * @param {!Object} element The JSON to turn into an input.
- * @param {string} warningPrefix The prefix to add to warnings to help the
- * developer debug.
- * @return {?Input} The input that has been created, or null if one
- * could not be created for some reason (should never happen).
- * @private
- */
-Block.prototype.inputFromJson_ = function(element, warningPrefix) {
- const alignmentLookup = {
- 'LEFT': Align.LEFT,
- 'RIGHT': Align.RIGHT,
- 'CENTRE': Align.CENTRE,
- 'CENTER': Align.CENTRE,
- };
-
- let input = null;
- switch (element['type']) {
- case 'input_value':
- input = this.appendValueInput(element['name']);
- break;
- case 'input_statement':
- input = this.appendStatementInput(element['name']);
- break;
- case 'input_dummy':
- input = this.appendDummyInput(element['name']);
- break;
- }
- // Should never be hit because of interpolate_'s checks, but just in case.
- if (!input) {
- return null;
- }
-
- if (element['check']) {
- input.setCheck(element['check']);
- }
- if (element['align']) {
- const alignment = alignmentLookup[element['align'].toUpperCase()];
- if (alignment === undefined) {
- console.warn(warningPrefix + 'Illegal align value: ', element['align']);
- } else {
- input.setAlign(alignment);
- }
- }
- return input;
-};
-
-/**
- * Returns true if the given string matches one of the input keywords.
- * @param {string} str The string to check.
- * @return {boolean} True if the given string matches one of the input keywords,
- * false otherwise.
- * @private
- */
-Block.prototype.isInputKeyword_ = function(str) {
- return str === 'input_value' || str === 'input_statement' ||
- str === 'input_dummy';
-};
-
-/**
- * Turns a string into the JSON definition of a label field. If the string
- * becomes an empty string when trimmed, this returns null.
- * @param {string} str String to turn into the JSON definition of a label field.
- * @return {?{text: string, type: string}} The JSON definition or null.
- * @private
- */
-Block.prototype.stringToFieldJson_ = function(str) {
- str = str.trim();
- if (str) {
- return {
- 'type': 'field_label',
- 'text': str,
- };
- }
- return null;
-};
-
-/**
- * Add a value input, statement input or local variable to this block.
- * @param {number} type One of Blockly.inputTypes.
- * @param {string} name Language-neutral identifier which may used to find this
- * input again. Should be unique to this block.
- * @return {!Input} The input object created.
- * @protected
- */
-Block.prototype.appendInput_ = function(type, name) {
- let connection = null;
- if (type === inputTypes.VALUE || type === inputTypes.STATEMENT) {
- connection = this.makeConnection_(type);
- }
- if (type === inputTypes.STATEMENT) {
- this.statementInputCount++;
- }
- const input = new Input(type, name, this, connection);
- // Append input to list.
- this.inputList.push(input);
- return input;
-};
-
-/**
- * Move a named input to a different location on this block.
- * @param {string} name The name of the input to move.
- * @param {?string} refName Name of input that should be after the moved input,
- * or null to be the input at the end.
- */
-Block.prototype.moveInputBefore = function(name, refName) {
- if (name === refName) {
- return;
- }
- // Find both inputs.
- let inputIndex = -1;
- let refIndex = refName ? -1 : this.inputList.length;
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.name === name) {
- inputIndex = i;
- if (refIndex !== -1) {
- break;
- }
- } else if (refName && input.name === refName) {
- refIndex = i;
- if (inputIndex !== -1) {
- break;
- }
- }
- }
- if (inputIndex === -1) {
- throw Error('Named input "' + name + '" not found.');
- }
- if (refIndex === -1) {
- throw Error('Reference input "' + refName + '" not found.');
- }
- this.moveNumberedInputBefore(inputIndex, refIndex);
-};
-
-/**
- * Move a numbered input to a different location on this block.
- * @param {number} inputIndex Index of the input to move.
- * @param {number} refIndex Index of input that should be after the moved input.
- */
-Block.prototype.moveNumberedInputBefore = function(inputIndex, refIndex) {
- // Validate arguments.
- if (inputIndex === refIndex) {
- throw Error('Can\'t move input to itself.');
- }
- if (inputIndex >= this.inputList.length) {
- throw RangeError('Input index ' + inputIndex + ' out of bounds.');
- }
- if (refIndex > this.inputList.length) {
- throw RangeError('Reference input ' + refIndex + ' out of bounds.');
- }
- // Remove input.
- const input = this.inputList[inputIndex];
- this.inputList.splice(inputIndex, 1);
- if (inputIndex < refIndex) {
- refIndex--;
- }
- // Reinsert input.
- this.inputList.splice(refIndex, 0, input);
-};
-
-/**
- * Remove an input from this block.
- * @param {string} name The name of the input.
- * @param {boolean=} opt_quiet True to prevent an error if input is not present.
- * @return {boolean} True if operation succeeds, false if input is not present
- * and opt_quiet is true.
- * @throws {Error} if the input is not present and opt_quiet is not true.
- */
-Block.prototype.removeInput = function(name, opt_quiet) {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.name === name) {
- if (input.type === inputTypes.STATEMENT) {
- this.statementInputCount--;
- }
- input.dispose();
- this.inputList.splice(i, 1);
- return true;
- }
- }
- if (opt_quiet) {
- return false;
- }
- throw Error('Input not found: ' + name);
-};
-
-/**
- * Fetches the named input object.
- * @param {string} name The name of the input.
- * @return {?Input} The input object, or null if input does not exist.
- */
-Block.prototype.getInput = function(name) {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.name === name) {
- return input;
- }
- }
- // This input does not exist.
- return null;
-};
-
-/**
- * Fetches the block attached to the named input.
- * @param {string} name The name of the input.
- * @return {?Block} The attached value block, or null if the input is
- * either disconnected or if the input does not exist.
- */
-Block.prototype.getInputTargetBlock = function(name) {
- const input = this.getInput(name);
- return input && input.connection && input.connection.targetBlock();
-};
-
-/**
- * Returns the comment on this block (or null if there is no comment).
- * @return {?string} Block's comment.
- */
-Block.prototype.getCommentText = function() {
- return this.commentModel.text;
-};
-
-/**
- * Set this block's comment text.
- * @param {?string} text The text, or null to delete.
- */
-Block.prototype.setCommentText = function(text) {
- if (this.commentModel.text === text) {
- return;
- }
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
- this, 'comment', null, this.commentModel.text, text));
- this.commentModel.text = text;
- this.comment = text; // For backwards compatibility.
-};
-
-/**
- * Set this block's warning text.
- * @param {?string} _text The text, or null to delete.
- * @param {string=} _opt_id An optional ID for the warning text to be able to
- * maintain multiple warnings.
- */
-Block.prototype.setWarningText = function(_text, _opt_id) {
- // NOP.
-};
-
-/**
- * Give this block a mutator dialog.
- * @param {Mutator} _mutator A mutator dialog instance or null to
- * remove.
- */
-Block.prototype.setMutator = function(_mutator) {
- // NOP.
-};
-
-/**
- * Return the coordinates of the top-left corner of this block relative to the
- * drawing surface's origin (0,0), in workspace units.
- * @return {!Coordinate} Object with .x and .y properties.
- */
-Block.prototype.getRelativeToSurfaceXY = function() {
- return this.xy_;
-};
-
-/**
- * Move a block by a relative offset.
- * @param {number} dx Horizontal offset, in workspace units.
- * @param {number} dy Vertical offset, in workspace units.
- */
-Block.prototype.moveBy = function(dx, dy) {
- if (this.parentBlock_) {
- throw Error('Block has parent.');
- }
- const event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(this);
- this.xy_.translate(dx, dy);
- event.recordNew();
- eventUtils.fire(event);
-};
-
-/**
- * Create a connection of the specified type.
- * @param {number} type The type of the connection to create.
- * @return {!Connection} A new connection of the specified type.
- * @protected
- */
-Block.prototype.makeConnection_ = function(type) {
- return new Connection(this, type);
-};
-
-/**
- * Recursively checks whether all statement and value inputs are filled with
- * blocks. Also checks all following statement blocks in this stack.
- * @param {boolean=} opt_shadowBlocksAreFilled An optional argument controlling
- * whether shadow blocks are counted as filled. Defaults to true.
- * @return {boolean} True if all inputs are filled, false otherwise.
- */
-Block.prototype.allInputsFilled = function(opt_shadowBlocksAreFilled) {
- // Account for the shadow block filledness toggle.
- if (opt_shadowBlocksAreFilled === undefined) {
- opt_shadowBlocksAreFilled = true;
- }
- if (!opt_shadowBlocksAreFilled && this.isShadow()) {
- return false;
- }
-
- // Recursively check each input block of the current block.
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (!input.connection) {
- continue;
- }
- const target = input.connection.targetBlock();
- if (!target || !target.allInputsFilled(opt_shadowBlocksAreFilled)) {
- return false;
- }
- }
-
- // Recursively check the next block after the current block.
- const next = this.getNextBlock();
- if (next) {
- return next.allInputsFilled(opt_shadowBlocksAreFilled);
- }
-
- return true;
-};
-
-/**
- * This method returns a string describing this Block in developer terms (type
- * name and ID; English only).
- *
- * Intended to on be used in console logs and errors. If you need a string that
- * uses the user's native language (including block text, field values, and
- * child blocks), use [toString()]{@link Block#toString}.
- * @return {string} The description.
- */
-Block.prototype.toDevString = function() {
- let msg = this.type ? '"' + this.type + '" block' : 'Block';
- if (this.id) {
- msg += ' (id="' + this.id + '")';
- }
- return msg;
-};
-
exports.Block = Block;
diff --git a/core/block_drag_surface.js b/core/block_drag_surface.js
index 2ff685cde..c0b01094a 100644
--- a/core/block_drag_surface.js
+++ b/core/block_drag_surface.js
@@ -35,234 +35,235 @@ const {Svg} = goog.require('Blockly.utils.Svg');
/**
* Class for a drag surface for the currently dragged block. This is a separate
* SVG that contains only the currently moving block, or nothing.
- * @param {!Element} container Containing element.
- * @constructor
* @alias Blockly.BlockDragSurfaceSvg
*/
-const BlockDragSurfaceSvg = function(container) {
+const BlockDragSurfaceSvg = class {
/**
- * @type {!Element}
+ * @param {!Element} container Containing element.
+ */
+ constructor(container) {
+ /**
+ * The SVG drag surface. Set once by BlockDragSurfaceSvg.createDom.
+ * @type {?SVGElement}
+ * @private
+ */
+ this.SVG_ = null;
+
+ /**
+ * This is where blocks live while they are being dragged if the drag
+ * surface is enabled.
+ * @type {?SVGElement}
+ * @private
+ */
+ this.dragGroup_ = null;
+
+ /**
+ * Containing HTML element; parent of the workspace and the drag surface.
+ * @type {!Element}
+ * @private
+ */
+ this.container_ = container;
+
+ /**
+ * Cached value for the scale of the drag surface.
+ * Used to set/get the correct translation during and after a drag.
+ * @type {number}
+ * @private
+ */
+ this.scale_ = 1;
+
+ /**
+ * Cached value for the translation of the drag surface.
+ * This translation is in pixel units, because the scale is applied to the
+ * drag group rather than the top-level SVG.
+ * @type {?Coordinate}
+ * @private
+ */
+ this.surfaceXY_ = null;
+
+ /**
+ * Cached value for the translation of the child drag surface in pixel
+ * units. Since the child drag surface tracks the translation of the
+ * workspace this is ultimately the translation of the workspace.
+ * @type {!Coordinate}
+ * @private
+ */
+ this.childSurfaceXY_ = new Coordinate(0, 0);
+
+ this.createDom();
+ }
+
+ /**
+ * Create the drag surface and inject it into the container.
+ */
+ createDom() {
+ if (this.SVG_) {
+ return; // Already created.
+ }
+ this.SVG_ = dom.createSvgElement(
+ Svg.SVG, {
+ 'xmlns': dom.SVG_NS,
+ 'xmlns:html': dom.HTML_NS,
+ 'xmlns:xlink': dom.XLINK_NS,
+ 'version': '1.1',
+ 'class': 'blocklyBlockDragSurface',
+ },
+ this.container_);
+ this.dragGroup_ = dom.createSvgElement(Svg.G, {}, this.SVG_);
+ }
+
+ /**
+ * Set the SVG blocks on the drag surface's group and show the surface.
+ * Only one block group should be on the drag surface at a time.
+ * @param {!SVGElement} blocks Block or group of blocks to place on the drag
+ * surface.
+ */
+ setBlocksAndShow(blocks) {
+ if (this.dragGroup_.childNodes.length) {
+ throw Error('Already dragging a block.');
+ }
+ // appendChild removes the blocks from the previous parent
+ this.dragGroup_.appendChild(blocks);
+ this.SVG_.style.display = 'block';
+ this.surfaceXY_ = new Coordinate(0, 0);
+ }
+
+ /**
+ * Translate and scale the entire drag surface group to the given position, to
+ * keep in sync with the workspace.
+ * @param {number} x X translation in pixel coordinates.
+ * @param {number} y Y translation in pixel coordinates.
+ * @param {number} scale Scale of the group.
+ */
+ translateAndScaleGroup(x, y, scale) {
+ this.scale_ = scale;
+ // This is a work-around to prevent a the blocks from rendering
+ // fuzzy while they are being dragged on the drag surface.
+ const fixedX = x.toFixed(0);
+ const fixedY = y.toFixed(0);
+
+ this.childSurfaceXY_.x = parseInt(fixedX, 10);
+ this.childSurfaceXY_.y = parseInt(fixedY, 10);
+
+ this.dragGroup_.setAttribute(
+ 'transform',
+ 'translate(' + fixedX + ',' + fixedY + ') scale(' + scale + ')');
+ }
+
+ /**
+ * Translate the drag surface's SVG based on its internal state.
* @private
*/
- this.container_ = container;
- this.createDom();
-};
+ translateSurfaceInternal_() {
+ let x = this.surfaceXY_.x;
+ let y = this.surfaceXY_.y;
+ // This is a work-around to prevent a the blocks from rendering
+ // fuzzy while they are being dragged on the drag surface.
+ x = x.toFixed(0);
+ y = y.toFixed(0);
+ this.SVG_.style.display = 'block';
-/**
- * The SVG drag surface. Set once by BlockDragSurfaceSvg.createDom.
- * @type {?SVGElement}
- * @private
- */
-BlockDragSurfaceSvg.prototype.SVG_ = null;
-
-/**
- * This is where blocks live while they are being dragged if the drag surface
- * is enabled.
- * @type {?SVGElement}
- * @private
- */
-BlockDragSurfaceSvg.prototype.dragGroup_ = null;
-
-/**
- * Containing HTML element; parent of the workspace and the drag surface.
- * @type {?Element}
- * @private
- */
-BlockDragSurfaceSvg.prototype.container_ = null;
-
-/**
- * Cached value for the scale of the drag surface.
- * Used to set/get the correct translation during and after a drag.
- * @type {number}
- * @private
- */
-BlockDragSurfaceSvg.prototype.scale_ = 1;
-
-/**
- * Cached value for the translation of the drag surface.
- * This translation is in pixel units, because the scale is applied to the
- * drag group rather than the top-level SVG.
- * @type {?Coordinate}
- * @private
- */
-BlockDragSurfaceSvg.prototype.surfaceXY_ = null;
-
-/**
- * Cached value for the translation of the child drag surface in pixel units.
- * Since the child drag surface tracks the translation of the workspace this is
- * ultimately the translation of the workspace.
- * @type {!Coordinate}
- * @private
- */
-BlockDragSurfaceSvg.prototype.childSurfaceXY_ = new Coordinate(0, 0);
-
-/**
- * Create the drag surface and inject it into the container.
- */
-BlockDragSurfaceSvg.prototype.createDom = function() {
- if (this.SVG_) {
- return; // Already created.
+ dom.setCssTransform(this.SVG_, 'translate3d(' + x + 'px, ' + y + 'px, 0)');
}
- this.SVG_ = dom.createSvgElement(
- Svg.SVG, {
- 'xmlns': dom.SVG_NS,
- 'xmlns:html': dom.HTML_NS,
- 'xmlns:xlink': dom.XLINK_NS,
- 'version': '1.1',
- 'class': 'blocklyBlockDragSurface',
- },
- this.container_);
- this.dragGroup_ = dom.createSvgElement(Svg.G, {}, this.SVG_);
-};
-/**
- * Set the SVG blocks on the drag surface's group and show the surface.
- * Only one block group should be on the drag surface at a time.
- * @param {!SVGElement} blocks Block or group of blocks to place on the drag
- * surface.
- */
-BlockDragSurfaceSvg.prototype.setBlocksAndShow = function(blocks) {
- if (this.dragGroup_.childNodes.length) {
- throw Error('Already dragging a block.');
+ /**
+ * Translates the entire surface by a relative offset.
+ * @param {number} deltaX Horizontal offset in pixel units.
+ * @param {number} deltaY Vertical offset in pixel units.
+ */
+ translateBy(deltaX, deltaY) {
+ const x = this.surfaceXY_.x + deltaX;
+ const y = this.surfaceXY_.y + deltaY;
+ this.surfaceXY_ = new Coordinate(x, y);
+ this.translateSurfaceInternal_();
}
- // appendChild removes the blocks from the previous parent
- this.dragGroup_.appendChild(blocks);
- this.SVG_.style.display = 'block';
- this.surfaceXY_ = new Coordinate(0, 0);
-};
-/**
- * Translate and scale the entire drag surface group to the given position, to
- * keep in sync with the workspace.
- * @param {number} x X translation in pixel coordinates.
- * @param {number} y Y translation in pixel coordinates.
- * @param {number} scale Scale of the group.
- */
-BlockDragSurfaceSvg.prototype.translateAndScaleGroup = function(x, y, scale) {
- this.scale_ = scale;
- // This is a work-around to prevent a the blocks from rendering
- // fuzzy while they are being dragged on the drag surface.
- const fixedX = x.toFixed(0);
- const fixedY = y.toFixed(0);
-
- this.childSurfaceXY_.x = parseInt(fixedX, 10);
- this.childSurfaceXY_.y = parseInt(fixedY, 10);
-
- this.dragGroup_.setAttribute(
- 'transform',
- 'translate(' + fixedX + ',' + fixedY + ') scale(' + scale + ')');
-};
-
-/**
- * Translate the drag surface's SVG based on its internal state.
- * @private
- */
-BlockDragSurfaceSvg.prototype.translateSurfaceInternal_ = function() {
- let x = this.surfaceXY_.x;
- let y = this.surfaceXY_.y;
- // This is a work-around to prevent a the blocks from rendering
- // fuzzy while they are being dragged on the drag surface.
- x = x.toFixed(0);
- y = y.toFixed(0);
- this.SVG_.style.display = 'block';
-
- dom.setCssTransform(this.SVG_, 'translate3d(' + x + 'px, ' + y + 'px, 0)');
-};
-
-/**
- * Translates the entire surface by a relative offset.
- * @param {number} deltaX Horizontal offset in pixel units.
- * @param {number} deltaY Vertical offset in pixel units.
- */
-BlockDragSurfaceSvg.prototype.translateBy = function(deltaX, deltaY) {
- const x = this.surfaceXY_.x + deltaX;
- const y = this.surfaceXY_.y + deltaY;
- this.surfaceXY_ = new Coordinate(x, y);
- this.translateSurfaceInternal_();
-};
-
-/**
- * Translate the entire drag surface during a drag.
- * We translate the drag surface instead of the blocks inside the surface
- * so that the browser avoids repainting the SVG.
- * Because of this, the drag coordinates must be adjusted by scale.
- * @param {number} x X translation for the entire surface.
- * @param {number} y Y translation for the entire surface.
- */
-BlockDragSurfaceSvg.prototype.translateSurface = function(x, y) {
- this.surfaceXY_ = new Coordinate(x * this.scale_, y * this.scale_);
- this.translateSurfaceInternal_();
-};
-
-/**
- * Reports the surface translation in scaled workspace coordinates.
- * Use this when finishing a drag to return blocks to the correct position.
- * @return {!Coordinate} Current translation of the surface.
- */
-BlockDragSurfaceSvg.prototype.getSurfaceTranslation = function() {
- const xy = svgMath.getRelativeXY(/** @type {!SVGElement} */ (this.SVG_));
- return new Coordinate(xy.x / this.scale_, xy.y / this.scale_);
-};
-
-/**
- * Provide a reference to the drag group (primarily for
- * BlockSvg.getRelativeToSurfaceXY).
- * @return {?SVGElement} Drag surface group element.
- */
-BlockDragSurfaceSvg.prototype.getGroup = function() {
- return this.dragGroup_;
-};
-
-/**
- * Returns the SVG drag surface.
- * @returns {?SVGElement} The SVG drag surface.
- */
-BlockDragSurfaceSvg.prototype.getSvgRoot = function() {
- return this.SVG_;
-};
-
-/**
- * Get the current blocks on the drag surface, if any (primarily
- * for BlockSvg.getRelativeToSurfaceXY).
- * @return {?Element} Drag surface block DOM element, or null if no blocks
- * exist.
- */
-BlockDragSurfaceSvg.prototype.getCurrentBlock = function() {
- return /** @type {Element} */ (this.dragGroup_.firstChild);
-};
-
-/**
- * Gets the translation of the child block surface
- * This surface is in charge of keeping track of how much the workspace has
- * moved.
- * @return {!Coordinate} The amount the workspace has been moved.
- */
-BlockDragSurfaceSvg.prototype.getWsTranslation = function() {
- // Returning a copy so the coordinate can not be changed outside this class.
- return this.childSurfaceXY_.clone();
-};
-
-/**
- * Clear the group and hide the surface; move the blocks off onto the provided
- * element.
- * If the block is being deleted it doesn't need to go back to the original
- * surface, since it would be removed immediately during dispose.
- * @param {Element=} opt_newSurface Surface the dragging blocks should be moved
- * to, or null if the blocks should be removed from this surface without
- * being moved to a different surface.
- */
-BlockDragSurfaceSvg.prototype.clearAndHide = function(opt_newSurface) {
- if (opt_newSurface) {
- // appendChild removes the node from this.dragGroup_
- opt_newSurface.appendChild(this.getCurrentBlock());
- } else {
- this.dragGroup_.removeChild(this.getCurrentBlock());
+ /**
+ * Translate the entire drag surface during a drag.
+ * We translate the drag surface instead of the blocks inside the surface
+ * so that the browser avoids repainting the SVG.
+ * Because of this, the drag coordinates must be adjusted by scale.
+ * @param {number} x X translation for the entire surface.
+ * @param {number} y Y translation for the entire surface.
+ */
+ translateSurface(x, y) {
+ this.surfaceXY_ = new Coordinate(x * this.scale_, y * this.scale_);
+ this.translateSurfaceInternal_();
}
- this.SVG_.style.display = 'none';
- if (this.dragGroup_.childNodes.length) {
- throw Error('Drag group was not cleared.');
+
+ /**
+ * Reports the surface translation in scaled workspace coordinates.
+ * Use this when finishing a drag to return blocks to the correct position.
+ * @return {!Coordinate} Current translation of the surface.
+ */
+ getSurfaceTranslation() {
+ const xy = svgMath.getRelativeXY(/** @type {!SVGElement} */ (this.SVG_));
+ return new Coordinate(xy.x / this.scale_, xy.y / this.scale_);
+ }
+
+ /**
+ * Provide a reference to the drag group (primarily for
+ * BlockSvg.getRelativeToSurfaceXY).
+ * @return {?SVGElement} Drag surface group element.
+ */
+ getGroup() {
+ return this.dragGroup_;
+ }
+
+ /**
+ * Returns the SVG drag surface.
+ * @returns {?SVGElement} The SVG drag surface.
+ */
+ getSvgRoot() {
+ return this.SVG_;
+ }
+
+ /**
+ * Get the current blocks on the drag surface, if any (primarily
+ * for BlockSvg.getRelativeToSurfaceXY).
+ * @return {?Element} Drag surface block DOM element, or null if no blocks
+ * exist.
+ */
+ getCurrentBlock() {
+ return /** @type {Element} */ (this.dragGroup_.firstChild);
+ }
+
+ /**
+ * Gets the translation of the child block surface
+ * This surface is in charge of keeping track of how much the workspace has
+ * moved.
+ * @return {!Coordinate} The amount the workspace has been moved.
+ */
+ getWsTranslation() {
+ // Returning a copy so the coordinate can not be changed outside this class.
+ return this.childSurfaceXY_.clone();
+ }
+
+ /**
+ * Clear the group and hide the surface; move the blocks off onto the provided
+ * element.
+ * If the block is being deleted it doesn't need to go back to the original
+ * surface, since it would be removed immediately during dispose.
+ * @param {Element=} opt_newSurface Surface the dragging blocks should be
+ * moved to, or null if the blocks should be removed from this surface
+ * without being moved to a different surface.
+ */
+ clearAndHide(opt_newSurface) {
+ const currentBlockElement = this.getCurrentBlock();
+ if (currentBlockElement) {
+ if (opt_newSurface) {
+ // appendChild removes the node from this.dragGroup_
+ opt_newSurface.appendChild(currentBlockElement);
+ } else {
+ this.dragGroup_.removeChild(currentBlockElement);
+ }
+ }
+ this.SVG_.style.display = 'none';
+ if (this.dragGroup_.childNodes.length) {
+ throw Error('Drag group was not cleared.');
+ }
+ this.surfaceXY_ = null;
}
- this.surfaceXY_ = null;
};
exports.BlockDragSurfaceSvg = BlockDragSurfaceSvg;
diff --git a/core/block_dragger.js b/core/block_dragger.js
index 817fa10fe..cc7648e7c 100644
--- a/core/block_dragger.js
+++ b/core/block_dragger.js
@@ -22,6 +22,8 @@ const dom = goog.require('Blockly.utils.dom');
const eventUtils = goog.require('Blockly.Events.utils');
const registry = goog.require('Blockly.registry');
/* eslint-disable-next-line no-unused-vars */
+const {BlockMove} = goog.requireType('Blockly.Events.BlockMove');
+/* eslint-disable-next-line no-unused-vars */
const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
const {Coordinate} = goog.require('Blockly.utils.Coordinate');
/* eslint-disable-next-line no-unused-vars */
@@ -40,76 +42,411 @@ goog.require('Blockly.Events.BlockMove');
/**
* Class for a block dragger. It moves blocks around the workspace when they
* are being dragged by a mouse or touch.
- * @param {!BlockSvg} block The block to drag.
- * @param {!WorkspaceSvg} workspace The workspace to drag on.
- * @constructor
* @implements {IBlockDragger}
* @alias Blockly.BlockDragger
*/
-const BlockDragger = function(block, workspace) {
+const BlockDragger = class {
/**
- * The top block in the stack that is being dragged.
- * @type {!BlockSvg}
+ * @param {!BlockSvg} block The block to drag.
+ * @param {!WorkspaceSvg} workspace The workspace to drag on.
+ */
+ constructor(block, workspace) {
+ /**
+ * The top block in the stack that is being dragged.
+ * @type {!BlockSvg}
+ * @protected
+ */
+ this.draggingBlock_ = block;
+
+ /**
+ * The workspace on which the block is being dragged.
+ * @type {!WorkspaceSvg}
+ * @protected
+ */
+ this.workspace_ = workspace;
+
+ /**
+ * Object that keeps track of connections on dragged blocks.
+ * @type {!InsertionMarkerManager}
+ * @protected
+ */
+ this.draggedConnectionManager_ =
+ new InsertionMarkerManager(this.draggingBlock_);
+
+ /**
+ * Which drag area the mouse pointer is over, if any.
+ * @type {?IDragTarget}
+ * @private
+ */
+ this.dragTarget_ = null;
+
+ /**
+ * Whether the block would be deleted if dropped immediately.
+ * @type {boolean}
+ * @protected
+ */
+ this.wouldDeleteBlock_ = false;
+
+ /**
+ * The location of the top left corner of the dragging block at the
+ * beginning of the drag in workspace coordinates.
+ * @type {!Coordinate}
+ * @protected
+ */
+ this.startXY_ = this.draggingBlock_.getRelativeToSurfaceXY();
+
+ /**
+ * A list of all of the icons (comment, warning, and mutator) that are
+ * on this block and its descendants. Moving an icon moves the bubble that
+ * extends from it if that bubble is open.
+ * @type {Array}
+ * @protected
+ */
+ this.dragIconData_ = initIconData(block);
+ }
+
+ /**
+ * Sever all links from this object.
+ * @package
+ */
+ dispose() {
+ this.dragIconData_.length = 0;
+
+ if (this.draggedConnectionManager_) {
+ this.draggedConnectionManager_.dispose();
+ }
+ }
+
+ /**
+ * Start dragging a block. This includes moving it to the drag surface.
+ * @param {!Coordinate} currentDragDeltaXY How far the pointer has
+ * moved from the position at mouse down, in pixel units.
+ * @param {boolean} healStack Whether or not to heal the stack after
+ * disconnecting.
+ * @public
+ */
+ startDrag(currentDragDeltaXY, healStack) {
+ if (!eventUtils.getGroup()) {
+ eventUtils.setGroup(true);
+ }
+ this.fireDragStartEvent_();
+
+ // Mutators don't have the same type of z-ordering as the normal workspace
+ // during a drag. They have to rely on the order of the blocks in the SVG.
+ // For performance reasons that usually happens at the end of a drag,
+ // but do it at the beginning for mutators.
+ if (this.workspace_.isMutator) {
+ this.draggingBlock_.bringToFront();
+ }
+
+ // During a drag there may be a lot of rerenders, but not field changes.
+ // Turn the cache on so we don't do spurious remeasures during the drag.
+ dom.startTextWidthCache();
+ this.workspace_.setResizesEnabled(false);
+ blockAnimation.disconnectUiStop();
+
+ if (this.shouldDisconnect_(healStack)) {
+ this.disconnectBlock_(healStack, currentDragDeltaXY);
+ }
+ this.draggingBlock_.setDragging(true);
+ // For future consideration: we may be able to put moveToDragSurface inside
+ // the block dragger, which would also let the block not track the block
+ // drag surface.
+ this.draggingBlock_.moveToDragSurface();
+ }
+
+ /**
+ * Whether or not we should disconnect the block when a drag is started.
+ * @param {boolean} healStack Whether or not to heal the stack after
+ * disconnecting.
+ * @return {boolean} True to disconnect the block, false otherwise.
* @protected
*/
- this.draggingBlock_ = block;
+ shouldDisconnect_(healStack) {
+ return !!(
+ this.draggingBlock_.getParent() ||
+ (healStack && this.draggingBlock_.nextConnection &&
+ this.draggingBlock_.nextConnection.targetBlock()));
+ }
/**
- * The workspace on which the block is being dragged.
- * @type {!WorkspaceSvg}
+ * Disconnects the block and moves it to a new location.
+ * @param {boolean} healStack Whether or not to heal the stack after
+ * disconnecting.
+ * @param {!Coordinate} currentDragDeltaXY How far the pointer has
+ * moved from the position at mouse down, in pixel units.
* @protected
*/
- this.workspace_ = workspace;
+ disconnectBlock_(healStack, currentDragDeltaXY) {
+ this.draggingBlock_.unplug(healStack);
+ const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
+ const newLoc = Coordinate.sum(this.startXY_, delta);
+
+ this.draggingBlock_.translate(newLoc.x, newLoc.y);
+ blockAnimation.disconnectUiEffect(this.draggingBlock_);
+ this.draggedConnectionManager_.updateAvailableConnections();
+ }
/**
- * Object that keeps track of connections on dragged blocks.
- * @type {!InsertionMarkerManager}
+ * Fire a UI event at the start of a block drag.
* @protected
*/
- this.draggedConnectionManager_ =
- new InsertionMarkerManager(this.draggingBlock_);
+ fireDragStartEvent_() {
+ const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
+ this.draggingBlock_, true, this.draggingBlock_.getDescendants(false));
+ eventUtils.fire(event);
+ }
/**
- * Which drag area the mouse pointer is over, if any.
- * @type {?IDragTarget}
- * @private
+ * Execute a step of block dragging, based on the given event. Update the
+ * display accordingly.
+ * @param {!Event} e The most recent move event.
+ * @param {!Coordinate} currentDragDeltaXY How far the pointer has
+ * moved from the position at the start of the drag, in pixel units.
+ * @public
*/
- this.dragTarget_ = null;
+ drag(e, currentDragDeltaXY) {
+ const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
+ const newLoc = Coordinate.sum(this.startXY_, delta);
+ this.draggingBlock_.moveDuringDrag(newLoc);
+ this.dragIcons_(delta);
+
+ const oldDragTarget = this.dragTarget_;
+ this.dragTarget_ = this.workspace_.getDragTarget(e);
+
+ this.draggedConnectionManager_.update(delta, this.dragTarget_);
+ const oldWouldDeleteBlock = this.wouldDeleteBlock_;
+ this.wouldDeleteBlock_ = this.draggedConnectionManager_.wouldDeleteBlock();
+ if (oldWouldDeleteBlock !== this.wouldDeleteBlock_) {
+ // Prevent unnecessary add/remove class calls.
+ this.updateCursorDuringBlockDrag_();
+ }
+
+ // Call drag enter/exit/over after wouldDeleteBlock is called in
+ // InsertionMarkerManager.update.
+ if (this.dragTarget_ !== oldDragTarget) {
+ oldDragTarget && oldDragTarget.onDragExit(this.draggingBlock_);
+ this.dragTarget_ && this.dragTarget_.onDragEnter(this.draggingBlock_);
+ }
+ this.dragTarget_ && this.dragTarget_.onDragOver(this.draggingBlock_);
+ }
/**
- * Whether the block would be deleted if dropped immediately.
- * @type {boolean}
+ * Finish a block drag and put the block back on the workspace.
+ * @param {!Event} e The mouseup/touchend event.
+ * @param {!Coordinate} currentDragDeltaXY How far the pointer has
+ * moved from the position at the start of the drag, in pixel units.
+ * @public
+ */
+ endDrag(e, currentDragDeltaXY) {
+ // Make sure internal state is fresh.
+ this.drag(e, currentDragDeltaXY);
+ this.dragIconData_ = [];
+ this.fireDragEndEvent_();
+
+ dom.stopTextWidthCache();
+
+ blockAnimation.disconnectUiStop();
+
+ const preventMove = !!this.dragTarget_ &&
+ this.dragTarget_.shouldPreventMove(this.draggingBlock_);
+ /** @type {Coordinate} */
+ let newLoc;
+ /** @type {Coordinate} */
+ let delta;
+ if (preventMove) {
+ newLoc = this.startXY_;
+ } else {
+ const newValues = this.getNewLocationAfterDrag_(currentDragDeltaXY);
+ delta = newValues.delta;
+ newLoc = newValues.newLocation;
+ }
+ this.draggingBlock_.moveOffDragSurface(newLoc);
+
+ if (this.dragTarget_) {
+ this.dragTarget_.onDrop(this.draggingBlock_);
+ }
+
+ const deleted = this.maybeDeleteBlock_();
+ if (!deleted) {
+ // These are expensive and don't need to be done if we're deleting.
+ this.draggingBlock_.setDragging(false);
+ if (delta) { // !preventMove
+ this.updateBlockAfterMove_(delta);
+ } else {
+ // Blocks dragged directly from a flyout may need to be bumped into
+ // bounds.
+ bumpObjects.bumpIntoBounds(
+ this.draggingBlock_.workspace,
+ this.workspace_.getMetricsManager().getScrollMetrics(true),
+ this.draggingBlock_);
+ }
+ }
+ this.workspace_.setResizesEnabled(true);
+
+ eventUtils.setGroup(false);
+ }
+
+ /**
+ * Calculates the drag delta and new location values after a block is dragged.
+ * @param {!Coordinate} currentDragDeltaXY How far the pointer has
+ * moved from the start of the drag, in pixel units.
+ * @return {{delta: !Coordinate, newLocation:
+ * !Coordinate}} New location after drag. delta is in
+ * workspace units. newLocation is the new coordinate where the block
+ * should end up.
* @protected
*/
- this.wouldDeleteBlock_ = false;
+ getNewLocationAfterDrag_(currentDragDeltaXY) {
+ const newValues = {};
+ newValues.delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
+ newValues.newLocation = Coordinate.sum(this.startXY_, newValues.delta);
+ return newValues;
+ }
/**
- * The location of the top left corner of the dragging block at the beginning
- * of the drag in workspace coordinates.
- * @type {!Coordinate}
+ * May delete the dragging block, if allowed. If `this.wouldDeleteBlock_` is
+ * not true, the block will not be deleted. This should be called at the end
+ * of a block drag.
+ * @return {boolean} True if the block was deleted.
* @protected
*/
- this.startXY_ = this.draggingBlock_.getRelativeToSurfaceXY();
+ maybeDeleteBlock_() {
+ if (this.wouldDeleteBlock_) {
+ // Fire a move event, so we know where to go back to for an undo.
+ this.fireMoveEvent_();
+ this.draggingBlock_.dispose(false, true);
+ common.draggingConnections.length = 0;
+ return true;
+ }
+ return false;
+ }
/**
- * A list of all of the icons (comment, warning, and mutator) that are
- * on this block and its descendants. Moving an icon moves the bubble that
- * extends from it if that bubble is open.
- * @type {Array}
+ * Updates the necessary information to place a block at a certain location.
+ * @param {!Coordinate} delta The change in location from where
+ * the block started the drag to where it ended the drag.
* @protected
*/
- this.dragIconData_ = initIconData(block);
-};
+ updateBlockAfterMove_(delta) {
+ this.draggingBlock_.moveConnections(delta.x, delta.y);
+ this.fireMoveEvent_();
+ if (this.draggedConnectionManager_.wouldConnectBlock()) {
+ // Applying connections also rerenders the relevant blocks.
+ this.draggedConnectionManager_.applyConnections();
+ } else {
+ this.draggingBlock_.render();
+ }
+ this.draggingBlock_.scheduleSnapAndBump();
+ }
-/**
- * Sever all links from this object.
- * @package
- */
-BlockDragger.prototype.dispose = function() {
- this.dragIconData_.length = 0;
+ /**
+ * Fire a UI event at the end of a block drag.
+ * @protected
+ */
+ fireDragEndEvent_() {
+ const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
+ this.draggingBlock_, false, this.draggingBlock_.getDescendants(false));
+ eventUtils.fire(event);
+ }
- if (this.draggedConnectionManager_) {
- this.draggedConnectionManager_.dispose();
+ /**
+ * Adds or removes the style of the cursor for the toolbox.
+ * This is what changes the cursor to display an x when a deletable block is
+ * held over the toolbox.
+ * @param {boolean} isEnd True if we are at the end of a drag, false
+ * otherwise.
+ * @protected
+ */
+ updateToolboxStyle_(isEnd) {
+ const toolbox = this.workspace_.getToolbox();
+
+ if (toolbox) {
+ const style = this.draggingBlock_.isDeletable() ? 'blocklyToolboxDelete' :
+ 'blocklyToolboxGrab';
+
+ if (isEnd && typeof toolbox.removeStyle === 'function') {
+ toolbox.removeStyle(style);
+ } else if (!isEnd && typeof toolbox.addStyle === 'function') {
+ toolbox.addStyle(style);
+ }
+ }
+ }
+
+ /**
+ * Fire a move event at the end of a block drag.
+ * @protected
+ */
+ fireMoveEvent_() {
+ const event = /** @type {!BlockMove} */
+ (new (eventUtils.get(eventUtils.BLOCK_MOVE))(this.draggingBlock_));
+ event.oldCoordinate = this.startXY_;
+ event.recordNew();
+ eventUtils.fire(event);
+ }
+
+ /**
+ * Update the cursor (and possibly the trash can lid) to reflect whether the
+ * dragging block would be deleted if released immediately.
+ * @protected
+ */
+ updateCursorDuringBlockDrag_() {
+ this.draggingBlock_.setDeleteStyle(this.wouldDeleteBlock_);
+ }
+
+ /**
+ * Convert a coordinate object from pixels to workspace units, including a
+ * correction for mutator workspaces.
+ * This function does not consider differing origins. It simply scales the
+ * input's x and y values.
+ * @param {!Coordinate} pixelCoord A coordinate with x and y
+ * values in CSS pixel units.
+ * @return {!Coordinate} The input coordinate divided by the
+ * workspace scale.
+ * @protected
+ */
+ pixelsToWorkspaceUnits_(pixelCoord) {
+ const result = new Coordinate(
+ pixelCoord.x / this.workspace_.scale,
+ pixelCoord.y / this.workspace_.scale);
+ if (this.workspace_.isMutator) {
+ // If we're in a mutator, its scale is always 1, purely because of some
+ // oddities in our rendering optimizations. The actual scale is the same
+ // as the scale on the parent workspace. Fix that for dragging.
+ const mainScale = this.workspace_.options.parentWorkspace.scale;
+ result.scale(1 / mainScale);
+ }
+ return result;
+ }
+
+ /**
+ * Move all of the icons connected to this drag.
+ * @param {!Coordinate} dxy How far to move the icons from their
+ * original positions, in workspace units.
+ * @protected
+ */
+ dragIcons_(dxy) {
+ // Moving icons moves their associated bubbles.
+ for (let i = 0; i < this.dragIconData_.length; i++) {
+ const data = this.dragIconData_[i];
+ data.icon.setIconLocation(Coordinate.sum(data.location, dxy));
+ }
+ }
+
+ /**
+ * Get a list of the insertion markers that currently exist. Drags have 0, 1,
+ * or 2 insertion markers.
+ * @return {!Array} A possibly empty list of insertion
+ * marker blocks.
+ * @public
+ */
+ getInsertionMarkers() {
+ // No insertion markers with the old style of dragged connection managers.
+ if (this.draggedConnectionManager_ &&
+ this.draggedConnectionManager_.getInsertionMarkers) {
+ return this.draggedConnectionManager_.getInsertionMarkers();
+ }
+ return [];
}
};
@@ -123,7 +460,8 @@ BlockDragger.prototype.dispose = function() {
const initIconData = function(block) {
// Build a list of icons that need to be moved and where they started.
const dragIconData = [];
- const descendants = block.getDescendants(false);
+ const descendants =
+ /** @type {!Array} */ (block.getDescendants(false));
for (let i = 0, descendant; (descendant = descendants[i]); i++) {
const icons = descendant.getIcons();
@@ -141,340 +479,6 @@ const initIconData = function(block) {
return dragIconData;
};
-/**
- * Start dragging a block. This includes moving it to the drag surface.
- * @param {!Coordinate} currentDragDeltaXY How far the pointer has
- * moved from the position at mouse down, in pixel units.
- * @param {boolean} healStack Whether or not to heal the stack after
- * disconnecting.
- * @public
- */
-BlockDragger.prototype.startDrag = function(currentDragDeltaXY, healStack) {
- if (!eventUtils.getGroup()) {
- eventUtils.setGroup(true);
- }
- this.fireDragStartEvent_();
-
- // Mutators don't have the same type of z-ordering as the normal workspace
- // during a drag. They have to rely on the order of the blocks in the SVG.
- // For performance reasons that usually happens at the end of a drag,
- // but do it at the beginning for mutators.
- if (this.workspace_.isMutator) {
- this.draggingBlock_.bringToFront();
- }
-
- // During a drag there may be a lot of rerenders, but not field changes.
- // Turn the cache on so we don't do spurious remeasures during the drag.
- dom.startTextWidthCache();
- this.workspace_.setResizesEnabled(false);
- blockAnimation.disconnectUiStop();
-
- if (this.shouldDisconnect_(healStack)) {
- this.disconnectBlock_(healStack, currentDragDeltaXY);
- }
- this.draggingBlock_.setDragging(true);
- // For future consideration: we may be able to put moveToDragSurface inside
- // the block dragger, which would also let the block not track the block drag
- // surface.
- this.draggingBlock_.moveToDragSurface();
-};
-
-/**
- * Whether or not we should disconnect the block when a drag is started.
- * @param {boolean} healStack Whether or not to heal the stack after
- * disconnecting.
- * @return {boolean} True to disconnect the block, false otherwise.
- * @protected
- */
-BlockDragger.prototype.shouldDisconnect_ = function(healStack) {
- return !!(
- this.draggingBlock_.getParent() ||
- (healStack && this.draggingBlock_.nextConnection &&
- this.draggingBlock_.nextConnection.targetBlock()));
-};
-
-/**
- * Disconnects the block and moves it to a new location.
- * @param {boolean} healStack Whether or not to heal the stack after
- * disconnecting.
- * @param {!Coordinate} currentDragDeltaXY How far the pointer has
- * moved from the position at mouse down, in pixel units.
- * @protected
- */
-BlockDragger.prototype.disconnectBlock_ = function(
- healStack, currentDragDeltaXY) {
- this.draggingBlock_.unplug(healStack);
- const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
- const newLoc = Coordinate.sum(this.startXY_, delta);
-
- this.draggingBlock_.translate(newLoc.x, newLoc.y);
- blockAnimation.disconnectUiEffect(this.draggingBlock_);
- this.draggedConnectionManager_.updateAvailableConnections();
-};
-
-/**
- * Fire a UI event at the start of a block drag.
- * @protected
- */
-BlockDragger.prototype.fireDragStartEvent_ = function() {
- const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
- this.draggingBlock_, true, this.draggingBlock_.getDescendants(false));
- eventUtils.fire(event);
-};
-
-/**
- * Execute a step of block dragging, based on the given event. Update the
- * display accordingly.
- * @param {!Event} e The most recent move event.
- * @param {!Coordinate} currentDragDeltaXY How far the pointer has
- * moved from the position at the start of the drag, in pixel units.
- * @public
- */
-BlockDragger.prototype.drag = function(e, currentDragDeltaXY) {
- const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
- const newLoc = Coordinate.sum(this.startXY_, delta);
- this.draggingBlock_.moveDuringDrag(newLoc);
- this.dragIcons_(delta);
-
- const oldDragTarget = this.dragTarget_;
- this.dragTarget_ = this.workspace_.getDragTarget(e);
-
- this.draggedConnectionManager_.update(delta, this.dragTarget_);
- const oldWouldDeleteBlock = this.wouldDeleteBlock_;
- this.wouldDeleteBlock_ = this.draggedConnectionManager_.wouldDeleteBlock();
- if (oldWouldDeleteBlock !== this.wouldDeleteBlock_) {
- // Prevent unnecessary add/remove class calls.
- this.updateCursorDuringBlockDrag_();
- }
-
- // Call drag enter/exit/over after wouldDeleteBlock is called in
- // InsertionMarkerManager.update.
- if (this.dragTarget_ !== oldDragTarget) {
- oldDragTarget && oldDragTarget.onDragExit(this.draggingBlock_);
- this.dragTarget_ && this.dragTarget_.onDragEnter(this.draggingBlock_);
- }
- this.dragTarget_ && this.dragTarget_.onDragOver(this.draggingBlock_);
-};
-
-/**
- * Finish a block drag and put the block back on the workspace.
- * @param {!Event} e The mouseup/touchend event.
- * @param {!Coordinate} currentDragDeltaXY How far the pointer has
- * moved from the position at the start of the drag, in pixel units.
- * @public
- */
-BlockDragger.prototype.endDrag = function(e, currentDragDeltaXY) {
- // Make sure internal state is fresh.
- this.drag(e, currentDragDeltaXY);
- this.dragIconData_ = [];
- this.fireDragEndEvent_();
-
- dom.stopTextWidthCache();
-
- blockAnimation.disconnectUiStop();
-
- const preventMove = !!this.dragTarget_ &&
- this.dragTarget_.shouldPreventMove(this.draggingBlock_);
- /** @type {Coordinate} */
- let newLoc;
- /** @type {Coordinate} */
- let delta;
- if (preventMove) {
- newLoc = this.startXY_;
- } else {
- const newValues = this.getNewLocationAfterDrag_(currentDragDeltaXY);
- delta = newValues.delta;
- newLoc = newValues.newLocation;
- }
- this.draggingBlock_.moveOffDragSurface(newLoc);
-
- if (this.dragTarget_) {
- this.dragTarget_.onDrop(this.draggingBlock_);
- }
-
- const deleted = this.maybeDeleteBlock_();
- if (!deleted) {
- // These are expensive and don't need to be done if we're deleting.
- this.draggingBlock_.setDragging(false);
- if (delta) { // !preventMove
- this.updateBlockAfterMove_(delta);
- } else {
- // Blocks dragged directly from a flyout may need to be bumped into
- // bounds.
- bumpObjects.bumpIntoBounds(
- this.draggingBlock_.workspace,
- this.workspace_.getMetricsManager().getScrollMetrics(true),
- this.draggingBlock_);
- }
- }
- this.workspace_.setResizesEnabled(true);
-
- eventUtils.setGroup(false);
-};
-
-/**
- * Calculates the drag delta and new location values after a block is dragged.
- * @param {!Coordinate} currentDragDeltaXY How far the pointer has
- * moved from the start of the drag, in pixel units.
- * @return {{delta: !Coordinate, newLocation:
- * !Coordinate}} New location after drag. delta is in
- * workspace units. newLocation is the new coordinate where the block should
- * end up.
- * @protected
- */
-BlockDragger.prototype.getNewLocationAfterDrag_ = function(currentDragDeltaXY) {
- const newValues = {};
- newValues.delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
- newValues.newLocation = Coordinate.sum(this.startXY_, newValues.delta);
- return newValues;
-};
-
-/**
- * May delete the dragging block, if allowed. If `this.wouldDeleteBlock_` is not
- * true, the block will not be deleted. This should be called at the end of a
- * block drag.
- * @return {boolean} True if the block was deleted.
- * @protected
- */
-BlockDragger.prototype.maybeDeleteBlock_ = function() {
- if (this.wouldDeleteBlock_) {
- // Fire a move event, so we know where to go back to for an undo.
- this.fireMoveEvent_();
- this.draggingBlock_.dispose(false, true);
- common.draggingConnections.length = 0;
- return true;
- }
- return false;
-};
-
-/**
- * Updates the necessary information to place a block at a certain location.
- * @param {!Coordinate} delta The change in location from where
- * the block started the drag to where it ended the drag.
- * @protected
- */
-BlockDragger.prototype.updateBlockAfterMove_ = function(delta) {
- this.draggingBlock_.moveConnections(delta.x, delta.y);
- this.fireMoveEvent_();
- if (this.draggedConnectionManager_.wouldConnectBlock()) {
- // Applying connections also rerenders the relevant blocks.
- this.draggedConnectionManager_.applyConnections();
- } else {
- this.draggingBlock_.render();
- }
- this.draggingBlock_.scheduleSnapAndBump();
-};
-
-/**
- * Fire a UI event at the end of a block drag.
- * @protected
- */
-BlockDragger.prototype.fireDragEndEvent_ = function() {
- const event = new (eventUtils.get(eventUtils.BLOCK_DRAG))(
- this.draggingBlock_, false, this.draggingBlock_.getDescendants(false));
- eventUtils.fire(event);
-};
-
-/**
- * Adds or removes the style of the cursor for the toolbox.
- * This is what changes the cursor to display an x when a deletable block is
- * held over the toolbox.
- * @param {boolean} isEnd True if we are at the end of a drag, false otherwise.
- * @protected
- */
-BlockDragger.prototype.updateToolboxStyle_ = function(isEnd) {
- const toolbox = this.workspace_.getToolbox();
-
- if (toolbox) {
- const style = this.draggingBlock_.isDeletable() ? 'blocklyToolboxDelete' :
- 'blocklyToolboxGrab';
-
- if (isEnd && typeof toolbox.removeStyle === 'function') {
- toolbox.removeStyle(style);
- } else if (!isEnd && typeof toolbox.addStyle === 'function') {
- toolbox.addStyle(style);
- }
- }
-};
-
-
-/**
- * Fire a move event at the end of a block drag.
- * @protected
- */
-BlockDragger.prototype.fireMoveEvent_ = function() {
- const event =
- new (eventUtils.get(eventUtils.BLOCK_MOVE))(this.draggingBlock_);
- event.oldCoordinate = this.startXY_;
- event.recordNew();
- eventUtils.fire(event);
-};
-
-/**
- * Update the cursor (and possibly the trash can lid) to reflect whether the
- * dragging block would be deleted if released immediately.
- * @protected
- */
-BlockDragger.prototype.updateCursorDuringBlockDrag_ = function() {
- this.draggingBlock_.setDeleteStyle(this.wouldDeleteBlock_);
-};
-
-/**
- * Convert a coordinate object from pixels to workspace units, including a
- * correction for mutator workspaces.
- * This function does not consider differing origins. It simply scales the
- * input's x and y values.
- * @param {!Coordinate} pixelCoord A coordinate with x and y
- * values in CSS pixel units.
- * @return {!Coordinate} The input coordinate divided by the
- * workspace scale.
- * @protected
- */
-BlockDragger.prototype.pixelsToWorkspaceUnits_ = function(pixelCoord) {
- const result = new Coordinate(
- pixelCoord.x / this.workspace_.scale,
- pixelCoord.y / this.workspace_.scale);
- if (this.workspace_.isMutator) {
- // If we're in a mutator, its scale is always 1, purely because of some
- // oddities in our rendering optimizations. The actual scale is the same as
- // the scale on the parent workspace.
- // Fix that for dragging.
- const mainScale = this.workspace_.options.parentWorkspace.scale;
- result.scale(1 / mainScale);
- }
- return result;
-};
-
-/**
- * Move all of the icons connected to this drag.
- * @param {!Coordinate} dxy How far to move the icons from their
- * original positions, in workspace units.
- * @protected
- */
-BlockDragger.prototype.dragIcons_ = function(dxy) {
- // Moving icons moves their associated bubbles.
- for (let i = 0; i < this.dragIconData_.length; i++) {
- const data = this.dragIconData_[i];
- data.icon.setIconLocation(Coordinate.sum(data.location, dxy));
- }
-};
-
-/**
- * Get a list of the insertion markers that currently exist. Drags have 0, 1,
- * or 2 insertion markers.
- * @return {!Array} A possibly empty list of insertion
- * marker blocks.
- * @public
- */
-BlockDragger.prototype.getInsertionMarkers = function() {
- // No insertion markers with the old style of dragged connection managers.
- if (this.draggedConnectionManager_ &&
- this.draggedConnectionManager_.getInsertionMarkers) {
- return this.draggedConnectionManager_.getInsertionMarkers();
- }
- return [];
-};
-
registry.register(registry.Type.BLOCK_DRAGGER, registry.DEFAULT, BlockDragger);
exports.BlockDragger = BlockDragger;
diff --git a/core/block_svg.js b/core/block_svg.js
index b3fcf2fc4..5b66d7ba9 100644
--- a/core/block_svg.js
+++ b/core/block_svg.js
@@ -25,13 +25,15 @@ const constants = goog.require('Blockly.constants');
const dom = goog.require('Blockly.utils.dom');
const eventUtils = goog.require('Blockly.Events.utils');
const internalConstants = goog.require('Blockly.internalConstants');
-const object = goog.require('Blockly.utils.object');
const svgMath = goog.require('Blockly.utils.svgMath');
const userAgent = goog.require('Blockly.utils.userAgent');
const {ASTNode} = goog.require('Blockly.ASTNode');
const {Block} = goog.require('Blockly.Block');
/* eslint-disable-next-line no-unused-vars */
+const {BlockMove} = goog.requireType('Blockly.Events.BlockMove');
+/* eslint-disable-next-line no-unused-vars */
const {Comment} = goog.requireType('Blockly.Comment');
+const {config} = goog.require('Blockly.config');
const {ConnectionType} = goog.require('Blockly.ConnectionType');
/* eslint-disable-next-line no-unused-vars */
const {Connection} = goog.requireType('Blockly.Connection');
@@ -81,107 +83,1797 @@ goog.require('Blockly.Touch');
/**
* Class for a block's SVG representation.
* Not normally called directly, workspace.newBlock() is preferred.
- * @param {!WorkspaceSvg} workspace The block's workspace.
- * @param {?string} prototypeName Name of the language object containing
- * type-specific functions for this block.
- * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise
- * create a new ID.
* @extends {Block}
* @implements {IASTNodeLocationSvg}
* @implements {IBoundedElement}
* @implements {ICopyable}
* @implements {IDraggable}
- * @constructor
* @alias Blockly.BlockSvg
*/
-const BlockSvg = function(workspace, prototypeName, opt_id) {
- // Create core elements for the block.
+class BlockSvg extends Block {
/**
- * @type {!SVGGElement}
- * @private
+ * @param {!WorkspaceSvg} workspace The block's workspace.
+ * @param {string} prototypeName Name of the language object containing
+ * type-specific functions for this block.
+ * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise
+ * create a new ID.
*/
- this.svgGroup_ = dom.createSvgElement(Svg.G, {}, null);
- this.svgGroup_.translate_ = '';
+ constructor(workspace, prototypeName, opt_id) {
+ super(workspace, prototypeName, opt_id);
+
+ /**
+ * An optional method called when a mutator dialog is first opened.
+ * This function must create and initialize a top-level block for the
+ * mutator dialog, and return it. This function should also populate this
+ * top-level block with any sub-blocks which are appropriate. This method
+ * must also be coupled with defining a `compose` method for the default
+ * mutation dialog button and UI to appear.
+ * @type {undefined|?function(WorkspaceSvg):!BlockSvg}
+ */
+ this.decompose = this.decompose;
+
+ /**
+ * An optional method called when a mutator dialog saves its content.
+ * This function is called to modify the original block according to new
+ * settings. This method must also be coupled with defining a `decompose`
+ * method for the default mutation dialog button and UI to appear.
+ * @type {undefined|?function(!BlockSvg)}
+ */
+ this.compose = this.compose;
+
+ /**
+ * An optional method called by the default mutator UI which gives the block
+ * a chance to save information about what child blocks are connected to
+ * what mutated connections.
+ * @type {undefined|?function(!BlockSvg)}
+ */
+ this.saveConnections = this.saveConnections;
+
+ /**
+ * An optional method for defining custom block context menu items.
+ * @type {undefined|?function(!Array)}
+ */
+ this.customContextMenu = this.customContextMenu;
+
+ /**
+ * An property used internally to reference the block's rendering debugger.
+ * @type {?BlockRenderingDebug}
+ * @package
+ */
+ this.renderingDebugger = null;
+
+ /**
+ * Height of this block, not including any statement blocks above or below.
+ * Height is in workspace units.
+ * @type {number}
+ */
+ this.height = 0;
+
+ /**
+ * Width of this block, including any connected value blocks.
+ * Width is in workspace units.
+ * @type {number}
+ */
+ this.width = 0;
+
+ /**
+ * Map from IDs for warnings text to PIDs of functions to apply them.
+ * Used to be able to maintain multiple warnings.
+ * @type {Object}
+ * @private
+ */
+ this.warningTextDb_ = null;
+
+ /**
+ * Block's mutator icon (if any).
+ * @type {?Mutator}
+ */
+ this.mutator = null;
+
+ /**
+ * Block's comment icon (if any).
+ * @type {?Comment}
+ * @deprecated August 2019. Use getCommentIcon instead.
+ */
+ this.comment = null;
+
+ /**
+ * Block's comment icon (if any).
+ * @type {?Comment}
+ * @private
+ */
+ this.commentIcon_ = null;
+
+ /**
+ * Block's warning icon (if any).
+ * @type {?Warning}
+ */
+ this.warning = null;
+
+ // Create core elements for the block.
+ /**
+ * @type {!SVGGElement}
+ * @private
+ */
+ this.svgGroup_ = dom.createSvgElement(Svg.G, {}, null);
+ this.svgGroup_.translate_ = '';
+
+ /**
+ * A block style object.
+ * @type {!Theme.BlockStyle}
+ */
+ this.style = workspace.getRenderer().getConstants().getBlockStyle(null);
+
+ /**
+ * The renderer's path object.
+ * @type {IPathObject}
+ * @package
+ */
+ this.pathObject =
+ workspace.getRenderer().makePathObject(this.svgGroup_, this.style);
+
+ /** @type {boolean} */
+ this.rendered = false;
+ /**
+ * Is this block currently rendering? Used to stop recursive render calls
+ * from actually triggering a re-render.
+ * @type {boolean}
+ * @private
+ */
+ this.renderIsInProgress_ = false;
+
+ /**
+ * Whether mousedown events have been bound yet.
+ * @type {boolean}
+ * @private
+ */
+ this.eventsInit_ = false;
+
+ /** @type {!WorkspaceSvg} */
+ this.workspace;
+ /** @type {RenderedConnection} */
+ this.outputConnection;
+ /** @type {RenderedConnection} */
+ this.nextConnection;
+ /** @type {RenderedConnection} */
+ this.previousConnection;
+
+ /**
+ * Whether to move the block to the drag surface when it is dragged.
+ * True if it should move, false if it should be translated directly.
+ * @type {boolean}
+ * @private
+ */
+ this.useDragSurface_ =
+ svgMath.is3dSupported() && !!workspace.getBlockDragSurface();
+
+ const svgPath = this.pathObject.svgPath;
+ svgPath.tooltip = this;
+ Tooltip.bindMouseEvents(svgPath);
+
+ // Expose this block's ID on its top-level SVG group.
+ if (this.svgGroup_.dataset) {
+ this.svgGroup_.dataset['id'] = this.id;
+ } else if (userAgent.IE) {
+ // SVGElement.dataset is not available on IE11, but data-* properties
+ // can be set with setAttribute().
+ this.svgGroup_.setAttribute('data-id', this.id);
+ }
+
+ this.doInit_();
+ }
/**
- * A block style object.
- * @type {!Theme.BlockStyle}
+ * Create and initialize the SVG representation of the block.
+ * May be called more than once.
*/
- this.style = workspace.getRenderer().getConstants().getBlockStyle(null);
+ initSvg() {
+ if (!this.workspace.rendered) {
+ throw TypeError('Workspace is headless.');
+ }
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ input.init();
+ }
+ const icons = this.getIcons();
+ for (let i = 0; i < icons.length; i++) {
+ icons[i].createIcon();
+ }
+ this.applyColour();
+ this.pathObject.updateMovable(this.isMovable());
+ const svg = this.getSvgRoot();
+ if (!this.workspace.options.readOnly && !this.eventsInit_ && svg) {
+ browserEvents.conditionalBind(svg, 'mousedown', this, this.onMouseDown_);
+ }
+ this.eventsInit_ = true;
+
+ if (!svg.parentNode) {
+ this.workspace.getCanvas().appendChild(svg);
+ }
+ }
/**
- * The renderer's path object.
- * @type {IPathObject}
+ * Get the secondary colour of a block.
+ * @return {?string} #RRGGBB string.
+ */
+ getColourSecondary() {
+ return this.style.colourSecondary;
+ }
+
+ /**
+ * Get the tertiary colour of a block.
+ * @return {?string} #RRGGBB string.
+ */
+ getColourTertiary() {
+ return this.style.colourTertiary;
+ }
+
+ /**
+ * Selects this block. Highlights the block visually and fires a select event
+ * if the block is not already selected.
+ */
+ select() {
+ if (this.isShadow() && this.getParent()) {
+ // Shadow blocks should not be selected.
+ this.getParent().select();
+ return;
+ }
+ if (common.getSelected() === this) {
+ return;
+ }
+ let oldId = null;
+ if (common.getSelected()) {
+ oldId = common.getSelected().id;
+ // Unselect any previously selected block.
+ eventUtils.disable();
+ try {
+ common.getSelected().unselect();
+ } finally {
+ eventUtils.enable();
+ }
+ }
+ const event = new (eventUtils.get(eventUtils.SELECTED))(
+ oldId, this.id, this.workspace.id);
+ eventUtils.fire(event);
+ common.setSelected(this);
+ this.addSelect();
+ }
+
+ /**
+ * Unselects this block. Unhighlights the block and fires a select (false)
+ * event if the block is currently selected.
+ */
+ unselect() {
+ if (common.getSelected() !== this) {
+ return;
+ }
+ const event = new (eventUtils.get(eventUtils.SELECTED))(
+ this.id, null, this.workspace.id);
+ event.workspaceId = this.workspace.id;
+ eventUtils.fire(event);
+ common.setSelected(null);
+ this.removeSelect();
+ }
+
+ /**
+ * Returns a list of mutator, comment, and warning icons.
+ * @return {!Array} List of icons.
+ */
+ getIcons() {
+ const icons = [];
+ if (this.mutator) {
+ icons.push(this.mutator);
+ }
+ if (this.commentIcon_) {
+ icons.push(this.commentIcon_);
+ }
+ if (this.warning) {
+ icons.push(this.warning);
+ }
+ return icons;
+ }
+
+ /**
+ * Sets the parent of this block to be a new block or null.
+ * @param {?Block} newParent New parent block.
+ * @package
+ * @override
+ */
+ setParent(newParent) {
+ const oldParent = this.parentBlock_;
+ if (newParent === oldParent) {
+ return;
+ }
+
+ dom.startTextWidthCache();
+ super.setParent(newParent);
+ dom.stopTextWidthCache();
+
+ const svgRoot = this.getSvgRoot();
+
+ // Bail early if workspace is clearing, or we aren't rendered.
+ // We won't need to reattach ourselves anywhere.
+ if (this.workspace.isClearing || !svgRoot) {
+ return;
+ }
+
+ const oldXY = this.getRelativeToSurfaceXY();
+ if (newParent) {
+ (/** @type {!BlockSvg} */ (newParent)).getSvgRoot().appendChild(svgRoot);
+ const newXY = this.getRelativeToSurfaceXY();
+ // Move the connections to match the child's new position.
+ this.moveConnections(newXY.x - oldXY.x, newXY.y - oldXY.y);
+ } else if (oldParent) {
+ // If we are losing a parent, we want to move our DOM element to the
+ // root of the workspace.
+ this.workspace.getCanvas().appendChild(svgRoot);
+ this.translate(oldXY.x, oldXY.y);
+ }
+
+ this.applyColour();
+ }
+
+ /**
+ * Return the coordinates of the top-left corner of this block relative to the
+ * drawing surface's origin (0,0), in workspace units.
+ * If the block is on the workspace, (0, 0) is the origin of the workspace
+ * coordinate system.
+ * This does not change with workspace scale.
+ * @return {!Coordinate} Object with .x and .y properties in
+ * workspace coordinates.
+ */
+ getRelativeToSurfaceXY() {
+ let x = 0;
+ let y = 0;
+
+ const dragSurfaceGroup = this.useDragSurface_ ?
+ this.workspace.getBlockDragSurface().getGroup() :
+ null;
+
+ let element = this.getSvgRoot();
+ if (element) {
+ do {
+ // Loop through this block and every parent.
+ const xy = svgMath.getRelativeXY(element);
+ x += xy.x;
+ y += xy.y;
+ // If this element is the current element on the drag surface, include
+ // the translation of the drag surface itself.
+ if (this.useDragSurface_ &&
+ this.workspace.getBlockDragSurface().getCurrentBlock() ===
+ element) {
+ const surfaceTranslation =
+ this.workspace.getBlockDragSurface().getSurfaceTranslation();
+ x += surfaceTranslation.x;
+ y += surfaceTranslation.y;
+ }
+ element = /** @type {!SVGElement} */ (element.parentNode);
+ } while (element && element !== this.workspace.getCanvas() &&
+ element !== dragSurfaceGroup);
+ }
+ return new Coordinate(x, y);
+ }
+
+ /**
+ * Move a block by a relative offset.
+ * @param {number} dx Horizontal offset in workspace units.
+ * @param {number} dy Vertical offset in workspace units.
+ */
+ moveBy(dx, dy) {
+ if (this.parentBlock_) {
+ throw Error('Block has parent.');
+ }
+ const eventsEnabled = eventUtils.isEnabled();
+ let event;
+ if (eventsEnabled) {
+ event = /** @type {!BlockMove} */
+ (new (eventUtils.get(eventUtils.BLOCK_MOVE))(this));
+ }
+ const xy = this.getRelativeToSurfaceXY();
+ this.translate(xy.x + dx, xy.y + dy);
+ this.moveConnections(dx, dy);
+ if (eventsEnabled) {
+ event.recordNew();
+ eventUtils.fire(event);
+ }
+ this.workspace.resizeContents();
+ }
+
+ /**
+ * Transforms a block by setting the translation on the transform attribute
+ * of the block's SVG.
+ * @param {number} x The x coordinate of the translation in workspace units.
+ * @param {number} y The y coordinate of the translation in workspace units.
+ */
+ translate(x, y) {
+ this.getSvgRoot().setAttribute(
+ 'transform', 'translate(' + x + ',' + y + ')');
+ }
+
+ /**
+ * Move this block to its workspace's drag surface, accounting for
+ * positioning. Generally should be called at the same time as
+ * setDragging_(true). Does nothing if useDragSurface_ is false.
* @package
*/
- this.pathObject =
- workspace.getRenderer().makePathObject(this.svgGroup_, this.style);
-
- /** @type {boolean} */
- this.rendered = false;
- /**
- * Is this block currently rendering? Used to stop recursive render calls
- * from actually triggering a re-render.
- * @type {boolean}
- * @private
- */
- this.renderIsInProgress_ = false;
-
-
- /** @type {!WorkspaceSvg} */
- this.workspace = workspace;
-
- /** @type {RenderedConnection} */
- this.outputConnection = null;
- /** @type {RenderedConnection} */
- this.nextConnection = null;
- /** @type {RenderedConnection} */
- this.previousConnection = null;
-
- /**
- * Whether to move the block to the drag surface when it is dragged.
- * True if it should move, false if it should be translated directly.
- * @type {boolean}
- * @private
- */
- this.useDragSurface_ =
- svgMath.is3dSupported() && !!workspace.getBlockDragSurface();
-
- const svgPath = this.pathObject.svgPath;
- svgPath.tooltip = this;
- Tooltip.bindMouseEvents(svgPath);
- BlockSvg.superClass_.constructor.call(this, workspace, prototypeName, opt_id);
-
- // Expose this block's ID on its top-level SVG group.
- if (this.svgGroup_.dataset) {
- this.svgGroup_.dataset['id'] = this.id;
- } else if (userAgent.IE) {
- // SVGElement.dataset is not available on IE11, but data-* properties
- // can be set with setAttribute().
- this.svgGroup_.setAttribute('data-id', this.id);
+ moveToDragSurface() {
+ if (!this.useDragSurface_) {
+ return;
+ }
+ // The translation for drag surface blocks,
+ // is equal to the current relative-to-surface position,
+ // to keep the position in sync as it move on/off the surface.
+ // This is in workspace coordinates.
+ const xy = this.getRelativeToSurfaceXY();
+ this.clearTransformAttributes_();
+ this.workspace.getBlockDragSurface().translateSurface(xy.x, xy.y);
+ // Execute the move on the top-level SVG component
+ const svg = this.getSvgRoot();
+ if (svg) {
+ this.workspace.getBlockDragSurface().setBlocksAndShow(svg);
+ }
}
-};
-object.inherits(BlockSvg, Block);
-/**
- * Height of this block, not including any statement blocks above or below.
- * Height is in workspace units.
- */
-BlockSvg.prototype.height = 0;
+ /**
+ * Move a block to a position.
+ * @param {Coordinate} xy The position to move to in workspace units.
+ */
+ moveTo(xy) {
+ const curXY = this.getRelativeToSurfaceXY();
+ this.moveBy(xy.x - curXY.x, xy.y - curXY.y);
+ }
-/**
- * Width of this block, including any connected value blocks.
- * Width is in workspace units.
- */
-BlockSvg.prototype.width = 0;
+ /**
+ * Move this block back to the workspace block canvas.
+ * Generally should be called at the same time as setDragging_(false).
+ * Does nothing if useDragSurface_ is false.
+ * @param {!Coordinate} newXY The position the block should take on
+ * on the workspace canvas, in workspace coordinates.
+ * @package
+ */
+ moveOffDragSurface(newXY) {
+ if (!this.useDragSurface_) {
+ return;
+ }
+ // Translate to current position, turning off 3d.
+ this.translate(newXY.x, newXY.y);
+ this.workspace.getBlockDragSurface().clearAndHide(
+ this.workspace.getCanvas());
+ }
-/**
- * Map from IDs for warnings text to PIDs of functions to apply them.
- * Used to be able to maintain multiple warnings.
- * @type {Object}
- * @private
- */
-BlockSvg.prototype.warningTextDb_ = null;
+ /**
+ * Move this block during a drag, taking into account whether we are using a
+ * drag surface to translate blocks.
+ * This block must be a top-level block.
+ * @param {!Coordinate} newLoc The location to translate to, in
+ * workspace coordinates.
+ * @package
+ */
+ moveDuringDrag(newLoc) {
+ if (this.useDragSurface_) {
+ this.workspace.getBlockDragSurface().translateSurface(newLoc.x, newLoc.y);
+ } else {
+ this.svgGroup_.translate_ =
+ 'translate(' + newLoc.x + ',' + newLoc.y + ')';
+ this.svgGroup_.setAttribute(
+ 'transform', this.svgGroup_.translate_ + this.svgGroup_.skew_);
+ }
+ }
+
+ /**
+ * Clear the block of transform="..." attributes.
+ * Used when the block is switching from 3d to 2d transform or vice versa.
+ * @private
+ */
+ clearTransformAttributes_() {
+ this.getSvgRoot().removeAttribute('transform');
+ }
+
+ /**
+ * Snap this block to the nearest grid point.
+ */
+ snapToGrid() {
+ if (!this.workspace) {
+ return; // Deleted block.
+ }
+ if (this.workspace.isDragging()) {
+ return; // Don't bump blocks during a drag.
+ }
+ if (this.getParent()) {
+ return; // Only snap top-level blocks.
+ }
+ if (this.isInFlyout) {
+ return; // Don't move blocks around in a flyout.
+ }
+ const grid = this.workspace.getGrid();
+ if (!grid || !grid.shouldSnap()) {
+ return; // Config says no snapping.
+ }
+ const spacing = grid.getSpacing();
+ const half = spacing / 2;
+ const xy = this.getRelativeToSurfaceXY();
+ const dx =
+ Math.round(Math.round((xy.x - half) / spacing) * spacing + half - xy.x);
+ const dy =
+ Math.round(Math.round((xy.y - half) / spacing) * spacing + half - xy.y);
+ if (dx || dy) {
+ this.moveBy(dx, dy);
+ }
+ }
+
+ /**
+ * Returns the coordinates of a bounding box describing the dimensions of this
+ * block and any blocks stacked below it.
+ * Coordinate system: workspace coordinates.
+ * @return {!Rect} Object with coordinates of the bounding box.
+ */
+ getBoundingRectangle() {
+ const blockXY = this.getRelativeToSurfaceXY();
+ const blockBounds = this.getHeightWidth();
+ let left;
+ let right;
+ if (this.RTL) {
+ left = blockXY.x - blockBounds.width;
+ right = blockXY.x;
+ } else {
+ left = blockXY.x;
+ right = blockXY.x + blockBounds.width;
+ }
+ return new Rect(blockXY.y, blockXY.y + blockBounds.height, left, right);
+ }
+
+ /**
+ * Notify every input on this block to mark its fields as dirty.
+ * A dirty field is a field that needs to be re-rendered.
+ */
+ markDirty() {
+ this.pathObject.constants = (/** @type {!WorkspaceSvg} */ (this.workspace))
+ .getRenderer()
+ .getConstants();
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ input.markDirty();
+ }
+ }
+
+ /**
+ * Set whether the block is collapsed or not.
+ * @param {boolean} collapsed True if collapsed.
+ */
+ setCollapsed(collapsed) {
+ if (this.collapsed_ === collapsed) {
+ return;
+ }
+ super.setCollapsed(collapsed);
+ if (!collapsed) {
+ this.updateCollapsed_();
+ } else if (this.rendered) {
+ this.render();
+ // Don't bump neighbours. Users like to store collapsed functions together
+ // and bumping makes them go out of alignment.
+ }
+ }
+
+ /**
+ * Makes sure that when the block is collapsed, it is rendered correctly
+ * for that state.
+ * @private
+ */
+ updateCollapsed_() {
+ const collapsed = this.isCollapsed();
+ const collapsedInputName = constants.COLLAPSED_INPUT_NAME;
+ const collapsedFieldName = constants.COLLAPSED_FIELD_NAME;
+
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.name !== collapsedInputName) {
+ input.setVisible(!collapsed);
+ }
+ }
+
+ if (!collapsed) {
+ this.updateDisabled();
+ this.removeInput(collapsedInputName);
+ return;
+ }
+
+ const icons = this.getIcons();
+ for (let i = 0, icon; (icon = icons[i]); i++) {
+ icon.setVisible(false);
+ }
+
+ const text = this.toString(internalConstants.COLLAPSE_CHARS);
+ const field = this.getField(collapsedFieldName);
+ if (field) {
+ field.setValue(text);
+ return;
+ }
+ const input = this.getInput(collapsedInputName) ||
+ this.appendDummyInput(collapsedInputName);
+ input.appendField(new FieldLabel(text), collapsedFieldName);
+ }
+
+ /**
+ * Open the next (or previous) FieldTextInput.
+ * @param {!Field} start Current field.
+ * @param {boolean} forward If true go forward, otherwise backward.
+ */
+ tab(start, forward) {
+ const tabCursor = new TabNavigateCursor();
+ tabCursor.setCurNode(ASTNode.createFieldNode(start));
+ const currentNode = tabCursor.getCurNode();
+
+ if (forward) {
+ tabCursor.next();
+ } else {
+ tabCursor.prev();
+ }
+
+ const nextNode = tabCursor.getCurNode();
+ if (nextNode && nextNode !== currentNode) {
+ const nextField = /** @type {!Field} */ (nextNode.getLocation());
+ nextField.showEditor();
+
+ // Also move the cursor if we're in keyboard nav mode.
+ if (this.workspace.keyboardAccessibilityMode) {
+ this.workspace.getCursor().setCurNode(nextNode);
+ }
+ }
+ }
+
+ /**
+ * Handle a mouse-down on an SVG block.
+ * @param {!Event} e Mouse down event or touch start event.
+ * @private
+ */
+ onMouseDown_(e) {
+ const gesture = this.workspace && this.workspace.getGesture(e);
+ if (gesture) {
+ gesture.handleBlockStart(e, this);
+ }
+ }
+
+ /**
+ * Load the block's help page in a new window.
+ * @package
+ */
+ showHelp() {
+ const url =
+ (typeof this.helpUrl === 'function') ? this.helpUrl() : this.helpUrl;
+ if (url) {
+ window.open(url);
+ }
+ }
+
+ /**
+ * Generate the context menu for this block.
+ * @return {?Array} Context menu options or null if no menu.
+ * @protected
+ */
+ generateContextMenu() {
+ if (this.workspace.options.readOnly || !this.contextMenu) {
+ return null;
+ }
+ const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
+ ContextMenuRegistry.ScopeType.BLOCK, {block: this});
+
+ // Allow the block to add or modify menuOptions.
+ if (this.customContextMenu) {
+ this.customContextMenu(menuOptions);
+ }
+
+ return menuOptions;
+ }
+
+ /**
+ * Show the context menu for this block.
+ * @param {!Event} e Mouse event.
+ * @package
+ */
+ showContextMenu(e) {
+ const menuOptions = this.generateContextMenu();
+
+ if (menuOptions && menuOptions.length) {
+ ContextMenu.show(e, menuOptions, this.RTL);
+ ContextMenu.setCurrentBlock(this);
+ }
+ }
+
+ /**
+ * Move the connections for this block and all blocks attached under it.
+ * Also update any attached bubbles.
+ * @param {number} dx Horizontal offset from current location, in workspace
+ * units.
+ * @param {number} dy Vertical offset from current location, in workspace
+ * units.
+ * @package
+ */
+ moveConnections(dx, dy) {
+ if (!this.rendered) {
+ // Rendering is required to lay out the blocks.
+ // This is probably an invisible block attached to a collapsed block.
+ return;
+ }
+ const myConnections = this.getConnections_(false);
+ for (let i = 0; i < myConnections.length; i++) {
+ myConnections[i].moveBy(dx, dy);
+ }
+ const icons = this.getIcons();
+ for (let i = 0; i < icons.length; i++) {
+ icons[i].computeIconLocation();
+ }
+
+ // Recurse through all blocks attached under this one.
+ for (let i = 0; i < this.childBlocks_.length; i++) {
+ (/** @type {!BlockSvg} */ (this.childBlocks_[i])).moveConnections(dx, dy);
+ }
+ }
+
+ /**
+ * Recursively adds or removes the dragging class to this node and its
+ * children.
+ * @param {boolean} adding True if adding, false if removing.
+ * @package
+ */
+ setDragging(adding) {
+ if (adding) {
+ const group = this.getSvgRoot();
+ group.translate_ = '';
+ group.skew_ = '';
+ common.draggingConnections.push(...this.getConnections_(true));
+ dom.addClass(
+ /** @type {!Element} */ (this.svgGroup_), 'blocklyDragging');
+ } else {
+ common.draggingConnections.length = 0;
+ dom.removeClass(
+ /** @type {!Element} */ (this.svgGroup_), 'blocklyDragging');
+ }
+ // Recurse through all blocks attached under this one.
+ for (let i = 0; i < this.childBlocks_.length; i++) {
+ (/** @type {!BlockSvg} */ (this.childBlocks_[i])).setDragging(adding);
+ }
+ }
+
+ /**
+ * Set whether this block is movable or not.
+ * @param {boolean} movable True if movable.
+ */
+ setMovable(movable) {
+ super.setMovable(movable);
+ this.pathObject.updateMovable(movable);
+ }
+
+ /**
+ * Set whether this block is editable or not.
+ * @param {boolean} editable True if editable.
+ */
+ setEditable(editable) {
+ super.setEditable(editable);
+ const icons = this.getIcons();
+ for (let i = 0; i < icons.length; i++) {
+ icons[i].updateEditable();
+ }
+ }
+
+ /**
+ * Sets whether this block is a shadow block or not.
+ * @param {boolean} shadow True if a shadow.
+ * @package
+ */
+ setShadow(shadow) {
+ super.setShadow(shadow);
+ this.applyColour();
+ }
+
+ /**
+ * Set whether this block is an insertion marker block or not.
+ * Once set this cannot be unset.
+ * @param {boolean} insertionMarker True if an insertion marker.
+ * @package
+ */
+ setInsertionMarker(insertionMarker) {
+ if (this.isInsertionMarker_ === insertionMarker) {
+ return; // No change.
+ }
+ this.isInsertionMarker_ = insertionMarker;
+ if (this.isInsertionMarker_) {
+ this.setColour(
+ this.workspace.getRenderer().getConstants().INSERTION_MARKER_COLOUR);
+ this.pathObject.updateInsertionMarker(true);
+ }
+ }
+
+ /**
+ * Return the root node of the SVG or null if none exists.
+ * @return {!SVGGElement} The root SVG node (probably a group).
+ */
+ getSvgRoot() {
+ return this.svgGroup_;
+ }
+
+ /**
+ * Dispose of this block.
+ * @param {boolean=} healStack If true, then try to heal any gap by connecting
+ * the next statement with the previous statement. Otherwise, dispose of
+ * all children of this block.
+ * @param {boolean=} animate If true, show a disposal animation and sound.
+ * @suppress {checkTypes}
+ */
+ dispose(healStack, animate) {
+ if (!this.workspace) {
+ // The block has already been deleted.
+ return;
+ }
+ Tooltip.dispose();
+ Tooltip.unbindMouseEvents(this.pathObject.svgPath);
+ dom.startTextWidthCache();
+ // Save the block's workspace temporarily so we can resize the
+ // contents once the block is disposed.
+ const blockWorkspace = this.workspace;
+ // If this block is being dragged, unlink the mouse events.
+ if (common.getSelected() === this) {
+ this.unselect();
+ this.workspace.cancelCurrentGesture();
+ }
+ // If this block has a context menu open, close it.
+ if (ContextMenu.getCurrentBlock() === this) {
+ ContextMenu.hide();
+ }
+
+ if (animate && this.rendered) {
+ this.unplug(healStack);
+ blockAnimations.disposeUiEffect(this);
+ }
+ // Stop rerendering.
+ this.rendered = false;
+
+ // Clear pending warnings.
+ if (this.warningTextDb_) {
+ for (const n in this.warningTextDb_) {
+ clearTimeout(this.warningTextDb_[n]);
+ }
+ this.warningTextDb_ = null;
+ }
+
+ const icons = this.getIcons();
+ for (let i = 0; i < icons.length; i++) {
+ icons[i].dispose();
+ }
+ super.dispose(!!healStack);
+
+ dom.removeNode(this.svgGroup_);
+ blockWorkspace.resizeContents();
+ // Sever JavaScript to DOM connections.
+ this.svgGroup_ = null;
+ dom.stopTextWidthCache();
+ }
+
+ /**
+ * Delete a block and hide chaff when doing so. The block will not be deleted
+ * if it's in a flyout. This is called from the context menu and keyboard
+ * shortcuts as the full delete action. If you are disposing of a block from
+ * the workspace and don't need to perform flyout checks, handle event
+ * grouping, or hide chaff, then use `block.dispose()` directly.
+ */
+ checkAndDelete() {
+ if (this.workspace.isFlyout) {
+ return;
+ }
+ eventUtils.setGroup(true);
+ this.workspace.hideChaff();
+ if (this.outputConnection) {
+ // Do not attempt to heal rows
+ // (https://github.com/google/blockly/issues/4832)
+ this.dispose(false, true);
+ } else {
+ this.dispose(/* heal */ true, true);
+ }
+ eventUtils.setGroup(false);
+ }
+
+ /**
+ * Encode a block for copying.
+ * @return {?ICopyable.CopyData} Copy metadata, or null if the block is
+ * an insertion marker.
+ * @package
+ */
+ toCopyData() {
+ if (this.isInsertionMarker_) {
+ return null;
+ }
+ return {
+ saveInfo: /** @type {!blocks.State} */ (
+ blocks.save(this, {addCoordinates: true, addNextBlocks: false})),
+ source: this.workspace,
+ typeCounts: common.getBlockTypeCounts(this, true),
+ };
+ }
+
+ /**
+ * Updates the colour of the block to match the block's state.
+ * @package
+ */
+ applyColour() {
+ this.pathObject.applyColour(this);
+
+ const icons = this.getIcons();
+ for (let i = 0; i < icons.length; i++) {
+ icons[i].applyColour();
+ }
+
+ for (let x = 0, input; (input = this.inputList[x]); x++) {
+ for (let y = 0, field; (field = input.fieldRow[y]); y++) {
+ field.applyColour();
+ }
+ }
+ }
+
+ /**
+ * Updates the color of the block (and children) to match the current disabled
+ * state.
+ * @package
+ */
+ updateDisabled() {
+ const children =
+ /** @type {!Array} */ (this.getChildren(false));
+ this.applyColour();
+ if (this.isCollapsed()) {
+ return;
+ }
+ for (let i = 0, child; (child = children[i]); i++) {
+ if (child.rendered) {
+ child.updateDisabled();
+ }
+ }
+ }
+
+ /**
+ * Get the comment icon attached to this block, or null if the block has no
+ * comment.
+ * @return {?Comment} The comment icon attached to this block, or null.
+ */
+ getCommentIcon() {
+ return this.commentIcon_;
+ }
+
+ /**
+ * Set this block's comment text.
+ * @param {?string} text The text, or null to delete.
+ */
+ setCommentText(text) {
+ const {Comment} = goog.module.get('Blockly.Comment');
+ if (!Comment) {
+ throw Error('Missing require for Blockly.Comment');
+ }
+ if (this.commentModel.text === text) {
+ return;
+ }
+ super.setCommentText(text);
+
+ const shouldHaveComment = text !== null;
+ if (!!this.commentIcon_ === shouldHaveComment) {
+ // If the comment's state of existence is correct, but the text is new
+ // that means we're just updating a comment.
+ this.commentIcon_.updateText();
+ return;
+ }
+ if (shouldHaveComment) {
+ this.commentIcon_ = new Comment(this);
+ this.comment = this.commentIcon_; // For backwards compatibility.
+ } else {
+ this.commentIcon_.dispose();
+ this.commentIcon_ = null;
+ this.comment = null; // For backwards compatibility.
+ }
+ if (this.rendered) {
+ this.render();
+ // Adding or removing a comment icon will cause the block to change shape.
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Set this block's warning text.
+ * @param {?string} text The text, or null to delete.
+ * @param {string=} opt_id An optional ID for the warning text to be able to
+ * maintain multiple warnings.
+ */
+ setWarningText(text, opt_id) {
+ const {Warning} = goog.module.get('Blockly.Warning');
+ if (!Warning) {
+ throw Error('Missing require for Blockly.Warning');
+ }
+ if (!this.warningTextDb_) {
+ // Create a database of warning PIDs.
+ // Only runs once per block (and only those with warnings).
+ this.warningTextDb_ = Object.create(null);
+ }
+ const id = opt_id || '';
+ if (!id) {
+ // Kill all previous pending processes, this edit supersedes them all.
+ for (const n of Object.keys(this.warningTextDb_)) {
+ clearTimeout(this.warningTextDb_[n]);
+ delete this.warningTextDb_[n];
+ }
+ } else if (this.warningTextDb_[id]) {
+ // Only queue up the latest change. Kill any earlier pending process.
+ clearTimeout(this.warningTextDb_[id]);
+ delete this.warningTextDb_[id];
+ }
+ if (this.workspace.isDragging()) {
+ // Don't change the warning text during a drag.
+ // Wait until the drag finishes.
+ const thisBlock = this;
+ this.warningTextDb_[id] = setTimeout(function() {
+ if (thisBlock.workspace) { // Check block wasn't deleted.
+ delete thisBlock.warningTextDb_[id];
+ thisBlock.setWarningText(text, id);
+ }
+ }, 100);
+ return;
+ }
+ if (this.isInFlyout) {
+ text = null;
+ }
+
+ let changedState = false;
+ if (typeof text === 'string') {
+ // Bubble up to add a warning on top-most collapsed block.
+ let parent = this.getSurroundParent();
+ let collapsedParent = null;
+ while (parent) {
+ if (parent.isCollapsed()) {
+ collapsedParent = parent;
+ }
+ parent = parent.getSurroundParent();
+ }
+ if (collapsedParent) {
+ collapsedParent.setWarningText(
+ Msg['COLLAPSED_WARNINGS_WARNING'], BlockSvg.COLLAPSED_WARNING_ID);
+ }
+
+ if (!this.warning) {
+ this.warning = new Warning(this);
+ changedState = true;
+ }
+ this.warning.setText(/** @type {string} */ (text), id);
+ } else {
+ // Dispose all warnings if no ID is given.
+ if (this.warning && !id) {
+ this.warning.dispose();
+ changedState = true;
+ } else if (this.warning) {
+ const oldText = this.warning.getText();
+ this.warning.setText('', id);
+ const newText = this.warning.getText();
+ if (!newText) {
+ this.warning.dispose();
+ }
+ changedState = oldText !== newText;
+ }
+ }
+ if (changedState && this.rendered) {
+ this.render();
+ // Adding or removing a warning icon will cause the block to change shape.
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Give this block a mutator dialog.
+ * @param {?Mutator} mutator A mutator dialog instance or null to remove.
+ */
+ setMutator(mutator) {
+ if (this.mutator && this.mutator !== mutator) {
+ this.mutator.dispose();
+ }
+ if (mutator) {
+ mutator.setBlock(this);
+ this.mutator = mutator;
+ mutator.createIcon();
+ }
+ if (this.rendered) {
+ this.render();
+ // Adding or removing a mutator icon will cause the block to change shape.
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Set whether the block is enabled or not.
+ * @param {boolean} enabled True if enabled.
+ */
+ setEnabled(enabled) {
+ if (this.isEnabled() !== enabled) {
+ super.setEnabled(enabled);
+ if (this.rendered && !this.getInheritedDisabled()) {
+ this.updateDisabled();
+ }
+ }
+ }
+
+ /**
+ * Set whether the block is highlighted or not. Block highlighting is
+ * often used to visually mark blocks currently being executed.
+ * @param {boolean} highlighted True if highlighted.
+ */
+ setHighlighted(highlighted) {
+ if (!this.rendered) {
+ return;
+ }
+ this.pathObject.updateHighlighted(highlighted);
+ }
+
+ /**
+ * Adds the visual "select" effect to the block, but does not actually select
+ * it or fire an event.
+ * @see BlockSvg#select
+ */
+ addSelect() {
+ this.pathObject.updateSelected(true);
+ }
+
+ /**
+ * Removes the visual "select" effect from the block, but does not actually
+ * unselect it or fire an event.
+ * @see BlockSvg#unselect
+ */
+ removeSelect() {
+ this.pathObject.updateSelected(false);
+ }
+
+ /**
+ * Update the cursor over this block by adding or removing a class.
+ * @param {boolean} enable True if the delete cursor should be shown, false
+ * otherwise.
+ * @package
+ */
+ setDeleteStyle(enable) {
+ this.pathObject.updateDraggingDelete(enable);
+ }
+
+ // Overrides of functions on Blockly.Block that take into account whether the
+
+ // block has been rendered.
+
+ /**
+ * Get the colour of a block.
+ * @return {string} #RRGGBB string.
+ */
+ getColour() {
+ return this.style.colourPrimary;
+ }
+
+ /**
+ * Change the colour of a block.
+ * @param {number|string} colour HSV hue value, or #RRGGBB string.
+ */
+ setColour(colour) {
+ super.setColour(colour);
+ const styleObj =
+ this.workspace.getRenderer().getConstants().getBlockStyleForColour(
+ this.colour_);
+
+ this.pathObject.setStyle(styleObj.style);
+ this.style = styleObj.style;
+ this.styleName_ = styleObj.name;
+
+ this.applyColour();
+ }
+
+ /**
+ * Set the style and colour values of a block.
+ * @param {string} blockStyleName Name of the block style.
+ * @throws {Error} if the block style does not exist.
+ */
+ setStyle(blockStyleName) {
+ const blockStyle =
+ this.workspace.getRenderer().getConstants().getBlockStyle(
+ blockStyleName);
+ this.styleName_ = blockStyleName;
+
+ if (blockStyle) {
+ this.hat = blockStyle.hat;
+ this.pathObject.setStyle(blockStyle);
+ // Set colour to match Block.
+ this.colour_ = blockStyle.colourPrimary;
+ this.style = blockStyle;
+
+ this.applyColour();
+ } else {
+ throw Error('Invalid style name: ' + blockStyleName);
+ }
+ }
+
+ /**
+ * Move this block to the front of the visible workspace.
+ * tags do not respect z-index so SVG renders them in the
+ * order that they are in the DOM. By placing this block first within the
+ * block group's , it will render on top of any other blocks.
+ * @package
+ */
+ bringToFront() {
+ let block = this;
+ do {
+ const root = block.getSvgRoot();
+ const parent = root.parentNode;
+ const childNodes = parent.childNodes;
+ // Avoid moving the block if it's already at the bottom.
+ if (childNodes[childNodes.length - 1] !== root) {
+ parent.appendChild(root);
+ }
+ block = block.getParent();
+ } while (block);
+ }
+
+ /**
+ * Set whether this block can chain onto the bottom of another block.
+ * @param {boolean} newBoolean True if there can be a previous statement.
+ * @param {(string|Array|null)=} opt_check Statement type or
+ * list of statement types. Null/undefined if any type could be
+ * connected.
+ */
+ setPreviousStatement(newBoolean, opt_check) {
+ super.setPreviousStatement(newBoolean, opt_check);
+
+ if (this.rendered) {
+ this.render();
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Set whether another block can chain onto the bottom of this block.
+ * @param {boolean} newBoolean True if there can be a next statement.
+ * @param {(string|Array|null)=} opt_check Statement type or
+ * list of statement types. Null/undefined if any type could be
+ * connected.
+ */
+ setNextStatement(newBoolean, opt_check) {
+ super.setNextStatement(newBoolean, opt_check);
+
+ if (this.rendered) {
+ this.render();
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Set whether this block returns a value.
+ * @param {boolean} newBoolean True if there is an output.
+ * @param {(string|Array|null)=} opt_check Returned type or list
+ * of returned types. Null or undefined if any type could be returned
+ * (e.g. variable get).
+ */
+ setOutput(newBoolean, opt_check) {
+ super.setOutput(newBoolean, opt_check);
+
+ if (this.rendered) {
+ this.render();
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Set whether value inputs are arranged horizontally or vertically.
+ * @param {boolean} newBoolean True if inputs are horizontal.
+ */
+ setInputsInline(newBoolean) {
+ super.setInputsInline(newBoolean);
+
+ if (this.rendered) {
+ this.render();
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Remove an input from this block.
+ * @param {string} name The name of the input.
+ * @param {boolean=} opt_quiet True to prevent error if input is not present.
+ * @return {boolean} True if operation succeeds, false if input is not present
+ * and opt_quiet is true
+ * @throws {Error} if the input is not present and opt_quiet is not true.
+ */
+ removeInput(name, opt_quiet) {
+ const removed = super.removeInput(name, opt_quiet);
+
+ if (this.rendered) {
+ this.render();
+ // Removing an input will cause the block to change shape.
+ this.bumpNeighbours();
+ }
+
+ return removed;
+ }
+
+ /**
+ * Move a numbered input to a different location on this block.
+ * @param {number} inputIndex Index of the input to move.
+ * @param {number} refIndex Index of input that should be after the moved
+ * input.
+ */
+ moveNumberedInputBefore(inputIndex, refIndex) {
+ super.moveNumberedInputBefore(inputIndex, refIndex);
+
+ if (this.rendered) {
+ this.render();
+ // Moving an input will cause the block to change shape.
+ this.bumpNeighbours();
+ }
+ }
+
+ /**
+ * Add a value input, statement input or local variable to this block.
+ * @param {number} type One of Blockly.inputTypes.
+ * @param {string} name Language-neutral identifier which may used to find
+ * this input again. Should be unique to this block.
+ * @return {!Input} The input object created.
+ * @protected
+ * @override
+ */
+ appendInput_(type, name) {
+ const input = super.appendInput_(type, name);
+
+ if (this.rendered) {
+ this.render();
+ // Adding an input will cause the block to change shape.
+ this.bumpNeighbours();
+ }
+ return input;
+ }
+
+ /**
+ * Sets whether this block's connections are tracked in the database or not.
+ *
+ * Used by the deserializer to be more efficient. Setting a connection's
+ * tracked_ value to false keeps it from adding itself to the db when it
+ * gets its first moveTo call, saving expensive ops for later.
+ * @param {boolean} track If true, start tracking. If false, stop tracking.
+ * @package
+ */
+ setConnectionTracking(track) {
+ if (this.previousConnection) {
+ /** @type {!RenderedConnection} */ (this.previousConnection)
+ .setTracking(track);
+ }
+ if (this.outputConnection) {
+ /** @type {!RenderedConnection} */ (this.outputConnection)
+ .setTracking(track);
+ }
+ if (this.nextConnection) {
+ /** @type {!RenderedConnection} */ (this.nextConnection)
+ .setTracking(track);
+ const child =
+ /** @type {!RenderedConnection} */ (this.nextConnection)
+ .targetBlock();
+ if (child) {
+ child.setConnectionTracking(track);
+ }
+ }
+
+ if (this.collapsed_) {
+ // When track is true, we don't want to start tracking collapsed
+ // connections. When track is false, we're already not tracking
+ // collapsed connections, so no need to update.
+ return;
+ }
+
+ for (let i = 0; i < this.inputList.length; i++) {
+ const conn =
+ /** @type {!RenderedConnection} */ (this.inputList[i].connection);
+ if (conn) {
+ conn.setTracking(track);
+
+ // Pass tracking on down the chain.
+ const block = conn.targetBlock();
+ if (block) {
+ block.setConnectionTracking(track);
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns connections originating from this block.
+ * @param {boolean} all If true, return all connections even hidden ones.
+ * Otherwise, for a non-rendered block return an empty list, and for a
+ * collapsed block don't return inputs connections.
+ * @return {!Array} Array of connections.
+ * @package
+ */
+ getConnections_(all) {
+ const myConnections = [];
+ if (all || this.rendered) {
+ if (this.outputConnection) {
+ myConnections.push(this.outputConnection);
+ }
+ if (this.previousConnection) {
+ myConnections.push(this.previousConnection);
+ }
+ if (this.nextConnection) {
+ myConnections.push(this.nextConnection);
+ }
+ if (all || !this.collapsed_) {
+ for (let i = 0, input; (input = this.inputList[i]); i++) {
+ if (input.connection) {
+ myConnections.push(input.connection);
+ }
+ }
+ }
+ }
+ return myConnections;
+ }
+
+ /**
+ * Walks down a stack of blocks and finds the last next connection on the
+ * stack.
+ * @param {boolean} ignoreShadows If true,the last connection on a non-shadow
+ * block will be returned. If false, this will follow shadows to find the
+ * last connection.
+ * @return {?RenderedConnection} The last next connection on the stack,
+ * or null.
+ * @package
+ * @override
+ */
+ lastConnectionInStack(ignoreShadows) {
+ return /** @type {RenderedConnection} */ (
+ super.lastConnectionInStack(ignoreShadows));
+ }
+
+ /**
+ * Find the connection on this block that corresponds to the given connection
+ * on the other block.
+ * Used to match connections between a block and its insertion marker.
+ * @param {!Block} otherBlock The other block to match against.
+ * @param {!Connection} conn The other connection to match.
+ * @return {?RenderedConnection} The matching connection on this block,
+ * or null.
+ * @package
+ * @override
+ */
+ getMatchingConnection(otherBlock, conn) {
+ return /** @type {RenderedConnection} */ (
+ super.getMatchingConnection(otherBlock, conn));
+ }
+
+ /**
+ * Create a connection of the specified type.
+ * @param {number} type The type of the connection to create.
+ * @return {!RenderedConnection} A new connection of the specified type.
+ * @protected
+ */
+ makeConnection_(type) {
+ return new RenderedConnection(this, type);
+ }
+
+ /**
+ * Bump unconnected blocks out of alignment. Two blocks which aren't actually
+ * connected should not coincidentally line up on screen.
+ */
+ bumpNeighbours() {
+ if (!this.workspace) {
+ return; // Deleted block.
+ }
+ if (this.workspace.isDragging()) {
+ return; // Don't bump blocks during a drag.
+ }
+ const rootBlock = this.getRootBlock();
+ if (rootBlock.isInFlyout) {
+ return; // Don't move blocks around in a flyout.
+ }
+ // Loop through every connection on this block.
+ const myConnections = this.getConnections_(false);
+ for (let i = 0, connection; (connection = myConnections[i]); i++) {
+ const renderedConn = /** @type {!RenderedConnection} */ (connection);
+ // Spider down from this block bumping all sub-blocks.
+ if (renderedConn.isConnected() && renderedConn.isSuperior()) {
+ renderedConn.targetBlock().bumpNeighbours();
+ }
+
+ const neighbours = connection.neighbours(config.snapRadius);
+ for (let j = 0, otherConnection; (otherConnection = neighbours[j]); j++) {
+ const renderedOther =
+ /** @type {!RenderedConnection} */ (otherConnection);
+ // If both connections are connected, that's probably fine. But if
+ // either one of them is unconnected, then there could be confusion.
+ if (!renderedConn.isConnected() || !renderedOther.isConnected()) {
+ // Only bump blocks if they are from different tree structures.
+ if (renderedOther.getSourceBlock().getRootBlock() !== rootBlock) {
+ // Always bump the inferior block.
+ if (renderedConn.isSuperior()) {
+ renderedOther.bumpAwayFrom(renderedConn);
+ } else {
+ renderedConn.bumpAwayFrom(renderedOther);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Schedule snapping to grid and bumping neighbours to occur after a brief
+ * delay.
+ * @package
+ */
+ scheduleSnapAndBump() {
+ const block = this;
+ // Ensure that any snap and bump are part of this move's event group.
+ const group = eventUtils.getGroup();
+
+ setTimeout(function() {
+ eventUtils.setGroup(group);
+ block.snapToGrid();
+ eventUtils.setGroup(false);
+ }, config.bumpDelay / 2);
+
+ setTimeout(function() {
+ eventUtils.setGroup(group);
+ block.bumpNeighbours();
+ eventUtils.setGroup(false);
+ }, config.bumpDelay);
+ }
+
+ /**
+ * Position a block so that it doesn't move the target block when connected.
+ * The block to position is usually either the first block in a dragged stack
+ * or an insertion marker.
+ * @param {!RenderedConnection} sourceConnection The connection on the
+ * moving block's stack.
+ * @param {!RenderedConnection} targetConnection The connection that
+ * should stay stationary as this block is positioned.
+ * @package
+ */
+ positionNearConnection(sourceConnection, targetConnection) {
+ // We only need to position the new block if it's before the existing one,
+ // otherwise its position is set by the previous block.
+ if (sourceConnection.type === ConnectionType.NEXT_STATEMENT ||
+ sourceConnection.type === ConnectionType.INPUT_VALUE) {
+ const dx = targetConnection.x - sourceConnection.x;
+ const dy = targetConnection.y - sourceConnection.y;
+
+ this.moveBy(dx, dy);
+ }
+ }
+
+ /**
+ * Return the parent block or null if this block is at the top level.
+ * @return {?BlockSvg} The block (if any) that holds the current block.
+ * @override
+ */
+ getParent() {
+ return /** @type {?BlockSvg} */ (super.getParent());
+ }
+
+ /**
+ * @return {?BlockSvg} The block (if any) that surrounds the current block.
+ * @override
+ */
+ getSurroundParent() {
+ return /** @type {?BlockSvg} */ (super.getSurroundParent());
+ }
+
+ /**
+ * @return {?BlockSvg} The next statement block or null.
+ * @override
+ */
+ getNextBlock() {
+ return /** @type {?BlockSvg} */ (super.getNextBlock());
+ }
+
+ /**
+ * @return {?BlockSvg} The previou statement block or null.
+ * @override
+ */
+ getPreviousBlock() {
+ return /** @type {?BlockSvg} */ (super.getPreviousBlock());
+ }
+
+ /**
+ * @return {?RenderedConnection} The first statement connection or null.
+ * @package
+ * @override
+ */
+ getFirstStatementConnection() {
+ return /** @type {?RenderedConnection} */ (
+ super.getFirstStatementConnection());
+ }
+
+ /**
+ * @return {!BlockSvg} The top block in a stack.
+ * @override
+ */
+ getTopStackBlock() {
+ return /** @type {!BlockSvg} */ (super.getTopStackBlock());
+ }
+
+ /**
+ * @param {boolean} ordered Sort the list if true.
+ * @return {!Array} Children of this block.
+ * @override
+ */
+ getChildren(ordered) {
+ return /** @type {!Array} */ (super.getChildren(ordered));
+ }
+
+ /**
+ * @param {boolean} ordered Sort the list if true.
+ * @return {!Array} Descendants of this block.
+ * @override
+ */
+ getDescendants(ordered) {
+ return /** @type {!Array} */ (super.getDescendants(ordered));
+ }
+
+ /**
+ * @param {string} name The name of the input.
+ * @return {?BlockSvg} The attached value block, or null if the input is
+ * either disconnected or if the input does not exist.
+ * @override
+ */
+ getInputTargetBlock(name) {
+ return /** @type {?BlockSvg} */ (super.getInputTargetBlock(name));
+ }
+
+ /**
+ * Return the top-most block in this block's tree.
+ * This will return itself if this block is at the top level.
+ * @return {!BlockSvg} The root block.
+ * @override
+ */
+ getRootBlock() {
+ return /** @type {!BlockSvg} */ (super.getRootBlock());
+ }
+
+ /**
+ * Lays out and reflows a block based on its contents and settings.
+ * @param {boolean=} opt_bubble If false, just render this block.
+ * If true, also render block's parent, grandparent, etc. Defaults to true.
+ */
+ render(opt_bubble) {
+ if (this.renderIsInProgress_) {
+ return; // Don't allow recursive renders.
+ }
+ this.renderIsInProgress_ = true;
+ try {
+ this.rendered = true;
+ dom.startTextWidthCache();
+
+ if (this.isCollapsed()) {
+ this.updateCollapsed_();
+ }
+ this.workspace.getRenderer().render(this);
+ this.updateConnectionLocations_();
+
+ if (opt_bubble !== false) {
+ const parentBlock = this.getParent();
+ if (parentBlock) {
+ parentBlock.render(true);
+ } else {
+ // Top-most block. Fire an event to allow scrollbars to resize.
+ this.workspace.resizeContents();
+ }
+ }
+
+ dom.stopTextWidthCache();
+ this.updateMarkers_();
+ } finally {
+ this.renderIsInProgress_ = false;
+ }
+ }
+
+ /**
+ * Redraw any attached marker or cursor svgs if needed.
+ * @protected
+ */
+ updateMarkers_() {
+ if (this.workspace.keyboardAccessibilityMode && this.pathObject.cursorSvg) {
+ this.workspace.getCursor().draw();
+ }
+ if (this.workspace.keyboardAccessibilityMode && this.pathObject.markerSvg) {
+ // TODO(#4592): Update all markers on the block.
+ this.workspace.getMarker(MarkerManager.LOCAL_MARKER).draw();
+ }
+ }
+
+ /**
+ * Update all of the connections on this block with the new locations
+ * calculated during rendering. Also move all of the connected blocks based
+ * on the new connection locations.
+ * @private
+ */
+ updateConnectionLocations_() {
+ const blockTL = this.getRelativeToSurfaceXY();
+ // Don't tighten previous or output connections because they are inferior
+ // connections.
+ if (this.previousConnection) {
+ this.previousConnection.moveToOffset(blockTL);
+ }
+ if (this.outputConnection) {
+ this.outputConnection.moveToOffset(blockTL);
+ }
+
+ for (let i = 0; i < this.inputList.length; i++) {
+ const conn =
+ /** @type {!RenderedConnection} */ (this.inputList[i].connection);
+ if (conn) {
+ conn.moveToOffset(blockTL);
+ if (conn.isConnected()) {
+ conn.tighten();
+ }
+ }
+ }
+
+ if (this.nextConnection) {
+ this.nextConnection.moveToOffset(blockTL);
+ if (this.nextConnection.isConnected()) {
+ this.nextConnection.tighten();
+ }
+ }
+ }
+
+ /**
+ * Add the cursor SVG to this block's SVG group.
+ * @param {SVGElement} cursorSvg The SVG root of the cursor to be added to the
+ * block SVG group.
+ * @package
+ */
+ setCursorSvg(cursorSvg) {
+ this.pathObject.setCursorSvg(cursorSvg);
+ }
+
+ /**
+ * Add the marker SVG to this block's SVG group.
+ * @param {SVGElement} markerSvg The SVG root of the marker to be added to the
+ * block SVG group.
+ * @package
+ */
+ setMarkerSvg(markerSvg) {
+ this.pathObject.setMarkerSvg(markerSvg);
+ }
+
+ /**
+ * Returns a bounding box describing the dimensions of this block
+ * and any blocks stacked below it.
+ * @return {!{height: number, width: number}} Object with height and width
+ * properties in workspace units.
+ * @package
+ */
+ getHeightWidth() {
+ let height = this.height;
+ let width = this.width;
+ // Recursively add size of subsequent blocks.
+ const nextBlock = this.getNextBlock();
+ if (nextBlock) {
+ const nextHeightWidth = nextBlock.getHeightWidth();
+ const workspace = /** @type {!WorkspaceSvg} */ (this.workspace);
+ const tabHeight = workspace.getRenderer().getConstants().NOTCH_HEIGHT;
+ height += nextHeightWidth.height - tabHeight;
+ width = Math.max(width, nextHeightWidth.width);
+ }
+ return {height: height, width: width};
+ }
+
+ /**
+ * Visual effect to show that if the dragging block is dropped, this block
+ * will be replaced. If a shadow block, it will disappear. Otherwise it will
+ * bump.
+ * @param {boolean} add True if highlighting should be added.
+ * @package
+ */
+ fadeForReplacement(add) {
+ this.pathObject.updateReplacementFade(add);
+ }
+
+ /**
+ * Visual effect to show that if the dragging block is dropped it will connect
+ * to this input.
+ * @param {Connection} conn The connection on the input to highlight.
+ * @param {boolean} add True if highlighting should be added.
+ * @package
+ */
+ highlightShapeForInput(conn, add) {
+ this.pathObject.updateShapeForInputHighlight(conn, add);
+ }
+}
/**
* Constant for identifying rows that are to be rendered inline.
@@ -199,1582 +1891,4 @@ BlockSvg.INLINE = -1;
*/
BlockSvg.COLLAPSED_WARNING_ID = 'TEMP_COLLAPSED_WARNING_';
-/**
- * An optional method called when a mutator dialog is first opened.
- * This function must create and initialize a top-level block for the mutator
- * dialog, and return it. This function should also populate this top-level
- * block with any sub-blocks which are appropriate. This method must also be
- * coupled with defining a `compose` method for the default mutation dialog
- * button and UI to appear.
- * @type {?function(WorkspaceSvg):!BlockSvg}
- */
-BlockSvg.prototype.decompose;
-
-/**
- * An optional method called when a mutator dialog saves its content.
- * This function is called to modify the original block according to new
- * settings. This method must also be coupled with defining a `decompose`
- * method for the default mutation dialog button and UI to appear.
- * @type {?function(!BlockSvg)}
- */
-BlockSvg.prototype.compose;
-
-/**
- * An optional method for defining custom block context menu items.
- * @type {?function(!Array)}
- */
-BlockSvg.prototype.customContextMenu;
-
-/**
- * An property used internally to reference the block's rendering debugger.
- * @type {?BlockRenderingDebug}
- * @package
- */
-BlockSvg.prototype.renderingDebugger;
-
-/**
- * Create and initialize the SVG representation of the block.
- * May be called more than once.
- */
-BlockSvg.prototype.initSvg = function() {
- if (!this.workspace.rendered) {
- throw TypeError('Workspace is headless.');
- }
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- input.init();
- }
- const icons = this.getIcons();
- for (let i = 0; i < icons.length; i++) {
- icons[i].createIcon();
- }
- this.applyColour();
- this.pathObject.updateMovable(this.isMovable());
- const svg = this.getSvgRoot();
- if (!this.workspace.options.readOnly && !this.eventsInit_ && svg) {
- browserEvents.conditionalBind(svg, 'mousedown', this, this.onMouseDown_);
- }
- this.eventsInit_ = true;
-
- if (!svg.parentNode) {
- this.workspace.getCanvas().appendChild(svg);
- }
-};
-
-/**
- * Get the secondary colour of a block.
- * @return {?string} #RRGGBB string.
- */
-BlockSvg.prototype.getColourSecondary = function() {
- return this.style.colourSecondary;
-};
-
-/**
- * Get the tertiary colour of a block.
- * @return {?string} #RRGGBB string.
- */
-BlockSvg.prototype.getColourTertiary = function() {
- return this.style.colourTertiary;
-};
-
-/**
- * Selects this block. Highlights the block visually and fires a select event
- * if the block is not already selected.
- */
-BlockSvg.prototype.select = function() {
- if (this.isShadow() && this.getParent()) {
- // Shadow blocks should not be selected.
- this.getParent().select();
- return;
- }
- if (common.getSelected() === this) {
- return;
- }
- let oldId = null;
- if (common.getSelected()) {
- oldId = common.getSelected().id;
- // Unselect any previously selected block.
- eventUtils.disable();
- try {
- common.getSelected().unselect();
- } finally {
- eventUtils.enable();
- }
- }
- const event = new (eventUtils.get(eventUtils.SELECTED))(
- oldId, this.id, this.workspace.id);
- eventUtils.fire(event);
- common.setSelected(this);
- this.addSelect();
-};
-
-/**
- * Unselects this block. Unhighlights the block and fires a select (false) event
- * if the block is currently selected.
- */
-BlockSvg.prototype.unselect = function() {
- if (common.getSelected() !== this) {
- return;
- }
- const event = new (eventUtils.get(eventUtils.SELECTED))(
- this.id, null, this.workspace.id);
- event.workspaceId = this.workspace.id;
- eventUtils.fire(event);
- common.setSelected(null);
- this.removeSelect();
-};
-
-/**
- * Block's mutator icon (if any).
- * @type {?Mutator}
- */
-BlockSvg.prototype.mutator = null;
-
-/**
- * Block's comment icon (if any).
- * @type {?Comment}
- * @deprecated August 2019. Use getCommentIcon instead.
- */
-BlockSvg.prototype.comment = null;
-
-/**
- * Block's comment icon (if any).
- * @type {?Comment}
- * @private
- */
-BlockSvg.prototype.commentIcon_ = null;
-
-/**
- * Block's warning icon (if any).
- * @type {?Warning}
- */
-BlockSvg.prototype.warning = null;
-
-/**
- * Returns a list of mutator, comment, and warning icons.
- * @return {!Array} List of icons.
- */
-BlockSvg.prototype.getIcons = function() {
- const icons = [];
- if (this.mutator) {
- icons.push(this.mutator);
- }
- if (this.commentIcon_) {
- icons.push(this.commentIcon_);
- }
- if (this.warning) {
- icons.push(this.warning);
- }
- return icons;
-};
-
-/**
- * Sets the parent of this block to be a new block or null.
- * @param {?Block} newParent New parent block.
- * @package
- * @override
- */
-BlockSvg.prototype.setParent = function(newParent) {
- const oldParent = this.parentBlock_;
- if (newParent === oldParent) {
- return;
- }
-
- dom.startTextWidthCache();
- BlockSvg.superClass_.setParent.call(this, newParent);
- dom.stopTextWidthCache();
-
- const svgRoot = this.getSvgRoot();
-
- // Bail early if workspace is clearing, or we aren't rendered.
- // We won't need to reattach ourselves anywhere.
- if (this.workspace.isClearing || !svgRoot) {
- return;
- }
-
- const oldXY = this.getRelativeToSurfaceXY();
- if (newParent) {
- newParent.getSvgRoot().appendChild(svgRoot);
- const newXY = this.getRelativeToSurfaceXY();
- // Move the connections to match the child's new position.
- this.moveConnections(newXY.x - oldXY.x, newXY.y - oldXY.y);
- } else if (oldParent) {
- // If we are losing a parent, we want to move our DOM element to the
- // root of the workspace.
- this.workspace.getCanvas().appendChild(svgRoot);
- this.translate(oldXY.x, oldXY.y);
- }
-
- this.applyColour();
-};
-
-/**
- * Return the coordinates of the top-left corner of this block relative to the
- * drawing surface's origin (0,0), in workspace units.
- * If the block is on the workspace, (0, 0) is the origin of the workspace
- * coordinate system.
- * This does not change with workspace scale.
- * @return {!Coordinate} Object with .x and .y properties in
- * workspace coordinates.
- */
-BlockSvg.prototype.getRelativeToSurfaceXY = function() {
- let x = 0;
- let y = 0;
-
- const dragSurfaceGroup = this.useDragSurface_ ?
- this.workspace.getBlockDragSurface().getGroup() :
- null;
-
- let element = this.getSvgRoot();
- if (element) {
- do {
- // Loop through this block and every parent.
- const xy = svgMath.getRelativeXY(element);
- x += xy.x;
- y += xy.y;
- // If this element is the current element on the drag surface, include
- // the translation of the drag surface itself.
- if (this.useDragSurface_ &&
- this.workspace.getBlockDragSurface().getCurrentBlock() === element) {
- const surfaceTranslation =
- this.workspace.getBlockDragSurface().getSurfaceTranslation();
- x += surfaceTranslation.x;
- y += surfaceTranslation.y;
- }
- element = /** @type {!SVGElement} */ (element.parentNode);
- } while (element && element !== this.workspace.getCanvas() &&
- element !== dragSurfaceGroup);
- }
- return new Coordinate(x, y);
-};
-
-/**
- * Move a block by a relative offset.
- * @param {number} dx Horizontal offset in workspace units.
- * @param {number} dy Vertical offset in workspace units.
- */
-BlockSvg.prototype.moveBy = function(dx, dy) {
- if (this.parentBlock_) {
- throw Error('Block has parent.');
- }
- const eventsEnabled = eventUtils.isEnabled();
- let event;
- if (eventsEnabled) {
- event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(this);
- }
- const xy = this.getRelativeToSurfaceXY();
- this.translate(xy.x + dx, xy.y + dy);
- this.moveConnections(dx, dy);
- if (eventsEnabled) {
- event.recordNew();
- eventUtils.fire(event);
- }
- this.workspace.resizeContents();
-};
-
-/**
- * Transforms a block by setting the translation on the transform attribute
- * of the block's SVG.
- * @param {number} x The x coordinate of the translation in workspace units.
- * @param {number} y The y coordinate of the translation in workspace units.
- */
-BlockSvg.prototype.translate = function(x, y) {
- this.getSvgRoot().setAttribute('transform', 'translate(' + x + ',' + y + ')');
-};
-
-/**
- * Move this block to its workspace's drag surface, accounting for positioning.
- * Generally should be called at the same time as setDragging_(true).
- * Does nothing if useDragSurface_ is false.
- * @package
- */
-BlockSvg.prototype.moveToDragSurface = function() {
- if (!this.useDragSurface_) {
- return;
- }
- // The translation for drag surface blocks,
- // is equal to the current relative-to-surface position,
- // to keep the position in sync as it move on/off the surface.
- // This is in workspace coordinates.
- const xy = this.getRelativeToSurfaceXY();
- this.clearTransformAttributes_();
- this.workspace.getBlockDragSurface().translateSurface(xy.x, xy.y);
- // Execute the move on the top-level SVG component
- const svg = this.getSvgRoot();
- if (svg) {
- this.workspace.getBlockDragSurface().setBlocksAndShow(svg);
- }
-};
-
-/**
- * Move a block to a position.
- * @param {Coordinate} xy The position to move to in workspace units.
- */
-BlockSvg.prototype.moveTo = function(xy) {
- const curXY = this.getRelativeToSurfaceXY();
- this.moveBy(xy.x - curXY.x, xy.y - curXY.y);
-};
-
-/**
- * Move this block back to the workspace block canvas.
- * Generally should be called at the same time as setDragging_(false).
- * Does nothing if useDragSurface_ is false.
- * @param {!Coordinate} newXY The position the block should take on
- * on the workspace canvas, in workspace coordinates.
- * @package
- */
-BlockSvg.prototype.moveOffDragSurface = function(newXY) {
- if (!this.useDragSurface_) {
- return;
- }
- // Translate to current position, turning off 3d.
- this.translate(newXY.x, newXY.y);
- this.workspace.getBlockDragSurface().clearAndHide(this.workspace.getCanvas());
-};
-
-/**
- * Move this block during a drag, taking into account whether we are using a
- * drag surface to translate blocks.
- * This block must be a top-level block.
- * @param {!Coordinate} newLoc The location to translate to, in
- * workspace coordinates.
- * @package
- */
-BlockSvg.prototype.moveDuringDrag = function(newLoc) {
- if (this.useDragSurface_) {
- this.workspace.getBlockDragSurface().translateSurface(newLoc.x, newLoc.y);
- } else {
- this.svgGroup_.translate_ = 'translate(' + newLoc.x + ',' + newLoc.y + ')';
- this.svgGroup_.setAttribute(
- 'transform', this.svgGroup_.translate_ + this.svgGroup_.skew_);
- }
-};
-
-/**
- * Clear the block of transform="..." attributes.
- * Used when the block is switching from 3d to 2d transform or vice versa.
- * @private
- */
-BlockSvg.prototype.clearTransformAttributes_ = function() {
- this.getSvgRoot().removeAttribute('transform');
-};
-
-/**
- * Snap this block to the nearest grid point.
- */
-BlockSvg.prototype.snapToGrid = function() {
- if (!this.workspace) {
- return; // Deleted block.
- }
- if (this.workspace.isDragging()) {
- return; // Don't bump blocks during a drag.
- }
- if (this.getParent()) {
- return; // Only snap top-level blocks.
- }
- if (this.isInFlyout) {
- return; // Don't move blocks around in a flyout.
- }
- const grid = this.workspace.getGrid();
- if (!grid || !grid.shouldSnap()) {
- return; // Config says no snapping.
- }
- const spacing = grid.getSpacing();
- const half = spacing / 2;
- const xy = this.getRelativeToSurfaceXY();
- const dx =
- Math.round(Math.round((xy.x - half) / spacing) * spacing + half - xy.x);
- const dy =
- Math.round(Math.round((xy.y - half) / spacing) * spacing + half - xy.y);
- if (dx || dy) {
- this.moveBy(dx, dy);
- }
-};
-
-/**
- * Returns the coordinates of a bounding box describing the dimensions of this
- * block and any blocks stacked below it.
- * Coordinate system: workspace coordinates.
- * @return {!Rect} Object with coordinates of the bounding box.
- */
-BlockSvg.prototype.getBoundingRectangle = function() {
- const blockXY = this.getRelativeToSurfaceXY();
- const blockBounds = this.getHeightWidth();
- let left;
- let right;
- if (this.RTL) {
- left = blockXY.x - blockBounds.width;
- right = blockXY.x;
- } else {
- left = blockXY.x;
- right = blockXY.x + blockBounds.width;
- }
- return new Rect(blockXY.y, blockXY.y + blockBounds.height, left, right);
-};
-
-/**
- * Notify every input on this block to mark its fields as dirty.
- * A dirty field is a field that needs to be re-rendered.
- */
-BlockSvg.prototype.markDirty = function() {
- this.pathObject.constants = (/** @type {!WorkspaceSvg} */ (this.workspace))
- .getRenderer()
- .getConstants();
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- input.markDirty();
- }
-};
-
-/**
- * Set whether the block is collapsed or not.
- * @param {boolean} collapsed True if collapsed.
- */
-BlockSvg.prototype.setCollapsed = function(collapsed) {
- if (this.collapsed_ === collapsed) {
- return;
- }
- BlockSvg.superClass_.setCollapsed.call(this, collapsed);
- if (!collapsed) {
- this.updateCollapsed_();
- } else if (this.rendered) {
- this.render();
- // Don't bump neighbours. Users like to store collapsed functions together
- // and bumping makes them go out of alignment.
- }
-};
-
-/**
- * Makes sure that when the block is collapsed, it is rendered correctly
- * for that state.
- * @private
- */
-BlockSvg.prototype.updateCollapsed_ = function() {
- const collapsed = this.isCollapsed();
- const collapsedInputName = constants.COLLAPSED_INPUT_NAME;
- const collapsedFieldName = constants.COLLAPSED_FIELD_NAME;
-
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.name !== collapsedInputName) {
- input.setVisible(!collapsed);
- }
- }
-
- if (!collapsed) {
- this.updateDisabled();
- this.removeInput(collapsedInputName);
- return;
- }
-
- const icons = this.getIcons();
- for (let i = 0, icon; (icon = icons[i]); i++) {
- icon.setVisible(false);
- }
-
- const text = this.toString(internalConstants.COLLAPSE_CHARS);
- const field = this.getField(collapsedFieldName);
- if (field) {
- field.setValue(text);
- return;
- }
- const input = this.getInput(collapsedInputName) ||
- this.appendDummyInput(collapsedInputName);
- input.appendField(new FieldLabel(text), collapsedFieldName);
-};
-
-/**
- * Open the next (or previous) FieldTextInput.
- * @param {!Field} start Current field.
- * @param {boolean} forward If true go forward, otherwise backward.
- */
-BlockSvg.prototype.tab = function(start, forward) {
- const tabCursor = new TabNavigateCursor();
- tabCursor.setCurNode(ASTNode.createFieldNode(start));
- const currentNode = tabCursor.getCurNode();
-
- if (forward) {
- tabCursor.next();
- } else {
- tabCursor.prev();
- }
-
- const nextNode = tabCursor.getCurNode();
- if (nextNode && nextNode !== currentNode) {
- const nextField = /** @type {!Field} */ (nextNode.getLocation());
- nextField.showEditor();
-
- // Also move the cursor if we're in keyboard nav mode.
- if (this.workspace.keyboardAccessibilityMode) {
- this.workspace.getCursor().setCurNode(nextNode);
- }
- }
-};
-
-/**
- * Handle a mouse-down on an SVG block.
- * @param {!Event} e Mouse down event or touch start event.
- * @private
- */
-BlockSvg.prototype.onMouseDown_ = function(e) {
- const gesture = this.workspace && this.workspace.getGesture(e);
- if (gesture) {
- gesture.handleBlockStart(e, this);
- }
-};
-
-/**
- * Load the block's help page in a new window.
- * @package
- */
-BlockSvg.prototype.showHelp = function() {
- const url =
- (typeof this.helpUrl === 'function') ? this.helpUrl() : this.helpUrl;
- if (url) {
- window.open(url);
- }
-};
-
-/**
- * Generate the context menu for this block.
- * @return {?Array} Context menu options or null if no menu.
- * @protected
- */
-BlockSvg.prototype.generateContextMenu = function() {
- if (this.workspace.options.readOnly || !this.contextMenu) {
- return null;
- }
- const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
- ContextMenuRegistry.ScopeType.BLOCK, {block: this});
-
- // Allow the block to add or modify menuOptions.
- if (this.customContextMenu) {
- this.customContextMenu(menuOptions);
- }
-
- return menuOptions;
-};
-
-/**
- * Show the context menu for this block.
- * @param {!Event} e Mouse event.
- * @package
- */
-BlockSvg.prototype.showContextMenu = function(e) {
- const menuOptions = this.generateContextMenu();
-
- if (menuOptions && menuOptions.length) {
- ContextMenu.show(e, menuOptions, this.RTL);
- ContextMenu.setCurrentBlock(this);
- }
-};
-
-/**
- * Move the connections for this block and all blocks attached under it.
- * Also update any attached bubbles.
- * @param {number} dx Horizontal offset from current location, in workspace
- * units.
- * @param {number} dy Vertical offset from current location, in workspace
- * units.
- * @package
- */
-BlockSvg.prototype.moveConnections = function(dx, dy) {
- if (!this.rendered) {
- // Rendering is required to lay out the blocks.
- // This is probably an invisible block attached to a collapsed block.
- return;
- }
- const myConnections = this.getConnections_(false);
- for (let i = 0; i < myConnections.length; i++) {
- myConnections[i].moveBy(dx, dy);
- }
- const icons = this.getIcons();
- for (let i = 0; i < icons.length; i++) {
- icons[i].computeIconLocation();
- }
-
- // Recurse through all blocks attached under this one.
- for (let i = 0; i < this.childBlocks_.length; i++) {
- this.childBlocks_[i].moveConnections(dx, dy);
- }
-};
-
-/**
- * Recursively adds or removes the dragging class to this node and its children.
- * @param {boolean} adding True if adding, false if removing.
- * @package
- */
-BlockSvg.prototype.setDragging = function(adding) {
- if (adding) {
- const group = this.getSvgRoot();
- group.translate_ = '';
- group.skew_ = '';
- common.draggingConnections.push(...this.getConnections_(true));
- dom.addClass(
- /** @type {!Element} */ (this.svgGroup_), 'blocklyDragging');
- } else {
- common.draggingConnections.length = 0;
- dom.removeClass(
- /** @type {!Element} */ (this.svgGroup_), 'blocklyDragging');
- }
- // Recurse through all blocks attached under this one.
- for (let i = 0; i < this.childBlocks_.length; i++) {
- this.childBlocks_[i].setDragging(adding);
- }
-};
-
-/**
- * Set whether this block is movable or not.
- * @param {boolean} movable True if movable.
- */
-BlockSvg.prototype.setMovable = function(movable) {
- BlockSvg.superClass_.setMovable.call(this, movable);
- this.pathObject.updateMovable(movable);
-};
-
-/**
- * Set whether this block is editable or not.
- * @param {boolean} editable True if editable.
- */
-BlockSvg.prototype.setEditable = function(editable) {
- BlockSvg.superClass_.setEditable.call(this, editable);
- const icons = this.getIcons();
- for (let i = 0; i < icons.length; i++) {
- icons[i].updateEditable();
- }
-};
-
-/**
- * Sets whether this block is a shadow block or not.
- * @param {boolean} shadow True if a shadow.
- * @package
- */
-BlockSvg.prototype.setShadow = function(shadow) {
- BlockSvg.superClass_.setShadow.call(this, shadow);
- this.applyColour();
-};
-
-/**
- * Set whether this block is an insertion marker block or not.
- * Once set this cannot be unset.
- * @param {boolean} insertionMarker True if an insertion marker.
- * @package
- */
-BlockSvg.prototype.setInsertionMarker = function(insertionMarker) {
- if (this.isInsertionMarker_ === insertionMarker) {
- return; // No change.
- }
- this.isInsertionMarker_ = insertionMarker;
- if (this.isInsertionMarker_) {
- this.setColour(
- this.workspace.getRenderer().getConstants().INSERTION_MARKER_COLOUR);
- this.pathObject.updateInsertionMarker(true);
- }
-};
-
-/**
- * Return the root node of the SVG or null if none exists.
- * @return {!SVGGElement} The root SVG node (probably a group).
- */
-BlockSvg.prototype.getSvgRoot = function() {
- return this.svgGroup_;
-};
-
-/**
- * Dispose of this block.
- * @param {boolean=} healStack If true, then try to heal any gap by connecting
- * the next statement with the previous statement. Otherwise, dispose of
- * all children of this block.
- * @param {boolean=} animate If true, show a disposal animation and sound.
- * @suppress {checkTypes}
- */
-BlockSvg.prototype.dispose = function(healStack, animate) {
- if (!this.workspace) {
- // The block has already been deleted.
- return;
- }
- Tooltip.dispose();
- Tooltip.unbindMouseEvents(this.pathObject.svgPath);
- dom.startTextWidthCache();
- // Save the block's workspace temporarily so we can resize the
- // contents once the block is disposed.
- const blockWorkspace = this.workspace;
- // If this block is being dragged, unlink the mouse events.
- if (common.getSelected() === this) {
- this.unselect();
- this.workspace.cancelCurrentGesture();
- }
- // If this block has a context menu open, close it.
- if (ContextMenu.getCurrentBlock() === this) {
- ContextMenu.hide();
- }
-
- if (animate && this.rendered) {
- this.unplug(healStack);
- blockAnimations.disposeUiEffect(this);
- }
- // Stop rerendering.
- this.rendered = false;
-
- // Clear pending warnings.
- if (this.warningTextDb_) {
- for (const n in this.warningTextDb_) {
- clearTimeout(this.warningTextDb_[n]);
- }
- this.warningTextDb_ = null;
- }
-
- const icons = this.getIcons();
- for (let i = 0; i < icons.length; i++) {
- icons[i].dispose();
- }
- BlockSvg.superClass_.dispose.call(this, !!healStack);
-
- dom.removeNode(this.svgGroup_);
- blockWorkspace.resizeContents();
- // Sever JavaScript to DOM connections.
- this.svgGroup_ = null;
- dom.stopTextWidthCache();
-};
-
-/**
- * Delete a block and hide chaff when doing so. The block will not be deleted if
- * it's in a flyout. This is called from the context menu and keyboard shortcuts
- * as the full delete action. If you are disposing of a block from the workspace
- * and don't need to perform flyout checks, handle event grouping, or hide
- * chaff, then use `block.dispose()` directly.
- */
-BlockSvg.prototype.checkAndDelete = function() {
- if (this.workspace.isFlyout) {
- return;
- }
- eventUtils.setGroup(true);
- this.workspace.hideChaff();
- if (this.outputConnection) {
- // Do not attempt to heal rows
- // (https://github.com/google/blockly/issues/4832)
- this.dispose(false, true);
- } else {
- this.dispose(/* heal */ true, true);
- }
- eventUtils.setGroup(false);
-};
-
-/**
- * Encode a block for copying.
- * @return {?ICopyable.CopyData} Copy metadata, or null if the block is
- * an insertion marker.
- * @package
- */
-BlockSvg.prototype.toCopyData = function() {
- if (this.isInsertionMarker_) {
- return null;
- }
- return {
- saveInfo: /** @type {!blocks.State} */ (
- blocks.save(this, {addCoordinates: true, addNextBlocks: false})),
- source: this.workspace,
- typeCounts: common.getBlockTypeCounts(this, true),
- };
-};
-
-/**
- * Updates the colour of the block to match the block's state.
- * @package
- */
-BlockSvg.prototype.applyColour = function() {
- this.pathObject.applyColour(this);
-
- const icons = this.getIcons();
- for (let i = 0; i < icons.length; i++) {
- icons[i].applyColour();
- }
-
- for (let x = 0, input; (input = this.inputList[x]); x++) {
- for (let y = 0, field; (field = input.fieldRow[y]); y++) {
- field.applyColour();
- }
- }
-};
-
-/**
- * Updates the color of the block (and children) to match the current disabled
- * state.
- * @package
- */
-BlockSvg.prototype.updateDisabled = function() {
- const children = this.getChildren(false);
- this.applyColour();
- if (this.isCollapsed()) {
- return;
- }
- for (let i = 0, child; (child = children[i]); i++) {
- if (child.rendered) {
- child.updateDisabled();
- }
- }
-};
-
-/**
- * Get the comment icon attached to this block, or null if the block has no
- * comment.
- * @return {?Comment} The comment icon attached to this block, or null.
- */
-BlockSvg.prototype.getCommentIcon = function() {
- return this.commentIcon_;
-};
-
-/**
- * Set this block's comment text.
- * @param {?string} text The text, or null to delete.
- */
-BlockSvg.prototype.setCommentText = function(text) {
- const {Comment} = goog.module.get('Blockly.Comment');
- if (!Comment) {
- throw Error('Missing require for Blockly.Comment');
- }
- if (this.commentModel.text === text) {
- return;
- }
- BlockSvg.superClass_.setCommentText.call(this, text);
-
- const shouldHaveComment = text !== null;
- if (!!this.commentIcon_ === shouldHaveComment) {
- // If the comment's state of existence is correct, but the text is new
- // that means we're just updating a comment.
- this.commentIcon_.updateText();
- return;
- }
- if (shouldHaveComment) {
- this.commentIcon_ = new Comment(this);
- this.comment = this.commentIcon_; // For backwards compatibility.
- } else {
- this.commentIcon_.dispose();
- this.commentIcon_ = null;
- this.comment = null; // For backwards compatibility.
- }
- if (this.rendered) {
- this.render();
- // Adding or removing a comment icon will cause the block to change shape.
- this.bumpNeighbours();
- }
-};
-
-/**
- * Set this block's warning text.
- * @param {?string} text The text, or null to delete.
- * @param {string=} opt_id An optional ID for the warning text to be able to
- * maintain multiple warnings.
- */
-BlockSvg.prototype.setWarningText = function(text, opt_id) {
- const {Warning} = goog.module.get('Blockly.Warning');
- if (!Warning) {
- throw Error('Missing require for Blockly.Warning');
- }
- if (!this.warningTextDb_) {
- // Create a database of warning PIDs.
- // Only runs once per block (and only those with warnings).
- this.warningTextDb_ = Object.create(null);
- }
- const id = opt_id || '';
- if (!id) {
- // Kill all previous pending processes, this edit supersedes them all.
- for (const n of Object.keys(this.warningTextDb_)) {
- clearTimeout(this.warningTextDb_[n]);
- delete this.warningTextDb_[n];
- }
- } else if (this.warningTextDb_[id]) {
- // Only queue up the latest change. Kill any earlier pending process.
- clearTimeout(this.warningTextDb_[id]);
- delete this.warningTextDb_[id];
- }
- if (this.workspace.isDragging()) {
- // Don't change the warning text during a drag.
- // Wait until the drag finishes.
- const thisBlock = this;
- this.warningTextDb_[id] = setTimeout(function() {
- if (thisBlock.workspace) { // Check block wasn't deleted.
- delete thisBlock.warningTextDb_[id];
- thisBlock.setWarningText(text, id);
- }
- }, 100);
- return;
- }
- if (this.isInFlyout) {
- text = null;
- }
-
- let changedState = false;
- if (typeof text === 'string') {
- // Bubble up to add a warning on top-most collapsed block.
- let parent = this.getSurroundParent();
- let collapsedParent = null;
- while (parent) {
- if (parent.isCollapsed()) {
- collapsedParent = parent;
- }
- parent = parent.getSurroundParent();
- }
- if (collapsedParent) {
- collapsedParent.setWarningText(
- Msg['COLLAPSED_WARNINGS_WARNING'], BlockSvg.COLLAPSED_WARNING_ID);
- }
-
- if (!this.warning) {
- this.warning = new Warning(this);
- changedState = true;
- }
- this.warning.setText(/** @type {string} */ (text), id);
- } else {
- // Dispose all warnings if no ID is given.
- if (this.warning && !id) {
- this.warning.dispose();
- changedState = true;
- } else if (this.warning) {
- const oldText = this.warning.getText();
- this.warning.setText('', id);
- const newText = this.warning.getText();
- if (!newText) {
- this.warning.dispose();
- }
- changedState = oldText !== newText;
- }
- }
- if (changedState && this.rendered) {
- this.render();
- // Adding or removing a warning icon will cause the block to change shape.
- this.bumpNeighbours();
- }
-};
-
-/**
- * Give this block a mutator dialog.
- * @param {?Mutator} mutator A mutator dialog instance or null to remove.
- */
-BlockSvg.prototype.setMutator = function(mutator) {
- if (this.mutator && this.mutator !== mutator) {
- this.mutator.dispose();
- }
- if (mutator) {
- mutator.setBlock(this);
- this.mutator = mutator;
- mutator.createIcon();
- }
- if (this.rendered) {
- this.render();
- // Adding or removing a mutator icon will cause the block to change shape.
- this.bumpNeighbours();
- }
-};
-
-/**
- * Set whether the block is enabled or not.
- * @param {boolean} enabled True if enabled.
- */
-BlockSvg.prototype.setEnabled = function(enabled) {
- if (this.isEnabled() !== enabled) {
- BlockSvg.superClass_.setEnabled.call(this, enabled);
- if (this.rendered && !this.getInheritedDisabled()) {
- this.updateDisabled();
- }
- }
-};
-
-/**
- * Set whether the block is highlighted or not. Block highlighting is
- * often used to visually mark blocks currently being executed.
- * @param {boolean} highlighted True if highlighted.
- */
-BlockSvg.prototype.setHighlighted = function(highlighted) {
- if (!this.rendered) {
- return;
- }
- this.pathObject.updateHighlighted(highlighted);
-};
-
-/**
- * Adds the visual "select" effect to the block, but does not actually select
- * it or fire an event.
- * @see BlockSvg#select
- */
-BlockSvg.prototype.addSelect = function() {
- this.pathObject.updateSelected(true);
-};
-
-/**
- * Removes the visual "select" effect from the block, but does not actually
- * unselect it or fire an event.
- * @see BlockSvg#unselect
- */
-BlockSvg.prototype.removeSelect = function() {
- this.pathObject.updateSelected(false);
-};
-
-/**
- * Update the cursor over this block by adding or removing a class.
- * @param {boolean} enable True if the delete cursor should be shown, false
- * otherwise.
- * @package
- */
-BlockSvg.prototype.setDeleteStyle = function(enable) {
- this.pathObject.updateDraggingDelete(enable);
-};
-
-
-// Overrides of functions on Blockly.Block that take into account whether the
-// block has been rendered.
-/**
- * Get the colour of a block.
- * @return {string} #RRGGBB string.
- */
-BlockSvg.prototype.getColour = function() {
- return this.style.colourPrimary;
-};
-
-/**
- * Change the colour of a block.
- * @param {number|string} colour HSV hue value, or #RRGGBB string.
- */
-BlockSvg.prototype.setColour = function(colour) {
- BlockSvg.superClass_.setColour.call(this, colour);
- const styleObj =
- this.workspace.getRenderer().getConstants().getBlockStyleForColour(
- this.colour_);
-
- this.pathObject.setStyle(styleObj.style);
- this.style = styleObj.style;
- this.styleName_ = styleObj.name;
-
- this.applyColour();
-};
-
-/**
- * Set the style and colour values of a block.
- * @param {string} blockStyleName Name of the block style.
- * @throws {Error} if the block style does not exist.
- */
-BlockSvg.prototype.setStyle = function(blockStyleName) {
- const blockStyle =
- this.workspace.getRenderer().getConstants().getBlockStyle(blockStyleName);
- this.styleName_ = blockStyleName;
-
- if (blockStyle) {
- this.hat = blockStyle.hat;
- this.pathObject.setStyle(blockStyle);
- // Set colour to match Block.
- this.colour_ = blockStyle.colourPrimary;
- this.style = blockStyle;
-
- this.applyColour();
- } else {
- throw Error('Invalid style name: ' + blockStyleName);
- }
-};
-
-/**
- * Move this block to the front of the visible workspace.
- * tags do not respect z-index so SVG renders them in the
- * order that they are in the DOM. By placing this block first within the
- * block group's , it will render on top of any other blocks.
- * @package
- */
-BlockSvg.prototype.bringToFront = function() {
- let block = this;
- do {
- const root = block.getSvgRoot();
- const parent = root.parentNode;
- const childNodes = parent.childNodes;
- // Avoid moving the block if it's already at the bottom.
- if (childNodes[childNodes.length - 1] !== root) {
- parent.appendChild(root);
- }
- block = block.getParent();
- } while (block);
-};
-
-/**
- * Set whether this block can chain onto the bottom of another block.
- * @param {boolean} newBoolean True if there can be a previous statement.
- * @param {(string|Array|null)=} opt_check Statement type or
- * list of statement types. Null/undefined if any type could be connected.
- */
-BlockSvg.prototype.setPreviousStatement = function(newBoolean, opt_check) {
- BlockSvg.superClass_.setPreviousStatement.call(this, newBoolean, opt_check);
-
- if (this.rendered) {
- this.render();
- this.bumpNeighbours();
- }
-};
-
-/**
- * Set whether another block can chain onto the bottom of this block.
- * @param {boolean} newBoolean True if there can be a next statement.
- * @param {(string|Array|null)=} opt_check Statement type or
- * list of statement types. Null/undefined if any type could be connected.
- */
-BlockSvg.prototype.setNextStatement = function(newBoolean, opt_check) {
- BlockSvg.superClass_.setNextStatement.call(this, newBoolean, opt_check);
-
- if (this.rendered) {
- this.render();
- this.bumpNeighbours();
- }
-};
-
-/**
- * Set whether this block returns a value.
- * @param {boolean} newBoolean True if there is an output.
- * @param {(string|Array|null)=} opt_check Returned type or list
- * of returned types. Null or undefined if any type could be returned
- * (e.g. variable get).
- */
-BlockSvg.prototype.setOutput = function(newBoolean, opt_check) {
- BlockSvg.superClass_.setOutput.call(this, newBoolean, opt_check);
-
- if (this.rendered) {
- this.render();
- this.bumpNeighbours();
- }
-};
-
-/**
- * Set whether value inputs are arranged horizontally or vertically.
- * @param {boolean} newBoolean True if inputs are horizontal.
- */
-BlockSvg.prototype.setInputsInline = function(newBoolean) {
- BlockSvg.superClass_.setInputsInline.call(this, newBoolean);
-
- if (this.rendered) {
- this.render();
- this.bumpNeighbours();
- }
-};
-
-/**
- * Remove an input from this block.
- * @param {string} name The name of the input.
- * @param {boolean=} opt_quiet True to prevent error if input is not present.
- * @return {boolean} True if operation succeeds, false if input is not present
- * and opt_quiet is true
- * @throws {Error} if the input is not present and opt_quiet is not true.
- */
-BlockSvg.prototype.removeInput = function(name, opt_quiet) {
- const removed = BlockSvg.superClass_.removeInput.call(this, name, opt_quiet);
-
- if (this.rendered) {
- this.render();
- // Removing an input will cause the block to change shape.
- this.bumpNeighbours();
- }
-
- return removed;
-};
-
-/**
- * Move a numbered input to a different location on this block.
- * @param {number} inputIndex Index of the input to move.
- * @param {number} refIndex Index of input that should be after the moved input.
- */
-BlockSvg.prototype.moveNumberedInputBefore = function(inputIndex, refIndex) {
- BlockSvg.superClass_.moveNumberedInputBefore.call(this, inputIndex, refIndex);
-
- if (this.rendered) {
- this.render();
- // Moving an input will cause the block to change shape.
- this.bumpNeighbours();
- }
-};
-
-/**
- * Add a value input, statement input or local variable to this block.
- * @param {number} type One of Blockly.inputTypes.
- * @param {string} name Language-neutral identifier which may used to find this
- * input again. Should be unique to this block.
- * @return {!Input} The input object created.
- * @protected
- * @override
- */
-BlockSvg.prototype.appendInput_ = function(type, name) {
- const input = BlockSvg.superClass_.appendInput_.call(this, type, name);
-
- if (this.rendered) {
- this.render();
- // Adding an input will cause the block to change shape.
- this.bumpNeighbours();
- }
- return input;
-};
-
-/**
- * Sets whether this block's connections are tracked in the database or not.
- *
- * Used by the deserializer to be more efficient. Setting a connection's
- * tracked_ value to false keeps it from adding itself to the db when it
- * gets its first moveTo call, saving expensive ops for later.
- * @param {boolean} track If true, start tracking. If false, stop tracking.
- * @package
- */
-BlockSvg.prototype.setConnectionTracking = function(track) {
- if (this.previousConnection) {
- this.previousConnection.setTracking(track);
- }
- if (this.outputConnection) {
- this.outputConnection.setTracking(track);
- }
- if (this.nextConnection) {
- this.nextConnection.setTracking(track);
- const child = this.nextConnection.targetBlock();
- if (child) {
- child.setConnectionTracking(track);
- }
- }
-
- if (this.collapsed_) {
- // When track is true, we don't want to start tracking collapsed
- // connections. When track is false, we're already not tracking
- // collapsed connections, so no need to update.
- return;
- }
-
- for (let i = 0; i < this.inputList.length; i++) {
- const conn = this.inputList[i].connection;
- if (conn) {
- conn.setTracking(track);
-
- // Pass tracking on down the chain.
- const block = conn.targetBlock();
- if (block) {
- block.setConnectionTracking(track);
- }
- }
- }
-};
-
-/**
- * Returns connections originating from this block.
- * @param {boolean} all If true, return all connections even hidden ones.
- * Otherwise, for a non-rendered block return an empty list, and for a
- * collapsed block don't return inputs connections.
- * @return {!Array} Array of connections.
- * @package
- */
-BlockSvg.prototype.getConnections_ = function(all) {
- const myConnections = [];
- if (all || this.rendered) {
- if (this.outputConnection) {
- myConnections.push(this.outputConnection);
- }
- if (this.previousConnection) {
- myConnections.push(this.previousConnection);
- }
- if (this.nextConnection) {
- myConnections.push(this.nextConnection);
- }
- if (all || !this.collapsed_) {
- for (let i = 0, input; (input = this.inputList[i]); i++) {
- if (input.connection) {
- myConnections.push(input.connection);
- }
- }
- }
- }
- return myConnections;
-};
-
-/**
- * Walks down a stack of blocks and finds the last next connection on the stack.
- * @param {boolean} ignoreShadows If true,the last connection on a non-shadow
- * block will be returned. If false, this will follow shadows to find the
- * last connection.
- * @return {?RenderedConnection} The last next connection on the stack,
- * or null.
- * @package
- * @override
- */
-BlockSvg.prototype.lastConnectionInStack = function(ignoreShadows) {
- return /** @type {RenderedConnection} */ (
- BlockSvg.superClass_.lastConnectionInStack.call(this, ignoreShadows));
-};
-
-/**
- * Find the connection on this block that corresponds to the given connection
- * on the other block.
- * Used to match connections between a block and its insertion marker.
- * @param {!Block} otherBlock The other block to match against.
- * @param {!Connection} conn The other connection to match.
- * @return {?RenderedConnection} The matching connection on this block,
- * or null.
- * @package
- * @override
- */
-BlockSvg.prototype.getMatchingConnection = function(otherBlock, conn) {
- return /** @type {RenderedConnection} */ (
- BlockSvg.superClass_.getMatchingConnection.call(this, otherBlock, conn));
-};
-
-/**
- * Create a connection of the specified type.
- * @param {number} type The type of the connection to create.
- * @return {!RenderedConnection} A new connection of the specified type.
- * @protected
- */
-BlockSvg.prototype.makeConnection_ = function(type) {
- return new RenderedConnection(this, type);
-};
-
-/**
- * Bump unconnected blocks out of alignment. Two blocks which aren't actually
- * connected should not coincidentally line up on screen.
- */
-BlockSvg.prototype.bumpNeighbours = function() {
- if (!this.workspace) {
- return; // Deleted block.
- }
- if (this.workspace.isDragging()) {
- return; // Don't bump blocks during a drag.
- }
- const rootBlock = this.getRootBlock();
- if (rootBlock.isInFlyout) {
- return; // Don't move blocks around in a flyout.
- }
- // Loop through every connection on this block.
- const myConnections = this.getConnections_(false);
- for (let i = 0, connection; (connection = myConnections[i]); i++) {
- // Spider down from this block bumping all sub-blocks.
- if (connection.isConnected() && connection.isSuperior()) {
- connection.targetBlock().bumpNeighbours();
- }
-
- const neighbours = connection.neighbours(internalConstants.SNAP_RADIUS);
- for (let j = 0, otherConnection; (otherConnection = neighbours[j]); j++) {
- // If both connections are connected, that's probably fine. But if
- // either one of them is unconnected, then there could be confusion.
- if (!connection.isConnected() || !otherConnection.isConnected()) {
- // Only bump blocks if they are from different tree structures.
- if (otherConnection.getSourceBlock().getRootBlock() !== rootBlock) {
- // Always bump the inferior block.
- if (connection.isSuperior()) {
- otherConnection.bumpAwayFrom(connection);
- } else {
- connection.bumpAwayFrom(otherConnection);
- }
- }
- }
- }
- }
-};
-
-/**
- * Schedule snapping to grid and bumping neighbours to occur after a brief
- * delay.
- * @package
- */
-BlockSvg.prototype.scheduleSnapAndBump = function() {
- const block = this;
- // Ensure that any snap and bump are part of this move's event group.
- const group = eventUtils.getGroup();
-
- setTimeout(function() {
- eventUtils.setGroup(group);
- block.snapToGrid();
- eventUtils.setGroup(false);
- }, internalConstants.BUMP_DELAY / 2);
-
- setTimeout(function() {
- eventUtils.setGroup(group);
- block.bumpNeighbours();
- eventUtils.setGroup(false);
- }, internalConstants.BUMP_DELAY);
-};
-
-/**
- * Position a block so that it doesn't move the target block when connected.
- * The block to position is usually either the first block in a dragged stack or
- * an insertion marker.
- * @param {!RenderedConnection} sourceConnection The connection on the
- * moving block's stack.
- * @param {!RenderedConnection} targetConnection The connection that
- * should stay stationary as this block is positioned.
- * @package
- */
-BlockSvg.prototype.positionNearConnection = function(
- sourceConnection, targetConnection) {
- // We only need to position the new block if it's before the existing one,
- // otherwise its position is set by the previous block.
- if (sourceConnection.type === ConnectionType.NEXT_STATEMENT ||
- sourceConnection.type === ConnectionType.INPUT_VALUE) {
- const dx = targetConnection.x - sourceConnection.x;
- const dy = targetConnection.y - sourceConnection.y;
-
- this.moveBy(dx, dy);
- }
-};
-
-/**
- * Return the parent block or null if this block is at the top level.
- * @return {?BlockSvg} The block (if any) that holds the current block.
- * @override
- */
-BlockSvg.prototype.getParent = function() {
- return /** @type {!BlockSvg} */ (BlockSvg.superClass_.getParent.call(this));
-};
-
-/**
- * Return the top-most block in this block's tree.
- * This will return itself if this block is at the top level.
- * @return {!BlockSvg} The root block.
- * @override
- */
-BlockSvg.prototype.getRootBlock = function() {
- return /** @type {!BlockSvg} */ (
- BlockSvg.superClass_.getRootBlock.call(this));
-};
-
-/**
- * Lays out and reflows a block based on its contents and settings.
- * @param {boolean=} opt_bubble If false, just render this block.
- * If true, also render block's parent, grandparent, etc. Defaults to true.
- */
-BlockSvg.prototype.render = function(opt_bubble) {
- if (this.renderIsInProgress_) {
- return; // Don't allow recursive renders.
- }
- this.renderIsInProgress_ = true;
- try {
- this.rendered = true;
- dom.startTextWidthCache();
-
- if (this.isCollapsed()) {
- this.updateCollapsed_();
- }
- this.workspace.getRenderer().render(this);
- this.updateConnectionLocations_();
-
- if (opt_bubble !== false) {
- const parentBlock = this.getParent();
- if (parentBlock) {
- parentBlock.render(true);
- } else {
- // Top-most block. Fire an event to allow scrollbars to resize.
- this.workspace.resizeContents();
- }
- }
-
- dom.stopTextWidthCache();
- this.updateMarkers_();
- } finally {
- this.renderIsInProgress_ = false;
- }
-};
-
-/**
- * Redraw any attached marker or cursor svgs if needed.
- * @protected
- */
-BlockSvg.prototype.updateMarkers_ = function() {
- if (this.workspace.keyboardAccessibilityMode && this.pathObject.cursorSvg) {
- this.workspace.getCursor().draw();
- }
- if (this.workspace.keyboardAccessibilityMode && this.pathObject.markerSvg) {
- // TODO(#4592): Update all markers on the block.
- this.workspace.getMarker(MarkerManager.LOCAL_MARKER).draw();
- }
-};
-
-/**
- * Update all of the connections on this block with the new locations calculated
- * during rendering. Also move all of the connected blocks based on the new
- * connection locations.
- * @private
- */
-BlockSvg.prototype.updateConnectionLocations_ = function() {
- const blockTL = this.getRelativeToSurfaceXY();
- // Don't tighten previous or output connections because they are inferior
- // connections.
- if (this.previousConnection) {
- this.previousConnection.moveToOffset(blockTL);
- }
- if (this.outputConnection) {
- this.outputConnection.moveToOffset(blockTL);
- }
-
- for (let i = 0; i < this.inputList.length; i++) {
- const conn = this.inputList[i].connection;
- if (conn) {
- conn.moveToOffset(blockTL);
- if (conn.isConnected()) {
- conn.tighten();
- }
- }
- }
-
- if (this.nextConnection) {
- this.nextConnection.moveToOffset(blockTL);
- if (this.nextConnection.isConnected()) {
- this.nextConnection.tighten();
- }
- }
-};
-
-/**
- * Add the cursor SVG to this block's SVG group.
- * @param {SVGElement} cursorSvg The SVG root of the cursor to be added to the
- * block SVG group.
- * @package
- */
-BlockSvg.prototype.setCursorSvg = function(cursorSvg) {
- this.pathObject.setCursorSvg(cursorSvg);
-};
-
-/**
- * Add the marker SVG to this block's SVG group.
- * @param {SVGElement} markerSvg The SVG root of the marker to be added to the
- * block SVG group.
- * @package
- */
-BlockSvg.prototype.setMarkerSvg = function(markerSvg) {
- this.pathObject.setMarkerSvg(markerSvg);
-};
-
-/**
- * Returns a bounding box describing the dimensions of this block
- * and any blocks stacked below it.
- * @return {!{height: number, width: number}} Object with height and width
- * properties in workspace units.
- * @package
- */
-BlockSvg.prototype.getHeightWidth = function() {
- let height = this.height;
- let width = this.width;
- // Recursively add size of subsequent blocks.
- const nextBlock = this.getNextBlock();
- if (nextBlock) {
- const nextHeightWidth = nextBlock.getHeightWidth();
- const workspace = /** @type {!WorkspaceSvg} */ (this.workspace);
- const tabHeight = workspace.getRenderer().getConstants().NOTCH_HEIGHT;
- height += nextHeightWidth.height - tabHeight;
- width = Math.max(width, nextHeightWidth.width);
- }
- return {height: height, width: width};
-};
-
-/**
- * Visual effect to show that if the dragging block is dropped, this block will
- * be replaced. If a shadow block, it will disappear. Otherwise it will bump.
- * @param {boolean} add True if highlighting should be added.
- * @package
- */
-BlockSvg.prototype.fadeForReplacement = function(add) {
- this.pathObject.updateReplacementFade(add);
-};
-
-/**
- * Visual effect to show that if the dragging block is dropped it will connect
- * to this input.
- * @param {Connection} conn The connection on the input to highlight.
- * @param {boolean} add True if highlighting should be added.
- * @package
- */
-BlockSvg.prototype.highlightShapeForInput = function(conn, add) {
- this.pathObject.updateShapeForInputHighlight(conn, add);
-};
-
exports.BlockSvg = BlockSvg;
diff --git a/core/blockly.js b/core/blockly.js
index b916abd6e..b2099f6d2 100644
--- a/core/blockly.js
+++ b/core/blockly.js
@@ -40,6 +40,7 @@ const common = goog.require('Blockly.common');
const constants = goog.require('Blockly.constants');
const deprecation = goog.require('Blockly.utils.deprecation');
const dialog = goog.require('Blockly.dialog');
+const dropDownDiv = goog.require('Blockly.dropDownDiv');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
const geras = goog.require('Blockly.geras');
const internalConstants = goog.require('Blockly.internalConstants');
@@ -71,6 +72,7 @@ const {Bubble} = goog.require('Blockly.Bubble');
const {CollapsibleToolboxCategory} = goog.require('Blockly.CollapsibleToolboxCategory');
const {Comment} = goog.require('Blockly.Comment');
const {ComponentManager} = goog.require('Blockly.ComponentManager');
+const {config} = goog.require('Blockly.config');
const {ConnectionChecker} = goog.require('Blockly.ConnectionChecker');
const {ConnectionDB} = goog.require('Blockly.ConnectionDB');
const {ConnectionType} = goog.require('Blockly.ConnectionType');
@@ -79,7 +81,6 @@ const {ContextMenuRegistry} = goog.require('Blockly.ContextMenuRegistry');
const {Cursor} = goog.require('Blockly.Cursor');
const {DeleteArea} = goog.require('Blockly.DeleteArea');
const {DragTarget} = goog.require('Blockly.DragTarget');
-const {DropDownDiv} = goog.require('Blockly.DropDownDiv');
const {FieldAngle} = goog.require('Blockly.FieldAngle');
const {FieldCheckbox} = goog.require('Blockly.FieldCheckbox');
const {FieldColour} = goog.require('Blockly.FieldColour');
@@ -306,7 +307,8 @@ exports.svgResize = common.svgResize;
* @alias Blockly.hideChaff
*/
const hideChaff = function(opt_onlyClosePopups) {
- common.getMainWorkspace().hideChaff(opt_onlyClosePopups);
+ /** @type {!WorkspaceSvg} */ (common.getMainWorkspace())
+ .hideChaff(opt_onlyClosePopups);
};
exports.hideChaff = hideChaff;
@@ -331,7 +333,7 @@ exports.defineBlocksWithJsonArray = common.defineBlocksWithJsonArray;
/**
* Set the parent container. This is the container element that the WidgetDiv,
- * DropDownDiv, and Tooltip are rendered into the first time `Blockly.inject`
+ * dropDownDiv, and Tooltip are rendered into the first time `Blockly.inject`
* is called.
* This method is a NOP if called after the first ``Blockly.inject``.
* @param {!Element} container The container element.
@@ -528,7 +530,7 @@ const paste = function() {
deprecation.warn(
'Blockly.paste', 'December 2021', 'December 2022',
'Blockly.clipboard.paste');
- return clipboard.paste();
+ return !!clipboard.paste();
};
exports.paste = paste;
@@ -655,25 +657,8 @@ const bindEventWithChecks_ = function(
exports.bindEventWithChecks_ = bindEventWithChecks_;
// Aliases to allow external code to access these values for legacy reasons.
-exports.LINE_MODE_MULTIPLIER = internalConstants.LINE_MODE_MULTIPLIER;
-exports.PAGE_MODE_MULTIPLIER = internalConstants.PAGE_MODE_MULTIPLIER;
-exports.DRAG_RADIUS = internalConstants.DRAG_RADIUS;
-exports.FLYOUT_DRAG_RADIUS = internalConstants.FLYOUT_DRAG_RADIUS;
-exports.SNAP_RADIUS = internalConstants.SNAP_RADIUS;
-exports.CONNECTING_SNAP_RADIUS = internalConstants.CONNECTING_SNAP_RADIUS;
-exports.CURRENT_CONNECTION_PREFERENCE =
- internalConstants.CURRENT_CONNECTION_PREFERENCE;
-exports.BUMP_DELAY = internalConstants.BUMP_DELAY;
-exports.BUMP_RANDOMNESS = internalConstants.BUMP_RANDOMNESS;
exports.COLLAPSE_CHARS = internalConstants.COLLAPSE_CHARS;
-exports.LONGPRESS = internalConstants.LONGPRESS;
-exports.SOUND_LIMIT = internalConstants.SOUND_LIMIT;
exports.DRAG_STACK = internalConstants.DRAG_STACK;
-exports.SPRITE = internalConstants.SPRITE;
-exports.DRAG_NONE = internalConstants.DRAG_NONE;
-exports.DRAG_STICKY = internalConstants.DRAG_STICKY;
-exports.DRAG_BEGIN = internalConstants.DRAG_BEGIN;
-exports.DRAG_FREE = internalConstants.DRAG_FREE;
exports.OPPOSITE_TYPE = internalConstants.OPPOSITE_TYPE;
exports.RENAME_VARIABLE_ID = internalConstants.RENAME_VARIABLE_ID;
exports.DELETE_VARIABLE_ID = internalConstants.DELETE_VARIABLE_ID;
@@ -731,7 +716,7 @@ exports.Css = Css;
exports.Cursor = Cursor;
exports.DeleteArea = DeleteArea;
exports.DragTarget = DragTarget;
-exports.DropDownDiv = DropDownDiv;
+exports.DropDownDiv = dropDownDiv;
exports.Events = Events;
exports.Extensions = Extensions;
exports.Field = Field;
@@ -833,6 +818,7 @@ exports.browserEvents = browserEvents;
exports.bumpObjects = bumpObjects;
exports.clipboard = clipboard;
exports.common = common;
+exports.config = config;
/** @deprecated Use Blockly.ConnectionType instead. */
exports.connectionTypes = ConnectionType;
exports.constants = constants;
diff --git a/core/blocks.js b/core/blocks.js
index 0af6f6135..c29eaa96f 100644
--- a/core/blocks.js
+++ b/core/blocks.js
@@ -16,11 +16,18 @@
goog.module('Blockly.blocks');
+/**
+ * A block definition. For now this very lose, but it can potentially
+ * be refined e.g. by replacing this typedef with a class definition.
+ * @typedef {!Object}
+ */
+let BlockDefinition;
+exports.BlockDefinition = BlockDefinition;
+
/**
* A mapping of block type names to block prototype objects.
- * @type {!Object}
+ * @type {!Object}
* @alias Blockly.blocks.Blocks
*/
const Blocks = Object.create(null);
-
exports.Blocks = Blocks;
diff --git a/core/browser_events.js b/core/browser_events.js
index c4f16a4df..3a33cbbcc 100644
--- a/core/browser_events.js
+++ b/core/browser_events.js
@@ -16,7 +16,6 @@
goog.module('Blockly.browserEvents');
const Touch = goog.require('Blockly.Touch');
-const internalConstants = goog.require('Blockly.internalConstants');
const userAgent = goog.require('Blockly.utils.userAgent');
const {globalThis} = goog.require('Blockly.utils.global');
@@ -30,6 +29,24 @@ const {globalThis} = goog.require('Blockly.utils.global');
let Data;
exports.Data = Data;
+/**
+ * The multiplier for scroll wheel deltas using the line delta mode.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode
+ * for more information on deltaMode.
+ * @type {number}
+ * @const
+ */
+const LINE_MODE_MULTIPLIER = 40;
+
+/**
+ * The multiplier for scroll wheel deltas using the page delta mode.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode
+ * for more information on deltaMode.
+ * @type {number}
+ * @const
+ */
+const PAGE_MODE_MULTIPLIER = 125;
+
/**
* Bind an event handler that can be ignored if it is not part of the active
* touch stream.
@@ -254,13 +271,13 @@ const getScrollDeltaPixels = function(e) {
return {x: e.deltaX, y: e.deltaY};
case 0x01: // Line mode.
return {
- x: e.deltaX * internalConstants.LINE_MODE_MULTIPLIER,
- y: e.deltaY * internalConstants.LINE_MODE_MULTIPLIER,
+ x: e.deltaX * LINE_MODE_MULTIPLIER,
+ y: e.deltaY * LINE_MODE_MULTIPLIER,
};
case 0x02: // Page mode.
return {
- x: e.deltaX * internalConstants.PAGE_MODE_MULTIPLIER,
- y: e.deltaY * internalConstants.PAGE_MODE_MULTIPLIER,
+ x: e.deltaX * PAGE_MODE_MULTIPLIER,
+ y: e.deltaY * PAGE_MODE_MULTIPLIER,
};
}
};
diff --git a/core/bubble.js b/core/bubble.js
index d9e6df9f0..0ab9bc10b 100644
--- a/core/bubble.js
+++ b/core/bubble.js
@@ -40,81 +40,906 @@ goog.require('Blockly.Workspace');
/**
* Class for UI bubble.
- * @param {!WorkspaceSvg} workspace The workspace on which to draw the
- * bubble.
- * @param {!Element} content SVG content for the bubble.
- * @param {!Element} shape SVG element to avoid eclipsing.
- * @param {!Coordinate} anchorXY Absolute position of bubble's
- * anchor point.
- * @param {?number} bubbleWidth Width of bubble, or null if not resizable.
- * @param {?number} bubbleHeight Height of bubble, or null if not resizable.
* @implements {IBubble}
- * @constructor
* @alias Blockly.Bubble
*/
-const Bubble = function(
- workspace, content, shape, anchorXY, bubbleWidth, bubbleHeight) {
- this.workspace_ = workspace;
- this.content_ = content;
- this.shape_ = shape;
+const Bubble = class {
+ /**
+ * @param {!WorkspaceSvg} workspace The workspace on which to draw the
+ * bubble.
+ * @param {!Element} content SVG content for the bubble.
+ * @param {!Element} shape SVG element to avoid eclipsing.
+ * @param {!Coordinate} anchorXY Absolute position of bubble's
+ * anchor point.
+ * @param {?number} bubbleWidth Width of bubble, or null if not resizable.
+ * @param {?number} bubbleHeight Height of bubble, or null if not resizable.
+ * @struct
+ */
+ constructor(workspace, content, shape, anchorXY, bubbleWidth, bubbleHeight) {
+ this.workspace_ = workspace;
+ this.content_ = content;
+ this.shape_ = shape;
+
+ /**
+ * Flag to stop incremental rendering during construction.
+ * @type {boolean}
+ * @private
+ */
+ this.rendered_ = false;
+
+ /**
+ * The SVG group containing all parts of the bubble.
+ * @type {SVGGElement}
+ * @private
+ */
+ this.bubbleGroup_ = null;
+
+ /**
+ * The SVG path for the arrow from the bubble to the icon on the block.
+ * @type {SVGPathElement}
+ * @private
+ */
+ this.bubbleArrow_ = null;
+
+ /**
+ * The SVG rect for the main body of the bubble.
+ * @type {SVGRectElement}
+ * @private
+ */
+ this.bubbleBack_ = null;
+
+ /**
+ * The SVG group for the resize hash marks on some bubbles.
+ * @type {SVGGElement}
+ * @private
+ */
+ this.resizeGroup_ = null;
+
+ /**
+ * Absolute coordinate of anchor point, in workspace coordinates.
+ * @type {Coordinate}
+ * @private
+ */
+ this.anchorXY_ = null;
+
+ /**
+ * Relative X coordinate of bubble with respect to the anchor's centre,
+ * in workspace units.
+ * In RTL mode the initial value is negated.
+ * @type {number}
+ * @private
+ */
+ this.relativeLeft_ = 0;
+
+ /**
+ * Relative Y coordinate of bubble with respect to the anchor's centre, in
+ * workspace units.
+ * @type {number}
+ * @private
+ */
+ this.relativeTop_ = 0;
+
+ /**
+ * Width of bubble, in workspace units.
+ * @type {number}
+ * @private
+ */
+ this.width_ = 0;
+
+ /**
+ * Height of bubble, in workspace units.
+ * @type {number}
+ * @private
+ */
+ this.height_ = 0;
+
+ /**
+ * Automatically position and reposition the bubble.
+ * @type {boolean}
+ * @private
+ */
+ this.autoLayout_ = true;
+
+ /**
+ * Method to call on resize of bubble.
+ * @type {?function()}
+ * @private
+ */
+ this.resizeCallback_ = null;
+
+ /**
+ * Method to call on move of bubble.
+ * @type {?function()}
+ * @private
+ */
+ this.moveCallback_ = null;
+
+ /**
+ * Mouse down on bubbleBack_ event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onMouseDownBubbleWrapper_ = null;
+
+ /**
+ * Mouse down on resizeGroup_ event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onMouseDownResizeWrapper_ = null;
+
+ /**
+ * Describes whether this bubble has been disposed of (nodes and event
+ * listeners removed from the page) or not.
+ * @type {boolean}
+ * @package
+ */
+ this.disposed = false;
+
+ let angle = Bubble.ARROW_ANGLE;
+ if (this.workspace_.RTL) {
+ angle = -angle;
+ }
+ this.arrow_radians_ = math.toRadians(angle);
+
+ const canvas = workspace.getBubbleCanvas();
+ canvas.appendChild(
+ this.createDom_(content, !!(bubbleWidth && bubbleHeight)));
+
+ this.setAnchorLocation(anchorXY);
+ if (!bubbleWidth || !bubbleHeight) {
+ const bBox = /** @type {SVGLocatable} */ (this.content_).getBBox();
+ bubbleWidth = bBox.width + 2 * Bubble.BORDER_WIDTH;
+ bubbleHeight = bBox.height + 2 * Bubble.BORDER_WIDTH;
+ }
+ this.setBubbleSize(bubbleWidth, bubbleHeight);
+
+ // Render the bubble.
+ this.positionBubble_();
+ this.renderArrow_();
+ this.rendered_ = true;
+ }
/**
- * Method to call on resize of bubble.
- * @type {?function()}
+ * Create the bubble's DOM.
+ * @param {!Element} content SVG content for the bubble.
+ * @param {boolean} hasResize Add diagonal resize gripper if true.
+ * @return {!SVGElement} The bubble's SVG group.
* @private
*/
- this.resizeCallback_ = null;
+ createDom_(content, hasResize) {
+ /* Create the bubble. Here's the markup that will be generated:
+
+
+
+
+
+
+
+
+
+
+ [...content goes here...]
+
+ */
+ this.bubbleGroup_ = dom.createSvgElement(Svg.G, {}, null);
+ let filter = {
+ 'filter': 'url(#' +
+ this.workspace_.getRenderer().getConstants().embossFilterId + ')',
+ };
+ if (userAgent.JAVA_FX) {
+ // Multiple reports that JavaFX can't handle filters.
+ // https://github.com/google/blockly/issues/99
+ filter = {};
+ }
+ const bubbleEmboss = dom.createSvgElement(Svg.G, filter, this.bubbleGroup_);
+ this.bubbleArrow_ = dom.createSvgElement(Svg.PATH, {}, bubbleEmboss);
+ this.bubbleBack_ = dom.createSvgElement(
+ Svg.RECT, {
+ 'class': 'blocklyDraggable',
+ 'x': 0,
+ 'y': 0,
+ 'rx': Bubble.BORDER_WIDTH,
+ 'ry': Bubble.BORDER_WIDTH,
+ },
+ bubbleEmboss);
+ if (hasResize) {
+ this.resizeGroup_ = dom.createSvgElement(
+ Svg.G, {
+ 'class': this.workspace_.RTL ? 'blocklyResizeSW' :
+ 'blocklyResizeSE',
+ },
+ this.bubbleGroup_);
+ const resizeSize = 2 * Bubble.BORDER_WIDTH;
+ dom.createSvgElement(
+ Svg.POLYGON,
+ {'points': '0,x x,x x,0'.replace(/x/g, resizeSize.toString())},
+ this.resizeGroup_);
+ dom.createSvgElement(
+ Svg.LINE, {
+ 'class': 'blocklyResizeLine',
+ 'x1': resizeSize / 3,
+ 'y1': resizeSize - 1,
+ 'x2': resizeSize - 1,
+ 'y2': resizeSize / 3,
+ },
+ this.resizeGroup_);
+ dom.createSvgElement(
+ Svg.LINE, {
+ 'class': 'blocklyResizeLine',
+ 'x1': resizeSize * 2 / 3,
+ 'y1': resizeSize - 1,
+ 'x2': resizeSize - 1,
+ 'y2': resizeSize * 2 / 3,
+ },
+ this.resizeGroup_);
+ } else {
+ this.resizeGroup_ = null;
+ }
+
+ if (!this.workspace_.options.readOnly) {
+ this.onMouseDownBubbleWrapper_ = browserEvents.conditionalBind(
+ this.bubbleBack_, 'mousedown', this, this.bubbleMouseDown_);
+ if (this.resizeGroup_) {
+ this.onMouseDownResizeWrapper_ = browserEvents.conditionalBind(
+ this.resizeGroup_, 'mousedown', this, this.resizeMouseDown_);
+ }
+ }
+ this.bubbleGroup_.appendChild(content);
+ return this.bubbleGroup_;
+ }
/**
- * Method to call on move of bubble.
- * @type {?function()}
+ * Return the root node of the bubble's SVG group.
+ * @return {!SVGElement} The root SVG node of the bubble's group.
+ */
+ getSvgRoot() {
+ return /** @type {!SVGElement} */ (this.bubbleGroup_);
+ }
+
+ /**
+ * Expose the block's ID on the bubble's top-level SVG group.
+ * @param {string} id ID of block.
+ */
+ setSvgId(id) {
+ if (this.bubbleGroup_.dataset) {
+ this.bubbleGroup_.dataset['blockId'] = id;
+ }
+ }
+
+ /**
+ * Handle a mouse-down on bubble's border.
+ * @param {!Event} e Mouse down event.
* @private
*/
- this.moveCallback_ = null;
+ bubbleMouseDown_(e) {
+ const gesture = this.workspace_.getGesture(e);
+ if (gesture) {
+ gesture.handleBubbleStart(e, this);
+ }
+ }
/**
- * Mouse down on bubbleBack_ event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onMouseDownBubbleWrapper_ = null;
-
- /**
- * Mouse down on resizeGroup_ event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onMouseDownResizeWrapper_ = null;
-
- /**
- * Describes whether this bubble has been disposed of (nodes and event
- * listeners removed from the page) or not.
- * @type {boolean}
+ * Show the context menu for this bubble.
+ * @param {!Event} _e Mouse event.
* @package
*/
- this.disposed = false;
-
- let angle = Bubble.ARROW_ANGLE;
- if (this.workspace_.RTL) {
- angle = -angle;
+ showContextMenu(_e) {
+ // NOP on bubbles, but used by the bubble dragger to pass events to
+ // workspace comments.
}
- this.arrow_radians_ = math.toRadians(angle);
- const canvas = workspace.getBubbleCanvas();
- canvas.appendChild(this.createDom_(content, !!(bubbleWidth && bubbleHeight)));
-
- this.setAnchorLocation(anchorXY);
- if (!bubbleWidth || !bubbleHeight) {
- const bBox = /** @type {SVGLocatable} */ (this.content_).getBBox();
- bubbleWidth = bBox.width + 2 * Bubble.BORDER_WIDTH;
- bubbleHeight = bBox.height + 2 * Bubble.BORDER_WIDTH;
+ /**
+ * Get whether this bubble is deletable or not.
+ * @return {boolean} True if deletable.
+ * @package
+ */
+ isDeletable() {
+ return false;
}
- this.setBubbleSize(bubbleWidth, bubbleHeight);
- // Render the bubble.
- this.positionBubble_();
- this.renderArrow_();
- this.rendered_ = true;
+ /**
+ * Update the style of this bubble when it is dragged over a delete area.
+ * @param {boolean} _enable True if the bubble is about to be deleted, false
+ * otherwise.
+ */
+ setDeleteStyle(_enable) {
+ // NOP if bubble is not deletable.
+ }
+
+ /**
+ * Handle a mouse-down on bubble's resize corner.
+ * @param {!Event} e Mouse down event.
+ * @private
+ */
+ resizeMouseDown_(e) {
+ this.promote();
+ Bubble.unbindDragEvents_();
+ if (browserEvents.isRightButton(e)) {
+ // No right-click.
+ e.stopPropagation();
+ return;
+ }
+ // Left-click (or middle click)
+ this.workspace_.startDrag(
+ e,
+ new Coordinate(
+ this.workspace_.RTL ? -this.width_ : this.width_, this.height_));
+
+ Bubble.onMouseUpWrapper_ = browserEvents.conditionalBind(
+ document, 'mouseup', this, Bubble.bubbleMouseUp_);
+ Bubble.onMouseMoveWrapper_ = browserEvents.conditionalBind(
+ document, 'mousemove', this, this.resizeMouseMove_);
+ this.workspace_.hideChaff();
+ // This event has been handled. No need to bubble up to the document.
+ e.stopPropagation();
+ }
+
+ /**
+ * Resize this bubble to follow the mouse.
+ * @param {!Event} e Mouse move event.
+ * @private
+ */
+ resizeMouseMove_(e) {
+ this.autoLayout_ = false;
+ const newXY = this.workspace_.moveDrag(e);
+ this.setBubbleSize(this.workspace_.RTL ? -newXY.x : newXY.x, newXY.y);
+ if (this.workspace_.RTL) {
+ // RTL requires the bubble to move its left edge.
+ this.positionBubble_();
+ }
+ }
+
+ /**
+ * Register a function as a callback event for when the bubble is resized.
+ * @param {!Function} callback The function to call on resize.
+ */
+ registerResizeEvent(callback) {
+ this.resizeCallback_ = callback;
+ }
+
+ /**
+ * Register a function as a callback event for when the bubble is moved.
+ * @param {!Function} callback The function to call on move.
+ */
+ registerMoveEvent(callback) {
+ this.moveCallback_ = callback;
+ }
+
+ /**
+ * Move this bubble to the top of the stack.
+ * @return {boolean} Whether or not the bubble has been moved.
+ * @package
+ */
+ promote() {
+ const svgGroup = this.bubbleGroup_.parentNode;
+ if (svgGroup.lastChild !== this.bubbleGroup_) {
+ svgGroup.appendChild(this.bubbleGroup_);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Notification that the anchor has moved.
+ * Update the arrow and bubble accordingly.
+ * @param {!Coordinate} xy Absolute location.
+ */
+ setAnchorLocation(xy) {
+ this.anchorXY_ = xy;
+ if (this.rendered_) {
+ this.positionBubble_();
+ }
+ }
+
+ /**
+ * Position the bubble so that it does not fall off-screen.
+ * @private
+ */
+ layoutBubble_() {
+ // Get the metrics in workspace units.
+ const viewMetrics =
+ this.workspace_.getMetricsManager().getViewMetrics(true);
+
+ const optimalLeft = this.getOptimalRelativeLeft_(viewMetrics);
+ const optimalTop = this.getOptimalRelativeTop_(viewMetrics);
+ const bbox = this.shape_.getBBox();
+
+ const topPosition = {
+ x: optimalLeft,
+ y: -this.height_ -
+ this.workspace_.getRenderer().getConstants().MIN_BLOCK_HEIGHT,
+ };
+ const startPosition = {x: -this.width_ - 30, y: optimalTop};
+ const endPosition = {x: bbox.width, y: optimalTop};
+ const bottomPosition = {x: optimalLeft, y: bbox.height};
+
+ const closerPosition =
+ bbox.width < bbox.height ? endPosition : bottomPosition;
+ const fartherPosition =
+ bbox.width < bbox.height ? bottomPosition : endPosition;
+
+ const topPositionOverlap = this.getOverlap_(topPosition, viewMetrics);
+ const startPositionOverlap = this.getOverlap_(startPosition, viewMetrics);
+ const closerPositionOverlap = this.getOverlap_(closerPosition, viewMetrics);
+ const fartherPositionOverlap =
+ this.getOverlap_(fartherPosition, viewMetrics);
+
+ // Set the position to whichever position shows the most of the bubble,
+ // with tiebreaks going in the order: top > start > close > far.
+ const mostOverlap = Math.max(
+ topPositionOverlap, startPositionOverlap, closerPositionOverlap,
+ fartherPositionOverlap);
+ if (topPositionOverlap === mostOverlap) {
+ this.relativeLeft_ = topPosition.x;
+ this.relativeTop_ = topPosition.y;
+ return;
+ }
+ if (startPositionOverlap === mostOverlap) {
+ this.relativeLeft_ = startPosition.x;
+ this.relativeTop_ = startPosition.y;
+ return;
+ }
+ if (closerPositionOverlap === mostOverlap) {
+ this.relativeLeft_ = closerPosition.x;
+ this.relativeTop_ = closerPosition.y;
+ return;
+ }
+ // TODO: I believe relativeLeft_ should actually be called relativeStart_
+ // and then the math should be fixed to reflect this. (hopefully it'll
+ // make it look simpler)
+ this.relativeLeft_ = fartherPosition.x;
+ this.relativeTop_ = fartherPosition.y;
+ }
+
+ /**
+ * Calculate the what percentage of the bubble overlaps with the visible
+ * workspace (what percentage of the bubble is visible).
+ * @param {!{x: number, y: number}} relativeMin The position of the top-left
+ * corner of the bubble relative to the anchor point.
+ * @param {!MetricsManager.ContainerRegion} viewMetrics The view metrics
+ * of the workspace the bubble will appear in.
+ * @return {number} The percentage of the bubble that is visible.
+ * @private
+ */
+ getOverlap_(relativeMin, viewMetrics) {
+ // The position of the top-left corner of the bubble in workspace units.
+ const bubbleMin = {
+ x: this.workspace_.RTL ?
+ (this.anchorXY_.x - relativeMin.x - this.width_) :
+ (relativeMin.x + this.anchorXY_.x),
+ y: relativeMin.y + this.anchorXY_.y,
+ };
+ // The position of the bottom-right corner of the bubble in workspace units.
+ const bubbleMax = {
+ x: bubbleMin.x + this.width_,
+ y: bubbleMin.y + this.height_,
+ };
+
+ // We could adjust these values to account for the scrollbars, but the
+ // bubbles should have been adjusted to not collide with them anyway, so
+ // giving the workspace a slightly larger "bounding box" shouldn't affect
+ // the calculation.
+
+ // The position of the top-left corner of the workspace.
+ const workspaceMin = {x: viewMetrics.left, y: viewMetrics.top};
+ // The position of the bottom-right corner of the workspace.
+ const workspaceMax = {
+ x: viewMetrics.left + viewMetrics.width,
+ y: viewMetrics.top + viewMetrics.height,
+ };
+
+ const overlapWidth = Math.min(bubbleMax.x, workspaceMax.x) -
+ Math.max(bubbleMin.x, workspaceMin.x);
+ const overlapHeight = Math.min(bubbleMax.y, workspaceMax.y) -
+ Math.max(bubbleMin.y, workspaceMin.y);
+ return Math.max(
+ 0,
+ Math.min(
+ 1, (overlapWidth * overlapHeight) / (this.width_ * this.height_)));
+ }
+
+ /**
+ * Calculate what the optimal horizontal position of the top-left corner of
+ * the bubble is (relative to the anchor point) so that the most area of the
+ * bubble is shown.
+ * @param {!MetricsManager.ContainerRegion} viewMetrics The view metrics
+ * of the workspace the bubble will appear in.
+ * @return {number} The optimal horizontal position of the top-left corner
+ * of the bubble.
+ * @private
+ */
+ getOptimalRelativeLeft_(viewMetrics) {
+ let relativeLeft = -this.width_ / 4;
+
+ // No amount of sliding left or right will give us a better overlap.
+ if (this.width_ > viewMetrics.width) {
+ return relativeLeft;
+ }
+
+ if (this.workspace_.RTL) {
+ // Bubble coordinates are flipped in RTL.
+ const bubbleRight = this.anchorXY_.x - relativeLeft;
+ const bubbleLeft = bubbleRight - this.width_;
+
+ const workspaceRight = viewMetrics.left + viewMetrics.width;
+ const workspaceLeft = viewMetrics.left +
+ // Thickness in workspace units.
+ (Scrollbar.scrollbarThickness / this.workspace_.scale);
+
+ if (bubbleLeft < workspaceLeft) {
+ // Slide the bubble right until it is onscreen.
+ relativeLeft = -(workspaceLeft - this.anchorXY_.x + this.width_);
+ } else if (bubbleRight > workspaceRight) {
+ // Slide the bubble left until it is onscreen.
+ relativeLeft = -(workspaceRight - this.anchorXY_.x);
+ }
+ } else {
+ const bubbleLeft = relativeLeft + this.anchorXY_.x;
+ const bubbleRight = bubbleLeft + this.width_;
+
+ const workspaceLeft = viewMetrics.left;
+ const workspaceRight = viewMetrics.left + viewMetrics.width -
+ // Thickness in workspace units.
+ (Scrollbar.scrollbarThickness / this.workspace_.scale);
+
+ if (bubbleLeft < workspaceLeft) {
+ // Slide the bubble right until it is onscreen.
+ relativeLeft = workspaceLeft - this.anchorXY_.x;
+ } else if (bubbleRight > workspaceRight) {
+ // Slide the bubble left until it is onscreen.
+ relativeLeft = workspaceRight - this.anchorXY_.x - this.width_;
+ }
+ }
+
+ return relativeLeft;
+ }
+
+ /**
+ * Calculate what the optimal vertical position of the top-left corner of
+ * the bubble is (relative to the anchor point) so that the most area of the
+ * bubble is shown.
+ * @param {!MetricsManager.ContainerRegion} viewMetrics The view metrics
+ * of the workspace the bubble will appear in.
+ * @return {number} The optimal vertical position of the top-left corner
+ * of the bubble.
+ * @private
+ */
+ getOptimalRelativeTop_(viewMetrics) {
+ let relativeTop = -this.height_ / 4;
+
+ // No amount of sliding up or down will give us a better overlap.
+ if (this.height_ > viewMetrics.height) {
+ return relativeTop;
+ }
+
+ const bubbleTop = this.anchorXY_.y + relativeTop;
+ const bubbleBottom = bubbleTop + this.height_;
+ const workspaceTop = viewMetrics.top;
+ const workspaceBottom = viewMetrics.top + viewMetrics.height -
+ // Thickness in workspace units.
+ (Scrollbar.scrollbarThickness / this.workspace_.scale);
+
+ const anchorY = this.anchorXY_.y;
+ if (bubbleTop < workspaceTop) {
+ // Slide the bubble down until it is onscreen.
+ relativeTop = workspaceTop - anchorY;
+ } else if (bubbleBottom > workspaceBottom) {
+ // Slide the bubble up until it is onscreen.
+ relativeTop = workspaceBottom - anchorY - this.height_;
+ }
+
+ return relativeTop;
+ }
+
+ /**
+ * Move the bubble to a location relative to the anchor's centre.
+ * @private
+ */
+ positionBubble_() {
+ let left = this.anchorXY_.x;
+ if (this.workspace_.RTL) {
+ left -= this.relativeLeft_ + this.width_;
+ } else {
+ left += this.relativeLeft_;
+ }
+ const top = this.relativeTop_ + this.anchorXY_.y;
+ this.moveTo(left, top);
+ }
+
+ /**
+ * Move the bubble group to the specified location in workspace coordinates.
+ * @param {number} x The x position to move to.
+ * @param {number} y The y position to move to.
+ * @package
+ */
+ moveTo(x, y) {
+ this.bubbleGroup_.setAttribute(
+ 'transform', 'translate(' + x + ',' + y + ')');
+ }
+
+ /**
+ * Triggers a move callback if one exists at the end of a drag.
+ * @param {boolean} adding True if adding, false if removing.
+ * @package
+ */
+ setDragging(adding) {
+ if (!adding && this.moveCallback_) {
+ this.moveCallback_();
+ }
+ }
+
+ /**
+ * Get the dimensions of this bubble.
+ * @return {!Size} The height and width of the bubble.
+ */
+ getBubbleSize() {
+ return new Size(this.width_, this.height_);
+ }
+
+ /**
+ * Size this bubble.
+ * @param {number} width Width of the bubble.
+ * @param {number} height Height of the bubble.
+ */
+ setBubbleSize(width, height) {
+ const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH;
+ // Minimum size of a bubble.
+ width = Math.max(width, doubleBorderWidth + 45);
+ height = Math.max(height, doubleBorderWidth + 20);
+ this.width_ = width;
+ this.height_ = height;
+ this.bubbleBack_.setAttribute('width', width);
+ this.bubbleBack_.setAttribute('height', height);
+ if (this.resizeGroup_) {
+ if (this.workspace_.RTL) {
+ // Mirror the resize group.
+ const resizeSize = 2 * Bubble.BORDER_WIDTH;
+ this.resizeGroup_.setAttribute(
+ 'transform',
+ 'translate(' + resizeSize + ',' + (height - doubleBorderWidth) +
+ ') scale(-1 1)');
+ } else {
+ this.resizeGroup_.setAttribute(
+ 'transform',
+ 'translate(' + (width - doubleBorderWidth) + ',' +
+ (height - doubleBorderWidth) + ')');
+ }
+ }
+ if (this.autoLayout_) {
+ this.layoutBubble_();
+ }
+ this.positionBubble_();
+ this.renderArrow_();
+
+ // Allow the contents to resize.
+ if (this.resizeCallback_) {
+ this.resizeCallback_();
+ }
+ }
+
+ /**
+ * Draw the arrow between the bubble and the origin.
+ * @private
+ */
+ renderArrow_() {
+ const steps = [];
+ // Find the relative coordinates of the center of the bubble.
+ const relBubbleX = this.width_ / 2;
+ const relBubbleY = this.height_ / 2;
+ // Find the relative coordinates of the center of the anchor.
+ let relAnchorX = -this.relativeLeft_;
+ let relAnchorY = -this.relativeTop_;
+ if (relBubbleX === relAnchorX && relBubbleY === relAnchorY) {
+ // Null case. Bubble is directly on top of the anchor.
+ // Short circuit this rather than wade through divide by zeros.
+ steps.push('M ' + relBubbleX + ',' + relBubbleY);
+ } else {
+ // Compute the angle of the arrow's line.
+ const rise = relAnchorY - relBubbleY;
+ let run = relAnchorX - relBubbleX;
+ if (this.workspace_.RTL) {
+ run *= -1;
+ }
+ const hypotenuse = Math.sqrt(rise * rise + run * run);
+ let angle = Math.acos(run / hypotenuse);
+ if (rise < 0) {
+ angle = 2 * Math.PI - angle;
+ }
+ // Compute a line perpendicular to the arrow.
+ let rightAngle = angle + Math.PI / 2;
+ if (rightAngle > Math.PI * 2) {
+ rightAngle -= Math.PI * 2;
+ }
+ const rightRise = Math.sin(rightAngle);
+ const rightRun = Math.cos(rightAngle);
+
+ // Calculate the thickness of the base of the arrow.
+ const bubbleSize = this.getBubbleSize();
+ let thickness =
+ (bubbleSize.width + bubbleSize.height) / Bubble.ARROW_THICKNESS;
+ thickness = Math.min(thickness, bubbleSize.width, bubbleSize.height) / 4;
+
+ // Back the tip of the arrow off of the anchor.
+ const backoffRatio = 1 - Bubble.ANCHOR_RADIUS / hypotenuse;
+ relAnchorX = relBubbleX + backoffRatio * run;
+ relAnchorY = relBubbleY + backoffRatio * rise;
+
+ // Coordinates for the base of the arrow.
+ const baseX1 = relBubbleX + thickness * rightRun;
+ const baseY1 = relBubbleY + thickness * rightRise;
+ const baseX2 = relBubbleX - thickness * rightRun;
+ const baseY2 = relBubbleY - thickness * rightRise;
+
+ // Distortion to curve the arrow.
+ let swirlAngle = angle + this.arrow_radians_;
+ if (swirlAngle > Math.PI * 2) {
+ swirlAngle -= Math.PI * 2;
+ }
+ const swirlRise = Math.sin(swirlAngle) * hypotenuse / Bubble.ARROW_BEND;
+ const swirlRun = Math.cos(swirlAngle) * hypotenuse / Bubble.ARROW_BEND;
+
+ steps.push('M' + baseX1 + ',' + baseY1);
+ steps.push(
+ 'C' + (baseX1 + swirlRun) + ',' + (baseY1 + swirlRise) + ' ' +
+ relAnchorX + ',' + relAnchorY + ' ' + relAnchorX + ',' + relAnchorY);
+ steps.push(
+ 'C' + relAnchorX + ',' + relAnchorY + ' ' + (baseX2 + swirlRun) +
+ ',' + (baseY2 + swirlRise) + ' ' + baseX2 + ',' + baseY2);
+ }
+ steps.push('z');
+ this.bubbleArrow_.setAttribute('d', steps.join(' '));
+ }
+
+ /**
+ * Change the colour of a bubble.
+ * @param {string} hexColour Hex code of colour.
+ */
+ setColour(hexColour) {
+ this.bubbleBack_.setAttribute('fill', hexColour);
+ this.bubbleArrow_.setAttribute('fill', hexColour);
+ }
+
+ /**
+ * Dispose of this bubble.
+ */
+ dispose() {
+ if (this.onMouseDownBubbleWrapper_) {
+ browserEvents.unbind(this.onMouseDownBubbleWrapper_);
+ }
+ if (this.onMouseDownResizeWrapper_) {
+ browserEvents.unbind(this.onMouseDownResizeWrapper_);
+ }
+ Bubble.unbindDragEvents_();
+ dom.removeNode(this.bubbleGroup_);
+ this.disposed = true;
+ }
+
+ /**
+ * Move this bubble during a drag, taking into account whether or not there is
+ * a drag surface.
+ * @param {BlockDragSurfaceSvg} dragSurface The surface that carries
+ * rendered items during a drag, or null if no drag surface is in use.
+ * @param {!Coordinate} newLoc The location to translate to, in
+ * workspace coordinates.
+ * @package
+ */
+ moveDuringDrag(dragSurface, newLoc) {
+ if (dragSurface) {
+ dragSurface.translateSurface(newLoc.x, newLoc.y);
+ } else {
+ this.moveTo(newLoc.x, newLoc.y);
+ }
+ if (this.workspace_.RTL) {
+ this.relativeLeft_ = this.anchorXY_.x - newLoc.x - this.width_;
+ } else {
+ this.relativeLeft_ = newLoc.x - this.anchorXY_.x;
+ }
+ this.relativeTop_ = newLoc.y - this.anchorXY_.y;
+ this.renderArrow_();
+ }
+
+ /**
+ * Return the coordinates of the top-left corner of this bubble's body
+ * relative to the drawing surface's origin (0,0), in workspace units.
+ * @return {!Coordinate} Object with .x and .y properties.
+ */
+ getRelativeToSurfaceXY() {
+ return new Coordinate(
+ this.workspace_.RTL ?
+ -this.relativeLeft_ + this.anchorXY_.x - this.width_ :
+ this.anchorXY_.x + this.relativeLeft_,
+ this.anchorXY_.y + this.relativeTop_);
+ }
+
+ /**
+ * Set whether auto-layout of this bubble is enabled. The first time a bubble
+ * is shown it positions itself to not cover any blocks. Once a user has
+ * dragged it to reposition, it renders where the user put it.
+ * @param {boolean} enable True if auto-layout should be enabled, false
+ * otherwise.
+ * @package
+ */
+ setAutoLayout(enable) {
+ this.autoLayout_ = enable;
+ }
+
+ /**
+ * Stop binding to the global mouseup and mousemove events.
+ * @private
+ */
+ static unbindDragEvents_() {
+ if (Bubble.onMouseUpWrapper_) {
+ browserEvents.unbind(Bubble.onMouseUpWrapper_);
+ Bubble.onMouseUpWrapper_ = null;
+ }
+ if (Bubble.onMouseMoveWrapper_) {
+ browserEvents.unbind(Bubble.onMouseMoveWrapper_);
+ Bubble.onMouseMoveWrapper_ = null;
+ }
+ }
+
+ /**
+ * Handle a mouse-up event while dragging a bubble's border or resize handle.
+ * @param {!Event} _e Mouse up event.
+ * @private
+ */
+ static bubbleMouseUp_(_e) {
+ Touch.clearTouchIdentifier();
+ Bubble.unbindDragEvents_();
+ }
+
+ /**
+ * Create the text for a non editable bubble.
+ * @param {string} text The text to display.
+ * @return {!SVGTextElement} The top-level node of the text.
+ * @package
+ */
+ static textToDom(text) {
+ const paragraph = dom.createSvgElement(
+ Svg.TEXT, {
+ 'class': 'blocklyText blocklyBubbleText blocklyNoPointerEvents',
+ 'y': Bubble.BORDER_WIDTH,
+ },
+ null);
+ const lines = text.split('\n');
+ for (let i = 0; i < lines.length; i++) {
+ const tspanElement = dom.createSvgElement(
+ Svg.TSPAN, {'dy': '1em', 'x': Bubble.BORDER_WIDTH}, paragraph);
+ const textNode = document.createTextNode(lines[i]);
+ tspanElement.appendChild(textNode);
+ }
+ return paragraph;
+ }
+
+ /**
+ * Creates a bubble that can not be edited.
+ * @param {!SVGTextElement} paragraphElement The text element for the non
+ * editable bubble.
+ * @param {!BlockSvg} block The block that the bubble is attached to.
+ * @param {!Coordinate} iconXY The coordinate of the icon.
+ * @return {!Bubble} The non editable bubble.
+ * @package
+ */
+ static createNonEditableBubble(paragraphElement, block, iconXY) {
+ const bubble = new Bubble(
+ /** @type {!WorkspaceSvg} */ (block.workspace), paragraphElement,
+ block.pathObject.svgPath,
+ /** @type {!Coordinate} */ (iconXY), null, null);
+ // Expose this bubble's block's ID on its top-level SVG group.
+ bubble.setSvgId(block.id);
+ if (block.RTL) {
+ // Right-align the paragraph.
+ // This cannot be done until the bubble is rendered on screen.
+ const maxWidth = paragraphElement.getBBox().width;
+ for (let i = 0, textElement;
+ (textElement = paragraphElement.childNodes[i]); i++) {
+ textElement.setAttribute('text-anchor', 'end');
+ textElement.setAttribute('x', maxWidth + Bubble.BORDER_WIDTH);
+ }
+ }
+ return bubble;
+ }
};
/**
@@ -157,785 +982,4 @@ Bubble.onMouseUpWrapper_ = null;
*/
Bubble.onMouseMoveWrapper_ = null;
-/**
- * Stop binding to the global mouseup and mousemove events.
- * @private
- */
-Bubble.unbindDragEvents_ = function() {
- if (Bubble.onMouseUpWrapper_) {
- browserEvents.unbind(Bubble.onMouseUpWrapper_);
- Bubble.onMouseUpWrapper_ = null;
- }
- if (Bubble.onMouseMoveWrapper_) {
- browserEvents.unbind(Bubble.onMouseMoveWrapper_);
- Bubble.onMouseMoveWrapper_ = null;
- }
-};
-
-/**
- * Handle a mouse-up event while dragging a bubble's border or resize handle.
- * @param {!Event} _e Mouse up event.
- * @private
- */
-Bubble.bubbleMouseUp_ = function(_e) {
- Touch.clearTouchIdentifier();
- Bubble.unbindDragEvents_();
-};
-
-/**
- * Flag to stop incremental rendering during construction.
- * @private
- */
-Bubble.prototype.rendered_ = false;
-
-/**
- * Absolute coordinate of anchor point, in workspace coordinates.
- * @type {Coordinate}
- * @private
- */
-Bubble.prototype.anchorXY_ = null;
-
-/**
- * Relative X coordinate of bubble with respect to the anchor's centre,
- * in workspace units.
- * In RTL mode the initial value is negated.
- * @private
- */
-Bubble.prototype.relativeLeft_ = 0;
-
-/**
- * Relative Y coordinate of bubble with respect to the anchor's centre, in
- * workspace units.
- * @private
- */
-Bubble.prototype.relativeTop_ = 0;
-
-/**
- * Width of bubble, in workspace units.
- * @private
- */
-Bubble.prototype.width_ = 0;
-
-/**
- * Height of bubble, in workspace units.
- * @private
- */
-Bubble.prototype.height_ = 0;
-
-/**
- * Automatically position and reposition the bubble.
- * @private
- */
-Bubble.prototype.autoLayout_ = true;
-
-/**
- * Create the bubble's DOM.
- * @param {!Element} content SVG content for the bubble.
- * @param {boolean} hasResize Add diagonal resize gripper if true.
- * @return {!SVGElement} The bubble's SVG group.
- * @private
- */
-Bubble.prototype.createDom_ = function(content, hasResize) {
- /* Create the bubble. Here's the markup that will be generated:
-
-
-
-
-
-
-
-
-
-
- [...content goes here...]
-
- */
- this.bubbleGroup_ = dom.createSvgElement(Svg.G, {}, null);
- let filter = {
- 'filter': 'url(#' +
- this.workspace_.getRenderer().getConstants().embossFilterId + ')',
- };
- if (userAgent.JAVA_FX) {
- // Multiple reports that JavaFX can't handle filters.
- // https://github.com/google/blockly/issues/99
- filter = {};
- }
- const bubbleEmboss = dom.createSvgElement(Svg.G, filter, this.bubbleGroup_);
- this.bubbleArrow_ = dom.createSvgElement(Svg.PATH, {}, bubbleEmboss);
- this.bubbleBack_ = dom.createSvgElement(
- Svg.RECT, {
- 'class': 'blocklyDraggable',
- 'x': 0,
- 'y': 0,
- 'rx': Bubble.BORDER_WIDTH,
- 'ry': Bubble.BORDER_WIDTH,
- },
- bubbleEmboss);
- if (hasResize) {
- this.resizeGroup_ = dom.createSvgElement(
- Svg.G,
- {'class': this.workspace_.RTL ? 'blocklyResizeSW' : 'blocklyResizeSE'},
- this.bubbleGroup_);
- const resizeSize = 2 * Bubble.BORDER_WIDTH;
- dom.createSvgElement(
- Svg.POLYGON,
- {'points': '0,x x,x x,0'.replace(/x/g, resizeSize.toString())},
- this.resizeGroup_);
- dom.createSvgElement(
- Svg.LINE, {
- 'class': 'blocklyResizeLine',
- 'x1': resizeSize / 3,
- 'y1': resizeSize - 1,
- 'x2': resizeSize - 1,
- 'y2': resizeSize / 3,
- },
- this.resizeGroup_);
- dom.createSvgElement(
- Svg.LINE, {
- 'class': 'blocklyResizeLine',
- 'x1': resizeSize * 2 / 3,
- 'y1': resizeSize - 1,
- 'x2': resizeSize - 1,
- 'y2': resizeSize * 2 / 3,
- },
- this.resizeGroup_);
- } else {
- this.resizeGroup_ = null;
- }
-
- if (!this.workspace_.options.readOnly) {
- this.onMouseDownBubbleWrapper_ = browserEvents.conditionalBind(
- this.bubbleBack_, 'mousedown', this, this.bubbleMouseDown_);
- if (this.resizeGroup_) {
- this.onMouseDownResizeWrapper_ = browserEvents.conditionalBind(
- this.resizeGroup_, 'mousedown', this, this.resizeMouseDown_);
- }
- }
- this.bubbleGroup_.appendChild(content);
- return this.bubbleGroup_;
-};
-
-/**
- * Return the root node of the bubble's SVG group.
- * @return {!SVGElement} The root SVG node of the bubble's group.
- */
-Bubble.prototype.getSvgRoot = function() {
- return this.bubbleGroup_;
-};
-
-/**
- * Expose the block's ID on the bubble's top-level SVG group.
- * @param {string} id ID of block.
- */
-Bubble.prototype.setSvgId = function(id) {
- if (this.bubbleGroup_.dataset) {
- this.bubbleGroup_.dataset['blockId'] = id;
- }
-};
-
-/**
- * Handle a mouse-down on bubble's border.
- * @param {!Event} e Mouse down event.
- * @private
- */
-Bubble.prototype.bubbleMouseDown_ = function(e) {
- const gesture = this.workspace_.getGesture(e);
- if (gesture) {
- gesture.handleBubbleStart(e, this);
- }
-};
-
-/**
- * Show the context menu for this bubble.
- * @param {!Event} _e Mouse event.
- * @package
- */
-Bubble.prototype.showContextMenu = function(_e) {
- // NOP on bubbles, but used by the bubble dragger to pass events to
- // workspace comments.
-};
-
-/**
- * Get whether this bubble is deletable or not.
- * @return {boolean} True if deletable.
- * @package
- */
-Bubble.prototype.isDeletable = function() {
- return false;
-};
-
-/**
- * Update the style of this bubble when it is dragged over a delete area.
- * @param {boolean} _enable True if the bubble is about to be deleted, false
- * otherwise.
- */
-Bubble.prototype.setDeleteStyle = function(_enable) {
- // NOP if bubble is not deletable.
-};
-
-/**
- * Handle a mouse-down on bubble's resize corner.
- * @param {!Event} e Mouse down event.
- * @private
- */
-Bubble.prototype.resizeMouseDown_ = function(e) {
- this.promote();
- Bubble.unbindDragEvents_();
- if (browserEvents.isRightButton(e)) {
- // No right-click.
- e.stopPropagation();
- return;
- }
- // Left-click (or middle click)
- this.workspace_.startDrag(
- e,
- new Coordinate(
- this.workspace_.RTL ? -this.width_ : this.width_, this.height_));
-
- Bubble.onMouseUpWrapper_ = browserEvents.conditionalBind(
- document, 'mouseup', this, Bubble.bubbleMouseUp_);
- Bubble.onMouseMoveWrapper_ = browserEvents.conditionalBind(
- document, 'mousemove', this, this.resizeMouseMove_);
- this.workspace_.hideChaff();
- // This event has been handled. No need to bubble up to the document.
- e.stopPropagation();
-};
-
-/**
- * Resize this bubble to follow the mouse.
- * @param {!Event} e Mouse move event.
- * @private
- */
-Bubble.prototype.resizeMouseMove_ = function(e) {
- this.autoLayout_ = false;
- const newXY = this.workspace_.moveDrag(e);
- this.setBubbleSize(this.workspace_.RTL ? -newXY.x : newXY.x, newXY.y);
- if (this.workspace_.RTL) {
- // RTL requires the bubble to move its left edge.
- this.positionBubble_();
- }
-};
-
-/**
- * Register a function as a callback event for when the bubble is resized.
- * @param {!Function} callback The function to call on resize.
- */
-Bubble.prototype.registerResizeEvent = function(callback) {
- this.resizeCallback_ = callback;
-};
-
-/**
- * Register a function as a callback event for when the bubble is moved.
- * @param {!Function} callback The function to call on move.
- */
-Bubble.prototype.registerMoveEvent = function(callback) {
- this.moveCallback_ = callback;
-};
-
-/**
- * Move this bubble to the top of the stack.
- * @return {boolean} Whether or not the bubble has been moved.
- * @package
- */
-Bubble.prototype.promote = function() {
- const svgGroup = this.bubbleGroup_.parentNode;
- if (svgGroup.lastChild !== this.bubbleGroup_) {
- svgGroup.appendChild(this.bubbleGroup_);
- return true;
- }
- return false;
-};
-
-/**
- * Notification that the anchor has moved.
- * Update the arrow and bubble accordingly.
- * @param {!Coordinate} xy Absolute location.
- */
-Bubble.prototype.setAnchorLocation = function(xy) {
- this.anchorXY_ = xy;
- if (this.rendered_) {
- this.positionBubble_();
- }
-};
-
-/**
- * Position the bubble so that it does not fall off-screen.
- * @private
- */
-Bubble.prototype.layoutBubble_ = function() {
- // Get the metrics in workspace units.
- const viewMetrics = this.workspace_.getMetricsManager().getViewMetrics(true);
-
- const optimalLeft = this.getOptimalRelativeLeft_(viewMetrics);
- const optimalTop = this.getOptimalRelativeTop_(viewMetrics);
- const bbox = this.shape_.getBBox();
-
- const topPosition = {
- x: optimalLeft,
- y: -this.height_ -
- this.workspace_.getRenderer().getConstants().MIN_BLOCK_HEIGHT,
- };
- const startPosition = {x: -this.width_ - 30, y: optimalTop};
- const endPosition = {x: bbox.width, y: optimalTop};
- const bottomPosition = {x: optimalLeft, y: bbox.height};
-
- const closerPosition =
- bbox.width < bbox.height ? endPosition : bottomPosition;
- const fartherPosition =
- bbox.width < bbox.height ? bottomPosition : endPosition;
-
- const topPositionOverlap = this.getOverlap_(topPosition, viewMetrics);
- const startPositionOverlap = this.getOverlap_(startPosition, viewMetrics);
- const closerPositionOverlap = this.getOverlap_(closerPosition, viewMetrics);
- const fartherPositionOverlap = this.getOverlap_(fartherPosition, viewMetrics);
-
- // Set the position to whichever position shows the most of the bubble,
- // with tiebreaks going in the order: top > start > close > far.
- const mostOverlap = Math.max(
- topPositionOverlap, startPositionOverlap, closerPositionOverlap,
- fartherPositionOverlap);
- if (topPositionOverlap === mostOverlap) {
- this.relativeLeft_ = topPosition.x;
- this.relativeTop_ = topPosition.y;
- return;
- }
- if (startPositionOverlap === mostOverlap) {
- this.relativeLeft_ = startPosition.x;
- this.relativeTop_ = startPosition.y;
- return;
- }
- if (closerPositionOverlap === mostOverlap) {
- this.relativeLeft_ = closerPosition.x;
- this.relativeTop_ = closerPosition.y;
- return;
- }
- // TODO: I believe relativeLeft_ should actually be called relativeStart_
- // and then the math should be fixed to reflect this. (hopefully it'll
- // make it look simpler)
- this.relativeLeft_ = fartherPosition.x;
- this.relativeTop_ = fartherPosition.y;
-};
-
-/**
- * Calculate the what percentage of the bubble overlaps with the visible
- * workspace (what percentage of the bubble is visible).
- * @param {!{x: number, y: number}} relativeMin The position of the top-left
- * corner of the bubble relative to the anchor point.
- * @param {!MetricsManager.ContainerRegion} viewMetrics The view metrics
- * of the workspace the bubble will appear in.
- * @return {number} The percentage of the bubble that is visible.
- * @private
- */
-Bubble.prototype.getOverlap_ = function(relativeMin, viewMetrics) {
- // The position of the top-left corner of the bubble in workspace units.
- const bubbleMin = {
- x: this.workspace_.RTL ? (this.anchorXY_.x - relativeMin.x - this.width_) :
- (relativeMin.x + this.anchorXY_.x),
- y: relativeMin.y + this.anchorXY_.y,
- };
- // The position of the bottom-right corner of the bubble in workspace units.
- const bubbleMax = {
- x: bubbleMin.x + this.width_,
- y: bubbleMin.y + this.height_,
- };
-
- // We could adjust these values to account for the scrollbars, but the
- // bubbles should have been adjusted to not collide with them anyway, so
- // giving the workspace a slightly larger "bounding box" shouldn't affect the
- // calculation.
-
- // The position of the top-left corner of the workspace.
- const workspaceMin = {x: viewMetrics.left, y: viewMetrics.top};
- // The position of the bottom-right corner of the workspace.
- const workspaceMax = {
- x: viewMetrics.left + viewMetrics.width,
- y: viewMetrics.top + viewMetrics.height,
- };
-
- const overlapWidth = Math.min(bubbleMax.x, workspaceMax.x) -
- Math.max(bubbleMin.x, workspaceMin.x);
- const overlapHeight = Math.min(bubbleMax.y, workspaceMax.y) -
- Math.max(bubbleMin.y, workspaceMin.y);
- return Math.max(
- 0,
- Math.min(
- 1, (overlapWidth * overlapHeight) / (this.width_ * this.height_)));
-};
-
-/**
- * Calculate what the optimal horizontal position of the top-left corner of the
- * bubble is (relative to the anchor point) so that the most area of the
- * bubble is shown.
- * @param {!MetricsManager.ContainerRegion} viewMetrics The view metrics
- * of the workspace the bubble will appear in.
- * @return {number} The optimal horizontal position of the top-left corner
- * of the bubble.
- * @private
- */
-Bubble.prototype.getOptimalRelativeLeft_ = function(viewMetrics) {
- let relativeLeft = -this.width_ / 4;
-
- // No amount of sliding left or right will give us a better overlap.
- if (this.width_ > viewMetrics.width) {
- return relativeLeft;
- }
-
- if (this.workspace_.RTL) {
- // Bubble coordinates are flipped in RTL.
- const bubbleRight = this.anchorXY_.x - relativeLeft;
- const bubbleLeft = bubbleRight - this.width_;
-
- const workspaceRight = viewMetrics.left + viewMetrics.width;
- const workspaceLeft = viewMetrics.left +
- // Thickness in workspace units.
- (Scrollbar.scrollbarThickness / this.workspace_.scale);
-
- if (bubbleLeft < workspaceLeft) {
- // Slide the bubble right until it is onscreen.
- relativeLeft = -(workspaceLeft - this.anchorXY_.x + this.width_);
- } else if (bubbleRight > workspaceRight) {
- // Slide the bubble left until it is onscreen.
- relativeLeft = -(workspaceRight - this.anchorXY_.x);
- }
- } else {
- const bubbleLeft = relativeLeft + this.anchorXY_.x;
- const bubbleRight = bubbleLeft + this.width_;
-
- const workspaceLeft = viewMetrics.left;
- const workspaceRight = viewMetrics.left + viewMetrics.width -
- // Thickness in workspace units.
- (Scrollbar.scrollbarThickness / this.workspace_.scale);
-
- if (bubbleLeft < workspaceLeft) {
- // Slide the bubble right until it is onscreen.
- relativeLeft = workspaceLeft - this.anchorXY_.x;
- } else if (bubbleRight > workspaceRight) {
- // Slide the bubble left until it is onscreen.
- relativeLeft = workspaceRight - this.anchorXY_.x - this.width_;
- }
- }
-
- return relativeLeft;
-};
-
-/**
- * Calculate what the optimal vertical position of the top-left corner of
- * the bubble is (relative to the anchor point) so that the most area of the
- * bubble is shown.
- * @param {!MetricsManager.ContainerRegion} viewMetrics The view metrics
- * of the workspace the bubble will appear in.
- * @return {number} The optimal vertical position of the top-left corner
- * of the bubble.
- * @private
- */
-Bubble.prototype.getOptimalRelativeTop_ = function(viewMetrics) {
- let relativeTop = -this.height_ / 4;
-
- // No amount of sliding up or down will give us a better overlap.
- if (this.height_ > viewMetrics.height) {
- return relativeTop;
- }
-
- const bubbleTop = this.anchorXY_.y + relativeTop;
- const bubbleBottom = bubbleTop + this.height_;
- const workspaceTop = viewMetrics.top;
- const workspaceBottom = viewMetrics.top + viewMetrics.height -
- // Thickness in workspace units.
- (Scrollbar.scrollbarThickness / this.workspace_.scale);
-
- const anchorY = this.anchorXY_.y;
- if (bubbleTop < workspaceTop) {
- // Slide the bubble down until it is onscreen.
- relativeTop = workspaceTop - anchorY;
- } else if (bubbleBottom > workspaceBottom) {
- // Slide the bubble up until it is onscreen.
- relativeTop = workspaceBottom - anchorY - this.height_;
- }
-
- return relativeTop;
-};
-
-/**
- * Move the bubble to a location relative to the anchor's centre.
- * @private
- */
-Bubble.prototype.positionBubble_ = function() {
- let left = this.anchorXY_.x;
- if (this.workspace_.RTL) {
- left -= this.relativeLeft_ + this.width_;
- } else {
- left += this.relativeLeft_;
- }
- const top = this.relativeTop_ + this.anchorXY_.y;
- this.moveTo(left, top);
-};
-
-/**
- * Move the bubble group to the specified location in workspace coordinates.
- * @param {number} x The x position to move to.
- * @param {number} y The y position to move to.
- * @package
- */
-Bubble.prototype.moveTo = function(x, y) {
- this.bubbleGroup_.setAttribute('transform', 'translate(' + x + ',' + y + ')');
-};
-
-/**
- * Triggers a move callback if one exists at the end of a drag.
- * @param {boolean} adding True if adding, false if removing.
- * @package
- */
-Bubble.prototype.setDragging = function(adding) {
- if (!adding && this.moveCallback_) {
- this.moveCallback_();
- }
-};
-
-/**
- * Get the dimensions of this bubble.
- * @return {!Size} The height and width of the bubble.
- */
-Bubble.prototype.getBubbleSize = function() {
- return new Size(this.width_, this.height_);
-};
-
-/**
- * Size this bubble.
- * @param {number} width Width of the bubble.
- * @param {number} height Height of the bubble.
- */
-Bubble.prototype.setBubbleSize = function(width, height) {
- const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH;
- // Minimum size of a bubble.
- width = Math.max(width, doubleBorderWidth + 45);
- height = Math.max(height, doubleBorderWidth + 20);
- this.width_ = width;
- this.height_ = height;
- this.bubbleBack_.setAttribute('width', width);
- this.bubbleBack_.setAttribute('height', height);
- if (this.resizeGroup_) {
- if (this.workspace_.RTL) {
- // Mirror the resize group.
- const resizeSize = 2 * Bubble.BORDER_WIDTH;
- this.resizeGroup_.setAttribute(
- 'transform',
- 'translate(' + resizeSize + ',' + (height - doubleBorderWidth) +
- ') scale(-1 1)');
- } else {
- this.resizeGroup_.setAttribute(
- 'transform',
- 'translate(' + (width - doubleBorderWidth) + ',' +
- (height - doubleBorderWidth) + ')');
- }
- }
- if (this.autoLayout_) {
- this.layoutBubble_();
- }
- this.positionBubble_();
- this.renderArrow_();
-
- // Allow the contents to resize.
- if (this.resizeCallback_) {
- this.resizeCallback_();
- }
-};
-
-/**
- * Draw the arrow between the bubble and the origin.
- * @private
- */
-Bubble.prototype.renderArrow_ = function() {
- const steps = [];
- // Find the relative coordinates of the center of the bubble.
- const relBubbleX = this.width_ / 2;
- const relBubbleY = this.height_ / 2;
- // Find the relative coordinates of the center of the anchor.
- let relAnchorX = -this.relativeLeft_;
- let relAnchorY = -this.relativeTop_;
- if (relBubbleX === relAnchorX && relBubbleY === relAnchorY) {
- // Null case. Bubble is directly on top of the anchor.
- // Short circuit this rather than wade through divide by zeros.
- steps.push('M ' + relBubbleX + ',' + relBubbleY);
- } else {
- // Compute the angle of the arrow's line.
- const rise = relAnchorY - relBubbleY;
- let run = relAnchorX - relBubbleX;
- if (this.workspace_.RTL) {
- run *= -1;
- }
- const hypotenuse = Math.sqrt(rise * rise + run * run);
- let angle = Math.acos(run / hypotenuse);
- if (rise < 0) {
- angle = 2 * Math.PI - angle;
- }
- // Compute a line perpendicular to the arrow.
- let rightAngle = angle + Math.PI / 2;
- if (rightAngle > Math.PI * 2) {
- rightAngle -= Math.PI * 2;
- }
- const rightRise = Math.sin(rightAngle);
- const rightRun = Math.cos(rightAngle);
-
- // Calculate the thickness of the base of the arrow.
- const bubbleSize = this.getBubbleSize();
- let thickness =
- (bubbleSize.width + bubbleSize.height) / Bubble.ARROW_THICKNESS;
- thickness = Math.min(thickness, bubbleSize.width, bubbleSize.height) / 4;
-
- // Back the tip of the arrow off of the anchor.
- const backoffRatio = 1 - Bubble.ANCHOR_RADIUS / hypotenuse;
- relAnchorX = relBubbleX + backoffRatio * run;
- relAnchorY = relBubbleY + backoffRatio * rise;
-
- // Coordinates for the base of the arrow.
- const baseX1 = relBubbleX + thickness * rightRun;
- const baseY1 = relBubbleY + thickness * rightRise;
- const baseX2 = relBubbleX - thickness * rightRun;
- const baseY2 = relBubbleY - thickness * rightRise;
-
- // Distortion to curve the arrow.
- let swirlAngle = angle + this.arrow_radians_;
- if (swirlAngle > Math.PI * 2) {
- swirlAngle -= Math.PI * 2;
- }
- const swirlRise = Math.sin(swirlAngle) * hypotenuse / Bubble.ARROW_BEND;
- const swirlRun = Math.cos(swirlAngle) * hypotenuse / Bubble.ARROW_BEND;
-
- steps.push('M' + baseX1 + ',' + baseY1);
- steps.push(
- 'C' + (baseX1 + swirlRun) + ',' + (baseY1 + swirlRise) + ' ' +
- relAnchorX + ',' + relAnchorY + ' ' + relAnchorX + ',' + relAnchorY);
- steps.push(
- 'C' + relAnchorX + ',' + relAnchorY + ' ' + (baseX2 + swirlRun) + ',' +
- (baseY2 + swirlRise) + ' ' + baseX2 + ',' + baseY2);
- }
- steps.push('z');
- this.bubbleArrow_.setAttribute('d', steps.join(' '));
-};
-
-/**
- * Change the colour of a bubble.
- * @param {string} hexColour Hex code of colour.
- */
-Bubble.prototype.setColour = function(hexColour) {
- this.bubbleBack_.setAttribute('fill', hexColour);
- this.bubbleArrow_.setAttribute('fill', hexColour);
-};
-
-/**
- * Dispose of this bubble.
- */
-Bubble.prototype.dispose = function() {
- if (this.onMouseDownBubbleWrapper_) {
- browserEvents.unbind(this.onMouseDownBubbleWrapper_);
- }
- if (this.onMouseDownResizeWrapper_) {
- browserEvents.unbind(this.onMouseDownResizeWrapper_);
- }
- Bubble.unbindDragEvents_();
- dom.removeNode(this.bubbleGroup_);
- this.disposed = true;
-};
-
-/**
- * Move this bubble during a drag, taking into account whether or not there is
- * a drag surface.
- * @param {BlockDragSurfaceSvg} dragSurface The surface that carries
- * rendered items during a drag, or null if no drag surface is in use.
- * @param {!Coordinate} newLoc The location to translate to, in
- * workspace coordinates.
- * @package
- */
-Bubble.prototype.moveDuringDrag = function(dragSurface, newLoc) {
- if (dragSurface) {
- dragSurface.translateSurface(newLoc.x, newLoc.y);
- } else {
- this.moveTo(newLoc.x, newLoc.y);
- }
- if (this.workspace_.RTL) {
- this.relativeLeft_ = this.anchorXY_.x - newLoc.x - this.width_;
- } else {
- this.relativeLeft_ = newLoc.x - this.anchorXY_.x;
- }
- this.relativeTop_ = newLoc.y - this.anchorXY_.y;
- this.renderArrow_();
-};
-
-/**
- * Return the coordinates of the top-left corner of this bubble's body relative
- * to the drawing surface's origin (0,0), in workspace units.
- * @return {!Coordinate} Object with .x and .y properties.
- */
-Bubble.prototype.getRelativeToSurfaceXY = function() {
- return new Coordinate(
- this.workspace_.RTL ?
- -this.relativeLeft_ + this.anchorXY_.x - this.width_ :
- this.anchorXY_.x + this.relativeLeft_,
- this.anchorXY_.y + this.relativeTop_);
-};
-
-/**
- * Set whether auto-layout of this bubble is enabled. The first time a bubble
- * is shown it positions itself to not cover any blocks. Once a user has
- * dragged it to reposition, it renders where the user put it.
- * @param {boolean} enable True if auto-layout should be enabled, false
- * otherwise.
- * @package
- */
-Bubble.prototype.setAutoLayout = function(enable) {
- this.autoLayout_ = enable;
-};
-
-/**
- * Create the text for a non editable bubble.
- * @param {string} text The text to display.
- * @return {!SVGTextElement} The top-level node of the text.
- * @package
- */
-Bubble.textToDom = function(text) {
- const paragraph = dom.createSvgElement(
- Svg.TEXT, {
- 'class': 'blocklyText blocklyBubbleText blocklyNoPointerEvents',
- 'y': Bubble.BORDER_WIDTH,
- },
- null);
- const lines = text.split('\n');
- for (let i = 0; i < lines.length; i++) {
- const tspanElement = dom.createSvgElement(
- Svg.TSPAN, {'dy': '1em', 'x': Bubble.BORDER_WIDTH}, paragraph);
- const textNode = document.createTextNode(lines[i]);
- tspanElement.appendChild(textNode);
- }
- return paragraph;
-};
-
-/**
- * Creates a bubble that can not be edited.
- * @param {!SVGTextElement} paragraphElement The text element for the non
- * editable bubble.
- * @param {!BlockSvg} block The block that the bubble is attached to.
- * @param {!Coordinate} iconXY The coordinate of the icon.
- * @return {!Bubble} The non editable bubble.
- * @package
- */
-Bubble.createNonEditableBubble = function(paragraphElement, block, iconXY) {
- const bubble = new Bubble(
- /** @type {!WorkspaceSvg} */ (block.workspace), paragraphElement,
- block.pathObject.svgPath,
- /** @type {!Coordinate} */ (iconXY), null, null);
- // Expose this bubble's block's ID on its top-level SVG group.
- bubble.setSvgId(block.id);
- if (block.RTL) {
- // Right-align the paragraph.
- // This cannot be done until the bubble is rendered on screen.
- const maxWidth = paragraphElement.getBBox().width;
- for (let i = 0, textElement; (textElement = paragraphElement.childNodes[i]);
- i++) {
- textElement.setAttribute('text-anchor', 'end');
- textElement.setAttribute('x', maxWidth + Bubble.BORDER_WIDTH);
- }
- }
- return bubble;
-};
-
exports.Bubble = Bubble;
diff --git a/core/bubble_dragger.js b/core/bubble_dragger.js
index e312ecdc2..f806841ef 100644
--- a/core/bubble_dragger.js
+++ b/core/bubble_dragger.js
@@ -19,6 +19,8 @@ const eventUtils = goog.require('Blockly.Events.utils');
const svgMath = goog.require('Blockly.utils.svgMath');
/* eslint-disable-next-line no-unused-vars */
const {BlockDragSurfaceSvg} = goog.requireType('Blockly.BlockDragSurfaceSvg');
+/* eslint-disable-next-line no-unused-vars */
+const {CommentMove} = goog.requireType('Blockly.Events.CommentMove');
const {ComponentManager} = goog.require('Blockly.ComponentManager');
const {Coordinate} = goog.require('Blockly.utils.Coordinate');
/* eslint-disable-next-line no-unused-vars */
@@ -43,249 +45,254 @@ goog.require('Blockly.constants');
* Class for a bubble dragger. It moves things on the bubble canvas around the
* workspace when they are being dragged by a mouse or touch. These can be
* block comments, mutators, warnings, or workspace comments.
- * @param {!IBubble} bubble The item on the bubble canvas to drag.
- * @param {!WorkspaceSvg} workspace The workspace to drag on.
- * @constructor
* @alias Blockly.BubbleDragger
*/
-const BubbleDragger = function(bubble, workspace) {
+const BubbleDragger = class {
/**
- * The item on the bubble canvas that is being dragged.
- * @type {!IBubble}
- * @private
+ * @param {!IBubble} bubble The item on the bubble canvas to drag.
+ * @param {!WorkspaceSvg} workspace The workspace to drag on.
*/
- this.draggingBubble_ = bubble;
+ constructor(bubble, workspace) {
+ /**
+ * The item on the bubble canvas that is being dragged.
+ * @type {!IBubble}
+ * @private
+ */
+ this.draggingBubble_ = bubble;
- /**
- * The workspace on which the bubble is being dragged.
- * @type {!WorkspaceSvg}
- * @private
- */
- this.workspace_ = workspace;
+ /**
+ * The workspace on which the bubble is being dragged.
+ * @type {!WorkspaceSvg}
+ * @private
+ */
+ this.workspace_ = workspace;
- /**
- * Which drag target the mouse pointer is over, if any.
- * @type {?IDragTarget}
- * @private
- */
- this.dragTarget_ = null;
+ /**
+ * Which drag target the mouse pointer is over, if any.
+ * @type {?IDragTarget}
+ * @private
+ */
+ this.dragTarget_ = null;
- /**
- * Whether the bubble would be deleted if dropped immediately.
- * @type {boolean}
- * @private
- */
- this.wouldDeleteBubble_ = false;
+ /**
+ * Whether the bubble would be deleted if dropped immediately.
+ * @type {boolean}
+ * @private
+ */
+ this.wouldDeleteBubble_ = false;
- /**
- * The location of the top left corner of the dragging bubble's body at the
- * beginning of the drag, in workspace coordinates.
- * @type {!Coordinate}
- * @private
- */
- this.startXY_ = this.draggingBubble_.getRelativeToSurfaceXY();
+ /**
+ * The location of the top left corner of the dragging bubble's body at the
+ * beginning of the drag, in workspace coordinates.
+ * @type {!Coordinate}
+ * @private
+ */
+ this.startXY_ = this.draggingBubble_.getRelativeToSurfaceXY();
- /**
- * The drag surface to move bubbles to during a drag, or null if none should
- * be used. Block dragging and bubble dragging use the same surface.
- * @type {BlockDragSurfaceSvg}
- * @private
- */
- this.dragSurface_ =
- svgMath.is3dSupported() && !!workspace.getBlockDragSurface() ?
- workspace.getBlockDragSurface() :
- null;
-};
-
-/**
- * Sever all links from this object.
- * @package
- * @suppress {checkTypes}
- */
-BubbleDragger.prototype.dispose = function() {
- this.draggingBubble_ = null;
- this.workspace_ = null;
- this.dragSurface_ = null;
-};
-
-/**
- * Start dragging a bubble. This includes moving it to the drag surface.
- * @package
- */
-BubbleDragger.prototype.startBubbleDrag = function() {
- if (!eventUtils.getGroup()) {
- eventUtils.setGroup(true);
+ /**
+ * The drag surface to move bubbles to during a drag, or null if none should
+ * be used. Block dragging and bubble dragging use the same surface.
+ * @type {BlockDragSurfaceSvg}
+ * @private
+ */
+ this.dragSurface_ =
+ svgMath.is3dSupported() && !!workspace.getBlockDragSurface() ?
+ workspace.getBlockDragSurface() :
+ null;
}
- this.workspace_.setResizesEnabled(false);
- this.draggingBubble_.setAutoLayout(false);
- if (this.dragSurface_) {
- this.moveToDragSurface_();
+ /**
+ * Sever all links from this object.
+ * @package
+ * @suppress {checkTypes}
+ */
+ dispose() {
+ this.draggingBubble_ = null;
+ this.workspace_ = null;
+ this.dragSurface_ = null;
}
- this.draggingBubble_.setDragging && this.draggingBubble_.setDragging(true);
-};
-
-/**
- * Execute a step of bubble dragging, based on the given event. Update the
- * display accordingly.
- * @param {!Event} e The most recent move event.
- * @param {!Coordinate} currentDragDeltaXY How far the pointer has
- * moved from the position at the start of the drag, in pixel units.
- * @package
- */
-BubbleDragger.prototype.dragBubble = function(e, currentDragDeltaXY) {
- const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
- const newLoc = Coordinate.sum(this.startXY_, delta);
- this.draggingBubble_.moveDuringDrag(this.dragSurface_, newLoc);
-
- const oldDragTarget = this.dragTarget_;
- this.dragTarget_ = this.workspace_.getDragTarget(e);
-
- const oldWouldDeleteBubble = this.wouldDeleteBubble_;
- this.wouldDeleteBubble_ = this.shouldDelete_(this.dragTarget_);
- if (oldWouldDeleteBubble !== this.wouldDeleteBubble_) {
- // Prevent unnecessary add/remove class calls.
- this.updateCursorDuringBubbleDrag_();
- }
-
- // Call drag enter/exit/over after wouldDeleteBlock is called in shouldDelete_
- if (this.dragTarget_ !== oldDragTarget) {
- oldDragTarget && oldDragTarget.onDragExit(this.draggingBubble_);
- this.dragTarget_ && this.dragTarget_.onDragEnter(this.draggingBubble_);
- }
- this.dragTarget_ && this.dragTarget_.onDragOver(this.draggingBubble_);
-};
-
-/**
- * Whether ending the drag would delete the bubble.
- * @param {?IDragTarget} dragTarget The drag target that the bubblee is
- * currently over.
- * @return {boolean} Whether dropping the bubble immediately would delete the
- * block.
- * @private
- */
-BubbleDragger.prototype.shouldDelete_ = function(dragTarget) {
- if (dragTarget) {
- const componentManager = this.workspace_.getComponentManager();
- const isDeleteArea = componentManager.hasCapability(
- dragTarget.id, ComponentManager.Capability.DELETE_AREA);
- if (isDeleteArea) {
- return (/** @type {!IDeleteArea} */ (dragTarget))
- .wouldDelete(this.draggingBubble_, false);
+ /**
+ * Start dragging a bubble. This includes moving it to the drag surface.
+ * @package
+ */
+ startBubbleDrag() {
+ if (!eventUtils.getGroup()) {
+ eventUtils.setGroup(true);
}
- }
- return false;
-};
-/**
- * Update the cursor (and possibly the trash can lid) to reflect whether the
- * dragging bubble would be deleted if released immediately.
- * @private
- */
-BubbleDragger.prototype.updateCursorDuringBubbleDrag_ = function() {
- this.draggingBubble_.setDeleteStyle(this.wouldDeleteBubble_);
-};
-
-/**
- * Finish a bubble drag and put the bubble back on the workspace.
- * @param {!Event} e The mouseup/touchend event.
- * @param {!Coordinate} currentDragDeltaXY How far the pointer has
- * moved from the position at the start of the drag, in pixel units.
- * @package
- */
-BubbleDragger.prototype.endBubbleDrag = function(e, currentDragDeltaXY) {
- // Make sure internal state is fresh.
- this.dragBubble(e, currentDragDeltaXY);
-
- const preventMove = this.dragTarget_ &&
- this.dragTarget_.shouldPreventMove(this.draggingBubble_);
- let newLoc;
- if (preventMove) {
- newLoc = this.startXY_;
- } else {
- const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
- newLoc = Coordinate.sum(this.startXY_, delta);
- }
- // Move the bubble to its final location.
- this.draggingBubble_.moveTo(newLoc.x, newLoc.y);
-
- if (this.dragTarget_) {
- this.dragTarget_.onDrop(this.draggingBubble_);
- }
-
- if (this.wouldDeleteBubble_) {
- // Fire a move event, so we know where to go back to for an undo.
- this.fireMoveEvent_();
- this.draggingBubble_.dispose(false, true);
- } else {
- // Put everything back onto the bubble canvas.
+ this.workspace_.setResizesEnabled(false);
+ this.draggingBubble_.setAutoLayout(false);
if (this.dragSurface_) {
- this.dragSurface_.clearAndHide(this.workspace_.getBubbleCanvas());
+ this.moveToDragSurface_();
}
- if (this.draggingBubble_.setDragging) {
- this.draggingBubble_.setDragging(false);
+
+ this.draggingBubble_.setDragging && this.draggingBubble_.setDragging(true);
+ }
+
+ /**
+ * Execute a step of bubble dragging, based on the given event. Update the
+ * display accordingly.
+ * @param {!Event} e The most recent move event.
+ * @param {!Coordinate} currentDragDeltaXY How far the pointer has
+ * moved from the position at the start of the drag, in pixel units.
+ * @package
+ */
+ dragBubble(e, currentDragDeltaXY) {
+ const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
+ const newLoc = Coordinate.sum(this.startXY_, delta);
+ this.draggingBubble_.moveDuringDrag(this.dragSurface_, newLoc);
+
+ const oldDragTarget = this.dragTarget_;
+ this.dragTarget_ = this.workspace_.getDragTarget(e);
+
+ const oldWouldDeleteBubble = this.wouldDeleteBubble_;
+ this.wouldDeleteBubble_ = this.shouldDelete_(this.dragTarget_);
+ if (oldWouldDeleteBubble !== this.wouldDeleteBubble_) {
+ // Prevent unnecessary add/remove class calls.
+ this.updateCursorDuringBubbleDrag_();
}
- this.fireMoveEvent_();
+
+ // Call drag enter/exit/over after wouldDeleteBlock is called in
+ // shouldDelete_
+ if (this.dragTarget_ !== oldDragTarget) {
+ oldDragTarget && oldDragTarget.onDragExit(this.draggingBubble_);
+ this.dragTarget_ && this.dragTarget_.onDragEnter(this.draggingBubble_);
+ }
+ this.dragTarget_ && this.dragTarget_.onDragOver(this.draggingBubble_);
}
- this.workspace_.setResizesEnabled(true);
- eventUtils.setGroup(false);
-};
-
-/**
- * Fire a move event at the end of a bubble drag.
- * @private
- */
-BubbleDragger.prototype.fireMoveEvent_ = function() {
- if (this.draggingBubble_.isComment) {
- // TODO (adodson): Resolve build errors when requiring WorkspaceCommentSvg.
- const event = new (eventUtils.get(eventUtils.COMMENT_MOVE))(
- /** @type {!WorkspaceCommentSvg} */ (this.draggingBubble_));
- event.setOldCoordinate(this.startXY_);
- event.recordNew();
- eventUtils.fire(event);
+ /**
+ * Whether ending the drag would delete the bubble.
+ * @param {?IDragTarget} dragTarget The drag target that the bubblee is
+ * currently over.
+ * @return {boolean} Whether dropping the bubble immediately would delete the
+ * block.
+ * @private
+ */
+ shouldDelete_(dragTarget) {
+ if (dragTarget) {
+ const componentManager = this.workspace_.getComponentManager();
+ const isDeleteArea = componentManager.hasCapability(
+ dragTarget.id, ComponentManager.Capability.DELETE_AREA);
+ if (isDeleteArea) {
+ return (/** @type {!IDeleteArea} */ (dragTarget))
+ .wouldDelete(this.draggingBubble_, false);
+ }
+ }
+ return false;
}
- // TODO (fenichel): move events for comments.
- return;
-};
-/**
- * Convert a coordinate object from pixels to workspace units, including a
- * correction for mutator workspaces.
- * This function does not consider differing origins. It simply scales the
- * input's x and y values.
- * @param {!Coordinate} pixelCoord A coordinate with x and y
- * values in CSS pixel units.
- * @return {!Coordinate} The input coordinate divided by the
- * workspace scale.
- * @private
- */
-BubbleDragger.prototype.pixelsToWorkspaceUnits_ = function(pixelCoord) {
- const result = new Coordinate(
- pixelCoord.x / this.workspace_.scale,
- pixelCoord.y / this.workspace_.scale);
- if (this.workspace_.isMutator) {
- // If we're in a mutator, its scale is always 1, purely because of some
- // oddities in our rendering optimizations. The actual scale is the same as
- // the scale on the parent workspace.
- // Fix that for dragging.
- const mainScale = this.workspace_.options.parentWorkspace.scale;
- result.scale(1 / mainScale);
+ /**
+ * Update the cursor (and possibly the trash can lid) to reflect whether the
+ * dragging bubble would be deleted if released immediately.
+ * @private
+ */
+ updateCursorDuringBubbleDrag_() {
+ this.draggingBubble_.setDeleteStyle(this.wouldDeleteBubble_);
}
- return result;
-};
-/**
- * Move the bubble onto the drag surface at the beginning of a drag. Move the
- * drag surface to preserve the apparent location of the bubble.
- * @private
- */
-BubbleDragger.prototype.moveToDragSurface_ = function() {
- this.draggingBubble_.moveTo(0, 0);
- this.dragSurface_.translateSurface(this.startXY_.x, this.startXY_.y);
- // Execute the move on the top-level SVG component.
- this.dragSurface_.setBlocksAndShow(this.draggingBubble_.getSvgRoot());
+ /**
+ * Finish a bubble drag and put the bubble back on the workspace.
+ * @param {!Event} e The mouseup/touchend event.
+ * @param {!Coordinate} currentDragDeltaXY How far the pointer has
+ * moved from the position at the start of the drag, in pixel units.
+ * @package
+ */
+ endBubbleDrag(e, currentDragDeltaXY) {
+ // Make sure internal state is fresh.
+ this.dragBubble(e, currentDragDeltaXY);
+
+ const preventMove = this.dragTarget_ &&
+ this.dragTarget_.shouldPreventMove(this.draggingBubble_);
+ let newLoc;
+ if (preventMove) {
+ newLoc = this.startXY_;
+ } else {
+ const delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY);
+ newLoc = Coordinate.sum(this.startXY_, delta);
+ }
+ // Move the bubble to its final location.
+ this.draggingBubble_.moveTo(newLoc.x, newLoc.y);
+
+ if (this.dragTarget_) {
+ this.dragTarget_.onDrop(this.draggingBubble_);
+ }
+
+ if (this.wouldDeleteBubble_) {
+ // Fire a move event, so we know where to go back to for an undo.
+ this.fireMoveEvent_();
+ this.draggingBubble_.dispose(false, true);
+ } else {
+ // Put everything back onto the bubble canvas.
+ if (this.dragSurface_) {
+ this.dragSurface_.clearAndHide(this.workspace_.getBubbleCanvas());
+ }
+ if (this.draggingBubble_.setDragging) {
+ this.draggingBubble_.setDragging(false);
+ }
+ this.fireMoveEvent_();
+ }
+ this.workspace_.setResizesEnabled(true);
+
+ eventUtils.setGroup(false);
+ }
+
+ /**
+ * Fire a move event at the end of a bubble drag.
+ * @private
+ */
+ fireMoveEvent_() {
+ if (this.draggingBubble_.isComment) {
+ // TODO (adodson): Resolve build errors when requiring
+ // WorkspaceCommentSvg.
+ const event = /** @type {!CommentMove} */
+ (new (eventUtils.get(eventUtils.COMMENT_MOVE))(
+ /** @type {!WorkspaceCommentSvg} */ (this.draggingBubble_)));
+ event.setOldCoordinate(this.startXY_);
+ event.recordNew();
+ eventUtils.fire(event);
+ }
+ // TODO (fenichel): move events for comments.
+ return;
+ }
+
+ /**
+ * Convert a coordinate object from pixels to workspace units, including a
+ * correction for mutator workspaces.
+ * This function does not consider differing origins. It simply scales the
+ * input's x and y values.
+ * @param {!Coordinate} pixelCoord A coordinate with x and y
+ * values in CSS pixel units.
+ * @return {!Coordinate} The input coordinate divided by the
+ * workspace scale.
+ * @private
+ */
+ pixelsToWorkspaceUnits_(pixelCoord) {
+ const result = new Coordinate(
+ pixelCoord.x / this.workspace_.scale,
+ pixelCoord.y / this.workspace_.scale);
+ if (this.workspace_.isMutator) {
+ // If we're in a mutator, its scale is always 1, purely because of some
+ // oddities in our rendering optimizations. The actual scale is the same
+ // as the scale on the parent workspace. Fix that for dragging.
+ const mainScale = this.workspace_.options.parentWorkspace.scale;
+ result.scale(1 / mainScale);
+ }
+ return result;
+ }
+
+ /**
+ * Move the bubble onto the drag surface at the beginning of a drag. Move the
+ * drag surface to preserve the apparent location of the bubble.
+ * @private
+ */
+ moveToDragSurface_() {
+ this.draggingBubble_.moveTo(0, 0);
+ this.dragSurface_.translateSurface(this.startXY_.x, this.startXY_.y);
+ // Execute the move on the top-level SVG component.
+ this.dragSurface_.setBlocksAndShow(this.draggingBubble_.getSvgRoot());
+ }
};
exports.BubbleDragger = BubbleDragger;
diff --git a/core/bump_objects.js b/core/bump_objects.js
index 2b7dc82a7..3cff5ed51 100644
--- a/core/bump_objects.js
+++ b/core/bump_objects.js
@@ -15,11 +15,11 @@
*/
goog.module('Blockly.bumpObjects');
-/* eslint-disable-next-line no-unused-vars */
-const Abstract = goog.requireType('Blockly.Events.Abstract');
const eventUtils = goog.require('Blockly.Events.utils');
const mathUtils = goog.require('Blockly.utils.math');
/* eslint-disable-next-line no-unused-vars */
+const {Abstract} = goog.requireType('Blockly.Events.Abstract');
+/* eslint-disable-next-line no-unused-vars */
const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
/* eslint-disable-next-line no-unused-vars */
const {IBoundedElement} = goog.requireType('Blockly.IBoundedElement');
diff --git a/core/clipboard.js b/core/clipboard.js
index ee7ef2a54..5d6d9526a 100644
--- a/core/clipboard.js
+++ b/core/clipboard.js
@@ -15,7 +15,6 @@
*/
goog.module('Blockly.clipboard');
-const eventUtils = goog.require('Blockly.Events.utils');
/* eslint-disable-next-line no-unused-vars */
const {ICopyable} = goog.requireType('Blockly.ICopyable');
@@ -39,13 +38,14 @@ exports.copy = copy;
/**
* Paste a block or workspace comment on to the main workspace.
- * @return {boolean} True if the paste was successful, false otherwise.
+ * @return {!ICopyable|null} The pasted thing if the paste
+ * was successful, null otherwise.
* @alias Blockly.clipboard.paste
* @package
*/
const paste = function() {
if (!copyData) {
- return false;
+ return null;
}
// Pasting always pastes to the main workspace, even if the copy
// started in a flyout workspace.
@@ -55,12 +55,9 @@ const paste = function() {
}
if (copyData.typeCounts &&
workspace.isCapacityAvailable(copyData.typeCounts)) {
- eventUtils.setGroup(true);
- workspace.paste(copyData.saveInfo);
- eventUtils.setGroup(false);
- return true;
+ return workspace.paste(copyData.saveInfo);
}
- return false;
+ return null;
};
exports.paste = paste;
@@ -68,13 +65,16 @@ exports.paste = paste;
* Duplicate this block and its children, or a workspace comment.
* @param {!ICopyable} toDuplicate Block or Workspace Comment to be
* duplicated.
+ * @return {!ICopyable|null} The block or workspace comment that was duplicated,
+ * or null if the duplication failed.
* @alias Blockly.clipboard.duplicate
* @package
*/
const duplicate = function(toDuplicate) {
const oldCopyData = copyData;
copy(toDuplicate);
- toDuplicate.workspace.paste(copyData.saveInfo);
+ const pastedThing = toDuplicate.workspace.paste(copyData.saveInfo);
copyData = oldCopyData;
+ return pastedThing;
};
exports.duplicate = duplicate;
diff --git a/core/comment.js b/core/comment.js
index 8dc56bdb8..251385ded 100644
--- a/core/comment.js
+++ b/core/comment.js
@@ -19,7 +19,6 @@ const Css = goog.require('Blockly.Css');
const browserEvents = goog.require('Blockly.browserEvents');
const dom = goog.require('Blockly.utils.dom');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const userAgent = goog.require('Blockly.utils.userAgent');
/* eslint-disable-next-line no-unused-vars */
const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
@@ -44,384 +43,406 @@ goog.require('Blockly.Warning');
/**
* Class for a comment.
- * @param {!Block} block The block associated with this comment.
* @extends {Icon}
- * @constructor
* @alias Blockly.Comment
*/
-const Comment = function(block) {
- Comment.superClass_.constructor.call(this, block);
-
+class Comment extends Icon {
/**
- * The model for this comment.
- * @type {!Block.CommentModel}
- * @private
+ * @param {!BlockSvg} block The block associated with this comment.
*/
- this.model_ = block.commentModel;
- // If someone creates the comment directly instead of calling
- // block.setCommentText we want to make sure the text is non-null;
- this.model_.text = this.model_.text || '';
+ constructor(block) {
+ super(block);
- /**
- * The model's text value at the start of an edit.
- * Used to tell if an event should be fired at the end of an edit.
- * @type {?string}
- * @private
- */
- this.cachedText_ = '';
+ /**
+ * The model for this comment.
+ * @type {!Block.CommentModel}
+ * @private
+ */
+ this.model_ = block.commentModel;
+ // If someone creates the comment directly instead of calling
+ // block.setCommentText we want to make sure the text is non-null;
+ this.model_.text = this.model_.text || '';
- /**
- * Mouse up event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onMouseUpWrapper_ = null;
+ /**
+ * The model's text value at the start of an edit.
+ * Used to tell if an event should be fired at the end of an edit.
+ * @type {?string}
+ * @private
+ */
+ this.cachedText_ = '';
- /**
- * Wheel event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onWheelWrapper_ = null;
-
- /**
- * Change event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onChangeWrapper_ = null;
-
- /**
- * Input event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onInputWrapper_ = null;
-
- this.createIcon();
-};
-object.inherits(Comment, Icon);
-
-/**
- * Draw the comment icon.
- * @param {!Element} group The icon group.
- * @protected
- */
-Comment.prototype.drawIcon_ = function(group) {
- // Circle.
- dom.createSvgElement(
- Svg.CIRCLE, {'class': 'blocklyIconShape', 'r': '8', 'cx': '8', 'cy': '8'},
- group);
- // Can't use a real '?' text character since different browsers and operating
- // systems render it differently.
- // Body of question mark.
- dom.createSvgElement(
- Svg.PATH, {
- 'class': 'blocklyIconSymbol',
- 'd': 'm6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.405' +
- '0.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25' +
- '-1.201,0.998 -1.201,1.528 -1.204,2.19z',
- },
- group);
- // Dot of question mark.
- dom.createSvgElement(
- Svg.RECT, {
- 'class': 'blocklyIconSymbol',
- 'x': '6.8',
- 'y': '10.78',
- 'height': '2',
- 'width': '2',
- },
- group);
-};
-
-/**
- * Create the editor for the comment's bubble.
- * @return {!SVGElement} The top-level node of the editor.
- * @private
- */
-Comment.prototype.createEditor_ = function() {
- /* Create the editor. Here's the markup that will be generated in
- * editable mode:
-
-
-
-
-
- * For non-editable mode see Warning.textToDom_.
- */
-
- this.foreignObject_ = dom.createSvgElement(
- Svg.FOREIGNOBJECT, {'x': Bubble.BORDER_WIDTH, 'y': Bubble.BORDER_WIDTH},
- null);
-
- const body = document.createElementNS(dom.HTML_NS, 'body');
- body.setAttribute('xmlns', dom.HTML_NS);
- body.className = 'blocklyMinimalBody';
-
- this.textarea_ = document.createElementNS(dom.HTML_NS, 'textarea');
- const textarea = this.textarea_;
- textarea.className = 'blocklyCommentTextarea';
- textarea.setAttribute('dir', this.block_.RTL ? 'RTL' : 'LTR');
- textarea.value = this.model_.text;
- this.resizeTextarea_();
-
- body.appendChild(textarea);
- this.foreignObject_.appendChild(body);
-
- // Ideally this would be hooked to the focus event for the comment.
- // However doing so in Firefox swallows the cursor for unknown reasons.
- // So this is hooked to mouseup instead. No big deal.
- this.onMouseUpWrapper_ = browserEvents.conditionalBind(
- textarea, 'mouseup', this, this.startEdit_, true, true);
- // Don't zoom with mousewheel.
- this.onWheelWrapper_ =
- browserEvents.conditionalBind(textarea, 'wheel', this, function(e) {
- e.stopPropagation();
- });
- this.onChangeWrapper_ = browserEvents.conditionalBind(
- textarea, 'change', this,
- /**
- * @this {Comment}
- * @param {Event} _e Unused event parameter.
- */
- function(_e) {
- if (this.cachedText_ !== this.model_.text) {
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
- this.block_, 'comment', null, this.cachedText_,
- this.model_.text));
- }
- });
- this.onInputWrapper_ = browserEvents.conditionalBind(
- textarea, 'input', this,
- /**
- * @this {Comment}
- * @param {Event} _e Unused event parameter.
- */
- function(_e) {
- this.model_.text = textarea.value;
- });
-
- setTimeout(textarea.focus.bind(textarea), 0);
-
- return this.foreignObject_;
-};
-
-/**
- * Add or remove editability of the comment.
- * @override
- */
-Comment.prototype.updateEditable = function() {
- Comment.superClass_.updateEditable.call(this);
- if (this.isVisible()) {
- // Recreate the bubble with the correct UI.
- this.disposeBubble_();
- this.createBubble_();
- }
-};
-
-/**
- * Callback function triggered when the bubble has resized.
- * Resize the text area accordingly.
- * @private
- */
-Comment.prototype.onBubbleResize_ = function() {
- if (!this.isVisible()) {
- return;
- }
- this.model_.size = this.bubble_.getBubbleSize();
- this.resizeTextarea_();
-};
-
-/**
- * Resizes the text area to match the size defined on the model (which is
- * the size of the bubble).
- * @private
- */
-Comment.prototype.resizeTextarea_ = function() {
- const size = this.model_.size;
- const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH;
- const widthMinusBorder = size.width - doubleBorderWidth;
- const heightMinusBorder = size.height - doubleBorderWidth;
- this.foreignObject_.setAttribute('width', widthMinusBorder);
- this.foreignObject_.setAttribute('height', heightMinusBorder);
- this.textarea_.style.width = (widthMinusBorder - 4) + 'px';
- this.textarea_.style.height = (heightMinusBorder - 4) + 'px';
-};
-
-/**
- * Show or hide the comment bubble.
- * @param {boolean} visible True if the bubble should be visible.
- */
-Comment.prototype.setVisible = function(visible) {
- if (visible === this.isVisible()) {
- return;
- }
- eventUtils.fire(new (eventUtils.get(eventUtils.BUBBLE_OPEN))(
- this.block_, visible, 'comment'));
- this.model_.pinned = visible;
- if (visible) {
- this.createBubble_();
- } else {
- this.disposeBubble_();
- }
-};
-
-/**
- * Show the bubble. Handles deciding if it should be editable or not.
- * @private
- */
-Comment.prototype.createBubble_ = function() {
- if (!this.block_.isEditable() || userAgent.IE) {
- // MSIE does not support foreignobject; textareas are impossible.
- // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-svg/56e6e04c-7c8c-44dd-8100-bd745ee42034
- // Always treat comments in IE as uneditable.
- this.createNonEditableBubble_();
- } else {
- this.createEditableBubble_();
- }
-};
-
-/**
- * Show an editable bubble.
- * @private
- */
-Comment.prototype.createEditableBubble_ = function() {
- this.bubble_ = new Bubble(
- /** @type {!WorkspaceSvg} */ (this.block_.workspace),
- this.createEditor_(), this.block_.pathObject.svgPath,
- /** @type {!Coordinate} */ (this.iconXY_), this.model_.size.width,
- this.model_.size.height);
- // Expose this comment's block's ID on its top-level SVG group.
- this.bubble_.setSvgId(this.block_.id);
- this.bubble_.registerResizeEvent(this.onBubbleResize_.bind(this));
- this.applyColour();
-};
-
-/**
- * Show a non-editable bubble.
- * @private
- * @suppress {checkTypes} Suppress `this` type mismatch.
- */
-Comment.prototype.createNonEditableBubble_ = function() {
- // TODO (#2917): It would be great if the comment could support line breaks.
- this.paragraphElement_ = Bubble.textToDom(this.block_.getCommentText());
- this.bubble_ = Bubble.createNonEditableBubble(
- this.paragraphElement_, /** @type {!BlockSvg} */ (this.block_),
- /** @type {!Coordinate} */ (this.iconXY_));
- this.applyColour();
-};
-
-/**
- * Dispose of the bubble.
- * @private
- * @suppress {checkTypes} Suppress `this` type mismatch.
- */
-Comment.prototype.disposeBubble_ = function() {
- if (this.onMouseUpWrapper_) {
- browserEvents.unbind(this.onMouseUpWrapper_);
+ /**
+ * Mouse up event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
this.onMouseUpWrapper_ = null;
- }
- if (this.onWheelWrapper_) {
- browserEvents.unbind(this.onWheelWrapper_);
+
+ /**
+ * Wheel event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
this.onWheelWrapper_ = null;
- }
- if (this.onChangeWrapper_) {
- browserEvents.unbind(this.onChangeWrapper_);
+
+ /**
+ * Change event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
this.onChangeWrapper_ = null;
- }
- if (this.onInputWrapper_) {
- browserEvents.unbind(this.onInputWrapper_);
+
+ /**
+ * Input event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
this.onInputWrapper_ = null;
- }
- this.bubble_.dispose();
- this.bubble_ = null;
- this.textarea_ = null;
- this.foreignObject_ = null;
- this.paragraphElement_ = null;
-};
-/**
- * Callback fired when an edit starts.
- *
- * Bring the comment to the top of the stack when clicked on. Also cache the
- * current text so it can be used to fire a change event.
- * @param {!Event} _e Mouse up event.
- * @private
- */
-Comment.prototype.startEdit_ = function(_e) {
- if (this.bubble_.promote()) {
- // Since the act of moving this node within the DOM causes a loss of focus,
- // we need to reapply the focus.
- this.textarea_.focus();
+ /**
+ * The SVG element that contains the text edit area, or null if not created.
+ * @type {?SVGForeignObjectElement}
+ * @private
+ */
+ this.foreignObject_ = null;
+
+ /**
+ * The editable text area, or null if not created.
+ * @type {?Element}
+ * @private
+ */
+ this.textarea_ = null;
+
+ /**
+ * The top-level node of the comment text, or null if not created.
+ * @type {?SVGTextElement}
+ * @private
+ */
+ this.paragraphElement_ = null;
+
+ this.createIcon();
}
- this.cachedText_ = this.model_.text;
-};
-
-/**
- * Get the dimensions of this comment's bubble.
- * @return {Size} Object with width and height properties.
- */
-Comment.prototype.getBubbleSize = function() {
- return this.model_.size;
-};
-
-/**
- * Size this comment's bubble.
- * @param {number} width Width of the bubble.
- * @param {number} height Height of the bubble.
- */
-Comment.prototype.setBubbleSize = function(width, height) {
- if (this.bubble_) {
- this.bubble_.setBubbleSize(width, height);
- } else {
- this.model_.size.width = width;
- this.model_.size.height = height;
+ /**
+ * Draw the comment icon.
+ * @param {!Element} group The icon group.
+ * @protected
+ */
+ drawIcon_(group) {
+ // Circle.
+ dom.createSvgElement(
+ Svg.CIRCLE,
+ {'class': 'blocklyIconShape', 'r': '8', 'cx': '8', 'cy': '8'}, group);
+ // Can't use a real '?' text character since different browsers and
+ // operating systems render it differently. Body of question mark.
+ dom.createSvgElement(
+ Svg.PATH, {
+ 'class': 'blocklyIconSymbol',
+ 'd': 'm6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.405' +
+ '0.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25' +
+ '-1.201,0.998 -1.201,1.528 -1.204,2.19z',
+ },
+ group);
+ // Dot of question mark.
+ dom.createSvgElement(
+ Svg.RECT, {
+ 'class': 'blocklyIconSymbol',
+ 'x': '6.8',
+ 'y': '10.78',
+ 'height': '2',
+ 'width': '2',
+ },
+ group);
}
-};
-/**
- * Update the comment's view to match the model.
- * @package
- */
-Comment.prototype.updateText = function() {
- if (this.textarea_) {
- this.textarea_.value = this.model_.text;
- } else if (this.paragraphElement_) {
- // Non-Editable mode.
- // TODO (#2917): If 2917 gets added this will probably need to be updated.
- this.paragraphElement_.firstChild.textContent = this.model_.text;
+ /**
+ * Create the editor for the comment's bubble.
+ * @return {!SVGElement} The top-level node of the editor.
+ * @private
+ */
+ createEditor_() {
+ /* Create the editor. Here's the markup that will be generated in
+ * editable mode:
+
+
+
+
+
+ * For non-editable mode see Warning.textToDom_.
+ */
+
+ this.foreignObject_ = dom.createSvgElement(
+ Svg.FOREIGNOBJECT, {'x': Bubble.BORDER_WIDTH, 'y': Bubble.BORDER_WIDTH},
+ null);
+
+ const body = document.createElementNS(dom.HTML_NS, 'body');
+ body.setAttribute('xmlns', dom.HTML_NS);
+ body.className = 'blocklyMinimalBody';
+
+ this.textarea_ = document.createElementNS(dom.HTML_NS, 'textarea');
+ const textarea = this.textarea_;
+ textarea.className = 'blocklyCommentTextarea';
+ textarea.setAttribute('dir', this.block_.RTL ? 'RTL' : 'LTR');
+ textarea.value = this.model_.text;
+ this.resizeTextarea_();
+
+ body.appendChild(textarea);
+ this.foreignObject_.appendChild(body);
+
+ // Ideally this would be hooked to the focus event for the comment.
+ // However doing so in Firefox swallows the cursor for unknown reasons.
+ // So this is hooked to mouseup instead. No big deal.
+ this.onMouseUpWrapper_ = browserEvents.conditionalBind(
+ textarea, 'mouseup', this, this.startEdit_, true, true);
+ // Don't zoom with mousewheel.
+ this.onWheelWrapper_ =
+ browserEvents.conditionalBind(textarea, 'wheel', this, function(e) {
+ e.stopPropagation();
+ });
+ this.onChangeWrapper_ = browserEvents.conditionalBind(
+ textarea, 'change', this,
+ /**
+ * @this {Comment}
+ * @param {Event} _e Unused event parameter.
+ */
+ function(_e) {
+ if (this.cachedText_ !== this.model_.text) {
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
+ this.block_, 'comment', null, this.cachedText_,
+ this.model_.text));
+ }
+ });
+ this.onInputWrapper_ = browserEvents.conditionalBind(
+ textarea, 'input', this,
+ /**
+ * @this {Comment}
+ * @param {Event} _e Unused event parameter.
+ */
+ function(_e) {
+ this.model_.text = textarea.value;
+ });
+
+ setTimeout(textarea.focus.bind(textarea), 0);
+
+ return this.foreignObject_;
}
-};
-/**
- * Dispose of this comment.
- *
- * If you want to receive a comment "delete" event (newValue: null), then this
- * should not be called directly. Instead call block.setCommentText(null);
- */
-Comment.prototype.dispose = function() {
- this.block_.comment = null;
- Icon.prototype.dispose.call(this);
-};
+ /**
+ * Add or remove editability of the comment.
+ * @override
+ */
+ updateEditable() {
+ super.updateEditable();
+ if (this.isVisible()) {
+ // Recreate the bubble with the correct UI.
+ this.disposeBubble_();
+ this.createBubble_();
+ }
+ }
+
+ /**
+ * Callback function triggered when the bubble has resized.
+ * Resize the text area accordingly.
+ * @private
+ */
+ onBubbleResize_() {
+ if (!this.isVisible()) {
+ return;
+ }
+ this.model_.size = this.bubble_.getBubbleSize();
+ this.resizeTextarea_();
+ }
+
+ /**
+ * Resizes the text area to match the size defined on the model (which is
+ * the size of the bubble).
+ * @private
+ */
+ resizeTextarea_() {
+ const size = this.model_.size;
+ const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH;
+ const widthMinusBorder = size.width - doubleBorderWidth;
+ const heightMinusBorder = size.height - doubleBorderWidth;
+ this.foreignObject_.setAttribute('width', widthMinusBorder);
+ this.foreignObject_.setAttribute('height', heightMinusBorder);
+ this.textarea_.style.width = (widthMinusBorder - 4) + 'px';
+ this.textarea_.style.height = (heightMinusBorder - 4) + 'px';
+ }
+
+ /**
+ * Show or hide the comment bubble.
+ * @param {boolean} visible True if the bubble should be visible.
+ */
+ setVisible(visible) {
+ if (visible === this.isVisible()) {
+ return;
+ }
+ eventUtils.fire(new (eventUtils.get(eventUtils.BUBBLE_OPEN))(
+ this.block_, visible, 'comment'));
+ this.model_.pinned = visible;
+ if (visible) {
+ this.createBubble_();
+ } else {
+ this.disposeBubble_();
+ }
+ }
+
+ /**
+ * Show the bubble. Handles deciding if it should be editable or not.
+ * @private
+ */
+ createBubble_() {
+ if (!this.block_.isEditable() || userAgent.IE) {
+ // MSIE does not support foreignobject; textareas are impossible.
+ // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-svg/56e6e04c-7c8c-44dd-8100-bd745ee42034
+ // Always treat comments in IE as uneditable.
+ this.createNonEditableBubble_();
+ } else {
+ this.createEditableBubble_();
+ }
+ }
+
+ /**
+ * Show an editable bubble.
+ * @private
+ */
+ createEditableBubble_() {
+ this.bubble_ = new Bubble(
+ /** @type {!WorkspaceSvg} */ (this.block_.workspace),
+ this.createEditor_(), this.block_.pathObject.svgPath,
+ /** @type {!Coordinate} */ (this.iconXY_), this.model_.size.width,
+ this.model_.size.height);
+ // Expose this comment's block's ID on its top-level SVG group.
+ this.bubble_.setSvgId(this.block_.id);
+ this.bubble_.registerResizeEvent(this.onBubbleResize_.bind(this));
+ this.applyColour();
+ }
+
+ /**
+ * Show a non-editable bubble.
+ * @private
+ * @suppress {checkTypes} Suppress `this` type mismatch.
+ */
+ createNonEditableBubble_() {
+ // TODO (#2917): It would be great if the comment could support line breaks.
+ this.paragraphElement_ = Bubble.textToDom(this.block_.getCommentText());
+ this.bubble_ = Bubble.createNonEditableBubble(
+ this.paragraphElement_, /** @type {!BlockSvg} */ (this.block_),
+ /** @type {!Coordinate} */ (this.iconXY_));
+ this.applyColour();
+ }
+
+ /**
+ * Dispose of the bubble.
+ * @private
+ * @suppress {checkTypes} Suppress `this` type mismatch.
+ */
+ disposeBubble_() {
+ if (this.onMouseUpWrapper_) {
+ browserEvents.unbind(this.onMouseUpWrapper_);
+ this.onMouseUpWrapper_ = null;
+ }
+ if (this.onWheelWrapper_) {
+ browserEvents.unbind(this.onWheelWrapper_);
+ this.onWheelWrapper_ = null;
+ }
+ if (this.onChangeWrapper_) {
+ browserEvents.unbind(this.onChangeWrapper_);
+ this.onChangeWrapper_ = null;
+ }
+ if (this.onInputWrapper_) {
+ browserEvents.unbind(this.onInputWrapper_);
+ this.onInputWrapper_ = null;
+ }
+ this.bubble_.dispose();
+ this.bubble_ = null;
+ this.textarea_ = null;
+ this.foreignObject_ = null;
+ this.paragraphElement_ = null;
+ }
+
+ /**
+ * Callback fired when an edit starts.
+ *
+ * Bring the comment to the top of the stack when clicked on. Also cache the
+ * current text so it can be used to fire a change event.
+ * @param {!Event} _e Mouse up event.
+ * @private
+ */
+ startEdit_(_e) {
+ if (this.bubble_.promote()) {
+ // Since the act of moving this node within the DOM causes a loss of
+ // focus, we need to reapply the focus.
+ this.textarea_.focus();
+ }
+
+ this.cachedText_ = this.model_.text;
+ }
+
+ /**
+ * Get the dimensions of this comment's bubble.
+ * @return {Size} Object with width and height properties.
+ */
+ getBubbleSize() {
+ return this.model_.size;
+ }
+
+ /**
+ * Size this comment's bubble.
+ * @param {number} width Width of the bubble.
+ * @param {number} height Height of the bubble.
+ */
+ setBubbleSize(width, height) {
+ if (this.bubble_) {
+ this.bubble_.setBubbleSize(width, height);
+ } else {
+ this.model_.size.width = width;
+ this.model_.size.height = height;
+ }
+ }
+
+ /**
+ * Update the comment's view to match the model.
+ * @package
+ */
+ updateText() {
+ if (this.textarea_) {
+ this.textarea_.value = this.model_.text;
+ } else if (this.paragraphElement_) {
+ // Non-Editable mode.
+ // TODO (#2917): If 2917 gets added this will probably need to be updated.
+ this.paragraphElement_.firstChild.textContent = this.model_.text;
+ }
+ }
+
+ /**
+ * Dispose of this comment.
+ *
+ * If you want to receive a comment "delete" event (newValue: null), then this
+ * should not be called directly. Instead call block.setCommentText(null);
+ */
+ dispose() {
+ this.block_.comment = null;
+ Icon.prototype.dispose.call(this);
+ }
+}
/**
* CSS for block comment. See css.js for use.
*/
Css.register(`
- .blocklyCommentTextarea {
- background-color: #fef49c;
- border: 0;
- display: block;
- margin: 0;
- outline: 0;
- padding: 3px;
- resize: none;
- text-overflow: hidden;
- }
+.blocklyCommentTextarea {
+ background-color: #fef49c;
+ border: 0;
+ display: block;
+ margin: 0;
+ outline: 0;
+ padding: 3px;
+ resize: none;
+ text-overflow: hidden;
+}
`);
exports.Comment = Comment;
diff --git a/core/common.js b/core/common.js
index 59052b55d..29a4af214 100644
--- a/core/common.js
+++ b/core/common.js
@@ -17,7 +17,8 @@
*/
goog.module('Blockly.common');
-const {Blocks} = goog.require('Blockly.blocks');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockDefinition, Blocks} = goog.require('Blockly.blocks');
/* eslint-disable-next-line no-unused-vars */
const {Connection} = goog.requireType('Blockly.Connection');
/* eslint-disable-next-line no-unused-vars */
@@ -210,27 +211,55 @@ const jsonInitFactory = function(jsonDef) {
* @alias Blockly.common.defineBlocksWithJsonArray
*/
const defineBlocksWithJsonArray = function(jsonArray) {
+ defineBlocks(createBlockDefinitionsFromJsonArray(jsonArray));
+};
+exports.defineBlocksWithJsonArray = defineBlocksWithJsonArray;
+
+/**
+ * Define blocks from an array of JSON block definitions, as might be generated
+ * by the Blockly Developer Tools.
+ * @param {!Array} jsonArray An array of JSON block definitions.
+ * @return {!Object} A map of the block
+ * definitions created.
+ * @alias Blockly.common.defineBlocksWithJsonArray
+ */
+const createBlockDefinitionsFromJsonArray = function(jsonArray) {
+ const /** @type {!Object} */ blocks = {};
for (let i = 0; i < jsonArray.length; i++) {
const elem = jsonArray[i];
if (!elem) {
- console.warn(
- 'Block definition #' + i + ' in JSON array is ' + elem + '. ' +
- 'Skipping.');
- } else {
- const typename = elem.type;
- if (!typename) {
- console.warn(
- 'Block definition #' + i +
- ' in JSON array is missing a type attribute. Skipping.');
- } else {
- if (Blocks[typename]) {
- console.warn(
- 'Block definition #' + i + ' in JSON array' +
- ' overwrites prior definition of "' + typename + '".');
- }
- Blocks[typename] = {init: jsonInitFactory(elem)};
- }
+ console.warn(`Block definition #${i} in JSON array is ${elem}. Skipping`);
+ continue;
}
+ const type = elem.type;
+ if (!type) {
+ console.warn(
+ `Block definition #${i} in JSON array is missing a type attribute. ` +
+ 'Skipping.');
+ continue;
+ }
+ blocks[type] = {init: jsonInitFactory(elem)};
+ }
+ return blocks;
+};
+exports.createBlockDefinitionsFromJsonArray =
+ createBlockDefinitionsFromJsonArray;
+
+/**
+ * Add the specified block definitions to the block definitions
+ * dictionary (Blockly.Blocks).
+ * @param {!Object} blocks A map of block
+ * type names to block definitions.
+ * @alias Blockly.common.defineBlocks
+ */
+const defineBlocks = function(blocks) {
+ // Iterate over own enumerable properties.
+ for (const type of Object.keys(blocks)) {
+ const definition = blocks[type];
+ if (type in Blocks) {
+ console.warn(`Block definiton "${type}" overwrites previous definition.`);
+ }
+ Blocks[type] = definition;
}
};
-exports.defineBlocksWithJsonArray = defineBlocksWithJsonArray;
+exports.defineBlocks = defineBlocks;
diff --git a/core/component_manager.js b/core/component_manager.js
index 5010f538e..1e1cf04d7 100644
--- a/core/component_manager.js
+++ b/core/component_manager.js
@@ -31,24 +31,179 @@ const {IPositionable} = goog.requireType('Blockly.IPositionable');
/**
* Manager for all items registered with the workspace.
- * @constructor
* @alias Blockly.ComponentManager
*/
-const ComponentManager = function() {
+class ComponentManager {
/**
- * A map of the components registered with the workspace, mapped to id.
- * @type {!Object}
- * @private
+ * Creates a new ComponentManager instance.
*/
- this.componentData_ = Object.create(null);
+ constructor() {
+ /**
+ * A map of the components registered with the workspace, mapped to id.
+ * @type {!Object}
+ * @private
+ */
+ this.componentData_ = Object.create(null);
+
+ /**
+ * A map of capabilities to component IDs.
+ * @type {!Object>}
+ * @private
+ */
+ this.capabilityToComponentIds_ = Object.create(null);
+ }
/**
- * A map of capabilities to component IDs.
- * @type {!Object>}
- * @private
+ * Adds a component.
+ * @param {!ComponentManager.ComponentDatum} componentInfo The data for
+ * the component to register.
+ * @param {boolean=} opt_allowOverrides True to prevent an error when
+ * overriding an already registered item.
*/
- this.capabilityToComponentIds_ = Object.create(null);
-};
+ addComponent(componentInfo, opt_allowOverrides) {
+ // Don't throw an error if opt_allowOverrides is true.
+ const id = componentInfo.component.id;
+ if (!opt_allowOverrides && this.componentData_[id]) {
+ throw Error(
+ 'Plugin "' + id + '" with capabilities "' +
+ this.componentData_[id].capabilities + '" already added.');
+ }
+ this.componentData_[id] = componentInfo;
+ const stringCapabilities = [];
+ for (let i = 0; i < componentInfo.capabilities.length; i++) {
+ const capability = String(componentInfo.capabilities[i]).toLowerCase();
+ stringCapabilities.push(capability);
+ if (this.capabilityToComponentIds_[capability] === undefined) {
+ this.capabilityToComponentIds_[capability] = [id];
+ } else {
+ this.capabilityToComponentIds_[capability].push(id);
+ }
+ }
+ this.componentData_[id].capabilities = stringCapabilities;
+ }
+
+ /**
+ * Removes a component.
+ * @param {string} id The ID of the component to remove.
+ */
+ removeComponent(id) {
+ const componentInfo = this.componentData_[id];
+ if (!componentInfo) {
+ return;
+ }
+ for (let i = 0; i < componentInfo.capabilities.length; i++) {
+ const capability = String(componentInfo.capabilities[i]).toLowerCase();
+ arrayUtils.removeElem(this.capabilityToComponentIds_[capability], id);
+ }
+ delete this.componentData_[id];
+ }
+
+ /**
+ * Adds a capability to a existing registered component.
+ * @param {string} id The ID of the component to add the capability to.
+ * @param {string|!ComponentManager.Capability} capability The
+ * capability to add.
+ * @template T
+ */
+ addCapability(id, capability) {
+ if (!this.getComponent(id)) {
+ throw Error(
+ 'Cannot add capability, "' + capability + '". Plugin "' + id +
+ '" has not been added to the ComponentManager');
+ }
+ if (this.hasCapability(id, capability)) {
+ console.warn(
+ 'Plugin "' + id + 'already has capability "' + capability + '"');
+ return;
+ }
+ capability = String(capability).toLowerCase();
+ this.componentData_[id].capabilities.push(capability);
+ this.capabilityToComponentIds_[capability].push(id);
+ }
+
+ /**
+ * Removes a capability from an existing registered component.
+ * @param {string} id The ID of the component to remove the capability from.
+ * @param {string|!ComponentManager.Capability} capability The
+ * capability to remove.
+ * @template T
+ */
+ removeCapability(id, capability) {
+ if (!this.getComponent(id)) {
+ throw Error(
+ 'Cannot remove capability, "' + capability + '". Plugin "' + id +
+ '" has not been added to the ComponentManager');
+ }
+ if (!this.hasCapability(id, capability)) {
+ console.warn(
+ 'Plugin "' + id + 'doesn\'t have capability "' + capability +
+ '" to remove');
+ return;
+ }
+ capability = String(capability).toLowerCase();
+ arrayUtils.removeElem(this.componentData_[id].capabilities, capability);
+ arrayUtils.removeElem(this.capabilityToComponentIds_[capability], id);
+ }
+
+ /**
+ * Returns whether the component with this id has the specified capability.
+ * @param {string} id The ID of the component to check.
+ * @param {string|!ComponentManager.Capability} capability The
+ * capability to check for.
+ * @return {boolean} Whether the component has the capability.
+ * @template T
+ */
+ hasCapability(id, capability) {
+ capability = String(capability).toLowerCase();
+ return this.componentData_[id].capabilities.indexOf(capability) !== -1;
+ }
+
+ /**
+ * Gets the component with the given ID.
+ * @param {string} id The ID of the component to get.
+ * @return {!IComponent|undefined} The component with the given name
+ * or undefined if not found.
+ */
+ getComponent(id) {
+ return this.componentData_[id] && this.componentData_[id].component;
+ }
+
+ /**
+ * Gets all the components with the specified capability.
+ * @param {string|!ComponentManager.Capability
+ * } capability The capability of the component.
+ * @param {boolean} sorted Whether to return list ordered by weights.
+ * @return {!Array} The components that match the specified capability.
+ * @template T
+ */
+ getComponents(capability, sorted) {
+ capability = String(capability).toLowerCase();
+ const componentIds = this.capabilityToComponentIds_[capability];
+ if (!componentIds) {
+ return [];
+ }
+ const components = [];
+ if (sorted) {
+ const componentDataList = [];
+ const componentData = this.componentData_;
+ componentIds.forEach(function(id) {
+ componentDataList.push(componentData[id]);
+ });
+ componentDataList.sort(function(a, b) {
+ return a.weight - b.weight;
+ });
+ componentDataList.forEach(function(ComponentDatum) {
+ components.push(ComponentDatum.component);
+ });
+ } else {
+ const componentData = this.componentData_;
+ componentIds.forEach(function(id) {
+ components.push(componentData[id].component);
+ });
+ }
+ return components;
+ }
+}
/**
* An object storing component information.
@@ -62,179 +217,31 @@ const ComponentManager = function() {
*/
ComponentManager.ComponentDatum;
-/**
- * Adds a component.
- * @param {!ComponentManager.ComponentDatum} componentInfo The data for
- * the component to register.
- * @param {boolean=} opt_allowOverrides True to prevent an error when overriding
- * an already registered item.
- */
-ComponentManager.prototype.addComponent = function(
- componentInfo, opt_allowOverrides) {
- // Don't throw an error if opt_allowOverrides is true.
- const id = componentInfo.component.id;
- if (!opt_allowOverrides && this.componentData_[id]) {
- throw Error(
- 'Plugin "' + id + '" with capabilities "' +
- this.componentData_[id].capabilities + '" already added.');
- }
- this.componentData_[id] = componentInfo;
- const stringCapabilities = [];
- for (let i = 0; i < componentInfo.capabilities.length; i++) {
- const capability = String(componentInfo.capabilities[i]).toLowerCase();
- stringCapabilities.push(capability);
- if (this.capabilityToComponentIds_[capability] === undefined) {
- this.capabilityToComponentIds_[capability] = [id];
- } else {
- this.capabilityToComponentIds_[capability].push(id);
- }
- }
- this.componentData_[id].capabilities = stringCapabilities;
-};
-
-/**
- * Removes a component.
- * @param {string} id The ID of the component to remove.
- */
-ComponentManager.prototype.removeComponent = function(id) {
- const componentInfo = this.componentData_[id];
- if (!componentInfo) {
- return;
- }
- for (let i = 0; i < componentInfo.capabilities.length; i++) {
- const capability = String(componentInfo.capabilities[i]).toLowerCase();
- arrayUtils.removeElem(this.capabilityToComponentIds_[capability], id);
- }
- delete this.componentData_[id];
-};
-
-/**
- * Adds a capability to a existing registered component.
- * @param {string} id The ID of the component to add the capability to.
- * @param {string|!ComponentManager.Capability} capability The
- * capability to add.
- * @template T
- */
-ComponentManager.prototype.addCapability = function(id, capability) {
- if (!this.getComponent(id)) {
- throw Error(
- 'Cannot add capability, "' + capability + '". Plugin "' + id +
- '" has not been added to the ComponentManager');
- }
- if (this.hasCapability(id, capability)) {
- console.warn(
- 'Plugin "' + id + 'already has capability "' + capability + '"');
- return;
- }
- capability = String(capability).toLowerCase();
- this.componentData_[id].capabilities.push(capability);
- this.capabilityToComponentIds_[capability].push(id);
-};
-
-/**
- * Removes a capability from an existing registered component.
- * @param {string} id The ID of the component to remove the capability from.
- * @param {string|!ComponentManager.Capability} capability The
- * capability to remove.
- * @template T
- */
-ComponentManager.prototype.removeCapability = function(id, capability) {
- if (!this.getComponent(id)) {
- throw Error(
- 'Cannot remove capability, "' + capability + '". Plugin "' + id +
- '" has not been added to the ComponentManager');
- }
- if (!this.hasCapability(id, capability)) {
- console.warn(
- 'Plugin "' + id + 'doesn\'t have capability "' + capability +
- '" to remove');
- return;
- }
- capability = String(capability).toLowerCase();
- arrayUtils.removeElem(this.componentData_[id].capabilities, capability);
- arrayUtils.removeElem(this.capabilityToComponentIds_[capability], id);
-};
-
-/**
- * Returns whether the component with this id has the specified capability.
- * @param {string} id The ID of the component to check.
- * @param {string|!ComponentManager.Capability} capability The
- * capability to check for.
- * @return {boolean} Whether the component has the capability.
- * @template T
- */
-ComponentManager.prototype.hasCapability = function(id, capability) {
- capability = String(capability).toLowerCase();
- return this.componentData_[id].capabilities.indexOf(capability) !== -1;
-};
-
-/**
- * Gets the component with the given ID.
- * @param {string} id The ID of the component to get.
- * @return {!IComponent|undefined} The component with the given name
- * or undefined if not found.
- */
-ComponentManager.prototype.getComponent = function(id) {
- return this.componentData_[id] && this.componentData_[id].component;
-};
-
-/**
- * Gets all the components with the specified capability.
- * @param {string|!ComponentManager.Capability
- * } capability The capability of the component.
- * @param {boolean} sorted Whether to return list ordered by weights.
- * @return {!Array} The components that match the specified capability.
- * @template T
- */
-ComponentManager.prototype.getComponents = function(capability, sorted) {
- capability = String(capability).toLowerCase();
- const componentIds = this.capabilityToComponentIds_[capability];
- if (!componentIds) {
- return [];
- }
- const components = [];
- if (sorted) {
- const componentDataList = [];
- const componentData = this.componentData_;
- componentIds.forEach(function(id) {
- componentDataList.push(componentData[id]);
- });
- componentDataList.sort(function(a, b) {
- return a.weight - b.weight;
- });
- componentDataList.forEach(function(ComponentDatum) {
- components.push(ComponentDatum.component);
- });
- } else {
- const componentData = this.componentData_;
- componentIds.forEach(function(id) {
- components.push(componentData[id].component);
- });
- }
- return components;
-};
-
/**
* A name with the capability of the element stored in the generic.
- * @param {string} name The name of the component capability.
- * @constructor
* @template T
+ * @alias Blockly.ComponentManager.Capability
*/
-ComponentManager.Capability = function(name) {
+ComponentManager.Capability = class {
/**
- * @type {string}
- * @private
+ * @param {string} name The name of the component capability.
*/
- this.name_ = name;
-};
+ constructor(name) {
+ /**
+ * @type {string}
+ * @private
+ */
+ this.name_ = name;
+ }
-/**
- * Returns the name of the capability.
- * @return {string} The name.
- * @override
- */
-ComponentManager.Capability.prototype.toString = function() {
- return this.name_;
+ /**
+ * Returns the name of the capability.
+ * @return {string} The name.
+ * @override
+ */
+ toString() {
+ return this.name_;
+ }
};
/** @type {!ComponentManager.Capability} */
diff --git a/core/config.js b/core/config.js
new file mode 100644
index 000000000..b211ebe5f
--- /dev/null
+++ b/core/config.js
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview All the values that we expect developers to be able to change
+ * before injecting Blockly. Changing these values during run time is not
+ * generally recommended.
+ */
+'use strict';
+
+/**
+ * All the values that we expect developers to be able to change
+ * before injecting Blockly. Changing these values during run time is not
+ * generally recommended.
+ * @namespace Blockly.config
+ */
+goog.module('Blockly.config');
+
+
+/**
+ * All the values that we expect developers to be able to change
+ * before injecting Blockly.
+ * @typedef {{
+ * dragRadius: number,
+ * flyoutDragRadius: number,
+ * snapRadius: number,
+ * currentConnectionPreference: number,
+ * bumpDelay: number,
+ * connectingSnapRadius: number
+ * }}
+ */
+let Config; // eslint-disable-line no-unused-vars
+
+/**
+ * Default snap radius.
+ * @type {number}
+ */
+const DEFAULT_SNAP_RADIUS = 28;
+
+/**
+ * Object holding all the values on Blockly that we expect developers to be
+ * able to change.
+ * @type {Config}
+ */
+const config = {
+ /**
+ * Number of pixels the mouse must move before a drag starts.
+ * @alias Blockly.config.dragRadius
+ */
+ dragRadius: 5,
+ /**
+ * Number of pixels the mouse must move before a drag/scroll starts from the
+ * flyout. Because the drag-intention is determined when this is reached, it
+ * is larger than dragRadius so that the drag-direction is clearer.
+ * @alias Blockly.config.flyoutDragRadius
+ */
+ flyoutDragRadius: 10,
+ /**
+ * Maximum misalignment between connections for them to snap together.
+ * @alias Blockly.config.snapRadius
+ */
+ snapRadius: DEFAULT_SNAP_RADIUS,
+ /**
+ * Maximum misalignment between connections for them to snap together.
+ * This should be the same as the snap radius.
+ * @alias Blockly.config.connectingSnapRadius
+ */
+ connectingSnapRadius: DEFAULT_SNAP_RADIUS,
+ /**
+ * How much to prefer staying connected to the current connection over moving
+ * to a new connection. The current previewed connection is considered to be
+ * this much closer to the matching connection on the block than it actually
+ * is.
+ * @alias Blockly.config.currentConnectionPreference
+ */
+ currentConnectionPreference: 8,
+ /**
+ * Delay in ms between trigger and bumping unconnected block out of alignment.
+ * @alias Blockly.config.bumpDelay
+ */
+ bumpDelay: 250,
+};
+
+exports.config = config;
diff --git a/core/connection.js b/core/connection.js
index 25f31bbe5..e12070e11 100644
--- a/core/connection.js
+++ b/core/connection.js
@@ -20,6 +20,8 @@ const blocks = goog.require('Blockly.serialization.blocks');
const eventUtils = goog.require('Blockly.Events.utils');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockMove} = goog.requireType('Blockly.Events.BlockMove');
const {ConnectionType} = goog.require('Blockly.ConnectionType');
/* eslint-disable-next-line no-unused-vars */
const {IASTNodeLocationWithBlock} = goog.require('Blockly.IASTNodeLocationWithBlock');
@@ -35,21 +37,629 @@ goog.require('Blockly.constants');
/**
* Class for a connection between blocks.
- * @param {!Block} source The block establishing this connection.
- * @param {number} type The type of the connection.
- * @constructor
* @implements {IASTNodeLocationWithBlock}
* @alias Blockly.Connection
*/
-const Connection = function(source, type) {
+class Connection {
/**
- * @type {!Block}
+ * @param {!Block} source The block establishing this connection.
+ * @param {number} type The type of the connection.
+ */
+ constructor(source, type) {
+ /**
+ * @type {!Block}
+ * @protected
+ */
+ this.sourceBlock_ = source;
+ /** @type {number} */
+ this.type = type;
+
+ /**
+ * Connection this connection connects to. Null if not connected.
+ * @type {Connection}
+ */
+ this.targetConnection = null;
+
+ /**
+ * Has this connection been disposed of?
+ * @type {boolean}
+ * @package
+ */
+ this.disposed = false;
+
+ /**
+ * List of compatible value types. Null if all types are compatible.
+ * @type {Array}
+ * @private
+ */
+ this.check_ = null;
+
+ /**
+ * DOM representation of a shadow block, or null if none.
+ * @type {Element}
+ * @private
+ */
+ this.shadowDom_ = null;
+
+ /**
+ * Horizontal location of this connection.
+ * @type {number}
+ * @package
+ */
+ this.x = 0;
+
+ /**
+ * Vertical location of this connection.
+ * @type {number}
+ * @package
+ */
+ this.y = 0;
+
+ /**
+ * @type {?blocks.State}
+ * @private
+ */
+ this.shadowState_ = null;
+ }
+
+ /**
+ * Connect two connections together. This is the connection on the superior
+ * block.
+ * @param {!Connection} childConnection Connection on inferior block.
* @protected
*/
- this.sourceBlock_ = source;
- /** @type {number} */
- this.type = type;
-};
+ connect_(childConnection) {
+ const INPUT = ConnectionType.INPUT_VALUE;
+ const parentConnection = this;
+ const parentBlock = parentConnection.getSourceBlock();
+ const childBlock = childConnection.getSourceBlock();
+
+ // Make sure the childConnection is available.
+ if (childConnection.isConnected()) {
+ childConnection.disconnect();
+ }
+
+ // Make sure the parentConnection is available.
+ let orphan;
+ if (parentConnection.isConnected()) {
+ const shadowState = parentConnection.stashShadowState_();
+ const target = parentConnection.targetBlock();
+ if (target.isShadow()) {
+ target.dispose(false);
+ } else {
+ parentConnection.disconnect();
+ orphan = target;
+ }
+ parentConnection.applyShadowState_(shadowState);
+ }
+
+ // Connect the new connection to the parent.
+ let event;
+ if (eventUtils.isEnabled()) {
+ event = /** @type {!BlockMove} */
+ (new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock));
+ }
+ connectReciprocally(parentConnection, childConnection);
+ childBlock.setParent(parentBlock);
+ if (event) {
+ event.recordNew();
+ eventUtils.fire(event);
+ }
+
+ // Deal with the orphan if it exists.
+ if (orphan) {
+ const orphanConnection = parentConnection.type === INPUT ?
+ orphan.outputConnection :
+ orphan.previousConnection;
+ const connection = Connection.getConnectionForOrphanedConnection(
+ childBlock, /** @type {!Connection} */ (orphanConnection));
+ if (connection) {
+ orphanConnection.connect(connection);
+ } else {
+ orphanConnection.onFailedConnect(parentConnection);
+ }
+ }
+ }
+
+ /**
+ * Dispose of this connection and deal with connected blocks.
+ * @package
+ */
+ dispose() {
+ // isConnected returns true for shadows and non-shadows.
+ if (this.isConnected()) {
+ // Destroy the attached shadow block & its children (if it exists).
+ this.setShadowStateInternal_();
+
+ const targetBlock = this.targetBlock();
+ if (targetBlock) {
+ // Disconnect the attached normal block.
+ targetBlock.unplug();
+ }
+ }
+
+ this.disposed = true;
+ }
+
+ /**
+ * Get the source block for this connection.
+ * @return {!Block} The source block.
+ */
+ getSourceBlock() {
+ return this.sourceBlock_;
+ }
+
+ /**
+ * Does the connection belong to a superior block (higher in the source
+ * stack)?
+ * @return {boolean} True if connection faces down or right.
+ */
+ isSuperior() {
+ return this.type === ConnectionType.INPUT_VALUE ||
+ this.type === ConnectionType.NEXT_STATEMENT;
+ }
+
+ /**
+ * Is the connection connected?
+ * @return {boolean} True if connection is connected to another connection.
+ */
+ isConnected() {
+ return !!this.targetConnection;
+ }
+
+ /**
+ * Get the workspace's connection type checker object.
+ * @return {!IConnectionChecker} The connection type checker for the
+ * source block's workspace.
+ * @package
+ */
+ getConnectionChecker() {
+ return this.sourceBlock_.workspace.connectionChecker;
+ }
+
+ /**
+ * Called when an attempted connection fails. NOP by default (i.e. for
+ * headless workspaces).
+ * @param {!Connection} _otherConnection Connection that this connection
+ * failed to connect to.
+ * @package
+ */
+ onFailedConnect(_otherConnection) {
+ // NOP
+ }
+
+ /**
+ * Connect this connection to another connection.
+ * @param {!Connection} otherConnection Connection to connect to.
+ * @return {boolean} Whether the the blocks are now connected or not.
+ */
+ connect(otherConnection) {
+ if (this.targetConnection === otherConnection) {
+ // Already connected together. NOP.
+ return true;
+ }
+
+ const checker = this.getConnectionChecker();
+ if (checker.canConnect(this, otherConnection, false)) {
+ const eventGroup = eventUtils.getGroup();
+ if (!eventGroup) {
+ eventUtils.setGroup(true);
+ }
+ // Determine which block is superior (higher in the source stack).
+ if (this.isSuperior()) {
+ // Superior block.
+ this.connect_(otherConnection);
+ } else {
+ // Inferior block.
+ otherConnection.connect_(this);
+ }
+ if (!eventGroup) {
+ eventUtils.setGroup(false);
+ }
+ }
+
+ return this.isConnected();
+ }
+
+ /**
+ * Disconnect this connection.
+ */
+ disconnect() {
+ const otherConnection = this.targetConnection;
+ if (!otherConnection) {
+ throw Error('Source connection not connected.');
+ }
+ if (otherConnection.targetConnection !== this) {
+ throw Error('Target connection not connected to source connection.');
+ }
+ let parentBlock;
+ let childBlock;
+ let parentConnection;
+ if (this.isSuperior()) {
+ // Superior block.
+ parentBlock = this.sourceBlock_;
+ childBlock = otherConnection.getSourceBlock();
+ parentConnection = this;
+ } else {
+ // Inferior block.
+ parentBlock = otherConnection.getSourceBlock();
+ childBlock = this.sourceBlock_;
+ parentConnection = otherConnection;
+ }
+
+ const eventGroup = eventUtils.getGroup();
+ if (!eventGroup) {
+ eventUtils.setGroup(true);
+ }
+ this.disconnectInternal_(parentBlock, childBlock);
+ if (!childBlock.isShadow()) {
+ // If we were disconnecting a shadow, no need to spawn a new one.
+ parentConnection.respawnShadow_();
+ }
+ if (!eventGroup) {
+ eventUtils.setGroup(false);
+ }
+ }
+
+ /**
+ * Disconnect two blocks that are connected by this connection.
+ * @param {!Block} parentBlock The superior block.
+ * @param {!Block} childBlock The inferior block.
+ * @protected
+ */
+ disconnectInternal_(parentBlock, childBlock) {
+ let event;
+ if (eventUtils.isEnabled()) {
+ event = /** @type {!BlockMove} */
+ (new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock));
+ }
+ const otherConnection = this.targetConnection;
+ otherConnection.targetConnection = null;
+ this.targetConnection = null;
+ childBlock.setParent(null);
+ if (event) {
+ event.recordNew();
+ eventUtils.fire(event);
+ }
+ }
+
+ /**
+ * Respawn the shadow block if there was one connected to the this connection.
+ * @protected
+ */
+ respawnShadow_() {
+ // Have to keep respawnShadow_ for backwards compatibility.
+ this.createShadowBlock_(true);
+ }
+
+ /**
+ * Returns the block that this connection connects to.
+ * @return {?Block} The connected block or null if none is connected.
+ */
+ targetBlock() {
+ if (this.isConnected()) {
+ return this.targetConnection.getSourceBlock();
+ }
+ return null;
+ }
+
+ /**
+ * Function to be called when this connection's compatible types have changed.
+ * @protected
+ */
+ onCheckChanged_() {
+ // The new value type may not be compatible with the existing connection.
+ if (this.isConnected() &&
+ (!this.targetConnection ||
+ !this.getConnectionChecker().canConnect(
+ this, this.targetConnection, false))) {
+ const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_;
+ child.unplug();
+ }
+ }
+
+ /**
+ * Change a connection's compatibility.
+ * @param {?(string|!Array)} check Compatible value type or list of
+ * value types. Null if all types are compatible.
+ * @return {!Connection} The connection being modified
+ * (to allow chaining).
+ */
+ setCheck(check) {
+ if (check) {
+ // Ensure that check is in an array.
+ if (!Array.isArray(check)) {
+ check = [check];
+ }
+ this.check_ = check;
+ this.onCheckChanged_();
+ } else {
+ this.check_ = null;
+ }
+ return this;
+ }
+
+ /**
+ * Get a connection's compatibility.
+ * @return {?Array} List of compatible value types.
+ * Null if all types are compatible.
+ * @public
+ */
+ getCheck() {
+ return this.check_;
+ }
+
+ /**
+ * Changes the connection's shadow block.
+ * @param {?Element} shadowDom DOM representation of a block or null.
+ */
+ setShadowDom(shadowDom) {
+ this.setShadowStateInternal_({shadowDom: shadowDom});
+ }
+
+ /**
+ * Returns the xml representation of the connection's shadow block.
+ * @param {boolean=} returnCurrent If true, and the shadow block is currently
+ * attached to this connection, this serializes the state of that block
+ * and returns it (so that field values are correct). Otherwise the saved
+ * shadowDom is just returned.
+ * @return {?Element} Shadow DOM representation of a block or null.
+ */
+ getShadowDom(returnCurrent) {
+ return (returnCurrent && this.targetBlock().isShadow()) ?
+ /** @type {!Element} */ (Xml.blockToDom(
+ /** @type {!Block} */ (this.targetBlock()))) :
+ this.shadowDom_;
+ }
+
+ /**
+ * Changes the connection's shadow block.
+ * @param {?blocks.State} shadowState An state represetation of the block or
+ * null.
+ */
+ setShadowState(shadowState) {
+ this.setShadowStateInternal_({shadowState: shadowState});
+ }
+
+ /**
+ * Returns the serialized object representation of the connection's shadow
+ * block.
+ * @param {boolean=} returnCurrent If true, and the shadow block is currently
+ * attached to this connection, this serializes the state of that block
+ * and returns it (so that field values are correct). Otherwise the saved
+ * state is just returned.
+ * @return {?blocks.State} Serialized object representation of the block, or
+ * null.
+ */
+ getShadowState(returnCurrent) {
+ if (returnCurrent && this.targetBlock() && this.targetBlock().isShadow()) {
+ return blocks.save(/** @type {!Block} */ (this.targetBlock()));
+ }
+ return this.shadowState_;
+ }
+
+ /**
+ * Find all nearby compatible connections to this connection.
+ * Type checking does not apply, since this function is used for bumping.
+ *
+ * Headless configurations (the default) do not have neighboring connection,
+ * and always return an empty list (the default).
+ * {@link Blockly.RenderedConnection} overrides this behavior with a list
+ * computed from the rendered positioning.
+ * @param {number} _maxLimit The maximum radius to another connection.
+ * @return {!Array} List of connections.
+ * @package
+ */
+ neighbours(_maxLimit) {
+ return [];
+ }
+
+ /**
+ * Get the parent input of a connection.
+ * @return {?Input} The input that the connection belongs to or null if
+ * no parent exists.
+ * @package
+ */
+ getParentInput() {
+ let parentInput = null;
+ const inputs = this.sourceBlock_.inputList;
+ for (let i = 0; i < inputs.length; i++) {
+ if (inputs[i].connection === this) {
+ parentInput = inputs[i];
+ break;
+ }
+ }
+ return parentInput;
+ }
+
+ /**
+ * This method returns a string describing this Connection in developer terms
+ * (English only). Intended to on be used in console logs and errors.
+ * @return {string} The description.
+ */
+ toString() {
+ const block = this.sourceBlock_;
+ if (!block) {
+ return 'Orphan Connection';
+ }
+ let msg;
+ if (block.outputConnection === this) {
+ msg = 'Output Connection of ';
+ } else if (block.previousConnection === this) {
+ msg = 'Previous Connection of ';
+ } else if (block.nextConnection === this) {
+ msg = 'Next Connection of ';
+ } else {
+ let parentInput = null;
+ for (let i = 0, input; (input = block.inputList[i]); i++) {
+ if (input.connection === this) {
+ parentInput = input;
+ break;
+ }
+ }
+ if (parentInput) {
+ msg = 'Input "' + parentInput.name + '" connection on ';
+ } else {
+ console.warn('Connection not actually connected to sourceBlock_');
+ return 'Orphan Connection';
+ }
+ }
+ return msg + block.toDevString();
+ }
+
+ /**
+ * Returns the state of the shadowDom_ and shadowState_ properties, then
+ * temporarily sets those properties to null so no shadow respawns.
+ * @return {{shadowDom: ?Element, shadowState: ?blocks.State}} The state of
+ * both the shadowDom_ and shadowState_ properties.
+ * @private
+ */
+ stashShadowState_() {
+ const shadowDom = this.getShadowDom(true);
+ const shadowState = this.getShadowState(true);
+ // Set to null so it doesn't respawn.
+ this.shadowDom_ = null;
+ this.shadowState_ = null;
+ return {shadowDom, shadowState};
+ }
+
+ /**
+ * Reapplies the stashed state of the shadowDom_ and shadowState_ properties.
+ * @param {{shadowDom: ?Element, shadowState: ?blocks.State}} param0 The state
+ * to reapply to the shadowDom_ and shadowState_ properties.
+ * @private
+ */
+ applyShadowState_({shadowDom, shadowState}) {
+ this.shadowDom_ = shadowDom;
+ this.shadowState_ = shadowState;
+ }
+
+ /**
+ * Sets the state of the shadow of this connection.
+ * @param {{shadowDom: (?Element|undefined), shadowState:
+ * (?blocks.State|undefined)}=} param0 The state to set the shadow of this
+ * connection to.
+ * @private
+ */
+ setShadowStateInternal_({shadowDom = null, shadowState = null} = {}) {
+ // One or both of these should always be null.
+ // If neither is null, the shadowState will get priority.
+ this.shadowDom_ = shadowDom;
+ this.shadowState_ = shadowState;
+
+ const target = this.targetBlock();
+ if (!target) {
+ this.respawnShadow_();
+ if (this.targetBlock() && this.targetBlock().isShadow()) {
+ this.serializeShadow_(this.targetBlock());
+ }
+ } else if (target.isShadow()) {
+ target.dispose(false);
+ this.respawnShadow_();
+ if (this.targetBlock() && this.targetBlock().isShadow()) {
+ this.serializeShadow_(this.targetBlock());
+ }
+ } else {
+ const shadow = this.createShadowBlock_(false);
+ this.serializeShadow_(shadow);
+ if (shadow) {
+ shadow.dispose(false);
+ }
+ }
+ }
+
+ /**
+ * Creates a shadow block based on the current shadowState_ or shadowDom_.
+ * shadowState_ gets priority.
+ * @param {boolean} attemptToConnect Whether to try to connect the shadow
+ * block to this connection or not.
+ * @return {?Block} The shadow block that was created, or null if both the
+ * shadowState_ and shadowDom_ are null.
+ * @private
+ */
+ createShadowBlock_(attemptToConnect) {
+ const parentBlock = this.getSourceBlock();
+ const shadowState = this.getShadowState();
+ const shadowDom = this.getShadowDom();
+ if (!parentBlock.workspace || (!shadowState && !shadowDom)) {
+ return null;
+ }
+
+ let blockShadow;
+ if (shadowState) {
+ blockShadow = blocks.appendInternal(shadowState, parentBlock.workspace, {
+ parentConnection: attemptToConnect ? this : undefined,
+ isShadow: true,
+ recordUndo: false,
+ });
+ return blockShadow;
+ }
+
+ if (shadowDom) {
+ blockShadow = Xml.domToBlock(shadowDom, parentBlock.workspace);
+ if (attemptToConnect) {
+ if (this.type === ConnectionType.INPUT_VALUE) {
+ if (!blockShadow.outputConnection) {
+ throw new Error('Shadow block is missing an output connection');
+ }
+ if (!this.connect(blockShadow.outputConnection)) {
+ throw new Error('Could not connect shadow block to connection');
+ }
+ } else if (this.type === ConnectionType.NEXT_STATEMENT) {
+ if (!blockShadow.previousConnection) {
+ throw new Error('Shadow block is missing previous connection');
+ }
+ if (!this.connect(blockShadow.previousConnection)) {
+ throw new Error('Could not connect shadow block to connection');
+ }
+ } else {
+ throw new Error(
+ 'Cannot connect a shadow block to a previous/output connection');
+ }
+ }
+ return blockShadow;
+ }
+ return null;
+ }
+
+ /**
+ * Saves the given shadow block to both the shadowDom_ and shadowState_
+ * properties, in their respective serialized forms.
+ * @param {?Block} shadow The shadow to serialize, or null.
+ * @private
+ */
+ serializeShadow_(shadow) {
+ if (!shadow) {
+ return;
+ }
+ this.shadowDom_ = /** @type {!Element} */ (Xml.blockToDom(shadow));
+ this.shadowState_ = blocks.save(shadow);
+ }
+
+ /**
+ * Returns the connection (starting at the startBlock) which will accept
+ * the given connection. This includes compatible connection types and
+ * connection checks.
+ * @param {!Block} startBlock The block on which to start the search.
+ * @param {!Connection} orphanConnection The connection that is looking
+ * for a home.
+ * @return {?Connection} The suitable connection point on the chain of
+ * blocks, or null.
+ */
+ static getConnectionForOrphanedConnection(startBlock, orphanConnection) {
+ if (orphanConnection.type === ConnectionType.OUTPUT_VALUE) {
+ return getConnectionForOrphanedOutput(
+ startBlock, orphanConnection.getSourceBlock());
+ }
+ // Otherwise we're dealing with a stack.
+ const connection = startBlock.lastConnectionInStack(true);
+ const checker = orphanConnection.getConnectionChecker();
+ if (connection && checker.canConnect(orphanConnection, connection, false)) {
+ return connection;
+ }
+ return null;
+ }
+}
/**
* Constants for checking whether two connections are compatible.
@@ -64,205 +674,6 @@ Connection.REASON_SHADOW_PARENT = 6;
Connection.REASON_DRAG_CHECKS_FAILED = 7;
Connection.REASON_PREVIOUS_AND_OUTPUT = 8;
-/**
- * Connection this connection connects to. Null if not connected.
- * @type {Connection}
- */
-Connection.prototype.targetConnection = null;
-
-/**
- * Has this connection been disposed of?
- * @type {boolean}
- * @package
- */
-Connection.prototype.disposed = false;
-
-/**
- * List of compatible value types. Null if all types are compatible.
- * @type {Array}
- * @private
- */
-Connection.prototype.check_ = null;
-
-/**
- * DOM representation of a shadow block, or null if none.
- * @type {Element}
- * @private
- */
-Connection.prototype.shadowDom_ = null;
-
-/**
- * Horizontal location of this connection.
- * @type {number}
- * @package
- */
-Connection.prototype.x = 0;
-
-/**
- * Vertical location of this connection.
- * @type {number}
- * @package
- */
-Connection.prototype.y = 0;
-
-/**
- * Connect two connections together. This is the connection on the superior
- * block.
- * @param {!Connection} childConnection Connection on inferior block.
- * @protected
- */
-Connection.prototype.connect_ = function(childConnection) {
- const INPUT = ConnectionType.INPUT_VALUE;
- const parentConnection = this;
- const parentBlock = parentConnection.getSourceBlock();
- const childBlock = childConnection.getSourceBlock();
-
- // Make sure the childConnection is available.
- if (childConnection.isConnected()) {
- childConnection.disconnect();
- }
-
- // Make sure the parentConnection is available.
- let orphan;
- if (parentConnection.isConnected()) {
- const shadowState = parentConnection.stashShadowState_();
- const target = parentConnection.targetBlock();
- if (target.isShadow()) {
- target.dispose(false);
- } else {
- parentConnection.disconnect();
- orphan = target;
- }
- parentConnection.applyShadowState_(shadowState);
- }
-
- // Connect the new connection to the parent.
- let event;
- if (eventUtils.isEnabled()) {
- event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock);
- }
- connectReciprocally(parentConnection, childConnection);
- childBlock.setParent(parentBlock);
- if (event) {
- event.recordNew();
- eventUtils.fire(event);
- }
-
- // Deal with the orphan if it exists.
- if (orphan) {
- const orphanConnection = parentConnection.type === INPUT ?
- orphan.outputConnection :
- orphan.previousConnection;
- const connection = Connection.getConnectionForOrphanedConnection(
- childBlock, /** @type {!Connection} */ (orphanConnection));
- if (connection) {
- orphanConnection.connect(connection);
- } else {
- orphanConnection.onFailedConnect(parentConnection);
- }
- }
-};
-
-
-/**
- * Dispose of this connection and deal with connected blocks.
- * @package
- */
-Connection.prototype.dispose = function() {
- // isConnected returns true for shadows and non-shadows.
- if (this.isConnected()) {
- // Destroy the attached shadow block & its children (if it exists).
- this.setShadowStateInternal_();
-
- const targetBlock = this.targetBlock();
- if (targetBlock) {
- // Disconnect the attached normal block.
- targetBlock.unplug();
- }
- }
-
- this.disposed = true;
-};
-
-/**
- * Get the source block for this connection.
- * @return {!Block} The source block.
- */
-Connection.prototype.getSourceBlock = function() {
- return this.sourceBlock_;
-};
-
-/**
- * Does the connection belong to a superior block (higher in the source stack)?
- * @return {boolean} True if connection faces down or right.
- */
-Connection.prototype.isSuperior = function() {
- return this.type === ConnectionType.INPUT_VALUE ||
- this.type === ConnectionType.NEXT_STATEMENT;
-};
-
-/**
- * Is the connection connected?
- * @return {boolean} True if connection is connected to another connection.
- */
-Connection.prototype.isConnected = function() {
- return !!this.targetConnection;
-};
-
-/**
- * Get the workspace's connection type checker object.
- * @return {!IConnectionChecker} The connection type checker for the
- * source block's workspace.
- * @package
- */
-Connection.prototype.getConnectionChecker = function() {
- return this.sourceBlock_.workspace.connectionChecker;
-};
-
-/**
- * Called when an attempted connection fails. NOP by default (i.e. for headless
- * workspaces).
- * @param {!Connection} _otherConnection Connection that this connection
- * failed to connect to.
- * @package
- */
-Connection.prototype.onFailedConnect = function(_otherConnection) {
- // NOP
-};
-
-/**
- * Connect this connection to another connection.
- * @param {!Connection} otherConnection Connection to connect to.
- * @return {boolean} Whether the the blocks are now connected or not.
- */
-Connection.prototype.connect = function(otherConnection) {
- if (this.targetConnection === otherConnection) {
- // Already connected together. NOP.
- return true;
- }
-
- const checker = this.getConnectionChecker();
- if (checker.canConnect(this, otherConnection, false)) {
- const eventGroup = eventUtils.getGroup();
- if (!eventGroup) {
- eventUtils.setGroup(true);
- }
- // Determine which block is superior (higher in the source stack).
- if (this.isSuperior()) {
- // Superior block.
- this.connect_(otherConnection);
- } else {
- // Inferior block.
- otherConnection.connect_(this);
- }
- if (!eventGroup) {
- eventUtils.setGroup(false);
- }
- }
-
- return this.isConnected();
-};
-
/**
* Update two connections to target each other.
* @param {Connection} first The first connection to update.
@@ -328,404 +739,4 @@ const getConnectionForOrphanedOutput = function(startBlock, orphanBlock) {
return null;
};
-/**
- * Returns the connection (starting at the startBlock) which will accept
- * the given connection. This includes compatible connection types and
- * connection checks.
- * @param {!Block} startBlock The block on which to start the search.
- * @param {!Connection} orphanConnection The connection that is looking
- * for a home.
- * @return {?Connection} The suitable connection point on the chain of
- * blocks, or null.
- */
-Connection.getConnectionForOrphanedConnection = function(
- startBlock, orphanConnection) {
- if (orphanConnection.type === ConnectionType.OUTPUT_VALUE) {
- return getConnectionForOrphanedOutput(
- startBlock, orphanConnection.getSourceBlock());
- }
- // Otherwise we're dealing with a stack.
- const connection = startBlock.lastConnectionInStack(true);
- const checker = orphanConnection.getConnectionChecker();
- if (connection && checker.canConnect(orphanConnection, connection, false)) {
- return connection;
- }
- return null;
-};
-
-/**
- * Disconnect this connection.
- */
-Connection.prototype.disconnect = function() {
- const otherConnection = this.targetConnection;
- if (!otherConnection) {
- throw Error('Source connection not connected.');
- }
- if (otherConnection.targetConnection !== this) {
- throw Error('Target connection not connected to source connection.');
- }
- let parentBlock;
- let childBlock;
- let parentConnection;
- if (this.isSuperior()) {
- // Superior block.
- parentBlock = this.sourceBlock_;
- childBlock = otherConnection.getSourceBlock();
- parentConnection = this;
- } else {
- // Inferior block.
- parentBlock = otherConnection.getSourceBlock();
- childBlock = this.sourceBlock_;
- parentConnection = otherConnection;
- }
-
- const eventGroup = eventUtils.getGroup();
- if (!eventGroup) {
- eventUtils.setGroup(true);
- }
- this.disconnectInternal_(parentBlock, childBlock);
- if (!childBlock.isShadow()) {
- // If we were disconnecting a shadow, no need to spawn a new one.
- parentConnection.respawnShadow_();
- }
- if (!eventGroup) {
- eventUtils.setGroup(false);
- }
-};
-
-/**
- * Disconnect two blocks that are connected by this connection.
- * @param {!Block} parentBlock The superior block.
- * @param {!Block} childBlock The inferior block.
- * @protected
- */
-Connection.prototype.disconnectInternal_ = function(parentBlock, childBlock) {
- let event;
- if (eventUtils.isEnabled()) {
- event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock);
- }
- const otherConnection = this.targetConnection;
- otherConnection.targetConnection = null;
- this.targetConnection = null;
- childBlock.setParent(null);
- if (event) {
- event.recordNew();
- eventUtils.fire(event);
- }
-};
-
-/**
- * Respawn the shadow block if there was one connected to the this connection.
- * @protected
- */
-Connection.prototype.respawnShadow_ = function() {
- // Have to keep respawnShadow_ for backwards compatibility.
- this.createShadowBlock_(true);
-};
-
-/**
- * Returns the block that this connection connects to.
- * @return {?Block} The connected block or null if none is connected.
- */
-Connection.prototype.targetBlock = function() {
- if (this.isConnected()) {
- return this.targetConnection.getSourceBlock();
- }
- return null;
-};
-
-/**
- * Function to be called when this connection's compatible types have changed.
- * @protected
- */
-Connection.prototype.onCheckChanged_ = function() {
- // The new value type may not be compatible with the existing connection.
- if (this.isConnected() &&
- (!this.targetConnection ||
- !this.getConnectionChecker().canConnect(
- this, this.targetConnection, false))) {
- const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_;
- child.unplug();
- }
-};
-
-/**
- * Change a connection's compatibility.
- * @param {?(string|!Array)} check Compatible value type or list of
- * value types. Null if all types are compatible.
- * @return {!Connection} The connection being modified
- * (to allow chaining).
- */
-Connection.prototype.setCheck = function(check) {
- if (check) {
- // Ensure that check is in an array.
- if (!Array.isArray(check)) {
- check = [check];
- }
- this.check_ = check;
- this.onCheckChanged_();
- } else {
- this.check_ = null;
- }
- return this;
-};
-
-/**
- * Get a connection's compatibility.
- * @return {?Array} List of compatible value types.
- * Null if all types are compatible.
- * @public
- */
-Connection.prototype.getCheck = function() {
- return this.check_;
-};
-
-/**
- * Changes the connection's shadow block.
- * @param {?Element} shadowDom DOM representation of a block or null.
- */
-Connection.prototype.setShadowDom = function(shadowDom) {
- this.setShadowStateInternal_({shadowDom: shadowDom});
-};
-
-/**
- * Returns the xml representation of the connection's shadow block.
- * @param {boolean=} returnCurrent If true, and the shadow block is currently
- * attached to this connection, this serializes the state of that block
- * and returns it (so that field values are correct). Otherwise the saved
- * shadowDom is just returned.
- * @return {?Element} Shadow DOM representation of a block or null.
- */
-Connection.prototype.getShadowDom = function(returnCurrent) {
- return (returnCurrent && this.targetBlock().isShadow()) ?
- /** @type {!Element} */ (Xml.blockToDom(
- /** @type {!Block} */ (this.targetBlock()))) :
- this.shadowDom_;
-};
-
-/**
- * Changes the connection's shadow block.
- * @param {?blocks.State} shadowState An state represetation of the block or
- * null.
- */
-Connection.prototype.setShadowState = function(shadowState) {
- this.setShadowStateInternal_({shadowState: shadowState});
-};
-
-/**
- * Returns the serialized object representation of the connection's shadow
- * block.
- * @param {boolean=} returnCurrent If true, and the shadow block is currently
- * attached to this connection, this serializes the state of that block
- * and returns it (so that field values are correct). Otherwise the saved
- * state is just returned.
- * @return {?blocks.State} Serialized object representation of the block, or
- * null.
- */
-Connection.prototype.getShadowState = function(returnCurrent) {
- if (returnCurrent && this.targetBlock() && this.targetBlock().isShadow()) {
- return blocks.save(/** @type {!Block} */ (this.targetBlock()));
- }
- return this.shadowState_;
-};
-
-/**
- * Find all nearby compatible connections to this connection.
- * Type checking does not apply, since this function is used for bumping.
- *
- * Headless configurations (the default) do not have neighboring connection,
- * and always return an empty list (the default).
- * {@link Blockly.RenderedConnection} overrides this behavior with a list
- * computed from the rendered positioning.
- * @param {number} _maxLimit The maximum radius to another connection.
- * @return {!Array} List of connections.
- * @package
- */
-Connection.prototype.neighbours = function(_maxLimit) {
- return [];
-};
-
-/**
- * Get the parent input of a connection.
- * @return {?Input} The input that the connection belongs to or null if
- * no parent exists.
- * @package
- */
-Connection.prototype.getParentInput = function() {
- let parentInput = null;
- const inputs = this.sourceBlock_.inputList;
- for (let i = 0; i < inputs.length; i++) {
- if (inputs[i].connection === this) {
- parentInput = inputs[i];
- break;
- }
- }
- return parentInput;
-};
-
-/**
- * This method returns a string describing this Connection in developer terms
- * (English only). Intended to on be used in console logs and errors.
- * @return {string} The description.
- */
-Connection.prototype.toString = function() {
- const block = this.sourceBlock_;
- if (!block) {
- return 'Orphan Connection';
- }
- let msg;
- if (block.outputConnection === this) {
- msg = 'Output Connection of ';
- } else if (block.previousConnection === this) {
- msg = 'Previous Connection of ';
- } else if (block.nextConnection === this) {
- msg = 'Next Connection of ';
- } else {
- let parentInput = null;
- for (let i = 0, input; (input = block.inputList[i]); i++) {
- if (input.connection === this) {
- parentInput = input;
- break;
- }
- }
- if (parentInput) {
- msg = 'Input "' + parentInput.name + '" connection on ';
- } else {
- console.warn('Connection not actually connected to sourceBlock_');
- return 'Orphan Connection';
- }
- }
- return msg + block.toDevString();
-};
-
-/**
- * Returns the state of the shadowDom_ and shadowState_ properties, then
- * temporarily sets those properties to null so no shadow respawns.
- * @return {{shadowDom: ?Element, shadowState: ?blocks.State}} The state of both
- * the shadowDom_ and shadowState_ properties.
- * @private
- */
-Connection.prototype.stashShadowState_ = function() {
- const shadowDom = this.getShadowDom(true);
- const shadowState = this.getShadowState(true);
- // Set to null so it doesn't respawn.
- this.shadowDom_ = null;
- this.shadowState_ = null;
- return {shadowDom, shadowState};
-};
-
-/**
- * Reapplies the stashed state of the shadowDom_ and shadowState_ properties.
- * @param {{shadowDom: ?Element, shadowState: ?blocks.State}} param0 The state
- * to reapply to the shadowDom_ and shadowState_ properties.
- * @private
- */
-Connection.prototype.applyShadowState_ = function({shadowDom, shadowState}) {
- this.shadowDom_ = shadowDom;
- this.shadowState_ = shadowState;
-};
-
-/**
- * Sets the state of the shadow of this connection.
- * @param {{shadowDom: (?Element|undefined), shadowState:
- * (?blocks.State|undefined)}=} param0 The state to set the shadow of this
- * connection to.
- * @private
- */
-Connection.prototype.setShadowStateInternal_ = function(
- {shadowDom = null, shadowState = null} = {}) {
- // One or both of these should always be null.
- // If neither is null, the shadowState will get priority.
- this.shadowDom_ = shadowDom;
- this.shadowState_ = shadowState;
-
- const target = this.targetBlock();
- if (!target) {
- this.respawnShadow_();
- if (this.targetBlock() && this.targetBlock().isShadow()) {
- this.serializeShadow_(this.targetBlock());
- }
- } else if (target.isShadow()) {
- target.dispose(false);
- this.respawnShadow_();
- if (this.targetBlock() && this.targetBlock().isShadow()) {
- this.serializeShadow_(this.targetBlock());
- }
- } else {
- const shadow = this.createShadowBlock_(false);
- this.serializeShadow_(shadow);
- if (shadow) {
- shadow.dispose(false);
- }
- }
-};
-
-/**
- * Creates a shadow block based on the current shadowState_ or shadowDom_.
- * shadowState_ gets priority.
- * @param {boolean} attemptToConnect Whether to try to connect the shadow block
- * to this connection or not.
- * @return {?Block} The shadow block that was created, or null if both the
- * shadowState_ and shadowDom_ are null.
- * @private
- */
-Connection.prototype.createShadowBlock_ = function(attemptToConnect) {
- const parentBlock = this.getSourceBlock();
- const shadowState = this.getShadowState();
- const shadowDom = this.getShadowDom();
- if (!parentBlock.workspace || (!shadowState && !shadowDom)) {
- return null;
- }
-
- let blockShadow;
- if (shadowState) {
- blockShadow = blocks.appendInternal(shadowState, parentBlock.workspace, {
- parentConnection: attemptToConnect ? this : undefined,
- isShadow: true,
- recordUndo: false,
- });
- return blockShadow;
- }
-
- if (shadowDom) {
- blockShadow = Xml.domToBlock(shadowDom, parentBlock.workspace);
- if (attemptToConnect) {
- if (this.type === ConnectionType.INPUT_VALUE) {
- if (!blockShadow.outputConnection) {
- throw new Error('Shadow block is missing an output connection');
- }
- if (!this.connect(blockShadow.outputConnection)) {
- throw new Error('Could not connect shadow block to connection');
- }
- } else if (this.type === ConnectionType.NEXT_STATEMENT) {
- if (!blockShadow.previousConnection) {
- throw new Error('Shadow block is missing previous connection');
- }
- if (!this.connect(blockShadow.previousConnection)) {
- throw new Error('Could not connect shadow block to connection');
- }
- } else {
- throw new Error(
- 'Cannot connect a shadow block to a previous/output connection');
- }
- }
- return blockShadow;
- }
- return null;
-};
-
-/**
- * Saves the given shadow block to both the shadowDom_ and shadowState_
- * properties, in their respective serialized forms.
- * @param {?Block} shadow The shadow to serialize, or null.
- * @private
- */
-Connection.prototype.serializeShadow_ = function(shadow) {
- if (!shadow) {
- return;
- }
- this.shadowDom_ = /** @type {!Element} */ (Xml.blockToDom(shadow));
- this.shadowState_ = blocks.save(shadow);
-};
-
exports.Connection = Connection;
diff --git a/core/connection_checker.js b/core/connection_checker.js
index d512488ef..33bf256a2 100644
--- a/core/connection_checker.js
+++ b/core/connection_checker.js
@@ -31,282 +31,280 @@ const {RenderedConnection} = goog.requireType('Blockly.RenderedConnection');
/**
* Class for connection type checking logic.
* @implements {IConnectionChecker}
- * @constructor
* @alias Blockly.ConnectionChecker
*/
-const ConnectionChecker = function() {};
-
-/**
- * Check whether the current connection can connect with the target
- * connection.
- * @param {Connection} a Connection to check compatibility with.
- * @param {Connection} b Connection to check compatibility with.
- * @param {boolean} isDragging True if the connection is being made by dragging
- * a block.
- * @param {number=} opt_distance The max allowable distance between the
- * connections for drag checks.
- * @return {boolean} Whether the connection is legal.
- * @public
- */
-ConnectionChecker.prototype.canConnect = function(
- a, b, isDragging, opt_distance) {
- return this.canConnectWithReason(a, b, isDragging, opt_distance) ===
- Connection.CAN_CONNECT;
-};
-
-/**
- * Checks whether the current connection can connect with the target
- * connection, and return an error code if there are problems.
- * @param {Connection} a Connection to check compatibility with.
- * @param {Connection} b Connection to check compatibility with.
- * @param {boolean} isDragging True if the connection is being made by dragging
- * a block.
- * @param {number=} opt_distance The max allowable distance between the
- * connections for drag checks.
- * @return {number} Connection.CAN_CONNECT if the connection is legal,
- * an error code otherwise.
- * @public
- */
-ConnectionChecker.prototype.canConnectWithReason = function(
- a, b, isDragging, opt_distance) {
- const safety = this.doSafetyChecks(a, b);
- if (safety !== Connection.CAN_CONNECT) {
- return safety;
+class ConnectionChecker {
+ /**
+ * Check whether the current connection can connect with the target
+ * connection.
+ * @param {Connection} a Connection to check compatibility with.
+ * @param {Connection} b Connection to check compatibility with.
+ * @param {boolean} isDragging True if the connection is being made by
+ * dragging a block.
+ * @param {number=} opt_distance The max allowable distance between the
+ * connections for drag checks.
+ * @return {boolean} Whether the connection is legal.
+ * @public
+ */
+ canConnect(a, b, isDragging, opt_distance) {
+ return this.canConnectWithReason(a, b, isDragging, opt_distance) ===
+ Connection.CAN_CONNECT;
}
- // If the safety checks passed, both connections are non-null.
- const connOne = /** @type {!Connection} **/ (a);
- const connTwo = /** @type {!Connection} **/ (b);
- if (!this.doTypeChecks(connOne, connTwo)) {
- return Connection.REASON_CHECKS_FAILED;
- }
-
- if (isDragging &&
- !this.doDragChecks(
- /** @type {!RenderedConnection} **/ (a),
- /** @type {!RenderedConnection} **/ (b), opt_distance || 0)) {
- return Connection.REASON_DRAG_CHECKS_FAILED;
- }
-
- return Connection.CAN_CONNECT;
-};
-
-/**
- * Helper method that translates a connection error code into a string.
- * @param {number} errorCode The error code.
- * @param {Connection} a One of the two connections being checked.
- * @param {Connection} b The second of the two connections being
- * checked.
- * @return {string} A developer-readable error string.
- * @public
- */
-ConnectionChecker.prototype.getErrorMessage = function(errorCode, a, b) {
- switch (errorCode) {
- case Connection.REASON_SELF_CONNECTION:
- return 'Attempted to connect a block to itself.';
- case Connection.REASON_DIFFERENT_WORKSPACES:
- // Usually this means one block has been deleted.
- return 'Blocks not on same workspace.';
- case Connection.REASON_WRONG_TYPE:
- return 'Attempt to connect incompatible types.';
- case Connection.REASON_TARGET_NULL:
- return 'Target connection is null.';
- case Connection.REASON_CHECKS_FAILED: {
- const connOne = /** @type {!Connection} **/ (a);
- const connTwo = /** @type {!Connection} **/ (b);
- let msg = 'Connection checks failed. ';
- msg += connOne + ' expected ' + connOne.getCheck() + ', found ' +
- connTwo.getCheck();
- return msg;
+ /**
+ * Checks whether the current connection can connect with the target
+ * connection, and return an error code if there are problems.
+ * @param {Connection} a Connection to check compatibility with.
+ * @param {Connection} b Connection to check compatibility with.
+ * @param {boolean} isDragging True if the connection is being made by
+ * dragging a block.
+ * @param {number=} opt_distance The max allowable distance between the
+ * connections for drag checks.
+ * @return {number} Connection.CAN_CONNECT if the connection is legal,
+ * an error code otherwise.
+ * @public
+ */
+ canConnectWithReason(a, b, isDragging, opt_distance) {
+ const safety = this.doSafetyChecks(a, b);
+ if (safety !== Connection.CAN_CONNECT) {
+ return safety;
}
- case Connection.REASON_SHADOW_PARENT:
- return 'Connecting non-shadow to shadow block.';
- case Connection.REASON_DRAG_CHECKS_FAILED:
- return 'Drag checks failed.';
- case Connection.REASON_PREVIOUS_AND_OUTPUT:
- return 'Block would have an output and a previous connection.';
- default:
- return 'Unknown connection failure: this should never happen!';
- }
-};
-/**
- * Check that connecting the given connections is safe, meaning that it would
- * not break any of Blockly's basic assumptions (e.g. no self connections).
- * @param {Connection} a The first of the connections to check.
- * @param {Connection} b The second of the connections to check.
- * @return {number} An enum with the reason this connection is safe or unsafe.
- * @public
- */
-ConnectionChecker.prototype.doSafetyChecks = function(a, b) {
- if (!a || !b) {
- return Connection.REASON_TARGET_NULL;
- }
- let superiorBlock;
- let inferiorBlock;
- let superiorConnection;
- let inferiorConnection;
- if (a.isSuperior()) {
- superiorBlock = a.getSourceBlock();
- inferiorBlock = b.getSourceBlock();
- superiorConnection = a;
- inferiorConnection = b;
- } else {
- inferiorBlock = a.getSourceBlock();
- superiorBlock = b.getSourceBlock();
- inferiorConnection = a;
- superiorConnection = b;
- }
- if (superiorBlock === inferiorBlock) {
- return Connection.REASON_SELF_CONNECTION;
- } else if (
- inferiorConnection.type !==
- internalConstants.OPPOSITE_TYPE[superiorConnection.type]) {
- return Connection.REASON_WRONG_TYPE;
- } else if (superiorBlock.workspace !== inferiorBlock.workspace) {
- return Connection.REASON_DIFFERENT_WORKSPACES;
- } else if (superiorBlock.isShadow() && !inferiorBlock.isShadow()) {
- return Connection.REASON_SHADOW_PARENT;
- } else if (
- inferiorConnection.type === ConnectionType.OUTPUT_VALUE &&
- inferiorBlock.previousConnection &&
- inferiorBlock.previousConnection.isConnected()) {
- return Connection.REASON_PREVIOUS_AND_OUTPUT;
- } else if (
- inferiorConnection.type === ConnectionType.PREVIOUS_STATEMENT &&
- inferiorBlock.outputConnection &&
- inferiorBlock.outputConnection.isConnected()) {
- return Connection.REASON_PREVIOUS_AND_OUTPUT;
- }
- return Connection.CAN_CONNECT;
-};
+ // If the safety checks passed, both connections are non-null.
+ const connOne = /** @type {!Connection} **/ (a);
+ const connTwo = /** @type {!Connection} **/ (b);
+ if (!this.doTypeChecks(connOne, connTwo)) {
+ return Connection.REASON_CHECKS_FAILED;
+ }
-/**
- * Check whether this connection is compatible with another connection with
- * respect to the value type system. E.g. square_root("Hello") is not
- * compatible.
- * @param {!Connection} a Connection to compare.
- * @param {!Connection} b Connection to compare against.
- * @return {boolean} True if the connections share a type.
- * @public
- */
-ConnectionChecker.prototype.doTypeChecks = function(a, b) {
- const checkArrayOne = a.getCheck();
- const checkArrayTwo = b.getCheck();
+ if (isDragging &&
+ !this.doDragChecks(
+ /** @type {!RenderedConnection} **/ (a),
+ /** @type {!RenderedConnection} **/ (b), opt_distance || 0)) {
+ return Connection.REASON_DRAG_CHECKS_FAILED;
+ }
- if (!checkArrayOne || !checkArrayTwo) {
- // One or both sides are promiscuous enough that anything will fit.
- return true;
+ return Connection.CAN_CONNECT;
}
- // Find any intersection in the check lists.
- for (let i = 0; i < checkArrayOne.length; i++) {
- if (checkArrayTwo.indexOf(checkArrayOne[i]) !== -1) {
+
+ /**
+ * Helper method that translates a connection error code into a string.
+ * @param {number} errorCode The error code.
+ * @param {Connection} a One of the two connections being checked.
+ * @param {Connection} b The second of the two connections being
+ * checked.
+ * @return {string} A developer-readable error string.
+ * @public
+ */
+ getErrorMessage(errorCode, a, b) {
+ switch (errorCode) {
+ case Connection.REASON_SELF_CONNECTION:
+ return 'Attempted to connect a block to itself.';
+ case Connection.REASON_DIFFERENT_WORKSPACES:
+ // Usually this means one block has been deleted.
+ return 'Blocks not on same workspace.';
+ case Connection.REASON_WRONG_TYPE:
+ return 'Attempt to connect incompatible types.';
+ case Connection.REASON_TARGET_NULL:
+ return 'Target connection is null.';
+ case Connection.REASON_CHECKS_FAILED: {
+ const connOne = /** @type {!Connection} **/ (a);
+ const connTwo = /** @type {!Connection} **/ (b);
+ let msg = 'Connection checks failed. ';
+ msg += connOne + ' expected ' + connOne.getCheck() + ', found ' +
+ connTwo.getCheck();
+ return msg;
+ }
+ case Connection.REASON_SHADOW_PARENT:
+ return 'Connecting non-shadow to shadow block.';
+ case Connection.REASON_DRAG_CHECKS_FAILED:
+ return 'Drag checks failed.';
+ case Connection.REASON_PREVIOUS_AND_OUTPUT:
+ return 'Block would have an output and a previous connection.';
+ default:
+ return 'Unknown connection failure: this should never happen!';
+ }
+ }
+
+ /**
+ * Check that connecting the given connections is safe, meaning that it would
+ * not break any of Blockly's basic assumptions (e.g. no self connections).
+ * @param {Connection} a The first of the connections to check.
+ * @param {Connection} b The second of the connections to check.
+ * @return {number} An enum with the reason this connection is safe or unsafe.
+ * @public
+ */
+ doSafetyChecks(a, b) {
+ if (!a || !b) {
+ return Connection.REASON_TARGET_NULL;
+ }
+ let superiorBlock;
+ let inferiorBlock;
+ let superiorConnection;
+ let inferiorConnection;
+ if (a.isSuperior()) {
+ superiorBlock = a.getSourceBlock();
+ inferiorBlock = b.getSourceBlock();
+ superiorConnection = a;
+ inferiorConnection = b;
+ } else {
+ inferiorBlock = a.getSourceBlock();
+ superiorBlock = b.getSourceBlock();
+ inferiorConnection = a;
+ superiorConnection = b;
+ }
+ if (superiorBlock === inferiorBlock) {
+ return Connection.REASON_SELF_CONNECTION;
+ } else if (
+ inferiorConnection.type !==
+ internalConstants.OPPOSITE_TYPE[superiorConnection.type]) {
+ return Connection.REASON_WRONG_TYPE;
+ } else if (superiorBlock.workspace !== inferiorBlock.workspace) {
+ return Connection.REASON_DIFFERENT_WORKSPACES;
+ } else if (superiorBlock.isShadow() && !inferiorBlock.isShadow()) {
+ return Connection.REASON_SHADOW_PARENT;
+ } else if (
+ inferiorConnection.type === ConnectionType.OUTPUT_VALUE &&
+ inferiorBlock.previousConnection &&
+ inferiorBlock.previousConnection.isConnected()) {
+ return Connection.REASON_PREVIOUS_AND_OUTPUT;
+ } else if (
+ inferiorConnection.type === ConnectionType.PREVIOUS_STATEMENT &&
+ inferiorBlock.outputConnection &&
+ inferiorBlock.outputConnection.isConnected()) {
+ return Connection.REASON_PREVIOUS_AND_OUTPUT;
+ }
+ return Connection.CAN_CONNECT;
+ }
+
+ /**
+ * Check whether this connection is compatible with another connection with
+ * respect to the value type system. E.g. square_root("Hello") is not
+ * compatible.
+ * @param {!Connection} a Connection to compare.
+ * @param {!Connection} b Connection to compare against.
+ * @return {boolean} True if the connections share a type.
+ * @public
+ */
+ doTypeChecks(a, b) {
+ const checkArrayOne = a.getCheck();
+ const checkArrayTwo = b.getCheck();
+
+ if (!checkArrayOne || !checkArrayTwo) {
+ // One or both sides are promiscuous enough that anything will fit.
return true;
}
- }
- // No intersection.
- return false;
-};
-
-/**
- * Check whether this connection can be made by dragging.
- * @param {!RenderedConnection} a Connection to compare.
- * @param {!RenderedConnection} b Connection to compare against.
- * @param {number} distance The maximum allowable distance between connections.
- * @return {boolean} True if the connection is allowed during a drag.
- * @public
- */
-ConnectionChecker.prototype.doDragChecks = function(a, b, distance) {
- if (a.distanceFrom(b) > distance) {
+ // Find any intersection in the check lists.
+ for (let i = 0; i < checkArrayOne.length; i++) {
+ if (checkArrayTwo.indexOf(checkArrayOne[i]) !== -1) {
+ return true;
+ }
+ }
+ // No intersection.
return false;
}
- // Don't consider insertion markers.
- if (b.getSourceBlock().isInsertionMarker()) {
- return false;
- }
-
- switch (b.type) {
- case ConnectionType.PREVIOUS_STATEMENT:
- return this.canConnectToPrevious_(a, b);
- case ConnectionType.OUTPUT_VALUE: {
- // Don't offer to connect an already connected left (male) value plug to
- // an available right (female) value plug.
- if ((b.isConnected() && !b.targetBlock().isInsertionMarker()) ||
- a.isConnected()) {
- return false;
- }
- break;
- }
- case ConnectionType.INPUT_VALUE: {
- // Offering to connect the left (male) of a value block to an already
- // connected value pair is ok, we'll splice it in.
- // However, don't offer to splice into an immovable block.
- if (b.isConnected() && !b.targetBlock().isMovable() &&
- !b.targetBlock().isShadow()) {
- return false;
- }
- break;
- }
- case ConnectionType.NEXT_STATEMENT: {
- // Don't let a block with no next connection bump other blocks out of the
- // stack. But covering up a shadow block or stack of shadow blocks is
- // fine. Similarly, replacing a terminal statement with another terminal
- // statement is allowed.
- if (b.isConnected() && !a.getSourceBlock().nextConnection &&
- !b.targetBlock().isShadow() && b.targetBlock().nextConnection) {
- return false;
- }
- break;
- }
- default:
- // Unexpected connection type.
+ /**
+ * Check whether this connection can be made by dragging.
+ * @param {!RenderedConnection} a Connection to compare.
+ * @param {!RenderedConnection} b Connection to compare against.
+ * @param {number} distance The maximum allowable distance between
+ * connections.
+ * @return {boolean} True if the connection is allowed during a drag.
+ * @public
+ */
+ doDragChecks(a, b, distance) {
+ if (a.distanceFrom(b) > distance) {
return false;
- }
+ }
- // Don't let blocks try to connect to themselves or ones they nest.
- if (common.draggingConnections.indexOf(b) !== -1) {
- return false;
- }
+ // Don't consider insertion markers.
+ if (b.getSourceBlock().isInsertionMarker()) {
+ return false;
+ }
- return true;
-};
+ switch (b.type) {
+ case ConnectionType.PREVIOUS_STATEMENT:
+ return this.canConnectToPrevious_(a, b);
+ case ConnectionType.OUTPUT_VALUE: {
+ // Don't offer to connect an already connected left (male) value plug to
+ // an available right (female) value plug.
+ if ((b.isConnected() && !b.targetBlock().isInsertionMarker()) ||
+ a.isConnected()) {
+ return false;
+ }
+ break;
+ }
+ case ConnectionType.INPUT_VALUE: {
+ // Offering to connect the left (male) of a value block to an already
+ // connected value pair is ok, we'll splice it in.
+ // However, don't offer to splice into an immovable block.
+ if (b.isConnected() && !b.targetBlock().isMovable() &&
+ !b.targetBlock().isShadow()) {
+ return false;
+ }
+ break;
+ }
+ case ConnectionType.NEXT_STATEMENT: {
+ // Don't let a block with no next connection bump other blocks out of
+ // the stack. But covering up a shadow block or stack of shadow blocks
+ // is fine. Similarly, replacing a terminal statement with another
+ // terminal statement is allowed.
+ if (b.isConnected() && !a.getSourceBlock().nextConnection &&
+ !b.targetBlock().isShadow() && b.targetBlock().nextConnection) {
+ return false;
+ }
+ break;
+ }
+ default:
+ // Unexpected connection type.
+ return false;
+ }
-/**
- * Helper function for drag checking.
- * @param {!Connection} a The connection to check, which must be a
- * statement input or next connection.
- * @param {!Connection} b A nearby connection to check, which
- * must be a previous connection.
- * @return {boolean} True if the connection is allowed, false otherwise.
- * @protected
- */
-ConnectionChecker.prototype.canConnectToPrevious_ = function(a, b) {
- if (a.targetConnection) {
- // This connection is already occupied.
- // A next connection will never disconnect itself mid-drag.
- return false;
- }
+ // Don't let blocks try to connect to themselves or ones they nest.
+ if (common.draggingConnections.indexOf(b) !== -1) {
+ return false;
+ }
- // Don't let blocks try to connect to themselves or ones they nest.
- if (common.draggingConnections.indexOf(b) !== -1) {
- return false;
- }
-
- if (!b.targetConnection) {
return true;
}
- const targetBlock = b.targetBlock();
- // If it is connected to a real block, game over.
- if (!targetBlock.isInsertionMarker()) {
- return false;
+ /**
+ * Helper function for drag checking.
+ * @param {!Connection} a The connection to check, which must be a
+ * statement input or next connection.
+ * @param {!Connection} b A nearby connection to check, which
+ * must be a previous connection.
+ * @return {boolean} True if the connection is allowed, false otherwise.
+ * @protected
+ */
+ canConnectToPrevious_(a, b) {
+ if (a.targetConnection) {
+ // This connection is already occupied.
+ // A next connection will never disconnect itself mid-drag.
+ return false;
+ }
+
+ // Don't let blocks try to connect to themselves or ones they nest.
+ if (common.draggingConnections.indexOf(b) !== -1) {
+ return false;
+ }
+
+ if (!b.targetConnection) {
+ return true;
+ }
+
+ const targetBlock = b.targetBlock();
+ // If it is connected to a real block, game over.
+ if (!targetBlock.isInsertionMarker()) {
+ return false;
+ }
+ // If it's connected to an insertion marker but that insertion marker
+ // is the first block in a stack, it's still fine. If that insertion
+ // marker is in the middle of a stack, it won't work.
+ return !targetBlock.getPreviousBlock();
}
- // If it's connected to an insertion marker but that insertion marker
- // is the first block in a stack, it's still fine. If that insertion
- // marker is in the middle of a stack, it won't work.
- return !targetBlock.getPreviousBlock();
-};
+}
registry.register(
registry.Type.CONNECTION_CHECKER, registry.DEFAULT, ConnectionChecker);
diff --git a/core/connection_db.js b/core/connection_db.js
index 35c1363a1..83931f370 100644
--- a/core/connection_db.js
+++ b/core/connection_db.js
@@ -34,276 +34,281 @@ goog.require('Blockly.constants');
* Database of connections.
* Connections are stored in order of their vertical component. This way
* connections in an area may be looked up quickly using a binary search.
- * @param {!IConnectionChecker} checker The workspace's
- * connection type checker, used to decide if connections are valid during a
- * drag.
- * @constructor
* @alias Blockly.ConnectionDB
*/
-const ConnectionDB = function(checker) {
+class ConnectionDB {
/**
- * Array of connections sorted by y position in workspace units.
- * @type {!Array}
+ * @param {!IConnectionChecker} checker The workspace's
+ * connection type checker, used to decide if connections are valid during
+ * a drag.
+ */
+ constructor(checker) {
+ /**
+ * Array of connections sorted by y position in workspace units.
+ * @type {!Array}
+ * @private
+ */
+ this.connections_ = [];
+ /**
+ * The workspace's connection type checker, used to decide if connections
+ * are valid during a drag.
+ * @type {!IConnectionChecker}
+ * @private
+ */
+ this.connectionChecker_ = checker;
+ }
+
+ /**
+ * Add a connection to the database. Should not already exist in the database.
+ * @param {!RenderedConnection} connection The connection to be added.
+ * @param {number} yPos The y position used to decide where to insert the
+ * connection.
+ * @package
+ */
+ addConnection(connection, yPos) {
+ const index = this.calculateIndexForYPos_(yPos);
+ this.connections_.splice(index, 0, connection);
+ }
+
+ /**
+ * Finds the index of the given connection.
+ *
+ * Starts by doing a binary search to find the approximate location, then
+ * linearly searches nearby for the exact connection.
+ * @param {!RenderedConnection} conn The connection to find.
+ * @param {number} yPos The y position used to find the index of the
+ * connection.
+ * @return {number} The index of the connection, or -1 if the connection was
+ * not found.
* @private
*/
- this.connections_ = [];
- /**
- * The workspace's connection type checker, used to decide if connections are
- * valid during a drag.
- * @type {!IConnectionChecker}
- * @private
- */
- this.connectionChecker_ = checker;
-};
+ findIndexOfConnection_(conn, yPos) {
+ if (!this.connections_.length) {
+ return -1;
+ }
-/**
- * Add a connection to the database. Should not already exist in the database.
- * @param {!RenderedConnection} connection The connection to be added.
- * @param {number} yPos The y position used to decide where to insert the
- * connection.
- * @package
- */
-ConnectionDB.prototype.addConnection = function(connection, yPos) {
- const index = this.calculateIndexForYPos_(yPos);
- this.connections_.splice(index, 0, connection);
-};
+ const bestGuess = this.calculateIndexForYPos_(yPos);
+ if (bestGuess >= this.connections_.length) {
+ // Not in list
+ return -1;
+ }
-/**
- * Finds the index of the given connection.
- *
- * Starts by doing a binary search to find the approximate location, then
- * linearly searches nearby for the exact connection.
- * @param {!RenderedConnection} conn The connection to find.
- * @param {number} yPos The y position used to find the index of the connection.
- * @return {number} The index of the connection, or -1 if the connection was
- * not found.
- * @private
- */
-ConnectionDB.prototype.findIndexOfConnection_ = function(conn, yPos) {
- if (!this.connections_.length) {
+ yPos = conn.y;
+ // Walk forward and back on the y axis looking for the connection.
+ let pointer = bestGuess;
+ while (pointer >= 0 && this.connections_[pointer].y === yPos) {
+ if (this.connections_[pointer] === conn) {
+ return pointer;
+ }
+ pointer--;
+ }
+
+ pointer = bestGuess;
+ while (pointer < this.connections_.length &&
+ this.connections_[pointer].y === yPos) {
+ if (this.connections_[pointer] === conn) {
+ return pointer;
+ }
+ pointer++;
+ }
return -1;
}
- const bestGuess = this.calculateIndexForYPos_(yPos);
- if (bestGuess >= this.connections_.length) {
- // Not in list
- return -1;
- }
-
- yPos = conn.y;
- // Walk forward and back on the y axis looking for the connection.
- let pointer = bestGuess;
- while (pointer >= 0 && this.connections_[pointer].y === yPos) {
- if (this.connections_[pointer] === conn) {
- return pointer;
- }
- pointer--;
- }
-
- pointer = bestGuess;
- while (pointer < this.connections_.length &&
- this.connections_[pointer].y === yPos) {
- if (this.connections_[pointer] === conn) {
- return pointer;
- }
- pointer++;
- }
- return -1;
-};
-
-/**
- * Finds the correct index for the given y position.
- * @param {number} yPos The y position used to decide where to
- * insert the connection.
- * @return {number} The candidate index.
- * @private
- */
-ConnectionDB.prototype.calculateIndexForYPos_ = function(yPos) {
- if (!this.connections_.length) {
- return 0;
- }
- let pointerMin = 0;
- let pointerMax = this.connections_.length;
- while (pointerMin < pointerMax) {
- const pointerMid = Math.floor((pointerMin + pointerMax) / 2);
- if (this.connections_[pointerMid].y < yPos) {
- pointerMin = pointerMid + 1;
- } else if (this.connections_[pointerMid].y > yPos) {
- pointerMax = pointerMid;
- } else {
- pointerMin = pointerMid;
- break;
- }
- }
- return pointerMin;
-};
-
-/**
- * Remove a connection from the database. Must already exist in DB.
- * @param {!RenderedConnection} connection The connection to be removed.
- * @param {number} yPos The y position used to find the index of the connection.
- * @throws {Error} If the connection cannot be found in the database.
- */
-ConnectionDB.prototype.removeConnection = function(connection, yPos) {
- const index = this.findIndexOfConnection_(connection, yPos);
- if (index === -1) {
- throw Error('Unable to find connection in connectionDB.');
- }
- this.connections_.splice(index, 1);
-};
-
-/**
- * Find all nearby connections to the given connection.
- * Type checking does not apply, since this function is used for bumping.
- * @param {!RenderedConnection} connection The connection whose
- * neighbours should be returned.
- * @param {number} maxRadius The maximum radius to another connection.
- * @return {!Array} List of connections.
- */
-ConnectionDB.prototype.getNeighbours = function(connection, maxRadius) {
- const db = this.connections_;
- const currentX = connection.x;
- const currentY = connection.y;
-
- // Binary search to find the closest y location.
- let pointerMin = 0;
- let pointerMax = db.length - 2;
- let pointerMid = pointerMax;
- while (pointerMin < pointerMid) {
- if (db[pointerMid].y < currentY) {
- pointerMin = pointerMid;
- } else {
- pointerMax = pointerMid;
- }
- pointerMid = Math.floor((pointerMin + pointerMax) / 2);
- }
-
- const neighbours = [];
/**
- * Computes if the current connection is within the allowed radius of another
- * connection.
- * This function is a closure and has access to outside variables.
- * @param {number} yIndex The other connection's index in the database.
- * @return {boolean} True if the current connection's vertical distance from
- * the other connection is less than the allowed radius.
+ * Finds the correct index for the given y position.
+ * @param {number} yPos The y position used to decide where to
+ * insert the connection.
+ * @return {number} The candidate index.
+ * @private
*/
- function checkConnection_(yIndex) {
- const dx = currentX - db[yIndex].x;
- const dy = currentY - db[yIndex].y;
- const r = Math.sqrt(dx * dx + dy * dy);
- if (r <= maxRadius) {
- neighbours.push(db[yIndex]);
+ calculateIndexForYPos_(yPos) {
+ if (!this.connections_.length) {
+ return 0;
}
- return dy < maxRadius;
+ let pointerMin = 0;
+ let pointerMax = this.connections_.length;
+ while (pointerMin < pointerMax) {
+ const pointerMid = Math.floor((pointerMin + pointerMax) / 2);
+ if (this.connections_[pointerMid].y < yPos) {
+ pointerMin = pointerMid + 1;
+ } else if (this.connections_[pointerMid].y > yPos) {
+ pointerMax = pointerMid;
+ } else {
+ pointerMin = pointerMid;
+ break;
+ }
+ }
+ return pointerMin;
}
- // Walk forward and back on the y axis looking for the closest x,y point.
- pointerMin = pointerMid;
- pointerMax = pointerMid;
- if (db.length) {
- while (pointerMin >= 0 && checkConnection_(pointerMin)) {
+ /**
+ * Remove a connection from the database. Must already exist in DB.
+ * @param {!RenderedConnection} connection The connection to be removed.
+ * @param {number} yPos The y position used to find the index of the
+ * connection.
+ * @throws {Error} If the connection cannot be found in the database.
+ */
+ removeConnection(connection, yPos) {
+ const index = this.findIndexOfConnection_(connection, yPos);
+ if (index === -1) {
+ throw Error('Unable to find connection in connectionDB.');
+ }
+ this.connections_.splice(index, 1);
+ }
+
+ /**
+ * Find all nearby connections to the given connection.
+ * Type checking does not apply, since this function is used for bumping.
+ * @param {!RenderedConnection} connection The connection whose
+ * neighbours should be returned.
+ * @param {number} maxRadius The maximum radius to another connection.
+ * @return {!Array} List of connections.
+ */
+ getNeighbours(connection, maxRadius) {
+ const db = this.connections_;
+ const currentX = connection.x;
+ const currentY = connection.y;
+
+ // Binary search to find the closest y location.
+ let pointerMin = 0;
+ let pointerMax = db.length - 2;
+ let pointerMid = pointerMax;
+ while (pointerMin < pointerMid) {
+ if (db[pointerMid].y < currentY) {
+ pointerMin = pointerMid;
+ } else {
+ pointerMax = pointerMid;
+ }
+ pointerMid = Math.floor((pointerMin + pointerMax) / 2);
+ }
+
+ const neighbours = [];
+ /**
+ * Computes if the current connection is within the allowed radius of
+ * another connection. This function is a closure and has access to outside
+ * variables.
+ * @param {number} yIndex The other connection's index in the database.
+ * @return {boolean} True if the current connection's vertical distance from
+ * the other connection is less than the allowed radius.
+ */
+ function checkConnection_(yIndex) {
+ const dx = currentX - db[yIndex].x;
+ const dy = currentY - db[yIndex].y;
+ const r = Math.sqrt(dx * dx + dy * dy);
+ if (r <= maxRadius) {
+ neighbours.push(db[yIndex]);
+ }
+ return dy < maxRadius;
+ }
+
+ // Walk forward and back on the y axis looking for the closest x,y point.
+ pointerMin = pointerMid;
+ pointerMax = pointerMid;
+ if (db.length) {
+ while (pointerMin >= 0 && checkConnection_(pointerMin)) {
+ pointerMin--;
+ }
+ do {
+ pointerMax++;
+ } while (pointerMax < db.length && checkConnection_(pointerMax));
+ }
+
+ return neighbours;
+ }
+
+ /**
+ * Is the candidate connection close to the reference connection.
+ * Extremely fast; only looks at Y distance.
+ * @param {number} index Index in database of candidate connection.
+ * @param {number} baseY Reference connection's Y value.
+ * @param {number} maxRadius The maximum radius to another connection.
+ * @return {boolean} True if connection is in range.
+ * @private
+ */
+ isInYRange_(index, baseY, maxRadius) {
+ return (Math.abs(this.connections_[index].y - baseY) <= maxRadius);
+ }
+
+ /**
+ * Find the closest compatible connection to this connection.
+ * @param {!RenderedConnection} conn The connection searching for a compatible
+ * mate.
+ * @param {number} maxRadius The maximum radius to another connection.
+ * @param {!Coordinate} dxy Offset between this connection's
+ * location in the database and the current location (as a result of
+ * dragging).
+ * @return {!{connection: RenderedConnection, radius: number}}
+ * Contains two properties: 'connection' which is either another
+ * connection or null, and 'radius' which is the distance.
+ */
+ searchForClosest(conn, maxRadius, dxy) {
+ if (!this.connections_.length) {
+ // Don't bother.
+ return {connection: null, radius: maxRadius};
+ }
+
+ // Stash the values of x and y from before the drag.
+ const baseY = conn.y;
+ const baseX = conn.x;
+
+ conn.x = baseX + dxy.x;
+ conn.y = baseY + dxy.y;
+
+ // calculateIndexForYPos_ finds an index for insertion, which is always
+ // after any block with the same y index. We want to search both forward
+ // and back, so search on both sides of the index.
+ const closestIndex = this.calculateIndexForYPos_(conn.y);
+
+ let bestConnection = null;
+ let bestRadius = maxRadius;
+ let temp;
+
+ // Walk forward and back on the y axis looking for the closest x,y point.
+ let pointerMin = closestIndex - 1;
+ while (pointerMin >= 0 && this.isInYRange_(pointerMin, conn.y, maxRadius)) {
+ temp = this.connections_[pointerMin];
+ if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) {
+ bestConnection = temp;
+ bestRadius = temp.distanceFrom(conn);
+ }
pointerMin--;
}
- do {
+
+ let pointerMax = closestIndex;
+ while (pointerMax < this.connections_.length &&
+ this.isInYRange_(pointerMax, conn.y, maxRadius)) {
+ temp = this.connections_[pointerMax];
+ if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) {
+ bestConnection = temp;
+ bestRadius = temp.distanceFrom(conn);
+ }
pointerMax++;
- } while (pointerMax < db.length && checkConnection_(pointerMax));
- }
-
- return neighbours;
-};
-
-/**
- * Is the candidate connection close to the reference connection.
- * Extremely fast; only looks at Y distance.
- * @param {number} index Index in database of candidate connection.
- * @param {number} baseY Reference connection's Y value.
- * @param {number} maxRadius The maximum radius to another connection.
- * @return {boolean} True if connection is in range.
- * @private
- */
-ConnectionDB.prototype.isInYRange_ = function(index, baseY, maxRadius) {
- return (Math.abs(this.connections_[index].y - baseY) <= maxRadius);
-};
-
-/**
- * Find the closest compatible connection to this connection.
- * @param {!RenderedConnection} conn The connection searching for a compatible
- * mate.
- * @param {number} maxRadius The maximum radius to another connection.
- * @param {!Coordinate} dxy Offset between this connection's
- * location in the database and the current location (as a result of
- * dragging).
- * @return {!{connection: RenderedConnection, radius: number}}
- * Contains two properties: 'connection' which is either another
- * connection or null, and 'radius' which is the distance.
- */
-ConnectionDB.prototype.searchForClosest = function(conn, maxRadius, dxy) {
- if (!this.connections_.length) {
- // Don't bother.
- return {connection: null, radius: maxRadius};
- }
-
- // Stash the values of x and y from before the drag.
- const baseY = conn.y;
- const baseX = conn.x;
-
- conn.x = baseX + dxy.x;
- conn.y = baseY + dxy.y;
-
- // calculateIndexForYPos_ finds an index for insertion, which is always
- // after any block with the same y index. We want to search both forward
- // and back, so search on both sides of the index.
- const closestIndex = this.calculateIndexForYPos_(conn.y);
-
- let bestConnection = null;
- let bestRadius = maxRadius;
- let temp;
-
- // Walk forward and back on the y axis looking for the closest x,y point.
- let pointerMin = closestIndex - 1;
- while (pointerMin >= 0 && this.isInYRange_(pointerMin, conn.y, maxRadius)) {
- temp = this.connections_[pointerMin];
- if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) {
- bestConnection = temp;
- bestRadius = temp.distanceFrom(conn);
}
- pointerMin--;
+
+ // Reset the values of x and y.
+ conn.x = baseX;
+ conn.y = baseY;
+
+ // If there were no valid connections, bestConnection will be null.
+ return {connection: bestConnection, radius: bestRadius};
}
- let pointerMax = closestIndex;
- while (pointerMax < this.connections_.length &&
- this.isInYRange_(pointerMax, conn.y, maxRadius)) {
- temp = this.connections_[pointerMax];
- if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) {
- bestConnection = temp;
- bestRadius = temp.distanceFrom(conn);
- }
- pointerMax++;
+ /**
+ * Initialize a set of connection DBs for a workspace.
+ * @param {!IConnectionChecker} checker The workspace's
+ * connection checker, used to decide if connections are valid during a
+ * drag.
+ * @return {!Array} Array of databases.
+ */
+ static init(checker) {
+ // Create four databases, one for each connection type.
+ const dbList = [];
+ dbList[ConnectionType.INPUT_VALUE] = new ConnectionDB(checker);
+ dbList[ConnectionType.OUTPUT_VALUE] = new ConnectionDB(checker);
+ dbList[ConnectionType.NEXT_STATEMENT] = new ConnectionDB(checker);
+ dbList[ConnectionType.PREVIOUS_STATEMENT] = new ConnectionDB(checker);
+ return dbList;
}
-
- // Reset the values of x and y.
- conn.x = baseX;
- conn.y = baseY;
-
- // If there were no valid connections, bestConnection will be null.
- return {connection: bestConnection, radius: bestRadius};
-};
-
-/**
- * Initialize a set of connection DBs for a workspace.
- * @param {!IConnectionChecker} checker The workspace's
- * connection checker, used to decide if connections are valid during a
- * drag.
- * @return {!Array} Array of databases.
- */
-ConnectionDB.init = function(checker) {
- // Create four databases, one for each connection type.
- const dbList = [];
- dbList[ConnectionType.INPUT_VALUE] = new ConnectionDB(checker);
- dbList[ConnectionType.OUTPUT_VALUE] = new ConnectionDB(checker);
- dbList[ConnectionType.NEXT_STATEMENT] = new ConnectionDB(checker);
- dbList[ConnectionType.PREVIOUS_STATEMENT] = new ConnectionDB(checker);
- return dbList;
-};
+}
exports.ConnectionDB = ConnectionDB;
diff --git a/core/contextmenu.js b/core/contextmenu.js
index 6f665eb49..b2fa7c265 100644
--- a/core/contextmenu.js
+++ b/core/contextmenu.js
@@ -23,11 +23,13 @@ const clipboard = goog.require('Blockly.clipboard');
const deprecation = goog.require('Blockly.utils.deprecation');
const dom = goog.require('Blockly.utils.dom');
const eventUtils = goog.require('Blockly.Events.utils');
-const internalConstants = goog.require('Blockly.internalConstants');
const userAgent = goog.require('Blockly.utils.userAgent');
const svgMath = goog.require('Blockly.utils.svgMath');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
+const {config} = goog.require('Blockly.config');
const {Coordinate} = goog.require('Blockly.utils.Coordinate');
const {MenuItem} = goog.require('Blockly.MenuItem');
const {Menu} = goog.require('Blockly.Menu');
@@ -262,15 +264,16 @@ const callbackFactory = function(block, xml) {
eventUtils.disable();
let newBlock;
try {
- newBlock = Xml.domToBlock(xml, block.workspace);
+ newBlock =
+ /** @type {!BlockSvg} */ (Xml.domToBlock(xml, block.workspace));
// Move the new block next to the old block.
const xy = block.getRelativeToSurfaceXY();
if (block.RTL) {
- xy.x -= internalConstants.SNAP_RADIUS;
+ xy.x -= config.snapRadius;
} else {
- xy.x += internalConstants.SNAP_RADIUS;
+ xy.x += config.snapRadius;
}
- xy.y += internalConstants.SNAP_RADIUS * 2;
+ xy.y += config.snapRadius * 2;
newBlock.moveBy(xy.x, xy.y);
} finally {
eventUtils.enable();
@@ -339,7 +342,7 @@ exports.commentDuplicateOption = commentDuplicateOption;
* @alias Blockly.ContextMenu.workspaceCommentOption
*/
const workspaceCommentOption = function(ws, e) {
- const WorkspaceCommentSvg = goog.module.get('Blockly.WorkspaceCommentSvg');
+ const {WorkspaceCommentSvg} = goog.module.get('Blockly.WorkspaceCommentSvg');
if (!WorkspaceCommentSvg) {
throw Error('Missing require for Blockly.WorkspaceCommentSvg');
}
diff --git a/core/contextmenu_items.js b/core/contextmenu_items.js
index aa0abbf5d..0d0696f42 100644
--- a/core/contextmenu_items.js
+++ b/core/contextmenu_items.js
@@ -239,8 +239,7 @@ const addDeletableBlocks_ = function(block, deleteList) {
if (block.isDeletable()) {
Array.prototype.push.apply(deleteList, block.getDescendants(false));
} else {
- const children = /* eslint-disable-next-line indent */
- /** @type {!Array} */ (block.getChildren(false));
+ const children = block.getChildren(false);
for (let i = 0; i < children.length; i++) {
addDeletableBlocks_(children[i], deleteList);
}
diff --git a/core/contextmenu_registry.js b/core/contextmenu_registry.js
index 91a015c3b..42130dbfc 100644
--- a/core/contextmenu_registry.js
+++ b/core/contextmenu_registry.js
@@ -25,21 +25,102 @@ const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg');
* Class for the registry of context menu items. This is intended to be a
* singleton. You should not create a new instance, and only access this class
* from ContextMenuRegistry.registry.
- * @constructor
- * @private
* @alias Blockly.ContextMenuRegistry
*/
-const ContextMenuRegistry = function() {
- // Singleton instance should be registered once.
- ContextMenuRegistry.registry = this;
+class ContextMenuRegistry {
+ /**
+ * Resets the existing singleton instance of ContextMenuRegistry.
+ */
+ constructor() {
+ this.reset();
+ }
/**
- * Registry of all registered RegistryItems, keyed by ID.
- * @type {!Object}
- * @private
+ * Clear and recreate the registry.
*/
- this.registry_ = Object.create(null);
-};
+ reset() {
+ /**
+ * Registry of all registered RegistryItems, keyed by ID.
+ * @type {!Object}
+ * @private
+ */
+ this.registry_ = Object.create(null);
+ }
+
+ /**
+ * Registers a RegistryItem.
+ * @param {!ContextMenuRegistry.RegistryItem} item Context menu item to
+ * register.
+ * @throws {Error} if an item with the given ID already exists.
+ */
+ register(item) {
+ if (this.registry_[item.id]) {
+ throw Error('Menu item with ID "' + item.id + '" is already registered.');
+ }
+ this.registry_[item.id] = item;
+ }
+
+ /**
+ * Unregisters a RegistryItem with the given ID.
+ * @param {string} id The ID of the RegistryItem to remove.
+ * @throws {Error} if an item with the given ID does not exist.
+ */
+ unregister(id) {
+ if (!this.registry_[id]) {
+ throw new Error('Menu item with ID "' + id + '" not found.');
+ }
+ delete this.registry_[id];
+ }
+
+ /**
+ * @param {string} id The ID of the RegistryItem to get.
+ * @return {?ContextMenuRegistry.RegistryItem} RegistryItem or null if not
+ * found
+ */
+ getItem(id) {
+ return this.registry_[id] || null;
+ }
+
+ /**
+ * Gets the valid context menu options for the given scope type (e.g. block or
+ * workspace) and scope. Blocks are only shown if the preconditionFn shows
+ * they should not be hidden.
+ * @param {!ContextMenuRegistry.ScopeType} scopeType Type of scope where menu
+ * should be shown (e.g. on a block or on a workspace)
+ * @param {!ContextMenuRegistry.Scope} scope Current scope of context menu
+ * (i.e., the exact workspace or block being clicked on)
+ * @return {!Array} the list of
+ * ContextMenuOptions
+ */
+ getContextMenuOptions(scopeType, scope) {
+ const menuOptions = [];
+ const registry = this.registry_;
+ Object.keys(registry).forEach(function(id) {
+ const item = registry[id];
+ if (scopeType === item.scopeType) {
+ const precondition = item.preconditionFn(scope);
+ if (precondition !== 'hidden') {
+ const displayText = typeof item.displayText === 'function' ?
+ item.displayText(scope) :
+ item.displayText;
+ /** @type {!ContextMenuRegistry.ContextMenuOption} */
+ const menuOption = {
+ text: displayText,
+ enabled: (precondition === 'enabled'),
+ callback: item.callback,
+ scope: scope,
+ weight: item.weight,
+ };
+ menuOptions.push(menuOption);
+ }
+ }
+ });
+ menuOptions.sort(function(a, b) {
+ return a.weight - b.weight;
+ });
+ return menuOptions;
+ }
+}
/**
* Where this menu item should be rendered. If the menu item should be rendered
@@ -90,85 +171,8 @@ ContextMenuRegistry.ContextMenuOption;
/**
* Singleton instance of this class. All interactions with this class should be
* done on this object.
- * @type {?ContextMenuRegistry}
+ * @type {!ContextMenuRegistry}
*/
-ContextMenuRegistry.registry = null;
-
-/**
- * Registers a RegistryItem.
- * @param {!ContextMenuRegistry.RegistryItem} item Context menu item to
- * register.
- * @throws {Error} if an item with the given ID already exists.
- */
-ContextMenuRegistry.prototype.register = function(item) {
- if (this.registry_[item.id]) {
- throw Error('Menu item with ID "' + item.id + '" is already registered.');
- }
- this.registry_[item.id] = item;
-};
-
-/**
- * Unregisters a RegistryItem with the given ID.
- * @param {string} id The ID of the RegistryItem to remove.
- * @throws {Error} if an item with the given ID does not exist.
- */
-ContextMenuRegistry.prototype.unregister = function(id) {
- if (!this.registry_[id]) {
- throw new Error('Menu item with ID "' + id + '" not found.');
- }
- delete this.registry_[id];
-};
-
-/**
- * @param {string} id The ID of the RegistryItem to get.
- * @return {?ContextMenuRegistry.RegistryItem} RegistryItem or null if not found
- */
-ContextMenuRegistry.prototype.getItem = function(id) {
- return this.registry_[id] || null;
-};
-
-/**
- * Gets the valid context menu options for the given scope type (e.g. block or
- * workspace) and scope. Blocks are only shown if the preconditionFn shows they
- * should not be hidden.
- * @param {!ContextMenuRegistry.ScopeType} scopeType Type of scope where menu
- * should be shown (e.g. on a block or on a workspace)
- * @param {!ContextMenuRegistry.Scope} scope Current scope of context menu
- * (i.e., the exact workspace or block being clicked on)
- * @return {!Array} the list of
- * ContextMenuOptions
- */
-ContextMenuRegistry.prototype.getContextMenuOptions = function(
- scopeType, scope) {
- const menuOptions = [];
- const registry = this.registry_;
- Object.keys(registry).forEach(function(id) {
- const item = registry[id];
- if (scopeType === item.scopeType) {
- const precondition = item.preconditionFn(scope);
- if (precondition !== 'hidden') {
- const displayText = typeof item.displayText === 'function' ?
- item.displayText(scope) :
- item.displayText;
- /** @type {!ContextMenuRegistry.ContextMenuOption} */
- const menuOption = {
- text: displayText,
- enabled: (precondition === 'enabled'),
- callback: item.callback,
- scope: scope,
- weight: item.weight,
- };
- menuOptions.push(menuOption);
- }
- }
- });
- menuOptions.sort(function(a, b) {
- return a.weight - b.weight;
- });
- return menuOptions;
-};
-
-// Creates and assigns the singleton instance.
-new ContextMenuRegistry();
+ContextMenuRegistry.registry = new ContextMenuRegistry();
exports.ContextMenuRegistry = ContextMenuRegistry;
diff --git a/core/css.js b/core/css.js
index 7cea9284f..e7ed4ec8c 100644
--- a/core/css.js
+++ b/core/css.js
@@ -89,480 +89,480 @@ exports.inject = inject;
* @alias Blockly.Css.content
*/
let content = (`
- .blocklySvg {
- background-color: #fff;
- outline: none;
- overflow: hidden; /* IE overflows by default. */
- position: absolute;
- display: block;
- }
+.blocklySvg {
+ background-color: #fff;
+ outline: none;
+ overflow: hidden; /* IE overflows by default. */
+ position: absolute;
+ display: block;
+}
- .blocklyWidgetDiv {
- display: none;
- position: absolute;
- z-index: 99999; /* big value for bootstrap3 compatibility */
- }
+.blocklyWidgetDiv {
+ display: none;
+ position: absolute;
+ z-index: 99999; /* big value for bootstrap3 compatibility */
+}
- .injectionDiv {
- height: 100%;
- position: relative;
- overflow: hidden; /* So blocks in drag surface disappear at edges */
- touch-action: none;
- }
+.injectionDiv {
+ height: 100%;
+ position: relative;
+ overflow: hidden; /* So blocks in drag surface disappear at edges */
+ touch-action: none;
+}
- .blocklyNonSelectable {
- user-select: none;
- -ms-user-select: none;
- -webkit-user-select: none;
- }
+.blocklyNonSelectable {
+ user-select: none;
+ -ms-user-select: none;
+ -webkit-user-select: none;
+}
- .blocklyWsDragSurface {
- display: none;
- position: absolute;
- top: 0;
- left: 0;
- }
+.blocklyWsDragSurface {
+ display: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+}
- /* Added as a separate rule with multiple classes to make it more specific
- than a bootstrap rule that selects svg:root. See issue #1275 for context.
+/* Added as a separate rule with multiple classes to make it more specific
+ than a bootstrap rule that selects svg:root. See issue #1275 for context.
+*/
+.blocklyWsDragSurface.blocklyOverflowVisible {
+ overflow: visible;
+}
+
+.blocklyBlockDragSurface {
+ display: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ overflow: visible !important;
+ z-index: 50; /* Display below toolbox, but above everything else. */
+}
+
+.blocklyBlockCanvas.blocklyCanvasTransitioning,
+.blocklyBubbleCanvas.blocklyCanvasTransitioning {
+ transition: transform .5s;
+}
+
+.blocklyTooltipDiv {
+ background-color: #ffffc7;
+ border: 1px solid #ddc;
+ box-shadow: 4px 4px 20px 1px rgba(0,0,0,.15);
+ color: #000;
+ display: none;
+ font: 9pt sans-serif;
+ opacity: .9;
+ padding: 2px;
+ position: absolute;
+ z-index: 100000; /* big value for bootstrap3 compatibility */
+}
+
+.blocklyDropDownDiv {
+ position: absolute;
+ left: 0;
+ top: 0;
+ z-index: 1000;
+ display: none;
+ border: 1px solid;
+ border-color: #dadce0;
+ background-color: #fff;
+ border-radius: 2px;
+ padding: 4px;
+ box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
+}
+
+.blocklyDropDownDiv.blocklyFocused {
+ box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
+}
+
+.blocklyDropDownContent {
+ max-height: 300px; // @todo: spec for maximum height.
+ overflow: auto;
+ overflow-x: hidden;
+ position: relative;
+}
+
+.blocklyDropDownArrow {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 16px;
+ height: 16px;
+ z-index: -1;
+ background-color: inherit;
+ border-color: inherit;
+}
+
+.blocklyDropDownButton {
+ display: inline-block;
+ float: left;
+ padding: 0;
+ margin: 4px;
+ border-radius: 4px;
+ outline: none;
+ border: 1px solid;
+ transition: box-shadow .1s;
+ cursor: pointer;
+}
+
+.blocklyArrowTop {
+ border-top: 1px solid;
+ border-left: 1px solid;
+ border-top-left-radius: 4px;
+ border-color: inherit;
+}
+
+.blocklyArrowBottom {
+ border-bottom: 1px solid;
+ border-right: 1px solid;
+ border-bottom-right-radius: 4px;
+ border-color: inherit;
+}
+
+.blocklyResizeSE {
+ cursor: se-resize;
+ fill: #aaa;
+}
+
+.blocklyResizeSW {
+ cursor: sw-resize;
+ fill: #aaa;
+}
+
+.blocklyResizeLine {
+ stroke: #515A5A;
+ stroke-width: 1;
+}
+
+.blocklyHighlightedConnectionPath {
+ fill: none;
+ stroke: #fc3;
+ stroke-width: 4px;
+}
+
+.blocklyPathLight {
+ fill: none;
+ stroke-linecap: round;
+ stroke-width: 1;
+}
+
+.blocklySelected>.blocklyPathLight {
+ display: none;
+}
+
+.blocklyDraggable {
+ /* backup for browsers (e.g. IE11) that don't support grab */
+ cursor: url("<<>>/handopen.cur"), auto;
+ cursor: grab;
+ cursor: -webkit-grab;
+}
+
+ /* backup for browsers (e.g. IE11) that don't support grabbing */
+.blocklyDragging {
+ /* backup for browsers (e.g. IE11) that don't support grabbing */
+ cursor: url("<<>>/handclosed.cur"), auto;
+ cursor: grabbing;
+ cursor: -webkit-grabbing;
+}
+
+ /* Changes cursor on mouse down. Not effective in Firefox because of
+ https://bugzilla.mozilla.org/show_bug.cgi?id=771241 */
+.blocklyDraggable:active {
+ /* backup for browsers (e.g. IE11) that don't support grabbing */
+ cursor: url("<<>>/handclosed.cur"), auto;
+ cursor: grabbing;
+ cursor: -webkit-grabbing;
+}
+
+/* Change the cursor on the whole drag surface in case the mouse gets
+ ahead of block during a drag. This way the cursor is still a closed hand.
*/
- .blocklyWsDragSurface.blocklyOverflowVisible {
- overflow: visible;
- }
-
- .blocklyBlockDragSurface {
- display: none;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- overflow: visible !important;
- z-index: 50; /* Display below toolbox, but above everything else. */
- }
-
- .blocklyBlockCanvas.blocklyCanvasTransitioning,
- .blocklyBubbleCanvas.blocklyCanvasTransitioning {
- transition: transform .5s;
- }
-
- .blocklyTooltipDiv {
- background-color: #ffffc7;
- border: 1px solid #ddc;
- box-shadow: 4px 4px 20px 1px rgba(0,0,0,.15);
- color: #000;
- display: none;
- font: 9pt sans-serif;
- opacity: .9;
- padding: 2px;
- position: absolute;
- z-index: 100000; /* big value for bootstrap3 compatibility */
- }
-
- .blocklyDropDownDiv {
- position: absolute;
- left: 0;
- top: 0;
- z-index: 1000;
- display: none;
- border: 1px solid;
- border-color: #dadce0;
- background-color: #fff;
- border-radius: 2px;
- padding: 4px;
- box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
- }
-
- .blocklyDropDownDiv.blocklyFocused {
- box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
- }
-
- .blocklyDropDownContent {
- max-height: 300px; // @todo: spec for maximum height.
- overflow: auto;
- overflow-x: hidden;
- position: relative;
- }
-
- .blocklyDropDownArrow {
- position: absolute;
- left: 0;
- top: 0;
- width: 16px;
- height: 16px;
- z-index: -1;
- background-color: inherit;
- border-color: inherit;
- }
-
- .blocklyDropDownButton {
- display: inline-block;
- float: left;
- padding: 0;
- margin: 4px;
- border-radius: 4px;
- outline: none;
- border: 1px solid;
- transition: box-shadow .1s;
- cursor: pointer;
- }
-
- .blocklyArrowTop {
- border-top: 1px solid;
- border-left: 1px solid;
- border-top-left-radius: 4px;
- border-color: inherit;
- }
-
- .blocklyArrowBottom {
- border-bottom: 1px solid;
- border-right: 1px solid;
- border-bottom-right-radius: 4px;
- border-color: inherit;
- }
-
- .blocklyResizeSE {
- cursor: se-resize;
- fill: #aaa;
- }
-
- .blocklyResizeSW {
- cursor: sw-resize;
- fill: #aaa;
- }
-
- .blocklyResizeLine {
- stroke: #515A5A;
- stroke-width: 1;
- }
-
- .blocklyHighlightedConnectionPath {
- fill: none;
- stroke: #fc3;
- stroke-width: 4px;
- }
-
- .blocklyPathLight {
- fill: none;
- stroke-linecap: round;
- stroke-width: 1;
- }
-
- .blocklySelected>.blocklyPathLight {
- display: none;
- }
-
- .blocklyDraggable {
- /* backup for browsers (e.g. IE11) that don't support grab */
- cursor: url("<<>>/handopen.cur"), auto;
- cursor: grab;
- cursor: -webkit-grab;
- }
-
- /* backup for browsers (e.g. IE11) that don't support grabbing */
- .blocklyDragging {
- /* backup for browsers (e.g. IE11) that don't support grabbing */
- cursor: url("<<>>/handclosed.cur"), auto;
- cursor: grabbing;
- cursor: -webkit-grabbing;
- }
-
- /* Changes cursor on mouse down. Not effective in Firefox because of
- https://bugzilla.mozilla.org/show_bug.cgi?id=771241 */
- .blocklyDraggable:active {
- /* backup for browsers (e.g. IE11) that don't support grabbing */
- cursor: url("<<>>/handclosed.cur"), auto;
- cursor: grabbing;
- cursor: -webkit-grabbing;
- }
-
- /* Change the cursor on the whole drag surface in case the mouse gets
- ahead of block during a drag. This way the cursor is still a closed hand.
- */
- .blocklyBlockDragSurface .blocklyDraggable {
- /* backup for browsers (e.g. IE11) that don't support grabbing */
- cursor: url("<<>>/handclosed.cur"), auto;
- cursor: grabbing;
- cursor: -webkit-grabbing;
- }
-
- .blocklyDragging.blocklyDraggingDelete {
- cursor: url("<<>>/handdelete.cur"), auto;
- }
-
- .blocklyDragging>.blocklyPath,
- .blocklyDragging>.blocklyPathLight {
- fill-opacity: .8;
- stroke-opacity: .8;
- }
-
- .blocklyDragging>.blocklyPathDark {
- display: none;
- }
-
- .blocklyDisabled>.blocklyPath {
- fill-opacity: .5;
- stroke-opacity: .5;
- }
-
- .blocklyDisabled>.blocklyPathLight,
- .blocklyDisabled>.blocklyPathDark {
- display: none;
- }
-
- .blocklyInsertionMarker>.blocklyPath,
- .blocklyInsertionMarker>.blocklyPathLight,
- .blocklyInsertionMarker>.blocklyPathDark {
- fill-opacity: .2;
- stroke: none;
- }
-
- .blocklyMultilineText {
- font-family: monospace;
- }
-
- .blocklyNonEditableText>text {
- pointer-events: none;
- }
-
- .blocklyFlyout {
- position: absolute;
- z-index: 20;
- }
-
- .blocklyText text {
- cursor: default;
- }
-
- /*
- Don't allow users to select text. It gets annoying when trying to
- drag a block and selected text moves instead.
- */
- .blocklySvg text,
- .blocklyBlockDragSurface text {
- user-select: none;
- -ms-user-select: none;
- -webkit-user-select: none;
- cursor: inherit;
- }
-
- .blocklyHidden {
- display: none;
- }
-
- .blocklyFieldDropdown:not(.blocklyHidden) {
- display: block;
- }
-
- .blocklyIconGroup {
- cursor: default;
- }
-
- .blocklyIconGroup:not(:hover),
- .blocklyIconGroupReadonly {
- opacity: .6;
- }
-
- .blocklyIconShape {
- fill: #00f;
- stroke: #fff;
- stroke-width: 1px;
- }
-
- .blocklyIconSymbol {
- fill: #fff;
- }
-
- .blocklyMinimalBody {
- margin: 0;
- padding: 0;
- }
-
- .blocklyHtmlInput {
- border: none;
- border-radius: 4px;
- height: 100%;
- margin: 0;
- outline: none;
- padding: 0;
- width: 100%;
- text-align: center;
- display: block;
- box-sizing: border-box;
- }
-
- /* Edge and IE introduce a close icon when the input value is longer than a
- certain length. This affects our sizing calculations of the text input.
- Hiding the close icon to avoid that. */
- .blocklyHtmlInput::-ms-clear {
- display: none;
- }
-
- .blocklyMainBackground {
- stroke-width: 1;
- stroke: #c6c6c6; /* Equates to #ddd due to border being off-pixel. */
- }
-
- .blocklyMutatorBackground {
- fill: #fff;
- stroke: #ddd;
- stroke-width: 1;
- }
-
- .blocklyFlyoutBackground {
- fill: #ddd;
- fill-opacity: .8;
- }
-
- .blocklyMainWorkspaceScrollbar {
- z-index: 20;
- }
-
- .blocklyFlyoutScrollbar {
- z-index: 30;
- }
-
- .blocklyScrollbarHorizontal,
- .blocklyScrollbarVertical {
- position: absolute;
- outline: none;
- }
-
- .blocklyScrollbarBackground {
- opacity: 0;
- }
-
- .blocklyScrollbarHandle {
- fill: #ccc;
- }
-
- .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,
- .blocklyScrollbarHandle:hover {
- fill: #bbb;
- }
-
- /* Darken flyout scrollbars due to being on a grey background. */
- /* By contrast, workspace scrollbars are on a white background. */
- .blocklyFlyout .blocklyScrollbarHandle {
- fill: #bbb;
- }
-
- .blocklyFlyout .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,
- .blocklyFlyout .blocklyScrollbarHandle:hover {
- fill: #aaa;
- }
-
- .blocklyInvalidInput {
- background: #faa;
- }
-
- .blocklyVerticalMarker {
- stroke-width: 3px;
- fill: rgba(255,255,255,.5);
- pointer-events: none;
- }
-
- .blocklyComputeCanvas {
- position: absolute;
- width: 0;
- height: 0;
- }
-
- .blocklyNoPointerEvents {
- pointer-events: none;
- }
-
- .blocklyContextMenu {
- border-radius: 4px;
- max-height: 100%;
- }
-
- .blocklyDropdownMenu {
- border-radius: 2px;
- padding: 0 !important;
- }
-
- .blocklyDropdownMenu .blocklyMenuItem {
- /* 28px on the left for icon or checkbox. */
- padding-left: 28px;
- }
-
- /* BiDi override for the resting state. */
- .blocklyDropdownMenu .blocklyMenuItemRtl {
- /* Flip left/right padding for BiDi. */
- padding-left: 5px;
- padding-right: 28px;
- }
-
- .blocklyWidgetDiv .blocklyMenu {
- background: #fff;
- border: 1px solid transparent;
- box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
- font: normal 13px Arial, sans-serif;
- margin: 0;
- outline: none;
- padding: 4px 0;
- position: absolute;
- overflow-y: auto;
- overflow-x: hidden;
- max-height: 100%;
- z-index: 20000; /* Arbitrary, but some apps depend on it... */
- }
-
- .blocklyWidgetDiv .blocklyMenu.blocklyFocused {
- box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
- }
-
- .blocklyDropDownDiv .blocklyMenu {
- background: inherit; /* Compatibility with gapi, reset from goog-menu */
- border: inherit; /* Compatibility with gapi, reset from goog-menu */
- font: normal 13px "Helvetica Neue", Helvetica, sans-serif;
- outline: none;
- position: relative; /* Compatibility with gapi, reset from goog-menu */
- z-index: 20000; /* Arbitrary, but some apps depend on it... */
- }
-
- /* State: resting. */
- .blocklyMenuItem {
- border: none;
- color: #000;
- cursor: pointer;
- list-style: none;
- margin: 0;
- /* 7em on the right for shortcut. */
- min-width: 7em;
- padding: 6px 15px;
- white-space: nowrap;
- }
-
- /* State: disabled. */
- .blocklyMenuItemDisabled {
- color: #ccc;
- cursor: inherit;
- }
-
- /* State: hover. */
- .blocklyMenuItemHighlight {
- background-color: rgba(0,0,0,.1);
- }
-
- /* State: selected/checked. */
- .blocklyMenuItemCheckbox {
- height: 16px;
- position: absolute;
- width: 16px;
- }
-
- .blocklyMenuItemSelected .blocklyMenuItemCheckbox {
- background: url(<<>>/sprites.png) no-repeat -48px -16px;
- float: left;
- margin-left: -24px;
- position: static; /* Scroll with the menu. */
- }
-
- .blocklyMenuItemRtl .blocklyMenuItemCheckbox {
- float: right;
- margin-right: -24px;
- }
+.blocklyBlockDragSurface .blocklyDraggable {
+ /* backup for browsers (e.g. IE11) that don't support grabbing */
+ cursor: url("<<>>/handclosed.cur"), auto;
+ cursor: grabbing;
+ cursor: -webkit-grabbing;
+}
+
+.blocklyDragging.blocklyDraggingDelete {
+ cursor: url("<<>>/handdelete.cur"), auto;
+}
+
+.blocklyDragging>.blocklyPath,
+.blocklyDragging>.blocklyPathLight {
+ fill-opacity: .8;
+ stroke-opacity: .8;
+}
+
+.blocklyDragging>.blocklyPathDark {
+ display: none;
+}
+
+.blocklyDisabled>.blocklyPath {
+ fill-opacity: .5;
+ stroke-opacity: .5;
+}
+
+.blocklyDisabled>.blocklyPathLight,
+.blocklyDisabled>.blocklyPathDark {
+ display: none;
+}
+
+.blocklyInsertionMarker>.blocklyPath,
+.blocklyInsertionMarker>.blocklyPathLight,
+.blocklyInsertionMarker>.blocklyPathDark {
+ fill-opacity: .2;
+ stroke: none;
+}
+
+.blocklyMultilineText {
+ font-family: monospace;
+}
+
+.blocklyNonEditableText>text {
+ pointer-events: none;
+}
+
+.blocklyFlyout {
+ position: absolute;
+ z-index: 20;
+}
+
+.blocklyText text {
+ cursor: default;
+}
+
+/*
+ Don't allow users to select text. It gets annoying when trying to
+ drag a block and selected text moves instead.
+*/
+.blocklySvg text,
+.blocklyBlockDragSurface text {
+ user-select: none;
+ -ms-user-select: none;
+ -webkit-user-select: none;
+ cursor: inherit;
+}
+
+.blocklyHidden {
+ display: none;
+}
+
+.blocklyFieldDropdown:not(.blocklyHidden) {
+ display: block;
+}
+
+.blocklyIconGroup {
+ cursor: default;
+}
+
+.blocklyIconGroup:not(:hover),
+.blocklyIconGroupReadonly {
+ opacity: .6;
+}
+
+.blocklyIconShape {
+ fill: #00f;
+ stroke: #fff;
+ stroke-width: 1px;
+}
+
+.blocklyIconSymbol {
+ fill: #fff;
+}
+
+.blocklyMinimalBody {
+ margin: 0;
+ padding: 0;
+}
+
+.blocklyHtmlInput {
+ border: none;
+ border-radius: 4px;
+ height: 100%;
+ margin: 0;
+ outline: none;
+ padding: 0;
+ width: 100%;
+ text-align: center;
+ display: block;
+ box-sizing: border-box;
+}
+
+/* Edge and IE introduce a close icon when the input value is longer than a
+ certain length. This affects our sizing calculations of the text input.
+ Hiding the close icon to avoid that. */
+.blocklyHtmlInput::-ms-clear {
+ display: none;
+}
+
+.blocklyMainBackground {
+ stroke-width: 1;
+ stroke: #c6c6c6; /* Equates to #ddd due to border being off-pixel. */
+}
+
+.blocklyMutatorBackground {
+ fill: #fff;
+ stroke: #ddd;
+ stroke-width: 1;
+}
+
+.blocklyFlyoutBackground {
+ fill: #ddd;
+ fill-opacity: .8;
+}
+
+.blocklyMainWorkspaceScrollbar {
+ z-index: 20;
+}
+
+.blocklyFlyoutScrollbar {
+ z-index: 30;
+}
+
+.blocklyScrollbarHorizontal,
+.blocklyScrollbarVertical {
+ position: absolute;
+ outline: none;
+}
+
+.blocklyScrollbarBackground {
+ opacity: 0;
+}
+
+.blocklyScrollbarHandle {
+ fill: #ccc;
+}
+
+.blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,
+.blocklyScrollbarHandle:hover {
+ fill: #bbb;
+}
+
+/* Darken flyout scrollbars due to being on a grey background. */
+/* By contrast, workspace scrollbars are on a white background. */
+.blocklyFlyout .blocklyScrollbarHandle {
+ fill: #bbb;
+}
+
+.blocklyFlyout .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,
+.blocklyFlyout .blocklyScrollbarHandle:hover {
+ fill: #aaa;
+}
+
+.blocklyInvalidInput {
+ background: #faa;
+}
+
+.blocklyVerticalMarker {
+ stroke-width: 3px;
+ fill: rgba(255,255,255,.5);
+ pointer-events: none;
+}
+
+.blocklyComputeCanvas {
+ position: absolute;
+ width: 0;
+ height: 0;
+}
+
+.blocklyNoPointerEvents {
+ pointer-events: none;
+}
+
+.blocklyContextMenu {
+ border-radius: 4px;
+ max-height: 100%;
+}
+
+.blocklyDropdownMenu {
+ border-radius: 2px;
+ padding: 0 !important;
+}
+
+.blocklyDropdownMenu .blocklyMenuItem {
+ /* 28px on the left for icon or checkbox. */
+ padding-left: 28px;
+}
+
+/* BiDi override for the resting state. */
+.blocklyDropdownMenu .blocklyMenuItemRtl {
+ /* Flip left/right padding for BiDi. */
+ padding-left: 5px;
+ padding-right: 28px;
+}
+
+.blocklyWidgetDiv .blocklyMenu {
+ background: #fff;
+ border: 1px solid transparent;
+ box-shadow: 0 0 3px 1px rgba(0,0,0,.3);
+ font: normal 13px Arial, sans-serif;
+ margin: 0;
+ outline: none;
+ padding: 4px 0;
+ position: absolute;
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-height: 100%;
+ z-index: 20000; /* Arbitrary, but some apps depend on it... */
+}
+
+.blocklyWidgetDiv .blocklyMenu.blocklyFocused {
+ box-shadow: 0 0 6px 1px rgba(0,0,0,.3);
+}
+
+.blocklyDropDownDiv .blocklyMenu {
+ background: inherit; /* Compatibility with gapi, reset from goog-menu */
+ border: inherit; /* Compatibility with gapi, reset from goog-menu */
+ font: normal 13px "Helvetica Neue", Helvetica, sans-serif;
+ outline: none;
+ position: relative; /* Compatibility with gapi, reset from goog-menu */
+ z-index: 20000; /* Arbitrary, but some apps depend on it... */
+}
+
+/* State: resting. */
+.blocklyMenuItem {
+ border: none;
+ color: #000;
+ cursor: pointer;
+ list-style: none;
+ margin: 0;
+ /* 7em on the right for shortcut. */
+ min-width: 7em;
+ padding: 6px 15px;
+ white-space: nowrap;
+}
+
+/* State: disabled. */
+.blocklyMenuItemDisabled {
+ color: #ccc;
+ cursor: inherit;
+}
+
+/* State: hover. */
+.blocklyMenuItemHighlight {
+ background-color: rgba(0,0,0,.1);
+}
+
+/* State: selected/checked. */
+.blocklyMenuItemCheckbox {
+ height: 16px;
+ position: absolute;
+ width: 16px;
+}
+
+.blocklyMenuItemSelected .blocklyMenuItemCheckbox {
+ background: url(<<>>/sprites.png) no-repeat -48px -16px;
+ float: left;
+ margin-left: -24px;
+ position: static; /* Scroll with the menu. */
+}
+
+.blocklyMenuItemRtl .blocklyMenuItemCheckbox {
+ float: right;
+ margin-right: -24px;
+}
`);
exports.content = content;
diff --git a/core/delete_area.js b/core/delete_area.js
index b856d8a97..3985fca90 100644
--- a/core/delete_area.js
+++ b/core/delete_area.js
@@ -18,7 +18,6 @@
*/
goog.module('Blockly.DeleteArea');
-const object = goog.require('Blockly.utils.object');
const {BlockSvg} = goog.require('Blockly.BlockSvg');
const {DragTarget} = goog.require('Blockly.DragTarget');
/* eslint-disable-next-line no-unused-vars */
@@ -32,53 +31,57 @@ const {IDraggable} = goog.requireType('Blockly.IDraggable');
* dropped on top of it.
* @extends {DragTarget}
* @implements {IDeleteArea}
- * @constructor
* @alias Blockly.DeleteArea
*/
-const DeleteArea = function() {
- DeleteArea.superClass_.constructor.call(this);
+class DeleteArea extends DragTarget {
+ /**
+ * Constructor for DeleteArea. Should not be called directly, only by a
+ * subclass.
+ */
+ constructor() {
+ super();
+
+ /**
+ * Whether the last block or bubble dragged over this delete area would be
+ * deleted if dropped on this component.
+ * This property is not updated after the block or bubble is deleted.
+ * @type {boolean}
+ * @protected
+ */
+ this.wouldDelete_ = false;
+ }
/**
- * Whether the last block or bubble dragged over this delete area would be
- * deleted if dropped on this component.
- * This property is not updated after the block or bubble is deleted.
- * @type {boolean}
+ * Returns whether the provided block or bubble would be deleted if dropped on
+ * this area.
+ * This method should check if the element is deletable and is always called
+ * before onDragEnter/onDragOver/onDragExit.
+ * @param {!IDraggable} element The block or bubble currently being
+ * dragged.
+ * @param {boolean} couldConnect Whether the element could could connect to
+ * another.
+ * @return {boolean} Whether the element provided would be deleted if dropped
+ * on this area.
+ */
+ wouldDelete(element, couldConnect) {
+ if (element instanceof BlockSvg) {
+ const block = /** @type {BlockSvg} */ (element);
+ const couldDeleteBlock = !block.getParent() && block.isDeletable();
+ this.updateWouldDelete_(couldDeleteBlock && !couldConnect);
+ } else {
+ this.updateWouldDelete_(element.isDeletable());
+ }
+ return this.wouldDelete_;
+ }
+
+ /**
+ * Updates the internal wouldDelete_ state.
+ * @param {boolean} wouldDelete The new value for the wouldDelete state.
* @protected
*/
- this.wouldDelete_ = false;
-};
-object.inherits(DeleteArea, DragTarget);
-
-/**
- * Returns whether the provided block or bubble would be deleted if dropped on
- * this area.
- * This method should check if the element is deletable and is always called
- * before onDragEnter/onDragOver/onDragExit.
- * @param {!IDraggable} element The block or bubble currently being
- * dragged.
- * @param {boolean} couldConnect Whether the element could could connect to
- * another.
- * @return {boolean} Whether the element provided would be deleted if dropped on
- * this area.
- */
-DeleteArea.prototype.wouldDelete = function(element, couldConnect) {
- if (element instanceof BlockSvg) {
- const block = /** @type {BlockSvg} */ (element);
- const couldDeleteBlock = !block.getParent() && block.isDeletable();
- this.updateWouldDelete_(couldDeleteBlock && !couldConnect);
- } else {
- this.updateWouldDelete_(element.isDeletable());
+ updateWouldDelete_(wouldDelete) {
+ this.wouldDelete_ = wouldDelete;
}
- return this.wouldDelete_;
-};
-
-/**
- * Updates the internal wouldDelete_ state.
- * @param {boolean} wouldDelete The new value for the wouldDelete state.
- * @protected
- */
-DeleteArea.prototype.updateWouldDelete_ = function(wouldDelete) {
- this.wouldDelete_ = wouldDelete;
-};
+}
exports.DeleteArea = DeleteArea;
diff --git a/core/drag_target.js b/core/drag_target.js
index f552586bd..64a3e3ce3 100644
--- a/core/drag_target.js
+++ b/core/drag_target.js
@@ -30,68 +30,69 @@ const {Rect} = goog.requireType('Blockly.utils.Rect');
* Abstract class for a component with custom behaviour when a block or bubble
* is dragged over or dropped on top of it.
* @implements {IDragTarget}
- * @constructor
* @alias Blockly.DragTarget
*/
-const DragTarget = function() {};
+class DragTarget {
+ /**
+ * Handles when a cursor with a block or bubble enters this drag target.
+ * @param {!IDraggable} _dragElement The block or bubble currently being
+ * dragged.
+ */
+ onDragEnter(_dragElement) {
+ // no-op
+ }
-/**
- * Returns the bounding rectangle of the drag target area in pixel units
- * relative to the Blockly injection div.
- * @return {?Rect} The component's bounding box. Null if drag
- * target area should be ignored.
- */
-DragTarget.prototype.getClientRect;
+ /**
+ * Handles when a cursor with a block or bubble is dragged over this drag
+ * target.
+ * @param {!IDraggable} _dragElement The block or bubble currently being
+ * dragged.
+ */
+ onDragOver(_dragElement) {
+ // no-op
+ }
-/**
- * Handles when a cursor with a block or bubble enters this drag target.
- * @param {!IDraggable} _dragElement The block or bubble currently being
- * dragged.
- */
-DragTarget.prototype.onDragEnter = function(_dragElement) {
- // no-op
-};
+ /**
+ * Handles when a cursor with a block or bubble exits this drag target.
+ * @param {!IDraggable} _dragElement The block or bubble currently being
+ * dragged.
+ */
+ onDragExit(_dragElement) {
+ // no-op
+ }
-/**
- * Handles when a cursor with a block or bubble is dragged over this drag
- * target.
- * @param {!IDraggable} _dragElement The block or bubble currently being
- * dragged.
- */
-DragTarget.prototype.onDragOver = function(_dragElement) {
- // no-op
-};
+ /**
+ * Handles when a block or bubble is dropped on this component.
+ * Should not handle delete here.
+ * @param {!IDraggable} _dragElement The block or bubble currently being
+ * dragged.
+ */
+ onDrop(_dragElement) {
+ // no-op
+ }
-/**
- * Handles when a cursor with a block or bubble exits this drag target.
- * @param {!IDraggable} _dragElement The block or bubble currently being
- * dragged.
- */
-DragTarget.prototype.onDragExit = function(_dragElement) {
- // no-op
-};
+ /**
+ * Returns the bounding rectangle of the drag target area in pixel units
+ * relative to the Blockly injection div.
+ * @return {?Rect} The component's bounding box. Null if drag
+ * target area should be ignored.
+ */
+ getClientRect() {
+ return null;
+ }
-/**
- * Handles when a block or bubble is dropped on this component.
- * Should not handle delete here.
- * @param {!IDraggable} _dragElement The block or bubble currently being
- * dragged.
- */
-DragTarget.prototype.onDrop = function(_dragElement) {
- // no-op
-};
-
-/**
- * Returns whether the provided block or bubble should not be moved after being
- * dropped on this component. If true, the element will return to where it was
- * when the drag started.
- * @param {!IDraggable} _dragElement The block or bubble currently being
- * dragged.
- * @return {boolean} Whether the block or bubble provided should be returned to
- * drag start.
- */
-DragTarget.prototype.shouldPreventMove = function(_dragElement) {
- return false;
-};
+ /**
+ * Returns whether the provided block or bubble should not be moved after
+ * being dropped on this component. If true, the element will return to where
+ * it was when the drag started.
+ * @param {!IDraggable} _dragElement The block or bubble currently being
+ * dragged.
+ * @return {boolean} Whether the block or bubble provided should be returned
+ * to drag start.
+ */
+ shouldPreventMove(_dragElement) {
+ return false;
+ }
+}
exports.DragTarget = DragTarget;
diff --git a/core/dropdowndiv.js b/core/dropdowndiv.js
index a22baa944..bd6e8afea 100644
--- a/core/dropdowndiv.js
+++ b/core/dropdowndiv.js
@@ -16,7 +16,7 @@
* A div that floats on top of the workspace, for drop-down menus.
* @class
*/
-goog.module('Blockly.DropDownDiv');
+goog.module('Blockly.dropDownDiv');
const common = goog.require('Blockly.common');
const dom = goog.require('Blockly.utils.dom');
@@ -33,21 +33,14 @@ const {Size} = goog.requireType('Blockly.utils.Size');
const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg');
-/**
- * Class for drop-down div.
- * @constructor
- * @package
- * @alias Blockly.DropDownDiv
- */
-const DropDownDiv = function() {};
-
/**
* Arrow size in px. Should match the value in CSS
* (need to position pre-render).
* @type {number}
* @const
*/
-DropDownDiv.ARROW_SIZE = 16;
+const ARROW_SIZE = 16;
+exports.ARROW_SIZE = ARROW_SIZE;
/**
* Drop-down border size in px. Should match the value in CSS (need to position
@@ -55,7 +48,8 @@ DropDownDiv.ARROW_SIZE = 16;
* @type {number}
* @const
*/
-DropDownDiv.BORDER_SIZE = 1;
+const BORDER_SIZE = 1;
+exports.BORDER_SIZE = BORDER_SIZE;
/**
* Amount the arrow must be kept away from the edges of the main drop-down div,
@@ -63,93 +57,86 @@ DropDownDiv.BORDER_SIZE = 1;
* @type {number}
* @const
*/
-DropDownDiv.ARROW_HORIZONTAL_PADDING = 12;
+const ARROW_HORIZONTAL_PADDING = 12;
+exports.ARROW_HORIZONTAL_PADDING = ARROW_HORIZONTAL_PADDING;
/**
* Amount drop-downs should be padded away from the source, in px.
* @type {number}
* @const
*/
-DropDownDiv.PADDING_Y = 16;
+const PADDING_Y = 16;
+exports.PADDING_Y = PADDING_Y;
/**
* Length of animations in seconds.
* @type {number}
* @const
*/
-DropDownDiv.ANIMATION_TIME = 0.25;
+const ANIMATION_TIME = 0.25;
+exports.ANIMATION_TIME = ANIMATION_TIME;
/**
* Timer for animation out, to be cleared if we need to immediately hide
* without disrupting new shows.
* @type {?number}
- * @private
*/
-DropDownDiv.animateOutTimer_ = null;
+let animateOutTimer = null;
/**
* Callback for when the drop-down is hidden.
* @type {?Function}
- * @private
*/
-DropDownDiv.onHide_ = null;
+let onHide = null;
/**
* A class name representing the current owner's workspace renderer.
* @type {string}
- * @private
*/
-DropDownDiv.rendererClassName_ = '';
+let renderedClassName = '';
/**
* A class name representing the current owner's workspace theme.
* @type {string}
- * @private
*/
-DropDownDiv.themeClassName_ = '';
+let themeClassName = '';
/**
* The content element.
- * @type {!Element}
- * @private
+ * @type {!HTMLDivElement}
*/
-DropDownDiv.DIV_;
+let div;
/**
* The content element.
- * @type {!Element}
- * @private
+ * @type {!HTMLDivElement}
*/
-DropDownDiv.content_;
+let content;
/**
* The arrow element.
- * @type {!Element}
- * @private
+ * @type {!HTMLDivElement}
*/
-DropDownDiv.arrow_;
+let arrow;
/**
* Drop-downs will appear within the bounds of this element if possible.
- * Set in DropDownDiv.setBoundsElement.
+ * Set in setBoundsElement.
* @type {?Element}
- * @private
*/
-DropDownDiv.boundsElement_ = null;
+let boundsElement = null;
/**
* The object currently using the drop-down.
* @type {?Object}
- * @private
*/
-DropDownDiv.owner_ = null;
+let owner = null;
/**
* Whether the dropdown was positioned to a field or the source block.
* @type {?boolean}
- * @private
*/
-DropDownDiv.positionToField_ = null;
+let positionToField = null;
/**
* Dropdown bounds info object used to encapsulate sizing information about a
@@ -163,7 +150,8 @@ DropDownDiv.positionToField_ = null;
* height:number
* }}
*/
-DropDownDiv.BoundsInfo;
+let BoundsInfo;
+exports.BoundsInfo = BoundsInfo;
/**
* Dropdown position metrics.
@@ -178,84 +166,85 @@ DropDownDiv.BoundsInfo;
* arrowVisible:boolean
* }}
*/
-DropDownDiv.PositionMetrics;
+let PositionMetrics;
+exports.PositionMetrics = PositionMetrics;
/**
* Create and insert the DOM element for this div.
* @package
*/
-DropDownDiv.createDom = function() {
- if (DropDownDiv.DIV_) {
+const createDom = function() {
+ if (div) {
return; // Already created.
}
- const containerDiv = document.createElement('div');
- containerDiv.className = 'blocklyDropDownDiv';
+ div = /** @type {!HTMLDivElement} */ (document.createElement('div'));
+ div.className = 'blocklyDropDownDiv';
const parentDiv = common.getParentContainer() || document.body;
- parentDiv.appendChild(containerDiv);
+ parentDiv.appendChild(div);
- DropDownDiv.DIV_ = containerDiv;
-
- const content = document.createElement('div');
+ content = /** @type {!HTMLDivElement} */ (document.createElement('div'));
content.className = 'blocklyDropDownContent';
- DropDownDiv.DIV_.appendChild(content);
- DropDownDiv.content_ = content;
+ div.appendChild(content);
- const arrow = document.createElement('div');
+ arrow = /** @type {!HTMLDivElement} */ (document.createElement('div'));
arrow.className = 'blocklyDropDownArrow';
- DropDownDiv.DIV_.appendChild(arrow);
- DropDownDiv.arrow_ = arrow;
+ div.appendChild(arrow);
- DropDownDiv.DIV_.style.opacity = 0;
+ div.style.opacity = 0;
// Transition animation for transform: translate() and opacity.
- DropDownDiv.DIV_.style.transition = 'transform ' +
- DropDownDiv.ANIMATION_TIME + 's, ' +
- 'opacity ' + DropDownDiv.ANIMATION_TIME + 's';
+ div.style.transition = 'transform ' + ANIMATION_TIME + 's, ' +
+ 'opacity ' + ANIMATION_TIME + 's';
// Handle focusin/out events to add a visual indicator when
// a child is focused or blurred.
- DropDownDiv.DIV_.addEventListener('focusin', function() {
- dom.addClass(DropDownDiv.DIV_, 'blocklyFocused');
+ div.addEventListener('focusin', function() {
+ dom.addClass(div, 'blocklyFocused');
});
- DropDownDiv.DIV_.addEventListener('focusout', function() {
- dom.removeClass(DropDownDiv.DIV_, 'blocklyFocused');
+ div.addEventListener('focusout', function() {
+ dom.removeClass(div, 'blocklyFocused');
});
};
+exports.createDom = createDom;
/**
* Set an element to maintain bounds within. Drop-downs will appear
* within the box of this element if possible.
- * @param {?Element} boundsElement Element to bind drop-down to.
+ * @param {?Element} boundsElem Element to bind drop-down to.
*/
-DropDownDiv.setBoundsElement = function(boundsElement) {
- DropDownDiv.boundsElement_ = boundsElement;
+const setBoundsElement = function(boundsElem) {
+ boundsElement = boundsElem;
};
+exports.setBoundsElement = setBoundsElement;
/**
* Provide the div for inserting content into the drop-down.
* @return {!Element} Div to populate with content.
*/
-DropDownDiv.getContentDiv = function() {
- return DropDownDiv.content_;
+const getContentDiv = function() {
+ return content;
};
+exports.getContentDiv = getContentDiv;
/**
* Clear the content of the drop-down.
*/
-DropDownDiv.clearContent = function() {
- DropDownDiv.content_.textContent = '';
- DropDownDiv.content_.style.width = '';
+const clearContent = function() {
+ content.textContent = '';
+ content.style.width = '';
};
+exports.clearContent = clearContent;
/**
* Set the colour for the drop-down.
* @param {string} backgroundColour Any CSS colour for the background.
* @param {string} borderColour Any CSS colour for the border.
*/
-DropDownDiv.setColour = function(backgroundColour, borderColour) {
- DropDownDiv.DIV_.style.backgroundColor = backgroundColour;
- DropDownDiv.DIV_.style.borderColor = borderColour;
+const setColour = function(backgroundColour, borderColour) {
+ div.style.backgroundColor = backgroundColour;
+ div.style.borderColor = borderColour;
};
+exports.setColour = setColour;
/**
* Shortcut to show and place the drop-down with positioning determined
@@ -270,11 +259,12 @@ DropDownDiv.setColour = function(backgroundColour, borderColour) {
* positioning.
* @return {boolean} True if the menu rendered below block; false if above.
*/
-DropDownDiv.showPositionedByBlock = function(
+const showPositionedByBlock = function(
field, block, opt_onHide, opt_secondaryYOffset) {
return showPositionedByRect(
getScaledBboxOfBlock(block), field, opt_onHide, opt_secondaryYOffset);
};
+exports.showPositionedByBlock = showPositionedByBlock;
/**
* Shortcut to show and place the drop-down with positioning determined
@@ -288,14 +278,13 @@ DropDownDiv.showPositionedByBlock = function(
* positioning.
* @return {boolean} True if the menu rendered below block; false if above.
*/
-DropDownDiv.showPositionedByField = function(
+const showPositionedByField = function(
field, opt_onHide, opt_secondaryYOffset) {
- DropDownDiv.positionToField_ = true;
+ positionToField = true;
return showPositionedByRect(
getScaledBboxOfField(field), field, opt_onHide, opt_secondaryYOffset);
};
-
-const internal = {};
+exports.showPositionedByField = showPositionedByField;
/**
* Get the scaled bounding box of a block.
@@ -353,9 +342,9 @@ const showPositionedByRect = function(
workspace =
/** @type {!WorkspaceSvg} */ (workspace.options.parentWorkspace);
}
- DropDownDiv.setBoundsElement(
+ setBoundsElement(
/** @type {?Element} */ (workspace.getParentSvg().parentNode));
- return DropDownDiv.show(
+ return show(
field, sourceBlock.RTL, primaryX, primaryY, secondaryX, secondaryY,
opt_onHide);
};
@@ -368,7 +357,7 @@ const showPositionedByRect = function(
* will point there, and the container will be positioned below it.
* If we can't maintain the container bounds at the primary point, fall-back to
* the secondary point and position above.
- * @param {?Object} owner The object showing the drop-down
+ * @param {?Object} newOwner The object showing the drop-down
* @param {boolean} rtl Right-to-left (true) or left-to-right (false).
* @param {number} primaryX Desired origin point x, in absolute px.
* @param {number} primaryY Desired origin point y, in absolute px.
@@ -381,20 +370,19 @@ const showPositionedByRect = function(
* @return {boolean} True if the menu rendered at the primary origin point.
* @package
*/
-DropDownDiv.show = function(
- owner, rtl, primaryX, primaryY, secondaryX, secondaryY, opt_onHide) {
- DropDownDiv.owner_ = owner;
- DropDownDiv.onHide_ = opt_onHide || null;
+const show = function(
+ newOwner, rtl, primaryX, primaryY, secondaryX, secondaryY, opt_onHide) {
+ owner = newOwner;
+ onHide = opt_onHide || null;
// Set direction.
- const div = DropDownDiv.DIV_;
div.style.direction = rtl ? 'rtl' : 'ltr';
const mainWorkspace =
/** @type {!WorkspaceSvg} */ (common.getMainWorkspace());
- DropDownDiv.rendererClassName_ = mainWorkspace.getRenderer().getClassName();
- DropDownDiv.themeClassName_ = mainWorkspace.getTheme().getClassName();
- dom.addClass(div, DropDownDiv.rendererClassName_);
- dom.addClass(div, DropDownDiv.themeClassName_);
+ renderedClassName = mainWorkspace.getRenderer().getClassName();
+ themeClassName = mainWorkspace.getTheme().getClassName();
+ dom.addClass(div, renderedClassName);
+ dom.addClass(div, themeClassName);
// When we change `translate` multiple times in close succession,
// Chrome may choose to wait and apply them all at once.
@@ -407,17 +395,20 @@ DropDownDiv.show = function(
return positionInternal(primaryX, primaryY, secondaryX, secondaryY);
};
+exports.show = show;
+
+const internal = {};
/**
* Get sizing info about the bounding element.
- * @return {!DropDownDiv.BoundsInfo} An object containing size
+ * @return {!BoundsInfo} An object containing size
* information about the bounding element (bounding box and width/height).
*/
internal.getBoundsInfo = function() {
const boundPosition = style.getPageOffset(
- /** @type {!Element} */ (DropDownDiv.boundsElement_));
+ /** @type {!Element} */ (boundsElement));
const boundSize = style.getSize(
- /** @type {!Element} */ (DropDownDiv.boundsElement_));
+ /** @type {!Element} */ (boundsElement));
return {
left: boundPosition.x,
@@ -431,21 +422,21 @@ internal.getBoundsInfo = function() {
/**
* Helper to position the drop-down and the arrow, maintaining bounds.
- * See explanation of origin points in DropDownDiv.show.
+ * See explanation of origin points in show.
* @param {number} primaryX Desired origin point x, in absolute px.
* @param {number} primaryY Desired origin point y, in absolute px.
* @param {number} secondaryX Secondary/alternative origin point x,
* in absolute px.
* @param {number} secondaryY Secondary/alternative origin point y,
* in absolute px.
- * @return {!DropDownDiv.PositionMetrics} Various final metrics,
+ * @return {!PositionMetrics} Various final metrics,
* including rendered positions for drop-down and arrow.
*/
internal.getPositionMetrics = function(
primaryX, primaryY, secondaryX, secondaryY) {
const boundsInfo = internal.getBoundsInfo();
const divSize = style.getSize(
- /** @type {!Element} */ (DropDownDiv.DIV_));
+ /** @type {!Element} */ (div));
// Can we fit in-bounds below the target?
if (primaryY + divSize.height < boundsInfo.bottom) {
@@ -472,20 +463,20 @@ internal.getPositionMetrics = function(
* Get the metrics for positioning the div below the source.
* @param {number} primaryX Desired origin point x, in absolute px.
* @param {number} primaryY Desired origin point y, in absolute px.
- * @param {!DropDownDiv.BoundsInfo} boundsInfo An object containing size
+ * @param {!BoundsInfo} boundsInfo An object containing size
* information about the bounding element (bounding box and width/height).
* @param {!Size} divSize An object containing information about
* the size of the DropDownDiv (width & height).
- * @return {!DropDownDiv.PositionMetrics} Various final metrics,
+ * @return {!PositionMetrics} Various final metrics,
* including rendered positions for drop-down and arrow.
*/
const getPositionBelowMetrics = function(
primaryX, primaryY, boundsInfo, divSize) {
- const xCoords = DropDownDiv.getPositionX(
- primaryX, boundsInfo.left, boundsInfo.right, divSize.width);
+ const xCoords =
+ getPositionX(primaryX, boundsInfo.left, boundsInfo.right, divSize.width);
- const arrowY = -(DropDownDiv.ARROW_SIZE / 2 + DropDownDiv.BORDER_SIZE);
- const finalY = primaryY + DropDownDiv.PADDING_Y;
+ const arrowY = -(ARROW_SIZE / 2 + BORDER_SIZE);
+ const finalY = primaryY + PADDING_Y;
return {
initialX: xCoords.divX,
@@ -505,21 +496,20 @@ const getPositionBelowMetrics = function(
* in absolute px.
* @param {number} secondaryY Secondary/alternative origin point y,
* in absolute px.
- * @param {!DropDownDiv.BoundsInfo} boundsInfo An object containing size
+ * @param {!BoundsInfo} boundsInfo An object containing size
* information about the bounding element (bounding box and width/height).
* @param {!Size} divSize An object containing information about
* the size of the DropDownDiv (width & height).
- * @return {!DropDownDiv.PositionMetrics} Various final metrics,
+ * @return {!PositionMetrics} Various final metrics,
* including rendered positions for drop-down and arrow.
*/
const getPositionAboveMetrics = function(
secondaryX, secondaryY, boundsInfo, divSize) {
- const xCoords = DropDownDiv.getPositionX(
+ const xCoords = getPositionX(
secondaryX, boundsInfo.left, boundsInfo.right, divSize.width);
- const arrowY = divSize.height - (DropDownDiv.BORDER_SIZE * 2) -
- (DropDownDiv.ARROW_SIZE / 2);
- const finalY = secondaryY - divSize.height - DropDownDiv.PADDING_Y;
+ const arrowY = divSize.height - (BORDER_SIZE * 2) - (ARROW_SIZE / 2);
+ const finalY = secondaryY - divSize.height - PADDING_Y;
const initialY = secondaryY - divSize.height; // No padding on Y.
return {
@@ -537,16 +527,16 @@ const getPositionAboveMetrics = function(
/**
* Get the metrics for positioning the div at the top of the page.
* @param {number} sourceX Desired origin point x, in absolute px.
- * @param {!DropDownDiv.BoundsInfo} boundsInfo An object containing size
+ * @param {!BoundsInfo} boundsInfo An object containing size
* information about the bounding element (bounding box and width/height).
* @param {!Size} divSize An object containing information about
* the size of the DropDownDiv (width & height).
- * @return {!DropDownDiv.PositionMetrics} Various final metrics,
+ * @return {!PositionMetrics} Various final metrics,
* including rendered positions for drop-down and arrow.
*/
const getPositionTopOfPageMetrics = function(sourceX, boundsInfo, divSize) {
- const xCoords = DropDownDiv.getPositionX(
- sourceX, boundsInfo.left, boundsInfo.right, divSize.width);
+ const xCoords =
+ getPositionX(sourceX, boundsInfo.left, boundsInfo.right, divSize.width);
// No need to provide arrow-specific information because it won't be visible.
return {
@@ -574,8 +564,7 @@ const getPositionTopOfPageMetrics = function(sourceX, boundsInfo, divSize) {
* the x positions of the left side of the DropDownDiv and the arrow.
* @package
*/
-DropDownDiv.getPositionX = function(
- sourceX, boundsLeft, boundsRight, divWidth) {
+const getPositionX = function(sourceX, boundsLeft, boundsRight, divWidth) {
let divX = sourceX;
// Offset the topLeft coord so that the dropdowndiv is centered.
divX -= divWidth / 2;
@@ -584,77 +573,79 @@ DropDownDiv.getPositionX = function(
let arrowX = sourceX;
// Offset the arrow coord so that the arrow is centered.
- arrowX -= DropDownDiv.ARROW_SIZE / 2;
+ arrowX -= ARROW_SIZE / 2;
// Convert the arrow position to be relative to the top left of the div.
let relativeArrowX = arrowX - divX;
- const horizPadding = DropDownDiv.ARROW_HORIZONTAL_PADDING;
+ const horizPadding = ARROW_HORIZONTAL_PADDING;
// Clamp the arrow position so that it stays attached to the dropdowndiv.
relativeArrowX = math.clamp(
- horizPadding, relativeArrowX,
- divWidth - horizPadding - DropDownDiv.ARROW_SIZE);
+ horizPadding, relativeArrowX, divWidth - horizPadding - ARROW_SIZE);
return {arrowX: relativeArrowX, divX: divX};
};
+exports.getPositionX = getPositionX;
/**
* Is the container visible?
* @return {boolean} True if visible.
*/
-DropDownDiv.isVisible = function() {
- return !!DropDownDiv.owner_;
+const isVisible = function() {
+ return !!owner;
};
+exports.isVisible = isVisible;
/**
* Hide the menu only if it is owned by the provided object.
- * @param {?Object} owner Object which must be owning the drop-down to hide.
+ * @param {?Object} divOwner Object which must be owning the drop-down to hide.
* @param {boolean=} opt_withoutAnimation True if we should hide the dropdown
* without animating.
* @return {boolean} True if hidden.
*/
-DropDownDiv.hideIfOwner = function(owner, opt_withoutAnimation) {
- if (DropDownDiv.owner_ === owner) {
+const hideIfOwner = function(divOwner, opt_withoutAnimation) {
+ if (owner === divOwner) {
if (opt_withoutAnimation) {
- DropDownDiv.hideWithoutAnimation();
+ hideWithoutAnimation();
} else {
- DropDownDiv.hide();
+ hide();
}
return true;
}
return false;
};
+exports.hideIfOwner = hideIfOwner;
/**
* Hide the menu, triggering animation.
*/
-DropDownDiv.hide = function() {
+const hide = function() {
// Start the animation by setting the translation and fading out.
// Reset to (initialX, initialY) - i.e., no translation.
- DropDownDiv.DIV_.style.transform = 'translate(0, 0)';
- DropDownDiv.DIV_.style.opacity = 0;
+ div.style.transform = 'translate(0, 0)';
+ div.style.opacity = 0;
// Finish animation - reset all values to default.
- DropDownDiv.animateOutTimer_ = setTimeout(function() {
- DropDownDiv.hideWithoutAnimation();
- }, DropDownDiv.ANIMATION_TIME * 1000);
- if (DropDownDiv.onHide_) {
- DropDownDiv.onHide_();
- DropDownDiv.onHide_ = null;
+ animateOutTimer = setTimeout(function() {
+ hideWithoutAnimation();
+ }, ANIMATION_TIME * 1000);
+ if (onHide) {
+ onHide();
+ onHide = null;
}
};
+exports.hide = hide;
/**
* Hide the menu, without animation.
*/
-DropDownDiv.hideWithoutAnimation = function() {
- if (!DropDownDiv.isVisible()) {
+const hideWithoutAnimation = function() {
+ if (!isVisible()) {
return;
}
- if (DropDownDiv.animateOutTimer_) {
- clearTimeout(DropDownDiv.animateOutTimer_);
+ if (animateOutTimer) {
+ clearTimeout(animateOutTimer);
}
// Reset style properties in case this gets called directly
// instead of hide() - see discussion on #2551.
- const div = DropDownDiv.DIV_;
div.style.transform = '';
div.style.left = '';
div.style.top = '';
@@ -663,23 +654,24 @@ DropDownDiv.hideWithoutAnimation = function() {
div.style.backgroundColor = '';
div.style.borderColor = '';
- if (DropDownDiv.onHide_) {
- DropDownDiv.onHide_();
- DropDownDiv.onHide_ = null;
+ if (onHide) {
+ onHide();
+ onHide = null;
}
- DropDownDiv.clearContent();
- DropDownDiv.owner_ = null;
+ clearContent();
+ owner = null;
- if (DropDownDiv.rendererClassName_) {
- dom.removeClass(div, DropDownDiv.rendererClassName_);
- DropDownDiv.rendererClassName_ = '';
+ if (renderedClassName) {
+ dom.removeClass(div, renderedClassName);
+ renderedClassName = '';
}
- if (DropDownDiv.themeClassName_) {
- dom.removeClass(div, DropDownDiv.themeClassName_);
- DropDownDiv.themeClassName_ = '';
+ if (themeClassName) {
+ dom.removeClass(div, themeClassName);
+ themeClassName = '';
}
(/** @type {!WorkspaceSvg} */ (common.getMainWorkspace())).markFocused();
};
+exports.hideWithoutAnimation = hideWithoutAnimation;
/**
* Set the dropdown div's position.
@@ -697,15 +689,15 @@ const positionInternal = function(primaryX, primaryY, secondaryX, secondaryY) {
// Update arrow CSS.
if (metrics.arrowVisible) {
- DropDownDiv.arrow_.style.display = '';
- DropDownDiv.arrow_.style.transform = 'translate(' + metrics.arrowX + 'px,' +
+ arrow.style.display = '';
+ arrow.style.transform = 'translate(' + metrics.arrowX + 'px,' +
metrics.arrowY + 'px) rotate(45deg)';
- DropDownDiv.arrow_.setAttribute(
+ arrow.setAttribute(
'class',
metrics.arrowAtTop ? 'blocklyDropDownArrow blocklyArrowTop' :
'blocklyDropDownArrow blocklyArrowBottom');
} else {
- DropDownDiv.arrow_.style.display = 'none';
+ arrow.style.display = 'none';
}
const initialX = Math.floor(metrics.initialX);
@@ -713,7 +705,6 @@ const positionInternal = function(primaryX, primaryY, secondaryX, secondaryY) {
const finalX = Math.floor(metrics.finalX);
const finalY = Math.floor(metrics.finalY);
- const div = DropDownDiv.DIV_;
// First apply initial translation.
div.style.left = initialX + 'px';
div.style.top = initialY + 'px';
@@ -736,17 +727,17 @@ const positionInternal = function(primaryX, primaryY, secondaryX, secondaryY) {
* calculate the new position, it will just hide it instead.
* @package
*/
-DropDownDiv.repositionForWindowResize = function() {
+const repositionForWindowResize = function() {
// This condition mainly catches the dropdown div when it is being used as a
// dropdown. It is important not to close it in this case because on Android,
// when a field is focused, the soft keyboard opens triggering a window resize
// event and we want the dropdown div to stick around so users can type into
// it.
- if (DropDownDiv.owner_) {
- const field = /** @type {!Field} */ (DropDownDiv.owner_);
+ if (owner) {
+ const field = /** @type {!Field} */ (owner);
const block = /** @type {!BlockSvg} */ (field.getSourceBlock());
- const bBox = DropDownDiv.positionToField_ ? getScaledBboxOfField(field) :
- getScaledBboxOfBlock(block);
+ const bBox = positionToField ? getScaledBboxOfField(field) :
+ getScaledBboxOfBlock(block);
// If we can fit it, render below the block.
const primaryX = bBox.left + (bBox.right - bBox.left) / 2;
const primaryY = bBox.bottom;
@@ -755,10 +746,9 @@ DropDownDiv.repositionForWindowResize = function() {
const secondaryY = bBox.top;
positionInternal(primaryX, primaryY, secondaryX, secondaryY);
} else {
- DropDownDiv.hide();
+ hide();
}
};
+exports.repositionForWindowResize = repositionForWindowResize;
-DropDownDiv.TEST_ONLY = internal;
-
-exports.DropDownDiv = DropDownDiv;
+exports.TEST_ONLY = internal;
diff --git a/core/events/events.js b/core/events/events.js
index d4cb5f7d4..cd393c309 100644
--- a/core/events/events.js
+++ b/core/events/events.js
@@ -15,9 +15,9 @@
*/
goog.module('Blockly.Events');
-const Abstract = goog.require('Blockly.Events.Abstract');
const deprecation = goog.require('Blockly.utils.deprecation');
const eventUtils = goog.require('Blockly.Events.utils');
+const {Abstract: AbstractEvent} = goog.require('Blockly.Events.Abstract');
const {BlockBase} = goog.require('Blockly.Events.BlockBase');
const {BlockChange} = goog.require('Blockly.Events.BlockChange');
const {BlockCreate} = goog.require('Blockly.Events.BlockCreate');
@@ -47,7 +47,7 @@ const {ViewportChange} = goog.require('Blockly.Events.ViewportChange');
// Events.
-exports.Abstract = Abstract;
+exports.Abstract = AbstractEvent;
exports.BubbleOpen = BubbleOpen;
exports.BlockBase = BlockBase;
exports.BlockChange = BlockChange;
diff --git a/core/events/events_abstract.js b/core/events/events_abstract.js
index d88d8efab..b2e2fc140 100644
--- a/core/events/events_abstract.js
+++ b/core/events/events_abstract.js
@@ -24,98 +24,110 @@ const {Workspace} = goog.requireType('Blockly.Workspace');
/**
* Abstract class for an event.
- * @constructor
+ * @abstract
* @alias Blockly.Events.Abstract
*/
-const Abstract = function() {
+class Abstract {
/**
- * Whether or not the event is blank (to be populated by fromJson).
- * @type {?boolean}
+ * @alias Blockly.Events.Abstract
*/
- this.isBlank = null;
+ constructor() {
+ /**
+ * Whether or not the event is blank (to be populated by fromJson).
+ * @type {?boolean}
+ */
+ this.isBlank = null;
- /**
- * The workspace identifier for this event.
- * @type {string|undefined}
- */
- this.workspaceId = undefined;
+ /**
+ * The workspace identifier for this event.
+ * @type {string|undefined}
+ */
+ this.workspaceId = undefined;
- /**
- * The event group id for the group this event belongs to. Groups define
- * events that should be treated as an single action from the user's
- * perspective, and should be undone together.
- * @type {string}
- */
- this.group = eventUtils.getGroup();
+ /**
+ * The event group id for the group this event belongs to. Groups define
+ * events that should be treated as an single action from the user's
+ * perspective, and should be undone together.
+ * @type {string}
+ */
+ this.group = eventUtils.getGroup();
- /**
- * Sets whether the event should be added to the undo stack.
- * @type {boolean}
- */
- this.recordUndo = eventUtils.getRecordUndo();
-};
+ /**
+ * Sets whether the event should be added to the undo stack.
+ * @type {boolean}
+ */
+ this.recordUndo = eventUtils.getRecordUndo();
-/**
- * Whether or not the event is a UI event.
- * @type {boolean}
- */
-Abstract.prototype.isUiEvent = false;
+ /**
+ * Whether or not the event is a UI event.
+ * @type {boolean}
+ */
+ this.isUiEvent = false;
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-Abstract.prototype.toJson = function() {
- const json = {'type': this.type};
- if (this.group) {
- json['group'] = this.group;
+ /**
+ * Type of this event.
+ * @type {string|undefined}
+ */
+ this.type = undefined;
}
- return json;
-};
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-Abstract.prototype.fromJson = function(json) {
- this.isBlank = false;
- this.group = json['group'];
-};
-
-/**
- * Does this event record any change of state?
- * @return {boolean} True if null, false if something changed.
- */
-Abstract.prototype.isNull = function() {
- return false;
-};
-
-/**
- * Run an event.
- * @param {boolean} _forward True if run forward, false if run backward (undo).
- */
-Abstract.prototype.run = function(_forward) {
- // Defined by subclasses.
-};
-
-/**
- * Get workspace the event belongs to.
- * @return {!Workspace} The workspace the event belongs to.
- * @throws {Error} if workspace is null.
- * @protected
- */
-Abstract.prototype.getEventWorkspace_ = function() {
- let workspace;
- if (this.workspaceId) {
- const {Workspace} = goog.module.get('Blockly.Workspace');
- workspace = Workspace.getById(this.workspaceId);
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = {'type': this.type};
+ if (this.group) {
+ json['group'] = this.group;
+ }
+ return json;
}
- if (!workspace) {
- throw Error(
- 'Workspace is null. Event must have been generated from real' +
- ' Blockly events.');
- }
- return workspace;
-};
-exports = Abstract;
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ this.isBlank = false;
+ this.group = json['group'];
+ }
+
+ /**
+ * Does this event record any change of state?
+ * @return {boolean} True if null, false if something changed.
+ */
+ isNull() {
+ return false;
+ }
+
+ /**
+ * Run an event.
+ * @param {boolean} _forward True if run forward, false if run backward
+ * (undo).
+ */
+ run(_forward) {
+ // Defined by subclasses.
+ }
+
+ /**
+ * Get workspace the event belongs to.
+ * @return {!Workspace} The workspace the event belongs to.
+ * @throws {Error} if workspace is null.
+ * @protected
+ */
+ getEventWorkspace_() {
+ let workspace;
+ if (this.workspaceId) {
+ const {Workspace} = goog.module.get('Blockly.Workspace');
+ workspace = Workspace.getById(this.workspaceId);
+ }
+ if (!workspace) {
+ throw Error(
+ 'Workspace is null. Event must have been generated from real' +
+ ' Blockly events.');
+ }
+ return workspace;
+ }
+}
+
+exports.Abstract = Abstract;
diff --git a/core/events/events_block_base.js b/core/events/events_block_base.js
index c243575fa..11d857847 100644
--- a/core/events/events_block_base.js
+++ b/core/events/events_block_base.js
@@ -15,55 +15,56 @@
*/
goog.module('Blockly.Events.BlockBase');
-const Abstract = goog.require('Blockly.Events.Abstract');
-const object = goog.require('Blockly.utils.object');
+const {Abstract: AbstractEvent} = goog.require('Blockly.Events.Abstract');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
/**
* Abstract class for a block event.
- * @param {!Block=} opt_block The block this event corresponds to.
- * Undefined for a blank event.
- * @extends {Abstract}
- * @constructor
+ * @extends {AbstractEvent}
* @alias Blockly.Events.BlockBase
*/
-const BlockBase = function(opt_block) {
- BlockBase.superClass_.constructor.call(this);
- this.isBlank = typeof opt_block === 'undefined';
+class BlockBase extends AbstractEvent {
+ /**
+ * @param {!Block=} opt_block The block this event corresponds to.
+ * Undefined for a blank event.
+ */
+ constructor(opt_block) {
+ super();
+ this.isBlank = typeof opt_block === 'undefined';
+
+ /**
+ * The block ID for the block this event pertains to
+ * @type {string}
+ */
+ this.blockId = this.isBlank ? '' : opt_block.id;
+
+ /**
+ * The workspace identifier for this event.
+ * @type {string}
+ */
+ this.workspaceId = this.isBlank ? '' : opt_block.workspace.id;
+ }
/**
- * The block ID for the block this event pertains to
- * @type {string}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.blockId = this.isBlank ? '' : opt_block.id;
+ toJson() {
+ const json = super.toJson();
+ json['blockId'] = this.blockId;
+ return json;
+ }
/**
- * The workspace identifier for this event.
- * @type {string}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.workspaceId = this.isBlank ? '' : opt_block.workspace.id;
-};
-object.inherits(BlockBase, Abstract);
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-BlockBase.prototype.toJson = function() {
- const json = BlockBase.superClass_.toJson.call(this);
- json['blockId'] = this.blockId;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-BlockBase.prototype.fromJson = function(json) {
- BlockBase.superClass_.fromJson.call(this, json);
- this.blockId = json['blockId'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.blockId = json['blockId'];
+ }
+}
exports.BlockBase = BlockBase;
diff --git a/core/events/events_block_change.js b/core/events/events_block_change.js
index 4a9720ad8..e197b0da3 100644
--- a/core/events/events_block_change.js
+++ b/core/events/events_block_change.js
@@ -17,7 +17,6 @@ goog.module('Blockly.Events.BlockChange');
const Xml = goog.require('Blockly.Xml');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {BlockBase} = goog.require('Blockly.Events.BlockBase');
/* eslint-disable-next-line no-unused-vars */
@@ -28,145 +27,152 @@ const {Block} = goog.requireType('Blockly.Block');
/**
* Class for a block change event.
- * @param {!Block=} opt_block The changed block. Undefined for a blank
- * event.
- * @param {string=} opt_element One of 'field', 'comment', 'disabled', etc.
- * @param {?string=} opt_name Name of input or field affected, or null.
- * @param {*=} opt_oldValue Previous value of element.
- * @param {*=} opt_newValue New value of element.
* @extends {BlockBase}
- * @constructor
* @alias Blockly.Events.BlockChange
*/
-const BlockChange = function(
- opt_block, opt_element, opt_name, opt_oldValue, opt_newValue) {
- BlockChange.superClass_.constructor.call(this, opt_block);
- if (!opt_block) {
- return; // Blank event to be populated by fromJson.
- }
- this.element = typeof opt_element === 'undefined' ? '' : opt_element;
- this.name = typeof opt_name === 'undefined' ? '' : opt_name;
- this.oldValue = typeof opt_oldValue === 'undefined' ? '' : opt_oldValue;
- this.newValue = typeof opt_newValue === 'undefined' ? '' : opt_newValue;
-};
-object.inherits(BlockChange, BlockBase);
+class BlockChange extends BlockBase {
+ /**
+ * @param {!Block=} opt_block The changed block. Undefined for a blank
+ * event.
+ * @param {string=} opt_element One of 'field', 'comment', 'disabled', etc.
+ * @param {?string=} opt_name Name of input or field affected, or null.
+ * @param {*=} opt_oldValue Previous value of element.
+ * @param {*=} opt_newValue New value of element.
+ */
+ constructor(opt_block, opt_element, opt_name, opt_oldValue, opt_newValue) {
+ super(opt_block);
-/**
- * Type of this event.
- * @type {string}
- */
-BlockChange.prototype.type = eventUtils.BLOCK_CHANGE;
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.BLOCK_CHANGE;
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-BlockChange.prototype.toJson = function() {
- const json = BlockChange.superClass_.toJson.call(this);
- json['element'] = this.element;
- if (this.name) {
- json['name'] = this.name;
- }
- json['oldValue'] = this.oldValue;
- json['newValue'] = this.newValue;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-BlockChange.prototype.fromJson = function(json) {
- BlockChange.superClass_.fromJson.call(this, json);
- this.element = json['element'];
- this.name = json['name'];
- this.oldValue = json['oldValue'];
- this.newValue = json['newValue'];
-};
-
-/**
- * Does this event record any change of state?
- * @return {boolean} False if something changed.
- */
-BlockChange.prototype.isNull = function() {
- return this.oldValue === this.newValue;
-};
-
-/**
- * Run a change event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-BlockChange.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- const block = workspace.getBlockById(this.blockId);
- if (!block) {
- console.warn('Can\'t change non-existent block: ' + this.blockId);
- return;
- }
- if (block.mutator) {
- // Close the mutator (if open) since we don't want to update it.
- block.mutator.setVisible(false);
- }
- const value = forward ? this.newValue : this.oldValue;
- switch (this.element) {
- case 'field': {
- const field = block.getField(this.name);
- if (field) {
- field.setValue(value);
- } else {
- console.warn('Can\'t set non-existent field: ' + this.name);
- }
- break;
+ if (!opt_block) {
+ return; // Blank event to be populated by fromJson.
}
- case 'comment':
- block.setCommentText(/** @type {string} */ (value) || null);
- break;
- case 'collapsed':
- block.setCollapsed(!!value);
- break;
- case 'disabled':
- block.setEnabled(!value);
- break;
- case 'inline':
- block.setInputsInline(!!value);
- break;
- case 'mutation': {
- const oldState = BlockChange.getExtraBlockState_(
- /** @type {!BlockSvg} */ (block));
- if (block.loadExtraState) {
- block.loadExtraState(JSON.parse(/** @type {string} */ (value) || '{}'));
- } else if (block.domToMutation) {
- block.domToMutation(
- Xml.textToDom(/** @type {string} */ (value) || ''));
- }
- eventUtils.fire(
- new BlockChange(block, 'mutation', null, oldState, value));
- break;
- }
- default:
- console.warn('Unknown change type: ' + this.element);
+ this.element = typeof opt_element === 'undefined' ? '' : opt_element;
+ this.name = typeof opt_name === 'undefined' ? '' : opt_name;
+ this.oldValue = typeof opt_oldValue === 'undefined' ? '' : opt_oldValue;
+ this.newValue = typeof opt_newValue === 'undefined' ? '' : opt_newValue;
}
-};
-// TODO (#5397): Encapsulate this in the BlocklyMutationChange event when
-// refactoring change events.
-/**
- * Returns the extra state of the given block (either as XML or a JSO, depending
- * on the block's definition).
- * @param {!BlockSvg} block The block to get the extra state of.
- * @return {string} A stringified version of the extra state of the given block.
- * @package
- */
-BlockChange.getExtraBlockState_ = function(block) {
- if (block.saveExtraState) {
- const state = block.saveExtraState();
- return state ? JSON.stringify(state) : '';
- } else if (block.mutationToDom) {
- const state = block.mutationToDom();
- return state ? Xml.domToText(state) : '';
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['element'] = this.element;
+ if (this.name) {
+ json['name'] = this.name;
+ }
+ json['oldValue'] = this.oldValue;
+ json['newValue'] = this.newValue;
+ return json;
}
- return '';
-};
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.element = json['element'];
+ this.name = json['name'];
+ this.oldValue = json['oldValue'];
+ this.newValue = json['newValue'];
+ }
+
+ /**
+ * Does this event record any change of state?
+ * @return {boolean} False if something changed.
+ */
+ isNull() {
+ return this.oldValue === this.newValue;
+ }
+
+ /**
+ * Run a change event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ const block = workspace.getBlockById(this.blockId);
+ if (!block) {
+ console.warn('Can\'t change non-existent block: ' + this.blockId);
+ return;
+ }
+
+ // Assume the block is rendered so that then we can check.
+ const blockSvg = /** @type {!BlockSvg} */ (block);
+ if (blockSvg.mutator) {
+ // Close the mutator (if open) since we don't want to update it.
+ blockSvg.mutator.setVisible(false);
+ }
+ const value = forward ? this.newValue : this.oldValue;
+ switch (this.element) {
+ case 'field': {
+ const field = block.getField(this.name);
+ if (field) {
+ field.setValue(value);
+ } else {
+ console.warn('Can\'t set non-existent field: ' + this.name);
+ }
+ break;
+ }
+ case 'comment':
+ block.setCommentText(/** @type {string} */ (value) || null);
+ break;
+ case 'collapsed':
+ block.setCollapsed(!!value);
+ break;
+ case 'disabled':
+ block.setEnabled(!value);
+ break;
+ case 'inline':
+ block.setInputsInline(!!value);
+ break;
+ case 'mutation': {
+ const oldState = BlockChange.getExtraBlockState_(
+ /** @type {!BlockSvg} */ (block));
+ if (block.loadExtraState) {
+ block.loadExtraState(
+ JSON.parse(/** @type {string} */ (value) || '{}'));
+ } else if (block.domToMutation) {
+ block.domToMutation(
+ Xml.textToDom(/** @type {string} */ (value) || ''));
+ }
+ eventUtils.fire(
+ new BlockChange(block, 'mutation', null, oldState, value));
+ break;
+ }
+ default:
+ console.warn('Unknown change type: ' + this.element);
+ }
+ }
+
+ // TODO (#5397): Encapsulate this in the BlocklyMutationChange event when
+ // refactoring change events.
+ /**
+ * Returns the extra state of the given block (either as XML or a JSO,
+ * depending on the block's definition).
+ * @param {!BlockSvg} block The block to get the extra state of.
+ * @return {string} A stringified version of the extra state of the given
+ * block.
+ * @package
+ */
+ static getExtraBlockState_(block) {
+ if (block.saveExtraState) {
+ const state = block.saveExtraState();
+ return state ? JSON.stringify(state) : '';
+ } else if (block.mutationToDom) {
+ const state = block.mutationToDom();
+ return state ? Xml.domToText(state) : '';
+ }
+ return '';
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.CHANGE, BlockChange);
diff --git a/core/events/events_block_create.js b/core/events/events_block_create.js
index 9a3254358..6d3a03368 100644
--- a/core/events/events_block_create.js
+++ b/core/events/events_block_create.js
@@ -18,7 +18,6 @@ goog.module('Blockly.Events.BlockCreate');
const Xml = goog.require('Blockly.Xml');
const blocks = goog.require('Blockly.serialization.blocks');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {BlockBase} = goog.require('Blockly.Events.BlockBase');
/* eslint-disable-next-line no-unused-vars */
@@ -27,90 +26,93 @@ const {Block} = goog.requireType('Blockly.Block');
/**
* Class for a block creation event.
- * @param {!Block=} opt_block The created block. Undefined for a blank
- * event.
* @extends {BlockBase}
- * @constructor
* @alias Blockly.Events.BlockCreate
*/
-const BlockCreate = function(opt_block) {
- BlockCreate.superClass_.constructor.call(this, opt_block);
- if (!opt_block) {
- return; // Blank event to be populated by fromJson.
- }
- if (opt_block.isShadow()) {
- // Moving shadow blocks is handled via disconnection.
- this.recordUndo = false;
- }
+class BlockCreate extends BlockBase {
+ /**
+ * @param {!Block=} opt_block The created block. Undefined for a blank
+ * event.
+ */
+ constructor(opt_block) {
+ super(opt_block);
- this.xml = Xml.blockToDomWithXY(opt_block);
- this.ids = eventUtils.getDescendantIds(opt_block);
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.BLOCK_CREATE;
+
+ if (!opt_block) {
+ return; // Blank event to be populated by fromJson.
+ }
+ if (opt_block.isShadow()) {
+ // Moving shadow blocks is handled via disconnection.
+ this.recordUndo = false;
+ }
+
+ this.xml = Xml.blockToDomWithXY(opt_block);
+ this.ids = eventUtils.getDescendantIds(opt_block);
+
+ /**
+ * JSON representation of the block that was just created.
+ * @type {!blocks.State}
+ */
+ this.json = /** @type {!blocks.State} */ (
+ blocks.save(opt_block, {addCoordinates: true}));
+ }
/**
- * JSON representation of the block that was just created.
- * @type {!blocks.State}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.json = /** @type {!blocks.State} */ (
- blocks.save(opt_block, {addCoordinates: true}));
-};
-object.inherits(BlockCreate, BlockBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-BlockCreate.prototype.type = eventUtils.BLOCK_CREATE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-BlockCreate.prototype.toJson = function() {
- const json = BlockCreate.superClass_.toJson.call(this);
- json['xml'] = Xml.domToText(this.xml);
- json['ids'] = this.ids;
- json['json'] = this.json;
- if (!this.recordUndo) {
- json['recordUndo'] = this.recordUndo;
+ toJson() {
+ const json = super.toJson();
+ json['xml'] = Xml.domToText(this.xml);
+ json['ids'] = this.ids;
+ json['json'] = this.json;
+ if (!this.recordUndo) {
+ json['recordUndo'] = this.recordUndo;
+ }
+ return json;
}
- return json;
-};
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-BlockCreate.prototype.fromJson = function(json) {
- BlockCreate.superClass_.fromJson.call(this, json);
- this.xml = Xml.textToDom(json['xml']);
- this.ids = json['ids'];
- this.json = /** @type {!blocks.State} */ (json['json']);
- if (json['recordUndo'] !== undefined) {
- this.recordUndo = json['recordUndo'];
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.xml = Xml.textToDom(json['xml']);
+ this.ids = json['ids'];
+ this.json = /** @type {!blocks.State} */ (json['json']);
+ if (json['recordUndo'] !== undefined) {
+ this.recordUndo = json['recordUndo'];
+ }
}
-};
-/**
- * Run a creation event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-BlockCreate.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- if (forward) {
- blocks.append(this.json, workspace);
- } else {
- for (let i = 0; i < this.ids.length; i++) {
- const id = this.ids[i];
- const block = workspace.getBlockById(id);
- if (block) {
- block.dispose(false);
- } else if (id === this.blockId) {
- // Only complain about root-level block.
- console.warn('Can\'t uncreate non-existent block: ' + id);
+ /**
+ * Run a creation event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ if (forward) {
+ blocks.append(this.json, workspace);
+ } else {
+ for (let i = 0; i < this.ids.length; i++) {
+ const id = this.ids[i];
+ const block = workspace.getBlockById(id);
+ if (block) {
+ block.dispose(false);
+ } else if (id === this.blockId) {
+ // Only complain about root-level block.
+ console.warn('Can\'t uncreate non-existent block: ' + id);
+ }
}
}
}
-};
+}
registry.register(registry.Type.EVENT, eventUtils.CREATE, BlockCreate);
diff --git a/core/events/events_block_delete.js b/core/events/events_block_delete.js
index 80ef65d5e..10b96dd69 100644
--- a/core/events/events_block_delete.js
+++ b/core/events/events_block_delete.js
@@ -18,7 +18,6 @@ goog.module('Blockly.Events.BlockDelete');
const Xml = goog.require('Blockly.Xml');
const blocks = goog.require('Blockly.serialization.blocks');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {BlockBase} = goog.require('Blockly.Events.BlockBase');
/* eslint-disable-next-line no-unused-vars */
@@ -27,102 +26,105 @@ const {Block} = goog.requireType('Blockly.Block');
/**
* Class for a block deletion event.
- * @param {!Block=} opt_block The deleted block. Undefined for a blank
- * event.
* @extends {BlockBase}
- * @constructor
* @alias Blockly.Events.BlockDelete
*/
-const BlockDelete = function(opt_block) {
- BlockDelete.superClass_.constructor.call(this, opt_block);
- if (!opt_block) {
- return; // Blank event to be populated by fromJson.
- }
- if (opt_block.getParent()) {
- throw Error('Connected blocks cannot be deleted.');
- }
- if (opt_block.isShadow()) {
- // Respawning shadow blocks is handled via disconnection.
- this.recordUndo = false;
- }
-
- this.oldXml = Xml.blockToDomWithXY(opt_block);
- this.ids = eventUtils.getDescendantIds(opt_block);
-
+class BlockDelete extends BlockBase {
/**
- * Was the block that was just deleted a shadow?
- * @type {boolean}
+ * @param {!Block=} opt_block The deleted block. Undefined for a blank
+ * event.
*/
- this.wasShadow = opt_block.isShadow();
+ constructor(opt_block) {
+ super(opt_block);
- /**
- * JSON representation of the block that was just deleted.
- * @type {!blocks.State}
- */
- this.oldJson = /** @type {!blocks.State} */ (
- blocks.save(opt_block, {addCoordinates: true}));
-};
-object.inherits(BlockDelete, BlockBase);
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.BLOCK_DELETE;
-/**
- * Type of this event.
- * @type {string}
- */
-BlockDelete.prototype.type = eventUtils.BLOCK_DELETE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-BlockDelete.prototype.toJson = function() {
- const json = BlockDelete.superClass_.toJson.call(this);
- json['oldXml'] = Xml.domToText(this.oldXml);
- json['ids'] = this.ids;
- json['wasShadow'] = this.wasShadow;
- json['oldJson'] = this.oldJson;
- if (!this.recordUndo) {
- json['recordUndo'] = this.recordUndo;
- }
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-BlockDelete.prototype.fromJson = function(json) {
- BlockDelete.superClass_.fromJson.call(this, json);
- this.oldXml = Xml.textToDom(json['oldXml']);
- this.ids = json['ids'];
- this.wasShadow =
- json['wasShadow'] || this.oldXml.tagName.toLowerCase() === 'shadow';
- this.oldJson = /** @type {!blocks.State} */ (json['oldJson']);
- if (json['recordUndo'] !== undefined) {
- this.recordUndo = json['recordUndo'];
- }
-};
-
-/**
- * Run a deletion event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-BlockDelete.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- if (forward) {
- for (let i = 0; i < this.ids.length; i++) {
- const id = this.ids[i];
- const block = workspace.getBlockById(id);
- if (block) {
- block.dispose(false);
- } else if (id === this.blockId) {
- // Only complain about root-level block.
- console.warn('Can\'t delete non-existent block: ' + id);
- }
+ if (!opt_block) {
+ return; // Blank event to be populated by fromJson.
}
- } else {
- blocks.append(this.oldJson, workspace);
+ if (opt_block.getParent()) {
+ throw Error('Connected blocks cannot be deleted.');
+ }
+ if (opt_block.isShadow()) {
+ // Respawning shadow blocks is handled via disconnection.
+ this.recordUndo = false;
+ }
+
+ this.oldXml = Xml.blockToDomWithXY(opt_block);
+ this.ids = eventUtils.getDescendantIds(opt_block);
+
+ /**
+ * Was the block that was just deleted a shadow?
+ * @type {boolean}
+ */
+ this.wasShadow = opt_block.isShadow();
+
+ /**
+ * JSON representation of the block that was just deleted.
+ * @type {!blocks.State}
+ */
+ this.oldJson = /** @type {!blocks.State} */ (
+ blocks.save(opt_block, {addCoordinates: true}));
}
-};
+
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['oldXml'] = Xml.domToText(this.oldXml);
+ json['ids'] = this.ids;
+ json['wasShadow'] = this.wasShadow;
+ json['oldJson'] = this.oldJson;
+ if (!this.recordUndo) {
+ json['recordUndo'] = this.recordUndo;
+ }
+ return json;
+ }
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.oldXml = Xml.textToDom(json['oldXml']);
+ this.ids = json['ids'];
+ this.wasShadow =
+ json['wasShadow'] || this.oldXml.tagName.toLowerCase() === 'shadow';
+ this.oldJson = /** @type {!blocks.State} */ (json['oldJson']);
+ if (json['recordUndo'] !== undefined) {
+ this.recordUndo = json['recordUndo'];
+ }
+ }
+
+ /**
+ * Run a deletion event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ if (forward) {
+ for (let i = 0; i < this.ids.length; i++) {
+ const id = this.ids[i];
+ const block = workspace.getBlockById(id);
+ if (block) {
+ block.dispose(false);
+ } else if (id === this.blockId) {
+ // Only complain about root-level block.
+ console.warn('Can\'t delete non-existent block: ' + id);
+ }
+ }
+ } else {
+ blocks.append(this.oldJson, workspace);
+ }
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.DELETE, BlockDelete);
diff --git a/core/events/events_block_drag.js b/core/events/events_block_drag.js
index 432246eb8..9c4850e93 100644
--- a/core/events/events_block_drag.js
+++ b/core/events/events_block_drag.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.BlockDrag');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
@@ -25,63 +24,65 @@ const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a block drag event.
- * @param {!Block=} opt_block The top block in the stack that is being
- * dragged. Undefined for a blank event.
- * @param {boolean=} opt_isStart Whether this is the start of a block drag.
- * Undefined for a blank event.
- * @param {!Array=} opt_blocks The blocks affected by this
- * drag. Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.BlockDrag
*/
-const BlockDrag = function(opt_block, opt_isStart, opt_blocks) {
- const workspaceId = opt_block ? opt_block.workspace.id : undefined;
- BlockDrag.superClass_.constructor.call(this, workspaceId);
- this.blockId = opt_block ? opt_block.id : null;
+class BlockDrag extends UiBase {
+ /**
+ * @param {!Block=} opt_block The top block in the stack that is being
+ * dragged. Undefined for a blank event.
+ * @param {boolean=} opt_isStart Whether this is the start of a block drag.
+ * Undefined for a blank event.
+ * @param {!Array=} opt_blocks The blocks affected by this
+ * drag. Undefined for a blank event.
+ */
+ constructor(opt_block, opt_isStart, opt_blocks) {
+ const workspaceId = opt_block ? opt_block.workspace.id : undefined;
+ super(workspaceId);
+ this.blockId = opt_block ? opt_block.id : null;
+
+ /**
+ * Whether this is the start of a block drag.
+ * @type {boolean|undefined}
+ */
+ this.isStart = opt_isStart;
+
+ /**
+ * The blocks affected by this drag event.
+ * @type {!Array|undefined}
+ */
+ this.blocks = opt_blocks;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.BLOCK_DRAG;
+ }
/**
- * Whether this is the start of a block drag.
- * @type {boolean|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.isStart = opt_isStart;
+ toJson() {
+ const json = super.toJson();
+ json['isStart'] = this.isStart;
+ json['blockId'] = this.blockId;
+ json['blocks'] = this.blocks;
+ return json;
+ }
/**
- * The blocks affected by this drag event.
- * @type {!Array|undefined}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.blocks = opt_blocks;
-};
-object.inherits(BlockDrag, UiBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-BlockDrag.prototype.type = eventUtils.BLOCK_DRAG;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-BlockDrag.prototype.toJson = function() {
- const json = BlockDrag.superClass_.toJson.call(this);
- json['isStart'] = this.isStart;
- json['blockId'] = this.blockId;
- json['blocks'] = this.blocks;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-BlockDrag.prototype.fromJson = function(json) {
- BlockDrag.superClass_.fromJson.call(this, json);
- this.isStart = json['isStart'];
- this.blockId = json['blockId'];
- this.blocks = json['blocks'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.isStart = json['isStart'];
+ this.blockId = json['blockId'];
+ this.blocks = json['blocks'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.BLOCK_DRAG, BlockDrag);
diff --git a/core/events/events_block_move.js b/core/events/events_block_move.js
index a90d74bd2..41e11a646 100644
--- a/core/events/events_block_move.js
+++ b/core/events/events_block_move.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.BlockMove');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {BlockBase} = goog.require('Blockly.Events.BlockBase');
/* eslint-disable-next-line no-unused-vars */
@@ -27,168 +26,176 @@ const {Coordinate} = goog.require('Blockly.utils.Coordinate');
/**
* Class for a block move event. Created before the move.
- * @param {!Block=} opt_block The moved block. Undefined for a blank
- * event.
* @extends {BlockBase}
- * @constructor
* @alias Blockly.Events.BlockMove
*/
-const BlockMove = function(opt_block) {
- BlockMove.superClass_.constructor.call(this, opt_block);
- if (!opt_block) {
- return; // Blank event to be populated by fromJson.
- }
- if (opt_block.isShadow()) {
- // Moving shadow blocks is handled via disconnection.
- this.recordUndo = false;
- }
+class BlockMove extends BlockBase {
+ /**
+ * @param {!Block=} opt_block The moved block. Undefined for a blank
+ * event.
+ */
+ constructor(opt_block) {
+ super(opt_block);
- const location = this.currentLocation_();
- this.oldParentId = location.parentId;
- this.oldInputName = location.inputName;
- this.oldCoordinate = location.coordinate;
-};
-object.inherits(BlockMove, BlockBase);
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.BLOCK_MOVE;
-/**
- * Type of this event.
- * @type {string}
- */
-BlockMove.prototype.type = eventUtils.BLOCK_MOVE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-BlockMove.prototype.toJson = function() {
- const json = BlockMove.superClass_.toJson.call(this);
- if (this.newParentId) {
- json['newParentId'] = this.newParentId;
- }
- if (this.newInputName) {
- json['newInputName'] = this.newInputName;
- }
- if (this.newCoordinate) {
- json['newCoordinate'] = Math.round(this.newCoordinate.x) + ',' +
- Math.round(this.newCoordinate.y);
- }
- if (!this.recordUndo) {
- json['recordUndo'] = this.recordUndo;
- }
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-BlockMove.prototype.fromJson = function(json) {
- BlockMove.superClass_.fromJson.call(this, json);
- this.newParentId = json['newParentId'];
- this.newInputName = json['newInputName'];
- if (json['newCoordinate']) {
- const xy = json['newCoordinate'].split(',');
- this.newCoordinate = new Coordinate(Number(xy[0]), Number(xy[1]));
- }
- if (json['recordUndo'] !== undefined) {
- this.recordUndo = json['recordUndo'];
- }
-};
-
-/**
- * Record the block's new location. Called after the move.
- */
-BlockMove.prototype.recordNew = function() {
- const location = this.currentLocation_();
- this.newParentId = location.parentId;
- this.newInputName = location.inputName;
- this.newCoordinate = location.coordinate;
-};
-
-/**
- * Returns the parentId and input if the block is connected,
- * or the XY location if disconnected.
- * @return {!Object} Collection of location info.
- * @private
- */
-BlockMove.prototype.currentLocation_ = function() {
- const workspace = this.getEventWorkspace_();
- const block = workspace.getBlockById(this.blockId);
- const location = {};
- const parent = block.getParent();
- if (parent) {
- location.parentId = parent.id;
- const input = parent.getInputWithBlock(block);
- if (input) {
- location.inputName = input.name;
+ if (!opt_block) {
+ return; // Blank event to be populated by fromJson.
+ }
+ if (opt_block.isShadow()) {
+ // Moving shadow blocks is handled via disconnection.
+ this.recordUndo = false;
}
- } else {
- location.coordinate = block.getRelativeToSurfaceXY();
- }
- return location;
-};
-/**
- * Does this event record any change of state?
- * @return {boolean} False if something changed.
- */
-BlockMove.prototype.isNull = function() {
- return this.oldParentId === this.newParentId &&
- this.oldInputName === this.newInputName &&
- Coordinate.equals(this.oldCoordinate, this.newCoordinate);
-};
+ const location = this.currentLocation_();
+ this.oldParentId = location.parentId;
+ this.oldInputName = location.inputName;
+ this.oldCoordinate = location.coordinate;
-/**
- * Run a move event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-BlockMove.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- const block = workspace.getBlockById(this.blockId);
- if (!block) {
- console.warn('Can\'t move non-existent block: ' + this.blockId);
- return;
+ this.newParentId = null;
+ this.newInputName = null;
+ this.newCoordinate = null;
}
- const parentId = forward ? this.newParentId : this.oldParentId;
- const inputName = forward ? this.newInputName : this.oldInputName;
- const coordinate = forward ? this.newCoordinate : this.oldCoordinate;
- let parentBlock;
- if (parentId) {
- parentBlock = workspace.getBlockById(parentId);
- if (!parentBlock) {
- console.warn('Can\'t connect to non-existent block: ' + parentId);
+
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ if (this.newParentId) {
+ json['newParentId'] = this.newParentId;
+ }
+ if (this.newInputName) {
+ json['newInputName'] = this.newInputName;
+ }
+ if (this.newCoordinate) {
+ json['newCoordinate'] = Math.round(this.newCoordinate.x) + ',' +
+ Math.round(this.newCoordinate.y);
+ }
+ if (!this.recordUndo) {
+ json['recordUndo'] = this.recordUndo;
+ }
+ return json;
+ }
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.newParentId = json['newParentId'];
+ this.newInputName = json['newInputName'];
+ if (json['newCoordinate']) {
+ const xy = json['newCoordinate'].split(',');
+ this.newCoordinate = new Coordinate(Number(xy[0]), Number(xy[1]));
+ }
+ if (json['recordUndo'] !== undefined) {
+ this.recordUndo = json['recordUndo'];
+ }
+ }
+
+ /**
+ * Record the block's new location. Called after the move.
+ */
+ recordNew() {
+ const location = this.currentLocation_();
+ this.newParentId = location.parentId;
+ this.newInputName = location.inputName;
+ this.newCoordinate = location.coordinate;
+ }
+
+ /**
+ * Returns the parentId and input if the block is connected,
+ * or the XY location if disconnected.
+ * @return {!Object} Collection of location info.
+ * @private
+ */
+ currentLocation_() {
+ const workspace = this.getEventWorkspace_();
+ const block = workspace.getBlockById(this.blockId);
+ const location = {};
+ const parent = block.getParent();
+ if (parent) {
+ location.parentId = parent.id;
+ const input = parent.getInputWithBlock(block);
+ if (input) {
+ location.inputName = input.name;
+ }
+ } else {
+ location.coordinate = block.getRelativeToSurfaceXY();
+ }
+ return location;
+ }
+
+ /**
+ * Does this event record any change of state?
+ * @return {boolean} False if something changed.
+ */
+ isNull() {
+ return this.oldParentId === this.newParentId &&
+ this.oldInputName === this.newInputName &&
+ Coordinate.equals(this.oldCoordinate, this.newCoordinate);
+ }
+
+ /**
+ * Run a move event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ const block = workspace.getBlockById(this.blockId);
+ if (!block) {
+ console.warn('Can\'t move non-existent block: ' + this.blockId);
return;
}
- }
- if (block.getParent()) {
- block.unplug();
- }
- if (coordinate) {
- const xy = block.getRelativeToSurfaceXY();
- block.moveBy(coordinate.x - xy.x, coordinate.y - xy.y);
- } else {
- let blockConnection = block.outputConnection;
- if (!blockConnection ||
- (block.previousConnection && block.previousConnection.isConnected())) {
- blockConnection = block.previousConnection;
- }
- let parentConnection;
- const connectionType = blockConnection.type;
- if (inputName) {
- const input = parentBlock.getInput(inputName);
- if (input) {
- parentConnection = input.connection;
+ const parentId = forward ? this.newParentId : this.oldParentId;
+ const inputName = forward ? this.newInputName : this.oldInputName;
+ const coordinate = forward ? this.newCoordinate : this.oldCoordinate;
+ let parentBlock;
+ if (parentId) {
+ parentBlock = workspace.getBlockById(parentId);
+ if (!parentBlock) {
+ console.warn('Can\'t connect to non-existent block: ' + parentId);
+ return;
}
- } else if (connectionType === ConnectionType.PREVIOUS_STATEMENT) {
- parentConnection = parentBlock.nextConnection;
}
- if (parentConnection) {
- blockConnection.connect(parentConnection);
+ if (block.getParent()) {
+ block.unplug();
+ }
+ if (coordinate) {
+ const xy = block.getRelativeToSurfaceXY();
+ block.moveBy(coordinate.x - xy.x, coordinate.y - xy.y);
} else {
- console.warn('Can\'t connect to non-existent input: ' + inputName);
+ let blockConnection = block.outputConnection;
+ if (!blockConnection ||
+ (block.previousConnection &&
+ block.previousConnection.isConnected())) {
+ blockConnection = block.previousConnection;
+ }
+ let parentConnection;
+ const connectionType = blockConnection.type;
+ if (inputName) {
+ const input = parentBlock.getInput(inputName);
+ if (input) {
+ parentConnection = input.connection;
+ }
+ } else if (connectionType === ConnectionType.PREVIOUS_STATEMENT) {
+ parentConnection = parentBlock.nextConnection;
+ }
+ if (parentConnection) {
+ blockConnection.connect(parentConnection);
+ } else {
+ console.warn('Can\'t connect to non-existent input: ' + inputName);
+ }
}
}
-};
+}
registry.register(registry.Type.EVENT, eventUtils.MOVE, BlockMove);
diff --git a/core/events/events_bubble_open.js b/core/events/events_bubble_open.js
index 9c76fd751..93d068790 100644
--- a/core/events/events_bubble_open.js
+++ b/core/events/events_bubble_open.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.BubbleOpen');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
/* eslint-disable-next-line no-unused-vars */
const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
@@ -25,64 +24,66 @@ const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a bubble open event.
- * @param {BlockSvg} opt_block The associated block. Undefined for a
- * blank event.
- * @param {boolean=} opt_isOpen Whether the bubble is opening (false if
- * closing). Undefined for a blank event.
- * @param {string=} opt_bubbleType The type of bubble. One of 'mutator',
- * 'comment'
- * or 'warning'. Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.BubbleOpen
*/
-const BubbleOpen = function(opt_block, opt_isOpen, opt_bubbleType) {
- const workspaceId = opt_block ? opt_block.workspace.id : undefined;
- BubbleOpen.superClass_.constructor.call(this, workspaceId);
- this.blockId = opt_block ? opt_block.id : null;
+class BubbleOpen extends UiBase {
+ /**
+ * @param {BlockSvg} opt_block The associated block. Undefined for a
+ * blank event.
+ * @param {boolean=} opt_isOpen Whether the bubble is opening (false if
+ * closing). Undefined for a blank event.
+ * @param {string=} opt_bubbleType The type of bubble. One of 'mutator',
+ * 'comment'
+ * or 'warning'. Undefined for a blank event.
+ */
+ constructor(opt_block, opt_isOpen, opt_bubbleType) {
+ const workspaceId = opt_block ? opt_block.workspace.id : undefined;
+ super(workspaceId);
+ this.blockId = opt_block ? opt_block.id : null;
+
+ /**
+ * Whether the bubble is opening (false if closing).
+ * @type {boolean|undefined}
+ */
+ this.isOpen = opt_isOpen;
+
+ /**
+ * The type of bubble. One of 'mutator', 'comment', or 'warning'.
+ * @type {string|undefined}
+ */
+ this.bubbleType = opt_bubbleType;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.BUBBLE_OPEN;
+ }
/**
- * Whether the bubble is opening (false if closing).
- * @type {boolean|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.isOpen = opt_isOpen;
+ toJson() {
+ const json = super.toJson();
+ json['isOpen'] = this.isOpen;
+ json['bubbleType'] = this.bubbleType;
+ json['blockId'] = this.blockId;
+ return json;
+ }
/**
- * The type of bubble. One of 'mutator', 'comment', or 'warning'.
- * @type {string|undefined}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.bubbleType = opt_bubbleType;
-};
-object.inherits(BubbleOpen, UiBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-BubbleOpen.prototype.type = eventUtils.BUBBLE_OPEN;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-BubbleOpen.prototype.toJson = function() {
- const json = BubbleOpen.superClass_.toJson.call(this);
- json['isOpen'] = this.isOpen;
- json['bubbleType'] = this.bubbleType;
- json['blockId'] = this.blockId;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-BubbleOpen.prototype.fromJson = function(json) {
- BubbleOpen.superClass_.fromJson.call(this, json);
- this.isOpen = json['isOpen'];
- this.bubbleType = json['bubbleType'];
- this.blockId = json['blockId'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.isOpen = json['isOpen'];
+ this.bubbleType = json['bubbleType'];
+ this.blockId = json['blockId'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.BUBBLE_OPEN, BubbleOpen);
diff --git a/core/events/events_click.js b/core/events/events_click.js
index 0bc9aa972..f61d83a4c 100644
--- a/core/events/events_click.js
+++ b/core/events/events_click.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.Click');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
@@ -25,58 +24,63 @@ const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a click event.
- * @param {?Block=} opt_block The affected block. Null for click events
- * that do not have an associated block (i.e. workspace click). Undefined
- * for a blank event.
- * @param {?string=} opt_workspaceId The workspace identifier for this event.
- * Not used if block is passed. Undefined for a blank event.
- * @param {string=} opt_targetType The type of element targeted by this click
- * event. Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.Click
*/
-const Click = function(opt_block, opt_workspaceId, opt_targetType) {
- const workspaceId = opt_block ? opt_block.workspace.id : opt_workspaceId;
- Click.superClass_.constructor.call(this, workspaceId);
- this.blockId = opt_block ? opt_block.id : null;
+class Click extends UiBase {
+ /**
+ * @param {?Block=} opt_block The affected block. Null for click events
+ * that do not have an associated block (i.e. workspace click). Undefined
+ * for a blank event.
+ * @param {?string=} opt_workspaceId The workspace identifier for this event.
+ * Not used if block is passed. Undefined for a blank event.
+ * @param {string=} opt_targetType The type of element targeted by this click
+ * event. Undefined for a blank event.
+ */
+ constructor(opt_block, opt_workspaceId, opt_targetType) {
+ let workspaceId = opt_block ? opt_block.workspace.id : opt_workspaceId;
+ if (workspaceId === null) {
+ workspaceId = undefined;
+ }
+ super(workspaceId);
+ this.blockId = opt_block ? opt_block.id : null;
+
+ /**
+ * The type of element targeted by this click event.
+ * @type {string|undefined}
+ */
+ this.targetType = opt_targetType;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.CLICK;
+ }
/**
- * The type of element targeted by this click event.
- * @type {string|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.targetType = opt_targetType;
-};
-object.inherits(Click, UiBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-Click.prototype.type = eventUtils.CLICK;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-Click.prototype.toJson = function() {
- const json = Click.superClass_.toJson.call(this);
- json['targetType'] = this.targetType;
- if (this.blockId) {
- json['blockId'] = this.blockId;
+ toJson() {
+ const json = super.toJson();
+ json['targetType'] = this.targetType;
+ if (this.blockId) {
+ json['blockId'] = this.blockId;
+ }
+ return json;
}
- return json;
-};
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-Click.prototype.fromJson = function(json) {
- Click.superClass_.fromJson.call(this, json);
- this.targetType = json['targetType'];
- this.blockId = json['blockId'];
-};
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.targetType = json['targetType'];
+ this.blockId = json['blockId'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.CLICK, Click);
diff --git a/core/events/events_comment_base.js b/core/events/events_comment_base.js
index 63de88dfe..5e1a65b99 100644
--- a/core/events/events_comment_base.js
+++ b/core/events/events_comment_base.js
@@ -15,11 +15,10 @@
*/
goog.module('Blockly.Events.CommentBase');
-const AbstractEvents = goog.require('Blockly.Events.Abstract');
const Xml = goog.require('Blockly.Xml');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const utilsXml = goog.require('Blockly.utils.xml');
+const {Abstract: AbstractEvent} = goog.require('Blockly.Events.Abstract');
/* eslint-disable-next-line no-unused-vars */
const {CommentCreate} = goog.requireType('Blockly.Events.CommentCreate');
/* eslint-disable-next-line no-unused-vars */
@@ -30,89 +29,93 @@ const {WorkspaceComment} = goog.requireType('Blockly.WorkspaceComment');
/**
* Abstract class for a comment event.
- * @param {!WorkspaceComment=} opt_comment The comment this event
- * corresponds to. Undefined for a blank event.
- * @extends {AbstractEvents}
- * @constructor
+ * @extends {AbstractEvent}
* @alias Blockly.Events.CommentBase
*/
-const CommentBase = function(opt_comment) {
+class CommentBase extends AbstractEvent {
/**
- * Whether or not an event is blank.
- * @type {boolean}
+ * @param {!WorkspaceComment=} opt_comment The comment this event
+ * corresponds to. Undefined for a blank event.
*/
- this.isBlank = typeof opt_comment === 'undefined';
+ constructor(opt_comment) {
+ super();
+ /**
+ * Whether or not an event is blank.
+ * @type {boolean}
+ */
+ this.isBlank = typeof opt_comment === 'undefined';
- /**
- * The ID of the comment this event pertains to.
- * @type {string}
- */
- this.commentId = this.isBlank ? '' : opt_comment.id;
+ /**
+ * The ID of the comment this event pertains to.
+ * @type {string}
+ */
+ this.commentId = this.isBlank ? '' : opt_comment.id;
- /**
- * The workspace identifier for this event.
- * @type {string}
- */
- this.workspaceId = this.isBlank ? '' : opt_comment.workspace.id;
+ /**
+ * The workspace identifier for this event.
+ * @type {string}
+ */
+ this.workspaceId = this.isBlank ? '' : opt_comment.workspace.id;
- /**
- * The event group id for the group this event belongs to. Groups define
- * events that should be treated as an single action from the user's
- * perspective, and should be undone together.
- * @type {string}
- */
- this.group = eventUtils.getGroup();
+ /**
+ * The event group id for the group this event belongs to. Groups define
+ * events that should be treated as an single action from the user's
+ * perspective, and should be undone together.
+ * @type {string}
+ */
+ this.group = eventUtils.getGroup();
- /**
- * Sets whether the event should be added to the undo stack.
- * @type {boolean}
- */
- this.recordUndo = eventUtils.getRecordUndo();
-};
-object.inherits(CommentBase, AbstractEvents);
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-CommentBase.prototype.toJson = function() {
- const json = CommentBase.superClass_.toJson.call(this);
- if (this.commentId) {
- json['commentId'] = this.commentId;
+ /**
+ * Sets whether the event should be added to the undo stack.
+ * @type {boolean}
+ */
+ this.recordUndo = eventUtils.getRecordUndo();
}
- return json;
-};
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-CommentBase.prototype.fromJson = function(json) {
- CommentBase.superClass_.fromJson.call(this, json);
- this.commentId = json['commentId'];
-};
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ if (this.commentId) {
+ json['commentId'] = this.commentId;
+ }
+ return json;
+ }
-/**
- * Helper function for Comment[Create|Delete]
- * @param {!CommentCreate|!CommentDelete} event
- * The event to run.
- * @param {boolean} create if True then Create, if False then Delete
- */
-CommentBase.CommentCreateDeleteHelper = function(event, create) {
- const workspace = event.getEventWorkspace_();
- if (create) {
- const xmlElement = utilsXml.createElement('xml');
- xmlElement.appendChild(event.xml);
- Xml.domToWorkspace(xmlElement, workspace);
- } else {
- const comment = workspace.getCommentById(event.commentId);
- if (comment) {
- comment.dispose();
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.commentId = json['commentId'];
+ }
+
+ /**
+ * Helper function for Comment[Create|Delete]
+ * @param {!CommentCreate|!CommentDelete} event
+ * The event to run.
+ * @param {boolean} create if True then Create, if False then Delete
+ */
+ static CommentCreateDeleteHelper(event, create) {
+ const workspace = event.getEventWorkspace_();
+ if (create) {
+ const xmlElement = utilsXml.createElement('xml');
+ xmlElement.appendChild(event.xml);
+ Xml.domToWorkspace(xmlElement, workspace);
} else {
- // Only complain about root-level block.
- console.warn('Can\'t uncreate non-existent comment: ' + event.commentId);
+ const comment = workspace.getCommentById(event.commentId);
+ if (comment) {
+ comment.dispose();
+ } else {
+ // Only complain about root-level block.
+ console.warn(
+ 'Can\'t uncreate non-existent comment: ' + event.commentId);
+ }
}
}
-};
+}
exports.CommentBase = CommentBase;
diff --git a/core/events/events_comment_change.js b/core/events/events_comment_change.js
index 1f6b44ba3..1053ad636 100644
--- a/core/events/events_comment_change.js
+++ b/core/events/events_comment_change.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.CommentChange');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {CommentBase} = goog.require('Blockly.Events.CommentBase');
/* eslint-disable-next-line no-unused-vars */
@@ -25,77 +24,80 @@ const {WorkspaceComment} = goog.requireType('Blockly.WorkspaceComment');
/**
* Class for a comment change event.
- * @param {!WorkspaceComment=} opt_comment The comment that is being
- * changed. Undefined for a blank event.
- * @param {string=} opt_oldContents Previous contents of the comment.
- * @param {string=} opt_newContents New contents of the comment.
* @extends {CommentBase}
- * @constructor
* @alias Blockly.Events.CommentChange
*/
-const CommentChange = function(opt_comment, opt_oldContents, opt_newContents) {
- CommentChange.superClass_.constructor.call(this, opt_comment);
- if (!opt_comment) {
- return; // Blank event to be populated by fromJson.
+class CommentChange extends CommentBase {
+ /**
+ * @param {!WorkspaceComment=} opt_comment The comment that is being
+ * changed. Undefined for a blank event.
+ * @param {string=} opt_oldContents Previous contents of the comment.
+ * @param {string=} opt_newContents New contents of the comment.
+ */
+ constructor(opt_comment, opt_oldContents, opt_newContents) {
+ super(opt_comment);
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.COMMENT_CHANGE;
+
+ if (!opt_comment) {
+ return; // Blank event to be populated by fromJson.
+ }
+
+ this.oldContents_ =
+ typeof opt_oldContents === 'undefined' ? '' : opt_oldContents;
+ this.newContents_ =
+ typeof opt_newContents === 'undefined' ? '' : opt_newContents;
}
- this.oldContents_ =
- typeof opt_oldContents === 'undefined' ? '' : opt_oldContents;
- this.newContents_ =
- typeof opt_newContents === 'undefined' ? '' : opt_newContents;
-};
-object.inherits(CommentChange, CommentBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-CommentChange.prototype.type = eventUtils.COMMENT_CHANGE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-CommentChange.prototype.toJson = function() {
- const json = CommentChange.superClass_.toJson.call(this);
- json['oldContents'] = this.oldContents_;
- json['newContents'] = this.newContents_;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-CommentChange.prototype.fromJson = function(json) {
- CommentChange.superClass_.fromJson.call(this, json);
- this.oldContents_ = json['oldContents'];
- this.newContents_ = json['newContents'];
-};
-
-/**
- * Does this event record any change of state?
- * @return {boolean} False if something changed.
- */
-CommentChange.prototype.isNull = function() {
- return this.oldContents_ === this.newContents_;
-};
-
-/**
- * Run a change event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-CommentChange.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- const comment = workspace.getCommentById(this.commentId);
- if (!comment) {
- console.warn('Can\'t change non-existent comment: ' + this.commentId);
- return;
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['oldContents'] = this.oldContents_;
+ json['newContents'] = this.newContents_;
+ return json;
}
- const contents = forward ? this.newContents_ : this.oldContents_;
- comment.setContent(contents);
-};
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.oldContents_ = json['oldContents'];
+ this.newContents_ = json['newContents'];
+ }
+
+ /**
+ * Does this event record any change of state?
+ * @return {boolean} False if something changed.
+ */
+ isNull() {
+ return this.oldContents_ === this.newContents_;
+ }
+
+ /**
+ * Run a change event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ const comment = workspace.getCommentById(this.commentId);
+ if (!comment) {
+ console.warn('Can\'t change non-existent comment: ' + this.commentId);
+ return;
+ }
+ const contents = forward ? this.newContents_ : this.oldContents_;
+
+ comment.setContent(contents);
+ }
+}
registry.register(
registry.Type.EVENT, eventUtils.COMMENT_CHANGE, CommentChange);
diff --git a/core/events/events_comment_create.js b/core/events/events_comment_create.js
index 5d9cb8d79..86c72f6d6 100644
--- a/core/events/events_comment_create.js
+++ b/core/events/events_comment_create.js
@@ -17,7 +17,6 @@ goog.module('Blockly.Events.CommentCreate');
const Xml = goog.require('Blockly.Xml');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {CommentBase} = goog.require('Blockly.Events.CommentBase');
/* eslint-disable-next-line no-unused-vars */
@@ -26,55 +25,58 @@ const {WorkspaceComment} = goog.requireType('Blockly.WorkspaceComment');
/**
* Class for a comment creation event.
- * @param {!WorkspaceComment=} opt_comment The created comment.
- * Undefined for a blank event.
* @extends {CommentBase}
- * @constructor
* @alias Blockly.Events.CommentCreate
*/
-const CommentCreate = function(opt_comment) {
- CommentCreate.superClass_.constructor.call(this, opt_comment);
- if (!opt_comment) {
- return; // Blank event to be populated by fromJson.
+class CommentCreate extends CommentBase {
+ /**
+ * @param {!WorkspaceComment=} opt_comment The created comment.
+ * Undefined for a blank event.
+ */
+ constructor(opt_comment) {
+ super(opt_comment);
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.COMMENT_CREATE;
+
+ if (!opt_comment) {
+ return; // Blank event to be populated by fromJson.
+ }
+
+ this.xml = opt_comment.toXmlWithXY();
}
- this.xml = opt_comment.toXmlWithXY();
-};
-object.inherits(CommentCreate, CommentBase);
+ // TODO (#1266): "Full" and "minimal" serialization.
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['xml'] = Xml.domToText(this.xml);
+ return json;
+ }
-/**
- * Type of this event.
- * @type {string}
- */
-CommentCreate.prototype.type = eventUtils.COMMENT_CREATE;
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.xml = Xml.textToDom(json['xml']);
+ }
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-// TODO (#1266): "Full" and "minimal" serialization.
-CommentCreate.prototype.toJson = function() {
- const json = CommentCreate.superClass_.toJson.call(this);
- json['xml'] = Xml.domToText(this.xml);
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-CommentCreate.prototype.fromJson = function(json) {
- CommentCreate.superClass_.fromJson.call(this, json);
- this.xml = Xml.textToDom(json['xml']);
-};
-
-/**
- * Run a creation event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-CommentCreate.prototype.run = function(forward) {
- CommentBase.CommentCreateDeleteHelper(this, forward);
-};
+ /**
+ * Run a creation event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ CommentBase.CommentCreateDeleteHelper(this, forward);
+ }
+}
registry.register(
registry.Type.EVENT, eventUtils.COMMENT_CREATE, CommentCreate);
diff --git a/core/events/events_comment_delete.js b/core/events/events_comment_delete.js
index 54e1df4ba..795e919ff 100644
--- a/core/events/events_comment_delete.js
+++ b/core/events/events_comment_delete.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.CommentDelete');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {CommentBase} = goog.require('Blockly.Events.CommentBase');
/* eslint-disable-next-line no-unused-vars */
@@ -25,53 +24,56 @@ const {WorkspaceComment} = goog.requireType('Blockly.WorkspaceComment');
/**
* Class for a comment deletion event.
- * @param {!WorkspaceComment=} opt_comment The deleted comment.
- * Undefined for a blank event.
* @extends {CommentBase}
- * @constructor
* @alias Blockly.Events.CommentDelete
*/
-const CommentDelete = function(opt_comment) {
- CommentDelete.superClass_.constructor.call(this, opt_comment);
- if (!opt_comment) {
- return; // Blank event to be populated by fromJson.
+class CommentDelete extends CommentBase {
+ /**
+ * @param {!WorkspaceComment=} opt_comment The deleted comment.
+ * Undefined for a blank event.
+ */
+ constructor(opt_comment) {
+ super(opt_comment);
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.COMMENT_DELETE;
+
+ if (!opt_comment) {
+ return; // Blank event to be populated by fromJson.
+ }
+
+ this.xml = opt_comment.toXmlWithXY();
}
- this.xml = opt_comment.toXmlWithXY();
-};
-object.inherits(CommentDelete, CommentBase);
+ // TODO (#1266): "Full" and "minimal" serialization.
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ return json;
+ }
-/**
- * Type of this event.
- * @type {string}
- */
-CommentDelete.prototype.type = eventUtils.COMMENT_DELETE;
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ }
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-// TODO (#1266): "Full" and "minimal" serialization.
-CommentDelete.prototype.toJson = function() {
- const json = CommentDelete.superClass_.toJson.call(this);
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-CommentDelete.prototype.fromJson = function(json) {
- CommentDelete.superClass_.fromJson.call(this, json);
-};
-
-/**
- * Run a creation event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-CommentDelete.prototype.run = function(forward) {
- CommentBase.CommentCreateDeleteHelper(this, !forward);
-};
+ /**
+ * Run a creation event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ CommentBase.CommentCreateDeleteHelper(this, !forward);
+ }
+}
registry.register(
registry.Type.EVENT, eventUtils.COMMENT_DELETE, CommentDelete);
diff --git a/core/events/events_comment_move.js b/core/events/events_comment_move.js
index 51ba01ab6..cade6586a 100644
--- a/core/events/events_comment_move.js
+++ b/core/events/events_comment_move.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.CommentMove');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {CommentBase} = goog.require('Blockly.Events.CommentBase');
const {Coordinate} = goog.require('Blockly.utils.Coordinate');
@@ -26,129 +25,132 @@ const {WorkspaceComment} = goog.requireType('Blockly.WorkspaceComment');
/**
* Class for a comment move event. Created before the move.
- * @param {!WorkspaceComment=} opt_comment The comment that is being
- * moved. Undefined for a blank event.
* @extends {CommentBase}
- * @constructor
* @alias Blockly.Events.CommentMove
*/
-const CommentMove = function(opt_comment) {
- CommentMove.superClass_.constructor.call(this, opt_comment);
- if (!opt_comment) {
- return; // Blank event to be populated by fromJson.
+class CommentMove extends CommentBase {
+ /**
+ * @param {!WorkspaceComment=} opt_comment The comment that is being
+ * moved. Undefined for a blank event.
+ */
+ constructor(opt_comment) {
+ super(opt_comment);
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.COMMENT_MOVE;
+
+ if (!opt_comment) {
+ return; // Blank event to be populated by fromJson.
+ }
+
+ /**
+ * The comment that is being moved. Will be cleared after recording the new
+ * location.
+ * @type {WorkspaceComment}
+ */
+ this.comment_ = opt_comment;
+
+ /**
+ * The location before the move, in workspace coordinates.
+ * @type {!Coordinate}
+ */
+ this.oldCoordinate_ = opt_comment.getXY();
+
+ /**
+ * The location after the move, in workspace coordinates.
+ * @type {Coordinate}
+ */
+ this.newCoordinate_ = null;
}
/**
- * The comment that is being moved. Will be cleared after recording the new
- * location.
- * @type {WorkspaceComment}
+ * Record the comment's new location. Called after the move. Can only be
+ * called once.
*/
- this.comment_ = opt_comment;
+ recordNew() {
+ if (!this.comment_) {
+ throw Error(
+ 'Tried to record the new position of a comment on the ' +
+ 'same event twice.');
+ }
+ this.newCoordinate_ = this.comment_.getXY();
+ this.comment_ = null;
+ }
/**
- * The location before the move, in workspace coordinates.
- * @type {!Coordinate}
+ * Override the location before the move. Use this if you don't create the
+ * event until the end of the move, but you know the original location.
+ * @param {!Coordinate} xy The location before the move,
+ * in workspace coordinates.
*/
- this.oldCoordinate_ = opt_comment.getXY();
+ setOldCoordinate(xy) {
+ this.oldCoordinate_ = xy;
+ }
+
+ // TODO (#1266): "Full" and "minimal" serialization.
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ if (this.oldCoordinate_) {
+ json['oldCoordinate'] = Math.round(this.oldCoordinate_.x) + ',' +
+ Math.round(this.oldCoordinate_.y);
+ }
+ if (this.newCoordinate_) {
+ json['newCoordinate'] = Math.round(this.newCoordinate_.x) + ',' +
+ Math.round(this.newCoordinate_.y);
+ }
+ return json;
+ }
/**
- * The location after the move, in workspace coordinates.
- * @type {Coordinate}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.newCoordinate_ = null;
-};
-object.inherits(CommentMove, CommentBase);
+ fromJson(json) {
+ super.fromJson(json);
-/**
- * Record the comment's new location. Called after the move. Can only be
- * called once.
- */
-CommentMove.prototype.recordNew = function() {
- if (!this.comment_) {
- throw Error(
- 'Tried to record the new position of a comment on the ' +
- 'same event twice.');
- }
- this.newCoordinate_ = this.comment_.getXY();
- this.comment_ = null;
-};
-
-/**
- * Type of this event.
- * @type {string}
- */
-CommentMove.prototype.type = eventUtils.COMMENT_MOVE;
-
-/**
- * Override the location before the move. Use this if you don't create the
- * event until the end of the move, but you know the original location.
- * @param {!Coordinate} xy The location before the move,
- * in workspace coordinates.
- */
-CommentMove.prototype.setOldCoordinate = function(xy) {
- this.oldCoordinate_ = xy;
-};
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-// TODO (#1266): "Full" and "minimal" serialization.
-CommentMove.prototype.toJson = function() {
- const json = CommentMove.superClass_.toJson.call(this);
- if (this.oldCoordinate_) {
- json['oldCoordinate'] = Math.round(this.oldCoordinate_.x) + ',' +
- Math.round(this.oldCoordinate_.y);
- }
- if (this.newCoordinate_) {
- json['newCoordinate'] = Math.round(this.newCoordinate_.x) + ',' +
- Math.round(this.newCoordinate_.y);
- }
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-CommentMove.prototype.fromJson = function(json) {
- CommentMove.superClass_.fromJson.call(this, json);
-
- if (json['oldCoordinate']) {
- const xy = json['oldCoordinate'].split(',');
- this.oldCoordinate_ = new Coordinate(Number(xy[0]), Number(xy[1]));
- }
- if (json['newCoordinate']) {
- const xy = json['newCoordinate'].split(',');
- this.newCoordinate_ = new Coordinate(Number(xy[0]), Number(xy[1]));
- }
-};
-
-/**
- * Does this event record any change of state?
- * @return {boolean} False if something changed.
- */
-CommentMove.prototype.isNull = function() {
- return Coordinate.equals(this.oldCoordinate_, this.newCoordinate_);
-};
-
-/**
- * Run a move event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-CommentMove.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- const comment = workspace.getCommentById(this.commentId);
- if (!comment) {
- console.warn('Can\'t move non-existent comment: ' + this.commentId);
- return;
+ if (json['oldCoordinate']) {
+ const xy = json['oldCoordinate'].split(',');
+ this.oldCoordinate_ = new Coordinate(Number(xy[0]), Number(xy[1]));
+ }
+ if (json['newCoordinate']) {
+ const xy = json['newCoordinate'].split(',');
+ this.newCoordinate_ = new Coordinate(Number(xy[0]), Number(xy[1]));
+ }
}
- const target = forward ? this.newCoordinate_ : this.oldCoordinate_;
- // TODO: Check if the comment is being dragged, and give up if so.
- const current = comment.getXY();
- comment.moveBy(target.x - current.x, target.y - current.y);
-};
+ /**
+ * Does this event record any change of state?
+ * @return {boolean} False if something changed.
+ */
+ isNull() {
+ return Coordinate.equals(this.oldCoordinate_, this.newCoordinate_);
+ }
+
+ /**
+ * Run a move event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ const comment = workspace.getCommentById(this.commentId);
+ if (!comment) {
+ console.warn('Can\'t move non-existent comment: ' + this.commentId);
+ return;
+ }
+
+ const target = forward ? this.newCoordinate_ : this.oldCoordinate_;
+ // TODO: Check if the comment is being dragged, and give up if so.
+ const current = comment.getXY();
+ comment.moveBy(target.x - current.x, target.y - current.y);
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.COMMENT_MOVE, CommentMove);
diff --git a/core/events/events_marker_move.js b/core/events/events_marker_move.js
index 97f473d95..8961919fc 100644
--- a/core/events/events_marker_move.js
+++ b/core/events/events_marker_move.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.MarkerMove');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {ASTNode} = goog.require('Blockly.ASTNode');
/* eslint-disable-next-line no-unused-vars */
@@ -28,81 +27,83 @@ const {Workspace} = goog.requireType('Blockly.Workspace');
/**
* Class for a marker move event.
- * @param {?Block=} opt_block The affected block. Null if current node
- * is of type workspace. Undefined for a blank event.
- * @param {boolean=} isCursor Whether this is a cursor event. Undefined for a
- * blank event.
- * @param {?ASTNode=} opt_oldNode The old node the marker used to be on.
- * Undefined for a blank event.
- * @param {!ASTNode=} opt_newNode The new node the marker is now on.
- * Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.MarkerMove
*/
-const MarkerMove = function(opt_block, isCursor, opt_oldNode, opt_newNode) {
- let workspaceId = opt_block ? opt_block.workspace.id : undefined;
- if (opt_newNode && opt_newNode.getType() === ASTNode.types.WORKSPACE) {
- workspaceId = (/** @type {!Workspace} */ (opt_newNode.getLocation())).id;
+class MarkerMove extends UiBase {
+ /**
+ * @param {?Block=} opt_block The affected block. Null if current node
+ * is of type workspace. Undefined for a blank event.
+ * @param {boolean=} isCursor Whether this is a cursor event. Undefined for a
+ * blank event.
+ * @param {?ASTNode=} opt_oldNode The old node the marker used to be on.
+ * Undefined for a blank event.
+ * @param {!ASTNode=} opt_newNode The new node the marker is now on.
+ * Undefined for a blank event.
+ */
+ constructor(opt_block, isCursor, opt_oldNode, opt_newNode) {
+ let workspaceId = opt_block ? opt_block.workspace.id : undefined;
+ if (opt_newNode && opt_newNode.getType() === ASTNode.types.WORKSPACE) {
+ workspaceId = (/** @type {!Workspace} */ (opt_newNode.getLocation())).id;
+ }
+ super(workspaceId);
+
+ /**
+ * The workspace identifier for this event.
+ * @type {?string}
+ */
+ this.blockId = opt_block ? opt_block.id : null;
+
+ /**
+ * The old node the marker used to be on.
+ * @type {?ASTNode|undefined}
+ */
+ this.oldNode = opt_oldNode;
+
+ /**
+ * The new node the marker is now on.
+ * @type {ASTNode|undefined}
+ */
+ this.newNode = opt_newNode;
+
+ /**
+ * Whether this is a cursor event.
+ * @type {boolean|undefined}
+ */
+ this.isCursor = isCursor;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.MARKER_MOVE;
}
- MarkerMove.superClass_.constructor.call(this, workspaceId);
/**
- * The workspace identifier for this event.
- * @type {?string}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.blockId = opt_block ? opt_block.id : null;
+ toJson() {
+ const json = super.toJson();
+ json['isCursor'] = this.isCursor;
+ json['blockId'] = this.blockId;
+ json['oldNode'] = this.oldNode;
+ json['newNode'] = this.newNode;
+ return json;
+ }
/**
- * The old node the marker used to be on.
- * @type {?ASTNode|undefined}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.oldNode = opt_oldNode;
-
- /**
- * The new node the marker is now on.
- * @type {ASTNode|undefined}
- */
- this.newNode = opt_newNode;
-
- /**
- * Whether this is a cursor event.
- * @type {boolean|undefined}
- */
- this.isCursor = isCursor;
-};
-object.inherits(MarkerMove, UiBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-MarkerMove.prototype.type = eventUtils.MARKER_MOVE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-MarkerMove.prototype.toJson = function() {
- const json = MarkerMove.superClass_.toJson.call(this);
- json['isCursor'] = this.isCursor;
- json['blockId'] = this.blockId;
- json['oldNode'] = this.oldNode;
- json['newNode'] = this.newNode;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-MarkerMove.prototype.fromJson = function(json) {
- MarkerMove.superClass_.fromJson.call(this, json);
- this.isCursor = json['isCursor'];
- this.blockId = json['blockId'];
- this.oldNode = json['oldNode'];
- this.newNode = json['newNode'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.isCursor = json['isCursor'];
+ this.blockId = json['blockId'];
+ this.oldNode = json['oldNode'];
+ this.newNode = json['newNode'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.MARKER_MOVE, MarkerMove);
diff --git a/core/events/events_selected.js b/core/events/events_selected.js
index 7009a8549..0ec33eb61 100644
--- a/core/events/events_selected.js
+++ b/core/events/events_selected.js
@@ -16,66 +16,67 @@
goog.module('Blockly.Events.Selected');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a selected event.
- * @param {?string=} opt_oldElementId The ID of the previously selected
- * element. Null if no element last selected. Undefined for a blank event.
- * @param {?string=} opt_newElementId The ID of the selected element. Null if no
- * element currently selected (deselect). Undefined for a blank event.
- * @param {string=} opt_workspaceId The workspace identifier for this event.
- * Null if no element previously selected. Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.Selected
*/
-const Selected = function(opt_oldElementId, opt_newElementId, opt_workspaceId) {
- Selected.superClass_.constructor.call(this, opt_workspaceId);
+class Selected extends UiBase {
+ /**
+ * @param {?string=} opt_oldElementId The ID of the previously selected
+ * element. Null if no element last selected. Undefined for a blank event.
+ * @param {?string=} opt_newElementId The ID of the selected element. Null if
+ * no element currently selected (deselect). Undefined for a blank event.
+ * @param {string=} opt_workspaceId The workspace identifier for this event.
+ * Null if no element previously selected. Undefined for a blank event.
+ */
+ constructor(opt_oldElementId, opt_newElementId, opt_workspaceId) {
+ super(opt_workspaceId);
+
+ /**
+ * The id of the last selected element.
+ * @type {?string|undefined}
+ */
+ this.oldElementId = opt_oldElementId;
+
+ /**
+ * The id of the selected element.
+ * @type {?string|undefined}
+ */
+ this.newElementId = opt_newElementId;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.SELECTED;
+ }
/**
- * The id of the last selected element.
- * @type {?string|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.oldElementId = opt_oldElementId;
+ toJson() {
+ const json = super.toJson();
+ json['oldElementId'] = this.oldElementId;
+ json['newElementId'] = this.newElementId;
+ return json;
+ }
/**
- * The id of the selected element.
- * @type {?string|undefined}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.newElementId = opt_newElementId;
-};
-object.inherits(Selected, UiBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-Selected.prototype.type = eventUtils.SELECTED;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-Selected.prototype.toJson = function() {
- const json = Selected.superClass_.toJson.call(this);
- json['oldElementId'] = this.oldElementId;
- json['newElementId'] = this.newElementId;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-Selected.prototype.fromJson = function(json) {
- Selected.superClass_.fromJson.call(this, json);
- this.oldElementId = json['oldElementId'];
- this.newElementId = json['newElementId'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.oldElementId = json['oldElementId'];
+ this.newElementId = json['newElementId'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.SELECTED, Selected);
diff --git a/core/events/events_theme_change.js b/core/events/events_theme_change.js
index e1cba44c2..a3c436372 100644
--- a/core/events/events_theme_change.js
+++ b/core/events/events_theme_change.js
@@ -16,55 +16,56 @@
goog.module('Blockly.Events.ThemeChange');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a theme change event.
- * @param {string=} opt_themeName The theme name. Undefined for a blank event.
- * @param {string=} opt_workspaceId The workspace identifier for this event.
- * event. Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.ThemeChange
*/
-const ThemeChange = function(opt_themeName, opt_workspaceId) {
- ThemeChange.superClass_.constructor.call(this, opt_workspaceId);
+class ThemeChange extends UiBase {
+ /**
+ * @param {string=} opt_themeName The theme name. Undefined for a blank event.
+ * @param {string=} opt_workspaceId The workspace identifier for this event.
+ * event. Undefined for a blank event.
+ */
+ constructor(opt_themeName, opt_workspaceId) {
+ super(opt_workspaceId);
+
+ /**
+ * The theme name.
+ * @type {string|undefined}
+ */
+ this.themeName = opt_themeName;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.THEME_CHANGE;
+ }
/**
- * The theme name.
- * @type {string|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.themeName = opt_themeName;
-};
-object.inherits(ThemeChange, UiBase);
+ toJson() {
+ const json = super.toJson();
+ json['themeName'] = this.themeName;
+ return json;
+ }
-/**
- * Type of this event.
- * @type {string}
- */
-ThemeChange.prototype.type = eventUtils.THEME_CHANGE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-ThemeChange.prototype.toJson = function() {
- const json = ThemeChange.superClass_.toJson.call(this);
- json['themeName'] = this.themeName;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-ThemeChange.prototype.fromJson = function(json) {
- ThemeChange.superClass_.fromJson.call(this, json);
- this.themeName = json['themeName'];
-};
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.themeName = json['themeName'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.THEME_CHANGE, ThemeChange);
diff --git a/core/events/events_toolbox_item_select.js b/core/events/events_toolbox_item_select.js
index f2ba9bf19..202c92ea8 100644
--- a/core/events/events_toolbox_item_select.js
+++ b/core/events/events_toolbox_item_select.js
@@ -16,66 +16,67 @@
goog.module('Blockly.Events.ToolboxItemSelect');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a toolbox item select event.
- * @param {?string=} opt_oldItem The previously selected toolbox item. Undefined
- * for a blank event.
- * @param {?string=} opt_newItem The newly selected toolbox item. Undefined for
- * a blank event.
- * @param {string=} opt_workspaceId The workspace identifier for this event.
- * Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.ToolboxItemSelect
*/
-const ToolboxItemSelect = function(opt_oldItem, opt_newItem, opt_workspaceId) {
- ToolboxItemSelect.superClass_.constructor.call(this, opt_workspaceId);
+class ToolboxItemSelect extends UiBase {
+ /**
+ * @param {?string=} opt_oldItem The previously selected toolbox item.
+ * Undefined for a blank event.
+ * @param {?string=} opt_newItem The newly selected toolbox item. Undefined
+ * for a blank event.
+ * @param {string=} opt_workspaceId The workspace identifier for this event.
+ * Undefined for a blank event.
+ */
+ constructor(opt_oldItem, opt_newItem, opt_workspaceId) {
+ super(opt_workspaceId);
+
+ /**
+ * The previously selected toolbox item.
+ * @type {?string|undefined}
+ */
+ this.oldItem = opt_oldItem;
+
+ /**
+ * The newly selected toolbox item.
+ * @type {?string|undefined}
+ */
+ this.newItem = opt_newItem;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.TOOLBOX_ITEM_SELECT;
+ }
/**
- * The previously selected toolbox item.
- * @type {?string|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.oldItem = opt_oldItem;
+ toJson() {
+ const json = super.toJson();
+ json['oldItem'] = this.oldItem;
+ json['newItem'] = this.newItem;
+ return json;
+ }
/**
- * The newly selected toolbox item.
- * @type {?string|undefined}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.newItem = opt_newItem;
-};
-object.inherits(ToolboxItemSelect, UiBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-ToolboxItemSelect.prototype.type = eventUtils.TOOLBOX_ITEM_SELECT;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-ToolboxItemSelect.prototype.toJson = function() {
- const json = ToolboxItemSelect.superClass_.toJson.call(this);
- json['oldItem'] = this.oldItem;
- json['newItem'] = this.newItem;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-ToolboxItemSelect.prototype.fromJson = function(json) {
- ToolboxItemSelect.superClass_.fromJson.call(this, json);
- this.oldItem = json['oldItem'];
- this.newItem = json['newItem'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.oldItem = json['oldItem'];
+ this.newItem = json['newItem'];
+ }
+}
registry.register(
registry.Type.EVENT, eventUtils.TOOLBOX_ITEM_SELECT, ToolboxItemSelect);
diff --git a/core/events/events_trashcan_open.js b/core/events/events_trashcan_open.js
index 21fd0d3b3..8c4d375bf 100644
--- a/core/events/events_trashcan_open.js
+++ b/core/events/events_trashcan_open.js
@@ -16,56 +16,57 @@
goog.module('Blockly.Events.TrashcanOpen');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a trashcan open event.
- * @param {boolean=} opt_isOpen Whether the trashcan flyout is opening (false if
- * opening). Undefined for a blank event.
- * @param {string=} opt_workspaceId The workspace identifier for this event.
- * Undefined for a blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.TrashcanOpen
*/
-const TrashcanOpen = function(opt_isOpen, opt_workspaceId) {
- TrashcanOpen.superClass_.constructor.call(this, opt_workspaceId);
+class TrashcanOpen extends UiBase {
+ /**
+ * @param {boolean=} opt_isOpen Whether the trashcan flyout is opening (false
+ * if opening). Undefined for a blank event.
+ * @param {string=} opt_workspaceId The workspace identifier for this event.
+ * Undefined for a blank event.
+ */
+ constructor(opt_isOpen, opt_workspaceId) {
+ super(opt_workspaceId);
+
+ /**
+ * Whether the trashcan flyout is opening (false if closing).
+ * @type {boolean|undefined}
+ */
+ this.isOpen = opt_isOpen;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.TRASHCAN_OPEN;
+ }
/**
- * Whether the trashcan flyout is opening (false if closing).
- * @type {boolean|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.isOpen = opt_isOpen;
-};
-object.inherits(TrashcanOpen, UiBase);
+ toJson() {
+ const json = super.toJson();
+ json['isOpen'] = this.isOpen;
+ return json;
+ }
-/**
- * Type of this event.
- * @type {string}
- */
-TrashcanOpen.prototype.type = eventUtils.TRASHCAN_OPEN;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-TrashcanOpen.prototype.toJson = function() {
- const json = TrashcanOpen.superClass_.toJson.call(this);
- json['isOpen'] = this.isOpen;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-TrashcanOpen.prototype.fromJson = function(json) {
- TrashcanOpen.superClass_.fromJson.call(this, json);
- this.isOpen = json['isOpen'];
-};
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.isOpen = json['isOpen'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.TRASHCAN_OPEN, TrashcanOpen);
diff --git a/core/events/events_ui.js b/core/events/events_ui.js
index b505d3b20..275655a36 100644
--- a/core/events/events_ui.js
+++ b/core/events/events_ui.js
@@ -18,7 +18,6 @@
goog.module('Blockly.Events.Ui');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
@@ -27,60 +26,62 @@ const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a UI event.
- * @param {?Block=} opt_block The affected block. Null for UI events
- * that do not have an associated block. Undefined for a blank event.
- * @param {string=} opt_element One of 'selected', 'comment', 'mutatorOpen',
- * etc.
- * @param {*=} opt_oldValue Previous value of element.
- * @param {*=} opt_newValue New value of element.
* @extends {UiBase}
* @deprecated December 2020. Instead use a more specific UI event.
- * @constructor
* @alias Blockly.Events.Ui
*/
-const Ui = function(opt_block, opt_element, opt_oldValue, opt_newValue) {
- const workspaceId = opt_block ? opt_block.workspace.id : undefined;
- Ui.superClass_.constructor.call(this, workspaceId);
+class Ui extends UiBase {
+ /**
+ * @param {?Block=} opt_block The affected block. Null for UI events
+ * that do not have an associated block. Undefined for a blank event.
+ * @param {string=} opt_element One of 'selected', 'comment', 'mutatorOpen',
+ * etc.
+ * @param {*=} opt_oldValue Previous value of element.
+ * @param {*=} opt_newValue New value of element.
+ */
+ constructor(opt_block, opt_element, opt_oldValue, opt_newValue) {
+ const workspaceId = opt_block ? opt_block.workspace.id : undefined;
+ super(workspaceId);
- this.blockId = opt_block ? opt_block.id : null;
- this.element = typeof opt_element === 'undefined' ? '' : opt_element;
- this.oldValue = typeof opt_oldValue === 'undefined' ? '' : opt_oldValue;
- this.newValue = typeof opt_newValue === 'undefined' ? '' : opt_newValue;
-};
-object.inherits(Ui, UiBase);
+ this.blockId = opt_block ? opt_block.id : null;
+ this.element = typeof opt_element === 'undefined' ? '' : opt_element;
+ this.oldValue = typeof opt_oldValue === 'undefined' ? '' : opt_oldValue;
+ this.newValue = typeof opt_newValue === 'undefined' ? '' : opt_newValue;
-/**
- * Type of this event.
- * @type {string}
- */
-Ui.prototype.type = eventUtils.UI;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-Ui.prototype.toJson = function() {
- const json = Ui.superClass_.toJson.call(this);
- json['element'] = this.element;
- if (this.newValue !== undefined) {
- json['newValue'] = this.newValue;
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.UI;
}
- if (this.blockId) {
- json['blockId'] = this.blockId;
- }
- return json;
-};
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-Ui.prototype.fromJson = function(json) {
- Ui.superClass_.fromJson.call(this, json);
- this.element = json['element'];
- this.newValue = json['newValue'];
- this.blockId = json['blockId'];
-};
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['element'] = this.element;
+ if (this.newValue !== undefined) {
+ json['newValue'] = this.newValue;
+ }
+ if (this.blockId) {
+ json['blockId'] = this.blockId;
+ }
+ return json;
+ }
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.element = json['element'];
+ this.newValue = json['newValue'];
+ this.blockId = json['blockId'];
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.UI, Ui);
diff --git a/core/events/events_ui_base.js b/core/events/events_ui_base.js
index 1618f2876..3df25abcf 100644
--- a/core/events/events_ui_base.js
+++ b/core/events/events_ui_base.js
@@ -17,8 +17,7 @@
*/
goog.module('Blockly.Events.UiBase');
-const Abstract = goog.require('Blockly.Events.Abstract');
-const object = goog.require('Blockly.utils.object');
+const {Abstract: AbstractEvent} = goog.require('Blockly.Events.Abstract');
/**
@@ -27,36 +26,38 @@ const object = goog.require('Blockly.utils.object');
* editing to work (e.g. scrolling the workspace, zooming, opening toolbox
* categories).
* UI events do not undo or redo.
- * @param {string=} opt_workspaceId The workspace identifier for this event.
- * Undefined for a blank event.
- * @extends {Abstract}
- * @constructor
+ * @extends {AbstractEvent}
* @alias Blockly.Events.UiBase
*/
-const UiBase = function(opt_workspaceId) {
- UiBase.superClass_.constructor.call(this);
-
+class UiBase extends AbstractEvent {
/**
- * Whether or not the event is blank (to be populated by fromJson).
- * @type {boolean}
+ * @param {string=} opt_workspaceId The workspace identifier for this event.
+ * Undefined for a blank event.
*/
- this.isBlank = typeof opt_workspaceId === 'undefined';
+ constructor(opt_workspaceId) {
+ super();
- /**
- * The workspace identifier for this event.
- * @type {string}
- */
- this.workspaceId = opt_workspaceId ? opt_workspaceId : '';
+ /**
+ * Whether or not the event is blank (to be populated by fromJson).
+ * @type {boolean}
+ */
+ this.isBlank = typeof opt_workspaceId === 'undefined';
- // UI events do not undo or redo.
- this.recordUndo = false;
-};
-object.inherits(UiBase, Abstract);
+ /**
+ * The workspace identifier for this event.
+ * @type {string}
+ */
+ this.workspaceId = opt_workspaceId ? opt_workspaceId : '';
-/**
- * Whether or not the event is a UI event.
- * @type {boolean}
- */
-UiBase.prototype.isUiEvent = true;
+ // UI events do not undo or redo.
+ this.recordUndo = false;
+
+ /**
+ * Whether or not the event is a UI event.
+ * @type {boolean}
+ */
+ this.isUiEvent = true;
+ }
+}
exports.UiBase = UiBase;
diff --git a/core/events/events_var_base.js b/core/events/events_var_base.js
index fd919a061..35d792df1 100644
--- a/core/events/events_var_base.js
+++ b/core/events/events_var_base.js
@@ -15,55 +15,56 @@
*/
goog.module('Blockly.Events.VarBase');
-const Abstract = goog.require('Blockly.Events.Abstract');
-const object = goog.require('Blockly.utils.object');
+const {Abstract: AbstractEvent} = goog.require('Blockly.Events.Abstract');
/* eslint-disable-next-line no-unused-vars */
const {VariableModel} = goog.requireType('Blockly.VariableModel');
/**
* Abstract class for a variable event.
- * @param {!VariableModel=} opt_variable The variable this event
- * corresponds to. Undefined for a blank event.
- * @extends {Abstract}
- * @constructor
+ * @extends {AbstractEvent}
* @alias Blockly.Events.VarBase
*/
-const VarBase = function(opt_variable) {
- VarBase.superClass_.constructor.call(this);
- this.isBlank = typeof opt_variable === 'undefined';
+class VarBase extends AbstractEvent {
+ /**
+ * @param {!VariableModel=} opt_variable The variable this event
+ * corresponds to. Undefined for a blank event.
+ */
+ constructor(opt_variable) {
+ super();
+ this.isBlank = typeof opt_variable === 'undefined';
+
+ /**
+ * The variable id for the variable this event pertains to.
+ * @type {string}
+ */
+ this.varId = this.isBlank ? '' : opt_variable.getId();
+
+ /**
+ * The workspace identifier for this event.
+ * @type {string}
+ */
+ this.workspaceId = this.isBlank ? '' : opt_variable.workspace.id;
+ }
/**
- * The variable id for the variable this event pertains to.
- * @type {string}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.varId = this.isBlank ? '' : opt_variable.getId();
+ toJson() {
+ const json = super.toJson();
+ json['varId'] = this.varId;
+ return json;
+ }
/**
- * The workspace identifier for this event.
- * @type {string}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.workspaceId = this.isBlank ? '' : opt_variable.workspace.id;
-};
-object.inherits(VarBase, Abstract);
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-VarBase.prototype.toJson = function() {
- const json = VarBase.superClass_.toJson.call(this);
- json['varId'] = this.varId;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-VarBase.prototype.fromJson = function(json) {
- VarBase.superClass_.toJson.call(this);
- this.varId = json['varId'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.varId = json['varId'];
+ }
+}
exports.VarBase = VarBase;
diff --git a/core/events/events_var_create.js b/core/events/events_var_create.js
index f924fd98d..13df20aad 100644
--- a/core/events/events_var_create.js
+++ b/core/events/events_var_create.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.VarCreate');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {VarBase} = goog.require('Blockly.Events.VarBase');
/* eslint-disable-next-line no-unused-vars */
@@ -25,62 +24,65 @@ const {VariableModel} = goog.requireType('Blockly.VariableModel');
/**
* Class for a variable creation event.
- * @param {!VariableModel=} opt_variable The created variable. Undefined
- * for a blank event.
* @extends {VarBase}
- * @constructor
* @alias Blockly.Events.VarCreate
*/
-const VarCreate = function(opt_variable) {
- VarCreate.superClass_.constructor.call(this, opt_variable);
- if (!opt_variable) {
- return; // Blank event to be populated by fromJson.
+class VarCreate extends VarBase {
+ /**
+ * @param {!VariableModel=} opt_variable The created variable. Undefined
+ * for a blank event.
+ */
+ constructor(opt_variable) {
+ super(opt_variable);
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.VAR_CREATE;
+
+ if (!opt_variable) {
+ return; // Blank event to be populated by fromJson.
+ }
+
+ this.varType = opt_variable.type;
+ this.varName = opt_variable.name;
}
- this.varType = opt_variable.type;
- this.varName = opt_variable.name;
-};
-object.inherits(VarCreate, VarBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-VarCreate.prototype.type = eventUtils.VAR_CREATE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-VarCreate.prototype.toJson = function() {
- const json = VarCreate.superClass_.toJson.call(this);
- json['varType'] = this.varType;
- json['varName'] = this.varName;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-VarCreate.prototype.fromJson = function(json) {
- VarCreate.superClass_.fromJson.call(this, json);
- this.varType = json['varType'];
- this.varName = json['varName'];
-};
-
-/**
- * Run a variable creation event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-VarCreate.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- if (forward) {
- workspace.createVariable(this.varName, this.varType, this.varId);
- } else {
- workspace.deleteVariableById(this.varId);
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['varType'] = this.varType;
+ json['varName'] = this.varName;
+ return json;
}
-};
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.varType = json['varType'];
+ this.varName = json['varName'];
+ }
+
+ /**
+ * Run a variable creation event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ if (forward) {
+ workspace.createVariable(this.varName, this.varType, this.varId);
+ } else {
+ workspace.deleteVariableById(this.varId);
+ }
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.VAR_CREATE, VarCreate);
diff --git a/core/events/events_var_delete.js b/core/events/events_var_delete.js
index 2c8b34fb2..0f23f49c8 100644
--- a/core/events/events_var_delete.js
+++ b/core/events/events_var_delete.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.VarDelete');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {VarBase} = goog.require('Blockly.Events.VarBase');
/* eslint-disable-next-line no-unused-vars */
@@ -25,62 +24,65 @@ const {VariableModel} = goog.requireType('Blockly.VariableModel');
/**
* Class for a variable deletion event.
- * @param {!VariableModel=} opt_variable The deleted variable. Undefined
- * for a blank event.
* @extends {VarBase}
- * @constructor
* @alias Blockly.Events.VarDelete
*/
-const VarDelete = function(opt_variable) {
- VarDelete.superClass_.constructor.call(this, opt_variable);
- if (!opt_variable) {
- return; // Blank event to be populated by fromJson.
+class VarDelete extends VarBase {
+ /**
+ * @param {!VariableModel=} opt_variable The deleted variable. Undefined
+ * for a blank event.
+ */
+ constructor(opt_variable) {
+ super(opt_variable);
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.VAR_DELETE;
+
+ if (!opt_variable) {
+ return; // Blank event to be populated by fromJson.
+ }
+
+ this.varType = opt_variable.type;
+ this.varName = opt_variable.name;
}
- this.varType = opt_variable.type;
- this.varName = opt_variable.name;
-};
-object.inherits(VarDelete, VarBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-VarDelete.prototype.type = eventUtils.VAR_DELETE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-VarDelete.prototype.toJson = function() {
- const json = VarDelete.superClass_.toJson.call(this);
- json['varType'] = this.varType;
- json['varName'] = this.varName;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-VarDelete.prototype.fromJson = function(json) {
- VarDelete.superClass_.fromJson.call(this, json);
- this.varType = json['varType'];
- this.varName = json['varName'];
-};
-
-/**
- * Run a variable deletion event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-VarDelete.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- if (forward) {
- workspace.deleteVariableById(this.varId);
- } else {
- workspace.createVariable(this.varName, this.varType, this.varId);
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['varType'] = this.varType;
+ json['varName'] = this.varName;
+ return json;
}
-};
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.varType = json['varType'];
+ this.varName = json['varName'];
+ }
+
+ /**
+ * Run a variable deletion event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ if (forward) {
+ workspace.deleteVariableById(this.varId);
+ } else {
+ workspace.createVariable(this.varName, this.varType, this.varId);
+ }
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.VAR_DELETE, VarDelete);
diff --git a/core/events/events_var_rename.js b/core/events/events_var_rename.js
index 97d8fd882..cca540b94 100644
--- a/core/events/events_var_rename.js
+++ b/core/events/events_var_rename.js
@@ -16,7 +16,6 @@
goog.module('Blockly.Events.VarRename');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {VarBase} = goog.require('Blockly.Events.VarBase');
/* eslint-disable-next-line no-unused-vars */
@@ -25,63 +24,66 @@ const {VariableModel} = goog.requireType('Blockly.VariableModel');
/**
* Class for a variable rename event.
- * @param {!VariableModel=} opt_variable The renamed variable. Undefined
- * for a blank event.
- * @param {string=} newName The new name the variable will be changed to.
* @extends {VarBase}
- * @constructor
* @alias Blockly.Events.VarRename
*/
-const VarRename = function(opt_variable, newName) {
- VarRename.superClass_.constructor.call(this, opt_variable);
- if (!opt_variable) {
- return; // Blank event to be populated by fromJson.
+class VarRename extends VarBase {
+ /**
+ * @param {!VariableModel=} opt_variable The renamed variable. Undefined
+ * for a blank event.
+ * @param {string=} newName The new name the variable will be changed to.
+ */
+ constructor(opt_variable, newName) {
+ super(opt_variable);
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.VAR_RENAME;
+
+ if (!opt_variable) {
+ return; // Blank event to be populated by fromJson.
+ }
+
+ this.oldName = opt_variable.name;
+ this.newName = typeof newName === 'undefined' ? '' : newName;
}
- this.oldName = opt_variable.name;
- this.newName = typeof newName === 'undefined' ? '' : newName;
-};
-object.inherits(VarRename, VarBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-VarRename.prototype.type = eventUtils.VAR_RENAME;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-VarRename.prototype.toJson = function() {
- const json = VarRename.superClass_.toJson.call(this);
- json['oldName'] = this.oldName;
- json['newName'] = this.newName;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-VarRename.prototype.fromJson = function(json) {
- VarRename.superClass_.fromJson.call(this, json);
- this.oldName = json['oldName'];
- this.newName = json['newName'];
-};
-
-/**
- * Run a variable rename event.
- * @param {boolean} forward True if run forward, false if run backward (undo).
- */
-VarRename.prototype.run = function(forward) {
- const workspace = this.getEventWorkspace_();
- if (forward) {
- workspace.renameVariableById(this.varId, this.newName);
- } else {
- workspace.renameVariableById(this.varId, this.oldName);
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = super.toJson();
+ json['oldName'] = this.oldName;
+ json['newName'] = this.newName;
+ return json;
}
-};
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ super.fromJson(json);
+ this.oldName = json['oldName'];
+ this.newName = json['newName'];
+ }
+
+ /**
+ * Run a variable rename event.
+ * @param {boolean} forward True if run forward, false if run backward (undo).
+ */
+ run(forward) {
+ const workspace = this.getEventWorkspace_();
+ if (forward) {
+ workspace.renameVariableById(this.varId, this.newName);
+ } else {
+ workspace.renameVariableById(this.varId, this.oldName);
+ }
+ }
+}
registry.register(registry.Type.EVENT, eventUtils.VAR_RENAME, VarRename);
diff --git a/core/events/events_viewport.js b/core/events/events_viewport.js
index 71ac3d3c7..bb5cda5a5 100644
--- a/core/events/events_viewport.js
+++ b/core/events/events_viewport.js
@@ -16,89 +16,90 @@
goog.module('Blockly.Events.ViewportChange');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const {UiBase} = goog.require('Blockly.Events.UiBase');
/**
* Class for a viewport change event.
- * @param {number=} opt_top Top-edge of the visible portion of the workspace,
- * relative to the workspace origin. Undefined for a blank event.
- * @param {number=} opt_left Left-edge of the visible portion of the workspace,
- * relative to the workspace origin. Undefined for a blank event.
- * @param {number=} opt_scale The scale of the workspace. Undefined for a blank
- * event.
- * @param {string=} opt_workspaceId The workspace identifier for this event.
- * Undefined for a blank event.
- * @param {number=} opt_oldScale The old scale of the workspace. Undefined for a
- * blank event.
* @extends {UiBase}
- * @constructor
* @alias Blockly.Events.ViewportChange
*/
-const ViewportChange = function(
- opt_top, opt_left, opt_scale, opt_workspaceId, opt_oldScale) {
- ViewportChange.superClass_.constructor.call(this, opt_workspaceId);
+class ViewportChange extends UiBase {
+ /**
+ * @param {number=} opt_top Top-edge of the visible portion of the workspace,
+ * relative to the workspace origin. Undefined for a blank event.
+ * @param {number=} opt_left Left-edge of the visible portion of the
+ * workspace relative to the workspace origin. Undefined for a blank
+ * event.
+ * @param {number=} opt_scale The scale of the workspace. Undefined for a
+ * blank event.
+ * @param {string=} opt_workspaceId The workspace identifier for this event.
+ * Undefined for a blank event.
+ * @param {number=} opt_oldScale The old scale of the workspace. Undefined for
+ * a blank event.
+ */
+ constructor(opt_top, opt_left, opt_scale, opt_workspaceId, opt_oldScale) {
+ super(opt_workspaceId);
+
+ /**
+ * Top-edge of the visible portion of the workspace, relative to the
+ * workspace origin.
+ * @type {number|undefined}
+ */
+ this.viewTop = opt_top;
+
+ /**
+ * Left-edge of the visible portion of the workspace, relative to the
+ * workspace origin.
+ * @type {number|undefined}
+ */
+ this.viewLeft = opt_left;
+
+ /**
+ * The scale of the workspace.
+ * @type {number|undefined}
+ */
+ this.scale = opt_scale;
+
+ /**
+ * The old scale of the workspace.
+ * @type {number|undefined}
+ */
+ this.oldScale = opt_oldScale;
+
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.VIEWPORT_CHANGE;
+ }
/**
- * Top-edge of the visible portion of the workspace, relative to the workspace
- * origin.
- * @type {number|undefined}
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
*/
- this.viewTop = opt_top;
+ toJson() {
+ const json = super.toJson();
+ json['viewTop'] = this.viewTop;
+ json['viewLeft'] = this.viewLeft;
+ json['scale'] = this.scale;
+ json['oldScale'] = this.oldScale;
+ return json;
+ }
/**
- * Left-edge of the visible portion of the workspace, relative to the
- * workspace origin.
- * @type {number|undefined}
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
*/
- this.viewLeft = opt_left;
-
- /**
- * The scale of the workspace.
- * @type {number|undefined}
- */
- this.scale = opt_scale;
-
- /**
- * The old scale of the workspace.
- * @type {number|undefined}
- */
- this.oldScale = opt_oldScale;
-};
-object.inherits(ViewportChange, UiBase);
-
-/**
- * Type of this event.
- * @type {string}
- */
-ViewportChange.prototype.type = eventUtils.VIEWPORT_CHANGE;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-ViewportChange.prototype.toJson = function() {
- const json = ViewportChange.superClass_.toJson.call(this);
- json['viewTop'] = this.viewTop;
- json['viewLeft'] = this.viewLeft;
- json['scale'] = this.scale;
- json['oldScale'] = this.oldScale;
- return json;
-};
-
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-ViewportChange.prototype.fromJson = function(json) {
- ViewportChange.superClass_.fromJson.call(this, json);
- this.viewTop = json['viewTop'];
- this.viewLeft = json['viewLeft'];
- this.scale = json['scale'];
- this.oldScale = json['oldScale'];
-};
+ fromJson(json) {
+ super.fromJson(json);
+ this.viewTop = json['viewTop'];
+ this.viewLeft = json['viewLeft'];
+ this.scale = json['scale'];
+ this.oldScale = json['oldScale'];
+ }
+}
registry.register(
registry.Type.EVENT, eventUtils.VIEWPORT_CHANGE, ViewportChange);
diff --git a/core/events/utils.js b/core/events/utils.js
index c405490b7..dfd2d4f06 100644
--- a/core/events/utils.js
+++ b/core/events/utils.js
@@ -17,11 +17,13 @@
*/
goog.module('Blockly.Events.utils');
-/* eslint-disable-next-line no-unused-vars */
-const Abstract = goog.requireType('Blockly.Events.Abstract');
const idGenerator = goog.require('Blockly.utils.idGenerator');
const registry = goog.require('Blockly.registry');
/* eslint-disable-next-line no-unused-vars */
+const {Abstract} = goog.requireType('Blockly.Events.Abstract');
+/* eslint-disable-next-line no-unused-vars */
+const {BlockChange} = goog.requireType('Blockly.Events.BlockChange');
+/* eslint-disable-next-line no-unused-vars */
const {BlockCreate} = goog.requireType('Blockly.Events.BlockCreate');
/* eslint-disable-next-line no-unused-vars */
const {BlockMove} = goog.requireType('Blockly.Events.BlockMove');
@@ -32,7 +34,11 @@ const {CommentCreate} = goog.requireType('Blockly.Events.CommentCreate');
/* eslint-disable-next-line no-unused-vars */
const {CommentMove} = goog.requireType('Blockly.Events.CommentMove');
/* eslint-disable-next-line no-unused-vars */
+const {ViewportChange} = goog.requireType('Blockly.Events.ViewportChange');
+/* eslint-disable-next-line no-unused-vars */
const {Workspace} = goog.requireType('Blockly.Workspace');
+/* eslint-disable-next-line no-unused-vars */
+const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg');
/**
@@ -307,6 +313,7 @@ exports.BUMP_EVENTS = BUMP_EVENTS;
/**
* List of events queued for firing.
+ * @type {!Array}
*/
const FIRE_QUEUE = [];
@@ -365,7 +372,9 @@ const filter = function(queueIn, forward) {
if (!event.isNull()) {
// Treat all UI events as the same type in hash table.
const eventType = event.isUiEvent ? UI : event.type;
- const key = [eventType, event.blockId, event.workspaceId].join(' ');
+ // TODO(#5927): Ceck whether `blockId` exists before accessing it.
+ const blockId = /** @type {*} */ (event).blockId;
+ const key = [eventType, blockId, event.workspaceId].join(' ');
const lastEntry = hash[key];
const lastEvent = lastEntry ? lastEntry.event : null;
@@ -376,22 +385,25 @@ const filter = function(queueIn, forward) {
hash[key] = {event: event, index: i};
mergedQueue.push(event);
} else if (event.type === MOVE && lastEntry.index === i - 1) {
+ const moveEvent = /** @type {!BlockMove} */ (event);
// Merge move events.
- lastEvent.newParentId = event.newParentId;
- lastEvent.newInputName = event.newInputName;
- lastEvent.newCoordinate = event.newCoordinate;
+ lastEvent.newParentId = moveEvent.newParentId;
+ lastEvent.newInputName = moveEvent.newInputName;
+ lastEvent.newCoordinate = moveEvent.newCoordinate;
lastEntry.index = i;
} else if (
event.type === CHANGE && event.element === lastEvent.element &&
event.name === lastEvent.name) {
+ const changeEvent = /** @type {!BlockChange} */ (event);
// Merge change events.
- lastEvent.newValue = event.newValue;
+ lastEvent.newValue = changeEvent.newValue;
} else if (event.type === VIEWPORT_CHANGE) {
+ const viewportEvent = /** @type {!ViewportChange} */ (event);
// Merge viewport change events.
- lastEvent.viewTop = event.viewTop;
- lastEvent.viewLeft = event.viewLeft;
- lastEvent.scale = event.scale;
- lastEvent.oldScale = event.oldScale;
+ lastEvent.viewTop = viewportEvent.viewTop;
+ lastEvent.viewLeft = viewportEvent.viewLeft;
+ lastEvent.scale = viewportEvent.scale;
+ lastEvent.oldScale = viewportEvent.oldScale;
} else if (event.type === CLICK && lastEvent.type === BUBBLE_OPEN) {
// Drop click events caused by opening/closing bubbles.
} else {
@@ -546,12 +558,15 @@ exports.get = get;
*/
const disableOrphans = function(event) {
if (event.type === MOVE || event.type === CREATE) {
- if (!event.workspaceId) {
+ const blockEvent = /** @type {!BlockMove|!BlockCreate} */ (event);
+ if (!blockEvent.workspaceId) {
return;
}
const {Workspace} = goog.module.get('Blockly.Workspace');
- const eventWorkspace = Workspace.getById(event.workspaceId);
- let block = eventWorkspace.getBlockById(event.blockId);
+ const eventWorkspace =
+ /** @type {!WorkspaceSvg} */ (
+ Workspace.getById(blockEvent.workspaceId));
+ let block = eventWorkspace.getBlockById(blockEvent.blockId);
if (block) {
// Changing blocks as part of this event shouldn't be undoable.
const initialUndoFlag = recordUndo;
diff --git a/core/events/workspace_events.js b/core/events/workspace_events.js
index 726a114c0..d2ad15714 100644
--- a/core/events/workspace_events.js
+++ b/core/events/workspace_events.js
@@ -15,10 +15,9 @@
*/
goog.module('Blockly.Events.FinishedLoading');
-const Abstract = goog.require('Blockly.Events.Abstract');
const eventUtils = goog.require('Blockly.Events.utils');
-const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
+const {Abstract: AbstractEvent} = goog.require('Blockly.Events.Abstract');
/* eslint-disable-next-line no-unused-vars */
const {Workspace} = goog.requireType('Blockly.Workspace');
@@ -28,70 +27,65 @@ const {Workspace} = goog.requireType('Blockly.Workspace');
* Used to notify the developer when the workspace has finished loading (i.e
* domToWorkspace).
* Finished loading events do not record undo or redo.
- * @param {!Workspace=} opt_workspace The workspace that has finished
- * loading. Undefined for a blank event.
- * @extends {Abstract}
- * @constructor
+ * @extends {AbstractEvent}
* @alias Blockly.Events.FinishedLoading
*/
-const FinishedLoading = function(opt_workspace) {
+class FinishedLoading extends AbstractEvent {
/**
- * Whether or not the event is blank (to be populated by fromJson).
- * @type {boolean}
+ * @param {!Workspace=} opt_workspace The workspace that has finished
+ * loading. Undefined for a blank event.
*/
- this.isBlank = typeof opt_workspace === 'undefined';
+ constructor(opt_workspace) {
+ super();
+ /**
+ * Whether or not the event is blank (to be populated by fromJson).
+ * @type {boolean}
+ */
+ this.isBlank = typeof opt_workspace === 'undefined';
- /**
- * The workspace identifier for this event.
- * @type {string}
- */
- this.workspaceId = opt_workspace ? opt_workspace.id : '';
+ /**
+ * The workspace identifier for this event.
+ * @type {string}
+ */
+ this.workspaceId = opt_workspace ? opt_workspace.id : '';
- /**
- * The event group ID for the group this event belongs to. Groups define
- * events that should be treated as an single action from the user's
- * perspective, and should be undone together.
- * @type {string}
- */
- this.group = eventUtils.getGroup();
+ // Workspace events do not undo or redo.
+ this.recordUndo = false;
- // Workspace events do not undo or redo.
- this.recordUndo = false;
-};
-object.inherits(FinishedLoading, Abstract);
-
-/**
- * Type of this event.
- * @type {string}
- */
-FinishedLoading.prototype.type = eventUtils.FINISHED_LOADING;
-
-/**
- * Encode the event as JSON.
- * @return {!Object} JSON representation.
- */
-FinishedLoading.prototype.toJson = function() {
- const json = {
- 'type': this.type,
- };
- if (this.group) {
- json['group'] = this.group;
+ /**
+ * Type of this event.
+ * @type {string}
+ */
+ this.type = eventUtils.FINISHED_LOADING;
}
- if (this.workspaceId) {
- json['workspaceId'] = this.workspaceId;
- }
- return json;
-};
-/**
- * Decode the JSON event.
- * @param {!Object} json JSON representation.
- */
-FinishedLoading.prototype.fromJson = function(json) {
- this.isBlank = false;
- this.workspaceId = json['workspaceId'];
- this.group = json['group'];
-};
+ /**
+ * Encode the event as JSON.
+ * @return {!Object} JSON representation.
+ */
+ toJson() {
+ const json = {
+ 'type': this.type,
+ };
+ if (this.group) {
+ json['group'] = this.group;
+ }
+ if (this.workspaceId) {
+ json['workspaceId'] = this.workspaceId;
+ }
+ return json;
+ }
+
+ /**
+ * Decode the JSON event.
+ * @param {!Object} json JSON representation.
+ */
+ fromJson(json) {
+ this.isBlank = false;
+ this.workspaceId = json['workspaceId'];
+ this.group = json['group'];
+ }
+}
registry.register(
registry.Type.EVENT, eventUtils.FINISHED_LOADING, FinishedLoading);
diff --git a/core/extensions.js b/core/extensions.js
index d0c219d4f..a05e05d61 100644
--- a/core/extensions.js
+++ b/core/extensions.js
@@ -24,6 +24,7 @@ goog.module('Blockly.Extensions');
const parsing = goog.require('Blockly.utils.parsing');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+const {FieldDropdown} = goog.require('Blockly.FieldDropdown');
goog.requireType('Blockly.Mutator');
@@ -454,7 +455,7 @@ exports.buildTooltipForDropdown = buildTooltipForDropdown;
const checkDropdownOptionsInTable = function(block, dropdownName, lookupTable) {
// Validate all dropdown options have values.
const dropdown = block.getField(dropdownName);
- if (!dropdown.isOptionListDynamic()) {
+ if (dropdown instanceof FieldDropdown && !dropdown.isOptionListDynamic()) {
const options = dropdown.getOptions();
for (let i = 0; i < options.length; i++) {
const optionKey = options[i][1]; // label, then value
@@ -512,11 +513,11 @@ exports.buildTooltipWithFieldText = buildTooltipWithFieldText;
* @this {Block}
*/
const extensionParentTooltip = function() {
- this.tooltipWhenNotConnected = this.tooltip;
+ const tooltipWhenNotConnected = this.tooltip;
this.setTooltip(function() {
const parent = this.getParent();
return (parent && parent.getInputsInline() && parent.tooltip) ||
- this.tooltipWhenNotConnected;
+ tooltipWhenNotConnected;
}.bind(this));
};
register('parent_tooltip_when_inline', extensionParentTooltip);
diff --git a/core/field.js b/core/field.js
index f1fd06b2f..a70d08944 100644
--- a/core/field.js
+++ b/core/field.js
@@ -24,6 +24,7 @@ const WidgetDiv = goog.require('Blockly.WidgetDiv');
const Xml = goog.require('Blockly.Xml');
const browserEvents = goog.require('Blockly.browserEvents');
const dom = goog.require('Blockly.utils.dom');
+const dropDownDiv = goog.require('Blockly.dropDownDiv');
const eventUtils = goog.require('Blockly.Events.utils');
const parsing = goog.require('Blockly.utils.parsing');
const style = goog.require('Blockly.utils.style');
@@ -37,7 +38,6 @@ const {Block} = goog.requireType('Blockly.Block');
const {ConstantProvider} = goog.requireType('Blockly.blockRendering.ConstantProvider');
/* eslint-disable-next-line no-unused-vars */
const {Coordinate} = goog.requireType('Blockly.utils.Coordinate');
-const {DropDownDiv} = goog.require('Blockly.DropDownDiv');
/* eslint-disable-next-line no-unused-vars */
const {IASTNodeLocationSvg} = goog.require('Blockly.IASTNodeLocationSvg');
/* eslint-disable-next-line no-unused-vars */
@@ -50,6 +50,7 @@ const {IRegistrable} = goog.require('Blockly.IRegistrable');
const {Input} = goog.requireType('Blockly.Input');
const {MarkerManager} = goog.require('Blockly.MarkerManager');
const {Rect} = goog.require('Blockly.utils.Rect');
+const {Sentinel} = goog.require('Blockly.utils.Sentinel');
/* eslint-disable-next-line no-unused-vars */
const {ShortcutRegistry} = goog.requireType('Blockly.ShortcutRegistry');
const {Size} = goog.require('Blockly.utils.Size');
@@ -64,114 +65,1198 @@ goog.require('Blockly.Gesture');
/**
* Abstract class for an editable field.
- * @param {*} value The initial value of the field.
- * @param {?Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a value & returns a validated
- * value, or null to abort the change.
- * @param {Object=} opt_config A map of options used to configure the field. See
- * the individual field's documentation for a list of properties this
- * parameter supports.
- * @constructor
- * @abstract
* @implements {IASTNodeLocationSvg}
* @implements {IASTNodeLocationWithBlock}
* @implements {IKeyboardAccessible}
* @implements {IRegistrable}
+ * @abstract
* @alias Blockly.Field
*/
-const Field = function(value, opt_validator, opt_config) {
+class Field {
/**
- * A generic value possessed by the field.
- * Should generally be non-null, only null when the field is created.
- * @type {*}
- * @protected
+ * @param {*} value The initial value of the field.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {?Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a value & returns a validated
+ * value, or null to abort the change.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * Refer to the individual field's documentation for a list of properties
+ * this parameter supports.
*/
- this.value_ = this.DEFAULT_VALUE;
+ constructor(value, opt_validator, opt_config) {
+ /**
+ * Name of field. Unique within each block.
+ * Static labels are usually unnamed.
+ * @type {string|undefined}
+ */
+ this.name = undefined;
+
+ /**
+ * A generic value possessed by the field.
+ * Should generally be non-null, only null when the field is created.
+ * @type {*}
+ * @protected
+ */
+ this.value_ =
+ /** @type {typeof Field} */ (new.target).prototype.DEFAULT_VALUE;
+
+ /**
+ * Validation function called when user edits an editable field.
+ * @type {Function}
+ * @protected
+ */
+ this.validator_ = null;
+
+ /**
+ * Used to cache the field's tooltip value if setTooltip is called when the
+ * field is not yet initialized. Is *not* guaranteed to be accurate.
+ * @type {?Tooltip.TipInfo}
+ * @private
+ */
+ this.tooltip_ = null;
+
+ /**
+ * The size of the area rendered by the field.
+ * @type {!Size}
+ * @protected
+ */
+ this.size_ = new Size(0, 0);
+
+ /**
+ * Holds the cursors svg element when the cursor is attached to the field.
+ * This is null if there is no cursor on the field.
+ * @type {SVGElement}
+ * @private
+ */
+ this.cursorSvg_ = null;
+
+ /**
+ * Holds the markers svg element when the marker is attached to the field.
+ * This is null if there is no marker on the field.
+ * @type {SVGElement}
+ * @private
+ */
+ this.markerSvg_ = null;
+
+ /**
+ * The rendered field's SVG group element.
+ * @type {SVGGElement}
+ * @protected
+ */
+ this.fieldGroup_ = null;
+
+ /**
+ * The rendered field's SVG border element.
+ * @type {SVGRectElement}
+ * @protected
+ */
+ this.borderRect_ = null;
+
+ /**
+ * The rendered field's SVG text element.
+ * @type {SVGTextElement}
+ * @protected
+ */
+ this.textElement_ = null;
+
+ /**
+ * The rendered field's text content element.
+ * @type {Text}
+ * @protected
+ */
+ this.textContent_ = null;
+
+ /**
+ * Mouse down event listener data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.mouseDownWrapper_ = null;
+
+ /**
+ * Constants associated with the source block's renderer.
+ * @type {ConstantProvider}
+ * @protected
+ */
+ this.constants_ = null;
+
+ /**
+ * Has this field been disposed of?
+ * @type {boolean}
+ * @package
+ */
+ this.disposed = false;
+
+ /**
+ * Maximum characters of text to display before adding an ellipsis.
+ * @type {number}
+ */
+ this.maxDisplayLength = 50;
+
+ /**
+ * Block this field is attached to. Starts as null, then set in init.
+ * @type {Block}
+ * @protected
+ */
+ this.sourceBlock_ = null;
+
+ /**
+ * Does this block need to be re-rendered?
+ * @type {boolean}
+ * @protected
+ */
+ this.isDirty_ = true;
+
+ /**
+ * Is the field visible, or hidden due to the block being collapsed?
+ * @type {boolean}
+ * @protected
+ */
+ this.visible_ = true;
+
+ /**
+ * Can the field value be changed using the editor on an editable block?
+ * @type {boolean}
+ * @protected
+ */
+ this.enabled_ = true;
+
+ /**
+ * The element the click handler is bound to.
+ * @type {Element}
+ * @protected
+ */
+ this.clickTarget_ = null;
+
+ /**
+ * The prefix field.
+ * @type {?string}
+ * @package
+ */
+ this.prefixField = null;
+
+ /**
+ * The suffix field.
+ * @type {?string}
+ * @package
+ */
+ this.suffixField = null;
+
+ /**
+ * Editable fields usually show some sort of UI indicating they are
+ * editable. They will also be saved by the serializer.
+ * @type {boolean}
+ */
+ this.EDITABLE = true;
+
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable. This is not the
+ * case by default so that SERIALIZABLE is backwards compatible.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = false;
+
+ /**
+ * Mouse cursor style when over the hotspot that initiates the editor.
+ * @type {string}
+ */
+ this.CURSOR = '';
+
+ if (value === Field.SKIP_SETUP) return;
+ if (opt_config) this.configure_(opt_config);
+ this.setValue(value);
+ if (opt_validator) this.setValidator(opt_validator);
+ }
/**
- * Validation function called when user edits an editable field.
- * @type {Function}
+ * Process the configuration map passed to the field.
+ * @param {!Object} config A map of options used to configure the field. See
+ * the individual field's documentation for a list of properties this
+ * parameter supports.
* @protected
*/
- this.validator_ = null;
+ configure_(config) {
+ let tooltip = config['tooltip'];
+ if (typeof tooltip === 'string') {
+ tooltip = parsing.replaceMessageReferences(config['tooltip']);
+ }
+ tooltip && this.setTooltip(tooltip);
+
+ // TODO (#2884): Possibly add CSS class config option.
+ // TODO (#2885): Possibly add cursor config option.
+ }
/**
- * Used to cache the field's tooltip value if setTooltip is called when the
- * field is not yet initialized. Is *not* guaranteed to be accurate.
- * @type {?Tooltip.TipInfo}
+ * Attach this field to a block.
+ * @param {!Block} block The block containing this field.
+ */
+ setSourceBlock(block) {
+ if (this.sourceBlock_) {
+ throw Error('Field already bound to a block');
+ }
+ this.sourceBlock_ = block;
+ }
+
+ /**
+ * Get the renderer constant provider.
+ * @return {?ConstantProvider} The renderer constant
+ * provider.
+ */
+ getConstants() {
+ if (!this.constants_ && this.sourceBlock_ && this.sourceBlock_.workspace &&
+ this.sourceBlock_.workspace.rendered) {
+ this.constants_ =
+ /** @type {!WorkspaceSvg} */ (this.sourceBlock_.workspace)
+ .getRenderer()
+ .getConstants();
+ }
+ return this.constants_;
+ }
+
+ /**
+ * Get the block this field is attached to.
+ * @return {Block} The block containing this field.
+ */
+ getSourceBlock() {
+ return this.sourceBlock_;
+ }
+
+ /**
+ * Initialize everything to render this field. Override
+ * methods initModel and initView rather than this method.
+ * @package
+ * @final
+ */
+ init() {
+ if (this.fieldGroup_) {
+ // Field has already been initialized once.
+ return;
+ }
+ this.fieldGroup_ = dom.createSvgElement(Svg.G, {}, null);
+ if (!this.isVisible()) {
+ this.fieldGroup_.style.display = 'none';
+ }
+ const sourceBlockSvg = /** @type {!BlockSvg} **/ (this.sourceBlock_);
+ sourceBlockSvg.getSvgRoot().appendChild(this.fieldGroup_);
+ this.initView();
+ this.updateEditable();
+ this.setTooltip(this.tooltip_);
+ this.bindEvents_();
+ this.initModel();
+ }
+
+ /**
+ * Create the block UI for this field.
+ * @package
+ */
+ initView() {
+ this.createBorderRect_();
+ this.createTextElement_();
+ }
+
+ /**
+ * Initializes the model of the field after it has been installed on a block.
+ * No-op by default.
+ * @package
+ */
+ initModel() {}
+
+ /**
+ * Create a field border rect element. Not to be overridden by subclasses.
+ * Instead modify the result of the function inside initView, or create a
+ * separate function to call.
+ * @protected
+ */
+ createBorderRect_() {
+ this.borderRect_ = dom.createSvgElement(
+ Svg.RECT, {
+ 'rx': this.getConstants().FIELD_BORDER_RECT_RADIUS,
+ 'ry': this.getConstants().FIELD_BORDER_RECT_RADIUS,
+ 'x': 0,
+ 'y': 0,
+ 'height': this.size_.height,
+ 'width': this.size_.width,
+ 'class': 'blocklyFieldRect',
+ },
+ this.fieldGroup_);
+ }
+
+ /**
+ * Create a field text element. Not to be overridden by subclasses. Instead
+ * modify the result of the function inside initView, or create a separate
+ * function to call.
+ * @protected
+ */
+ createTextElement_() {
+ this.textElement_ = dom.createSvgElement(
+ Svg.TEXT, {
+ 'class': 'blocklyText',
+ },
+ this.fieldGroup_);
+ if (this.getConstants().FIELD_TEXT_BASELINE_CENTER) {
+ this.textElement_.setAttribute('dominant-baseline', 'central');
+ }
+ this.textContent_ = document.createTextNode('');
+ this.textElement_.appendChild(this.textContent_);
+ }
+
+ /**
+ * Bind events to the field. Can be overridden by subclasses if they need to
+ * do custom input handling.
+ * @protected
+ */
+ bindEvents_() {
+ Tooltip.bindMouseEvents(this.getClickTarget_());
+ this.mouseDownWrapper_ = browserEvents.conditionalBind(
+ this.getClickTarget_(), 'mousedown', this, this.onMouseDown_);
+ }
+
+ /**
+ * Sets the field's value based on the given XML element. Should only be
+ * called by Blockly.Xml.
+ * @param {!Element} fieldElement The element containing info about the
+ * field's state.
+ * @package
+ */
+ fromXml(fieldElement) {
+ this.setValue(fieldElement.textContent);
+ }
+
+ /**
+ * Serializes this field's value to XML. Should only be called by Blockly.Xml.
+ * @param {!Element} fieldElement The element to populate with info about the
+ * field's state.
+ * @return {!Element} The element containing info about the field's state.
+ * @package
+ */
+ toXml(fieldElement) {
+ fieldElement.textContent = this.getValue();
+ return fieldElement;
+ }
+
+ /**
+ * Saves this fields value as something which can be serialized to JSON.
+ * Should only be called by the serialization system.
+ * @param {boolean=} _doFullSerialization If true, this signals to the field
+ * that if it normally just saves a reference to some state (eg variable
+ * fields) it should instead serialize the full state of the thing being
+ * referenced.
+ * @return {*} JSON serializable state.
+ * @package
+ */
+ saveState(_doFullSerialization) {
+ const legacyState = this.saveLegacyState(Field);
+ if (legacyState !== null) {
+ return legacyState;
+ }
+ return this.getValue();
+ }
+
+ /**
+ * Sets the field's state based on the given state value. Should only be
+ * called by the serialization system.
+ * @param {*} state The state we want to apply to the field.
+ * @package
+ */
+ loadState(state) {
+ if (this.loadLegacyState(Field, state)) {
+ return;
+ }
+ this.setValue(state);
+ }
+
+ /**
+ * Returns a stringified version of the XML state, if it should be used.
+ * Otherwise this returns null, to signal the field should use its own
+ * serialization.
+ * @param {*} callingClass The class calling this method.
+ * Used to see if `this` has overridden any relevant hooks.
+ * @return {?string} The stringified version of the XML state, or null.
+ * @protected
+ */
+ saveLegacyState(callingClass) {
+ if (callingClass.prototype.saveState === this.saveState &&
+ callingClass.prototype.toXml !== this.toXml) {
+ const elem = utilsXml.createElement('field');
+ elem.setAttribute('name', this.name || '');
+ const text = Xml.domToText(this.toXml(elem));
+ return text.replace(
+ ' xmlns="https://developers.google.com/blockly/xml"', '');
+ }
+ // Either they called this on purpose from their saveState, or they have
+ // no implementations of either hook. Just do our thing.
+ return null;
+ }
+
+ /**
+ * Loads the given state using either the old XML hoooks, if they should be
+ * used. Returns true to indicate loading has been handled, false otherwise.
+ * @param {*} callingClass The class calling this method.
+ * Used to see if `this` has overridden any relevant hooks.
+ * @param {*} state The state to apply to the field.
+ * @return {boolean} Whether the state was applied or not.
+ */
+ loadLegacyState(callingClass, state) {
+ if (callingClass.prototype.loadState === this.loadState &&
+ callingClass.prototype.fromXml !== this.fromXml) {
+ this.fromXml(Xml.textToDom(/** @type {string} */ (state)));
+ return true;
+ }
+ // Either they called this on purpose from their loadState, or they have
+ // no implementations of either hook. Just do our thing.
+ return false;
+ }
+
+ /**
+ * Dispose of all DOM objects and events belonging to this editable field.
+ * @package
+ */
+ dispose() {
+ dropDownDiv.hideIfOwner(this);
+ WidgetDiv.hideIfOwner(this);
+ Tooltip.unbindMouseEvents(this.getClickTarget_());
+
+ if (this.mouseDownWrapper_) {
+ browserEvents.unbind(this.mouseDownWrapper_);
+ }
+
+ dom.removeNode(this.fieldGroup_);
+
+ this.disposed = true;
+ }
+
+ /**
+ * Add or remove the UI indicating if this field is editable or not.
+ */
+ updateEditable() {
+ const group = this.fieldGroup_;
+ if (!this.EDITABLE || !group) {
+ return;
+ }
+ if (this.enabled_ && this.sourceBlock_.isEditable()) {
+ dom.addClass(group, 'blocklyEditableText');
+ dom.removeClass(group, 'blocklyNonEditableText');
+ group.style.cursor = this.CURSOR;
+ } else {
+ dom.addClass(group, 'blocklyNonEditableText');
+ dom.removeClass(group, 'blocklyEditableText');
+ group.style.cursor = '';
+ }
+ }
+
+ /**
+ * Set whether this field's value can be changed using the editor when the
+ * source block is editable.
+ * @param {boolean} enabled True if enabled.
+ */
+ setEnabled(enabled) {
+ this.enabled_ = enabled;
+ this.updateEditable();
+ }
+
+ /**
+ * Check whether this field's value can be changed using the editor when the
+ * source block is editable.
+ * @return {boolean} Whether this field is enabled.
+ */
+ isEnabled() {
+ return this.enabled_;
+ }
+
+ /**
+ * Check whether this field defines the showEditor_ function.
+ * @return {boolean} Whether this field is clickable.
+ */
+ isClickable() {
+ return this.enabled_ && !!this.sourceBlock_ &&
+ this.sourceBlock_.isEditable() &&
+ this.showEditor_ !== Field.prototype.showEditor_;
+ }
+
+ /**
+ * Check whether this field is currently editable. Some fields are never
+ * EDITABLE (e.g. text labels). Other fields may be EDITABLE but may exist on
+ * non-editable blocks or be currently disabled.
+ * @return {boolean} Whether this field is currently enabled, editable and on
+ * an editable block.
+ */
+ isCurrentlyEditable() {
+ return this.enabled_ && this.EDITABLE && !!this.sourceBlock_ &&
+ this.sourceBlock_.isEditable();
+ }
+
+ /**
+ * Check whether this field should be serialized by the XML renderer.
+ * Handles the logic for backwards compatibility and incongruous states.
+ * @return {boolean} Whether this field should be serialized or not.
+ */
+ isSerializable() {
+ let isSerializable = false;
+ if (this.name) {
+ if (this.SERIALIZABLE) {
+ isSerializable = true;
+ } else if (this.EDITABLE) {
+ console.warn(
+ 'Detected an editable field that was not serializable.' +
+ ' Please define SERIALIZABLE property as true on all editable custom' +
+ ' fields. Proceeding with serialization.');
+ isSerializable = true;
+ }
+ }
+ return isSerializable;
+ }
+
+ /**
+ * Gets whether this editable field is visible or not.
+ * @return {boolean} True if visible.
+ */
+ isVisible() {
+ return this.visible_;
+ }
+
+ /**
+ * Sets whether this editable field is visible or not. Should only be called
+ * by input.setVisible.
+ * @param {boolean} visible True if visible.
+ * @package
+ */
+ setVisible(visible) {
+ if (this.visible_ === visible) {
+ return;
+ }
+ this.visible_ = visible;
+ const root = this.getSvgRoot();
+ if (root) {
+ root.style.display = visible ? 'block' : 'none';
+ }
+ }
+
+ /**
+ * Sets a new validation function for editable fields, or clears a previously
+ * set validator.
+ *
+ * The validator function takes in the new field value, and returns
+ * validated value. The validated value could be the input value, a modified
+ * version of the input value, or null to abort the change.
+ *
+ * If the function does not return anything (or returns undefined) the new
+ * value is accepted as valid. This is to allow for fields using the
+ * validated function as a field-level change event notification.
+ *
+ * @param {Function} handler The validator function
+ * or null to clear a previous validator.
+ */
+ setValidator(handler) {
+ this.validator_ = handler;
+ }
+
+ /**
+ * Gets the validation function for editable fields, or null if not set.
+ * @return {?Function} Validation function, or null.
+ */
+ getValidator() {
+ return this.validator_;
+ }
+
+ /**
+ * Gets the group element for this editable field.
+ * Used for measuring the size and for positioning.
+ * @return {!SVGGElement} The group element.
+ */
+ getSvgRoot() {
+ return /** @type {!SVGGElement} */ (this.fieldGroup_);
+ }
+
+ /**
+ * Updates the field to match the colour/style of the block. Should only be
+ * called by BlockSvg.applyColour().
+ * @package
+ */
+ applyColour() {
+ // Non-abstract sub-classes may wish to implement this. See FieldDropdown.
+ }
+
+ /**
+ * Used by getSize() to move/resize any DOM elements, and get the new size.
+ *
+ * All rendering that has an effect on the size/shape of the block should be
+ * done here, and should be triggered by getSize().
+ * @protected
+ */
+ render_() {
+ if (this.textContent_) {
+ this.textContent_.nodeValue = this.getDisplayText_();
+ }
+ this.updateSize_();
+ }
+
+ /**
+ * Calls showEditor_ when the field is clicked if the field is clickable.
+ * Do not override.
+ * @param {Event=} opt_e Optional mouse event that triggered the field to
+ * open, or undefined if triggered programmatically.
+ * @package
+ * @final
+ */
+ showEditor(opt_e) {
+ if (this.isClickable()) {
+ this.showEditor_(opt_e);
+ }
+ }
+
+ /**
+ * A developer hook to create an editor for the field. This is no-op by
+ * default, and must be overriden to create an editor.
+ * @param {Event=} _e Optional mouse event that triggered the field to
+ * open, or undefined if triggered programmatically.
+ * @return {void}
+ * @protected
+ */
+ showEditor_(_e) {
+ // NOP
+ }
+
+ /**
+ * Updates the size of the field based on the text.
+ * @param {number=} opt_margin margin to use when positioning the text
+ * element.
+ * @protected
+ */
+ updateSize_(opt_margin) {
+ const constants = this.getConstants();
+ const xOffset = opt_margin !== undefined ?
+ opt_margin :
+ (this.borderRect_ ? this.getConstants().FIELD_BORDER_RECT_X_PADDING :
+ 0);
+ let totalWidth = xOffset * 2;
+ let totalHeight = constants.FIELD_TEXT_HEIGHT;
+
+ let contentWidth = 0;
+ if (this.textElement_) {
+ contentWidth = dom.getFastTextWidth(
+ this.textElement_, constants.FIELD_TEXT_FONTSIZE,
+ constants.FIELD_TEXT_FONTWEIGHT, constants.FIELD_TEXT_FONTFAMILY);
+ totalWidth += contentWidth;
+ }
+ if (this.borderRect_) {
+ totalHeight = Math.max(totalHeight, constants.FIELD_BORDER_RECT_HEIGHT);
+ }
+
+ this.size_.height = totalHeight;
+ this.size_.width = totalWidth;
+
+ this.positionTextElement_(xOffset, contentWidth);
+ this.positionBorderRect_();
+ }
+
+ /**
+ * Position a field's text element after a size change. This handles both LTR
+ * and RTL positioning.
+ * @param {number} xOffset x offset to use when positioning the text element.
+ * @param {number} contentWidth The content width.
+ * @protected
+ */
+ positionTextElement_(xOffset, contentWidth) {
+ if (!this.textElement_) {
+ return;
+ }
+ const constants = this.getConstants();
+ const halfHeight = this.size_.height / 2;
+
+ this.textElement_.setAttribute(
+ 'x',
+ this.sourceBlock_.RTL ? this.size_.width - contentWidth - xOffset :
+ xOffset);
+ this.textElement_.setAttribute(
+ 'y',
+ constants.FIELD_TEXT_BASELINE_CENTER ?
+ halfHeight :
+ halfHeight - constants.FIELD_TEXT_HEIGHT / 2 +
+ constants.FIELD_TEXT_BASELINE);
+ }
+
+ /**
+ * Position a field's border rect after a size change.
+ * @protected
+ */
+ positionBorderRect_() {
+ if (!this.borderRect_) {
+ return;
+ }
+ this.borderRect_.setAttribute('width', this.size_.width);
+ this.borderRect_.setAttribute('height', this.size_.height);
+ this.borderRect_.setAttribute(
+ 'rx', this.getConstants().FIELD_BORDER_RECT_RADIUS);
+ this.borderRect_.setAttribute(
+ 'ry', this.getConstants().FIELD_BORDER_RECT_RADIUS);
+ }
+
+ /**
+ * Returns the height and width of the field.
+ *
+ * This should *in general* be the only place render_ gets called from.
+ * @return {!Size} Height and width.
+ */
+ getSize() {
+ if (!this.isVisible()) {
+ return new Size(0, 0);
+ }
+
+ if (this.isDirty_) {
+ this.render_();
+ this.isDirty_ = false;
+ } else if (this.visible_ && this.size_.width === 0) {
+ // If the field is not visible the width will be 0 as well, one of the
+ // problems with the old system.
+ console.warn(
+ 'Deprecated use of setting size_.width to 0 to rerender a' +
+ ' field. Set field.isDirty_ to true instead.');
+ this.render_();
+ }
+ return this.size_;
+ }
+
+ /**
+ * Returns the bounding box of the rendered field, accounting for workspace
+ * scaling.
+ * @return {!Rect} An object with top, bottom, left, and right in
+ * pixels relative to the top left corner of the page (window
+ * coordinates).
+ * @package
+ */
+ getScaledBBox() {
+ let scaledWidth;
+ let scaledHeight;
+ let xy;
+ if (!this.borderRect_) {
+ // Browsers are inconsistent in what they return for a bounding box.
+ // - Webkit / Blink: fill-box / object bounding box
+ // - Gecko / Triden / EdgeHTML: stroke-box
+ const bBox = this.sourceBlock_.getHeightWidth();
+ const scale =
+ /** @type {!WorkspaceSvg} */ (this.sourceBlock_.workspace).scale;
+ xy = this.getAbsoluteXY_();
+ scaledWidth = bBox.width * scale;
+ scaledHeight = bBox.height * scale;
+
+ if (userAgent.GECKO) {
+ xy.x += 1.5 * scale;
+ xy.y += 1.5 * scale;
+ scaledWidth += 1 * scale;
+ scaledHeight += 1 * scale;
+ } else {
+ if (!userAgent.EDGE && !userAgent.IE) {
+ xy.x -= 0.5 * scale;
+ xy.y -= 0.5 * scale;
+ }
+ scaledWidth += 1 * scale;
+ scaledHeight += 1 * scale;
+ }
+ } else {
+ const bBox = this.borderRect_.getBoundingClientRect();
+ xy = style.getPageOffset(this.borderRect_);
+ scaledWidth = bBox.width;
+ scaledHeight = bBox.height;
+ }
+ return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth);
+ }
+
+ /**
+ * Get the text from this field to display on the block. May differ from
+ * ``getText`` due to ellipsis, and other formatting.
+ * @return {string} Text to display.
+ * @protected
+ */
+ getDisplayText_() {
+ let text = this.getText();
+ if (!text) {
+ // Prevent the field from disappearing if empty.
+ return Field.NBSP;
+ }
+ if (text.length > this.maxDisplayLength) {
+ // Truncate displayed string and add an ellipsis ('...').
+ text = text.substring(0, this.maxDisplayLength - 2) + '\u2026';
+ }
+ // Replace whitespace with non-breaking spaces so the text doesn't collapse.
+ text = text.replace(/\s/g, Field.NBSP);
+ if (this.sourceBlock_ && this.sourceBlock_.RTL) {
+ // The SVG is LTR, force text to be RTL.
+ text += '\u200F';
+ }
+ return text;
+ }
+
+ /**
+ * Get the text from this field.
+ * Override getText_ to provide a different behavior than simply casting the
+ * value to a string.
+ * @return {string} Current text.
+ * @final
+ */
+ getText() {
+ // this.getText_ was intended so that devs don't have to remember to call
+ // super when overriding how the text of the field is generated. (#2910)
+ const text = this.getText_();
+ if (text !== null) return String(text);
+ return String(this.getValue());
+ }
+
+ /**
+ * A developer hook to override the returned text of this field.
+ * Override if the text representation of the value of this field
+ * is not just a string cast of its value.
+ * Return null to resort to a string cast.
+ * @return {?string} Current text or null.
+ * @protected
+ */
+ getText_() {
+ return null;
+ }
+
+ /**
+ * Force a rerender of the block that this field is installed on, which will
+ * rerender this field and adjust for any sizing changes.
+ * Other fields on the same block will not rerender, because their sizes have
+ * already been recorded.
+ * @package
+ */
+ markDirty() {
+ this.isDirty_ = true;
+ this.constants_ = null;
+ }
+
+ /**
+ * Force a rerender of the block that this field is installed on, which will
+ * rerender this field and adjust for any sizing changes.
+ * Other fields on the same block will not rerender, because their sizes have
+ * already been recorded.
+ * @package
+ */
+ forceRerender() {
+ this.isDirty_ = true;
+ if (this.sourceBlock_ && this.sourceBlock_.rendered) {
+ this.sourceBlock_.render();
+ this.sourceBlock_.bumpNeighbours();
+ this.updateMarkers_();
+ }
+ }
+
+ /**
+ * Used to change the value of the field. Handles validation and events.
+ * Subclasses should override doClassValidation_ and doValueUpdate_ rather
+ * than this method.
+ * @param {*} newValue New value.
+ * @final
+ */
+ setValue(newValue) {
+ const doLogging = false;
+ if (newValue === null) {
+ doLogging && console.log('null, return');
+ // Not a valid value to check.
+ return;
+ }
+
+ let validatedValue = this.doClassValidation_(newValue);
+ // Class validators might accidentally forget to return, we'll ignore that.
+ newValue = this.processValidation_(newValue, validatedValue);
+ if (newValue instanceof Error) {
+ doLogging && console.log('invalid class validation, return');
+ return;
+ }
+
+ const localValidator = this.getValidator();
+ if (localValidator) {
+ validatedValue = localValidator.call(this, newValue);
+ // Local validators might accidentally forget to return, we'll ignore
+ // that.
+ newValue = this.processValidation_(newValue, validatedValue);
+ if (newValue instanceof Error) {
+ doLogging && console.log('invalid local validation, return');
+ return;
+ }
+ }
+ const source = this.sourceBlock_;
+ if (source && source.disposed) {
+ doLogging && console.log('source disposed, return');
+ return;
+ }
+ const oldValue = this.getValue();
+ if (oldValue === newValue) {
+ doLogging && console.log('same, doValueUpdate_, return');
+ this.doValueUpdate_(newValue);
+ return;
+ }
+
+ this.doValueUpdate_(newValue);
+ if (source && eventUtils.isEnabled()) {
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
+ source, 'field', this.name || null, oldValue, newValue));
+ }
+ if (this.isDirty_) {
+ this.forceRerender();
+ }
+ doLogging && console.log(this.value_);
+ }
+
+ /**
+ * Process the result of validation.
+ * @param {*} newValue New value.
+ * @param {*} validatedValue Validated value.
+ * @return {*} New value, or an Error object.
* @private
*/
- this.tooltip_ = null;
+ processValidation_(newValue, validatedValue) {
+ if (validatedValue === null) {
+ this.doValueInvalid_(newValue);
+ if (this.isDirty_) {
+ this.forceRerender();
+ }
+ return Error();
+ }
+ if (validatedValue !== undefined) {
+ newValue = validatedValue;
+ }
+ return newValue;
+ }
/**
- * The size of the area rendered by the field.
- * @type {!Size}
+ * Get the current value of the field.
+ * @return {*} Current value.
+ */
+ getValue() {
+ return this.value_;
+ }
+
+ /**
+ * Used to validate a value. Returns input by default. Can be overridden by
+ * subclasses, see FieldDropdown.
+ * @param {*=} opt_newValue The value to be validated.
+ * @return {*} The validated value, same as input by default.
* @protected
*/
- this.size_ = new Size(0, 0);
+ doClassValidation_(opt_newValue) {
+ if (opt_newValue === null || opt_newValue === undefined) {
+ return null;
+ }
+ return opt_newValue;
+ }
/**
- * Holds the cursors svg element when the cursor is attached to the field.
- * This is null if there is no cursor on the field.
- * @type {SVGElement}
- * @private
- */
- this.cursorSvg_ = null;
-
- /**
- * Holds the markers svg element when the marker is attached to the field.
- * This is null if there is no marker on the field.
- * @type {SVGElement}
- * @private
- */
- this.markerSvg_ = null;
-
- /**
- * The rendered field's SVG group element.
- * @type {SVGGElement}
+ * Used to update the value of a field. Can be overridden by subclasses to do
+ * custom storage of values/updating of external things.
+ * @param {*} newValue The value to be saved.
* @protected
*/
- this.fieldGroup_ = null;
+ doValueUpdate_(newValue) {
+ this.value_ = newValue;
+ this.isDirty_ = true;
+ }
/**
- * The rendered field's SVG border element.
- * @type {SVGRectElement}
+ * Used to notify the field an invalid value was input. Can be overridden by
+ * subclasses, see FieldTextInput.
+ * No-op by default.
+ * @param {*} _invalidValue The input value that was determined to be invalid.
* @protected
*/
- this.borderRect_ = null;
+ doValueInvalid_(_invalidValue) {
+ // NOP
+ }
/**
- * The rendered field's SVG text element.
- * @type {SVGTextElement}
+ * Handle a mouse down event on a field.
+ * @param {!Event} e Mouse down event.
* @protected
*/
- this.textElement_ = null;
+ onMouseDown_(e) {
+ if (!this.sourceBlock_ || !this.sourceBlock_.workspace) {
+ return;
+ }
+ const gesture =
+ /** @type {!WorkspaceSvg} */ (this.sourceBlock_.workspace)
+ .getGesture(e);
+ if (gesture) {
+ gesture.setStartField(this);
+ }
+ }
/**
- * The rendered field's text content element.
- * @type {Text}
+ * Sets the tooltip for this field.
+ * @param {?Tooltip.TipInfo} newTip The
+ * text for the tooltip, a function that returns the text for the tooltip,
+ * a parent object whose tooltip will be used, or null to display the tooltip
+ * of the parent block. To not display a tooltip pass the empty string.
+ */
+ setTooltip(newTip) {
+ if (!newTip && newTip !== '') { // If null or undefined.
+ newTip = this.sourceBlock_;
+ }
+ const clickTarget = this.getClickTarget_();
+ if (clickTarget) {
+ clickTarget.tooltip = newTip;
+ } else {
+ // Field has not been initialized yet.
+ this.tooltip_ = newTip;
+ }
+ }
+
+ /**
+ * Returns the tooltip text for this field.
+ * @return {string} The tooltip text for this field.
+ */
+ getTooltip() {
+ const clickTarget = this.getClickTarget_();
+ if (clickTarget) {
+ return Tooltip.getTooltipOfObject(clickTarget);
+ }
+ // Field has not been initialized yet. Return stashed this.tooltip_ value.
+ return Tooltip.getTooltipOfObject({tooltip: this.tooltip_});
+ }
+
+ /**
+ * The element to bind the click handler to. If not set explicitly, defaults
+ * to the SVG root of the field. When this element is
+ * clicked on an editable field, the editor will open.
+ * @return {!Element} Element to bind click handler to.
* @protected
*/
- this.textContent_ = null;
+ getClickTarget_() {
+ return this.clickTarget_ || this.getSvgRoot();
+ }
/**
- * Mouse down event listener data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.mouseDownWrapper_ = null;
-
- /**
- * Constants associated with the source block's renderer.
- * @type {ConstantProvider}
+ * Return the absolute coordinates of the top-left corner of this field.
+ * The origin (0,0) is the top-left corner of the page body.
+ * @return {!Coordinate} Object with .x and .y properties.
* @protected
*/
- this.constants_ = null;
+ getAbsoluteXY_() {
+ return style.getPageOffset(
+ /** @type {!SVGRectElement} */ (this.getClickTarget_()));
+ }
- opt_config && this.configure_(opt_config);
- this.setValue(value);
- opt_validator && this.setValidator(opt_validator);
-};
+ /**
+ * Whether this field references any Blockly variables. If true it may need
+ * to be handled differently during serialization and deserialization.
+ * Subclasses may override this.
+ * @return {boolean} True if this field has any variable references.
+ * @package
+ */
+ referencesVariables() {
+ return false;
+ }
+
+ /**
+ * Refresh the variable name referenced by this field if this field references
+ * variables.
+ * @package
+ */
+ refreshVariableName() {
+ // NOP
+ }
+
+ /**
+ * Search through the list of inputs and their fields in order to find the
+ * parent input of a field.
+ * @return {Input} The input that the field belongs to.
+ * @package
+ */
+ getParentInput() {
+ let parentInput = null;
+ const block = this.sourceBlock_;
+ const inputs = block.inputList;
+
+ for (let idx = 0; idx < block.inputList.length; idx++) {
+ const input = inputs[idx];
+ const fieldRows = input.fieldRow;
+ for (let j = 0; j < fieldRows.length; j++) {
+ if (fieldRows[j] === this) {
+ parentInput = input;
+ break;
+ }
+ }
+ }
+ return parentInput;
+ }
+
+ /**
+ * Returns whether or not we should flip the field in RTL.
+ * @return {boolean} True if we should flip in RTL.
+ */
+ getFlipRtl() {
+ return false;
+ }
+
+ /**
+ * Returns whether or not the field is tab navigable.
+ * @return {boolean} True if the field is tab navigable.
+ */
+ isTabNavigable() {
+ return false;
+ }
+
+ /**
+ * Handles the given keyboard shortcut.
+ * @param {!ShortcutRegistry.KeyboardShortcut} _shortcut The shortcut to be
+ * handled.
+ * @return {boolean} True if the shortcut has been handled, false otherwise.
+ * @public
+ */
+ onShortcut(_shortcut) {
+ return false;
+ }
+
+ /**
+ * Add the cursor SVG to this fields SVG group.
+ * @param {SVGElement} cursorSvg The SVG root of the cursor to be added to the
+ * field group.
+ * @package
+ */
+ setCursorSvg(cursorSvg) {
+ if (!cursorSvg) {
+ this.cursorSvg_ = null;
+ return;
+ }
+
+ this.fieldGroup_.appendChild(cursorSvg);
+ this.cursorSvg_ = cursorSvg;
+ }
+
+ /**
+ * Add the marker SVG to this fields SVG group.
+ * @param {SVGElement} markerSvg The SVG root of the marker to be added to the
+ * field group.
+ * @package
+ */
+ setMarkerSvg(markerSvg) {
+ if (!markerSvg) {
+ this.markerSvg_ = null;
+ return;
+ }
+
+ this.fieldGroup_.appendChild(markerSvg);
+ this.markerSvg_ = markerSvg;
+ }
+
+ /**
+ * Redraw any attached marker or cursor svgs if needed.
+ * @protected
+ */
+ updateMarkers_() {
+ const workspace =
+ /** @type {!WorkspaceSvg} */ (this.sourceBlock_.workspace);
+ if (workspace.keyboardAccessibilityMode && this.cursorSvg_) {
+ workspace.getCursor().draw();
+ }
+ if (workspace.keyboardAccessibilityMode && this.markerSvg_) {
+ // TODO(#4592): Update all markers on the field.
+ workspace.getMarker(MarkerManager.LOCAL_MARKER).draw();
+ }
+ }
+}
/**
* The default value for this field.
@@ -180,82 +1265,6 @@ const Field = function(value, opt_validator, opt_config) {
*/
Field.prototype.DEFAULT_VALUE = null;
-/**
- * Name of field. Unique within each block.
- * Static labels are usually unnamed.
- * @type {string|undefined}
- */
-Field.prototype.name = undefined;
-
-/**
- * Has this field been disposed of?
- * @type {boolean}
- * @package
- */
-Field.prototype.disposed = false;
-
-/**
- * Maximum characters of text to display before adding an ellipsis.
- * @type {number}
- */
-Field.prototype.maxDisplayLength = 50;
-
-/**
- * Block this field is attached to. Starts as null, then set in init.
- * @type {Block}
- * @protected
- */
-Field.prototype.sourceBlock_ = null;
-
-/**
- * Does this block need to be re-rendered?
- * @type {boolean}
- * @protected
- */
-Field.prototype.isDirty_ = true;
-
-/**
- * Is the field visible, or hidden due to the block being collapsed?
- * @type {boolean}
- * @protected
- */
-Field.prototype.visible_ = true;
-
-/**
- * Can the field value be changed using the editor on an editable block?
- * @type {boolean}
- * @protected
- */
-Field.prototype.enabled_ = true;
-
-/**
- * The element the click handler is bound to.
- * @type {Element}
- * @protected
- */
-Field.prototype.clickTarget_ = null;
-
-/**
- * A developer hook to override the returned text of this field.
- * Override if the text representation of the value of this field
- * is not just a string cast of its value.
- * Return null to resort to a string cast.
- * @return {?string} Current text. Return null to resort to a string cast.
- * @protected
- */
-Field.prototype.getText_;
-
-/**
- * An optional method that can be defined to show an editor when the field is
- * clicked. Blockly will automatically set the field as clickable if this
- * method is defined.
- * @param {Event=} opt_e Optional mouse event that triggered the field to open,
- * or undefined if triggered programmatically.
- * @return {void}
- * @protected
- */
-Field.prototype.showEditor_;
-
/**
* Non-breaking space.
* @const
@@ -263,956 +1272,11 @@ Field.prototype.showEditor_;
Field.NBSP = '\u00A0';
/**
- * Editable fields usually show some sort of UI indicating they are editable.
- * They will also be saved by the XML renderer.
- * @type {boolean}
+ * A value used to signal when a field's constructor should *not* set the
+ * field's value or run configure_, and should allow a subclass to do that
+ * instead.
+ * @const
*/
-Field.prototype.EDITABLE = true;
-
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable. This is not the
- * case by default so that SERIALIZABLE is backwards compatible.
- * @type {boolean}
- */
-Field.prototype.SERIALIZABLE = false;
-
-/**
- * Process the configuration map passed to the field.
- * @param {!Object} config A map of options used to configure the field. See
- * the individual field's documentation for a list of properties this
- * parameter supports.
- * @protected
- */
-Field.prototype.configure_ = function(config) {
- let tooltip = config['tooltip'];
- if (typeof tooltip === 'string') {
- tooltip = parsing.replaceMessageReferences(config['tooltip']);
- }
- tooltip && this.setTooltip(tooltip);
-
- // TODO (#2884): Possibly add CSS class config option.
- // TODO (#2885): Possibly add cursor config option.
-};
-
-/**
- * Attach this field to a block.
- * @param {!Block} block The block containing this field.
- */
-Field.prototype.setSourceBlock = function(block) {
- if (this.sourceBlock_) {
- throw Error('Field already bound to a block');
- }
- this.sourceBlock_ = block;
-};
-
-/**
- * Get the renderer constant provider.
- * @return {?ConstantProvider} The renderer constant
- * provider.
- */
-Field.prototype.getConstants = function() {
- if (!this.constants_ && this.sourceBlock_ && this.sourceBlock_.workspace &&
- this.sourceBlock_.workspace.rendered) {
- this.constants_ = this.sourceBlock_.workspace.getRenderer().getConstants();
- }
- return this.constants_;
-};
-
-/**
- * Get the block this field is attached to.
- * @return {Block} The block containing this field.
- */
-Field.prototype.getSourceBlock = function() {
- return this.sourceBlock_;
-};
-
-/**
- * Initialize everything to render this field. Override
- * methods initModel and initView rather than this method.
- * @package
- */
-Field.prototype.init = function() {
- if (this.fieldGroup_) {
- // Field has already been initialized once.
- return;
- }
- this.fieldGroup_ = dom.createSvgElement(Svg.G, {}, null);
- if (!this.isVisible()) {
- this.fieldGroup_.style.display = 'none';
- }
- const sourceBlockSvg = /** @type {!BlockSvg} **/ (this.sourceBlock_);
- sourceBlockSvg.getSvgRoot().appendChild(this.fieldGroup_);
- this.initView();
- this.updateEditable();
- this.setTooltip(this.tooltip_);
- this.bindEvents_();
- this.initModel();
-};
-
-/**
- * Create the block UI for this field.
- * @package
- */
-Field.prototype.initView = function() {
- this.createBorderRect_();
- this.createTextElement_();
-};
-
-/**
- * Initializes the model of the field after it has been installed on a block.
- * No-op by default.
- * @package
- */
-Field.prototype.initModel = function() {};
-
-/**
- * Create a field border rect element. Not to be overridden by subclasses.
- * Instead modify the result of the function inside initView, or create a
- * separate function to call.
- * @protected
- */
-Field.prototype.createBorderRect_ = function() {
- this.borderRect_ = dom.createSvgElement(
- Svg.RECT, {
- 'rx': this.getConstants().FIELD_BORDER_RECT_RADIUS,
- 'ry': this.getConstants().FIELD_BORDER_RECT_RADIUS,
- 'x': 0,
- 'y': 0,
- 'height': this.size_.height,
- 'width': this.size_.width,
- 'class': 'blocklyFieldRect',
- },
- this.fieldGroup_);
-};
-
-/**
- * Create a field text element. Not to be overridden by subclasses. Instead
- * modify the result of the function inside initView, or create a separate
- * function to call.
- * @protected
- */
-Field.prototype.createTextElement_ = function() {
- this.textElement_ = dom.createSvgElement(
- Svg.TEXT, {
- 'class': 'blocklyText',
- },
- this.fieldGroup_);
- if (this.getConstants().FIELD_TEXT_BASELINE_CENTER) {
- this.textElement_.setAttribute('dominant-baseline', 'central');
- }
- this.textContent_ = document.createTextNode('');
- this.textElement_.appendChild(this.textContent_);
-};
-
-/**
- * Bind events to the field. Can be overridden by subclasses if they need to do
- * custom input handling.
- * @protected
- */
-Field.prototype.bindEvents_ = function() {
- Tooltip.bindMouseEvents(this.getClickTarget_());
- this.mouseDownWrapper_ = browserEvents.conditionalBind(
- this.getClickTarget_(), 'mousedown', this, this.onMouseDown_);
-};
-
-/**
- * Sets the field's value based on the given XML element. Should only be called
- * by Blockly.Xml.
- * @param {!Element} fieldElement The element containing info about the
- * field's state.
- * @package
- */
-Field.prototype.fromXml = function(fieldElement) {
- this.setValue(fieldElement.textContent);
-};
-
-/**
- * Serializes this field's value to XML. Should only be called by Blockly.Xml.
- * @param {!Element} fieldElement The element to populate with info about the
- * field's state.
- * @return {!Element} The element containing info about the field's state.
- * @package
- */
-Field.prototype.toXml = function(fieldElement) {
- fieldElement.textContent = this.getValue();
- return fieldElement;
-};
-
-/**
- * Saves this fields value as something which can be serialized to JSON. Should
- * only be called by the serialization system.
- * @param {boolean=} _doFullSerialization If true, this signals to the field
- * that if it normally just saves a reference to some state (eg variable
- * fields) it should instead serialize the full state of the thing being
- * referenced.
- * @return {*} JSON serializable state.
- * @package
- */
-Field.prototype.saveState = function(_doFullSerialization) {
- const legacyState = this.saveLegacyState(Field);
- if (legacyState !== null) {
- return legacyState;
- }
- return this.getValue();
-};
-
-/**
- * Sets the field's state based on the given state value. Should only be called
- * by the serialization system.
- * @param {*} state The state we want to apply to the field.
- * @package
- */
-Field.prototype.loadState = function(state) {
- if (this.loadLegacyState(Field, state)) {
- return;
- }
- this.setValue(state);
-};
-
-/**
- * Returns a stringified version of the XML state, if it should be used.
- * Otherwise this returns null, to signal the field should use its own
- * serialization.
- * @param {*} callingClass The class calling this method.
- * Used to see if `this` has overridden any relevant hooks.
- * @return {?string} The stringified version of the XML state, or null.
- * @protected
- */
-Field.prototype.saveLegacyState = function(callingClass) {
- if (callingClass.prototype.saveState === this.saveState &&
- callingClass.prototype.toXml !== this.toXml) {
- const elem = utilsXml.createElement('field');
- elem.setAttribute('name', this.name || '');
- const text = Xml.domToText(this.toXml(elem));
- return text.replace(
- ' xmlns="https://developers.google.com/blockly/xml"', '');
- }
- // Either they called this on purpose from their saveState, or they have
- // no implementations of either hook. Just do our thing.
- return null;
-};
-
-/**
- * Loads the given state using either the old XML hoooks, if they should be
- * used. Returns true to indicate loading has been handled, false otherwise.
- * @param {*} callingClass The class calling this method.
- * Used to see if `this` has overridden any relevant hooks.
- * @param {*} state The state to apply to the field.
- * @return {boolean} Whether the state was applied or not.
- */
-Field.prototype.loadLegacyState = function(callingClass, state) {
- if (callingClass.prototype.loadState === this.loadState &&
- callingClass.prototype.fromXml !== this.fromXml) {
- this.fromXml(Xml.textToDom(/** @type {string} */ (state)));
- return true;
- }
- // Either they called this on purpose from their loadState, or they have
- // no implementations of either hook. Just do our thing.
- return false;
-};
-
-/**
- * Dispose of all DOM objects and events belonging to this editable field.
- * @package
- */
-Field.prototype.dispose = function() {
- DropDownDiv.hideIfOwner(this);
- WidgetDiv.hideIfOwner(this);
- Tooltip.unbindMouseEvents(this.getClickTarget_());
-
- if (this.mouseDownWrapper_) {
- browserEvents.unbind(this.mouseDownWrapper_);
- }
-
- dom.removeNode(this.fieldGroup_);
-
- this.disposed = true;
-};
-
-/**
- * Add or remove the UI indicating if this field is editable or not.
- */
-Field.prototype.updateEditable = function() {
- const group = this.fieldGroup_;
- if (!this.EDITABLE || !group) {
- return;
- }
- if (this.enabled_ && this.sourceBlock_.isEditable()) {
- dom.addClass(group, 'blocklyEditableText');
- dom.removeClass(group, 'blocklyNonEditableText');
- group.style.cursor = this.CURSOR;
- } else {
- dom.addClass(group, 'blocklyNonEditableText');
- dom.removeClass(group, 'blocklyEditableText');
- group.style.cursor = '';
- }
-};
-
-/**
- * Set whether this field's value can be changed using the editor when the
- * source block is editable.
- * @param {boolean} enabled True if enabled.
- */
-Field.prototype.setEnabled = function(enabled) {
- this.enabled_ = enabled;
- this.updateEditable();
-};
-
-/**
- * Check whether this field's value can be changed using the editor when the
- * source block is editable.
- * @return {boolean} Whether this field is enabled.
- */
-Field.prototype.isEnabled = function() {
- return this.enabled_;
-};
-
-/**
- * Check whether this field defines the showEditor_ function.
- * @return {boolean} Whether this field is clickable.
- */
-Field.prototype.isClickable = function() {
- return this.enabled_ && !!this.sourceBlock_ &&
- this.sourceBlock_.isEditable() && !!this.showEditor_ &&
- (typeof this.showEditor_ === 'function');
-};
-
-/**
- * Check whether this field is currently editable. Some fields are never
- * EDITABLE (e.g. text labels). Other fields may be EDITABLE but may exist on
- * non-editable blocks or be currently disabled.
- * @return {boolean} Whether this field is currently enabled, editable and on
- * an editable block.
- */
-Field.prototype.isCurrentlyEditable = function() {
- return this.enabled_ && this.EDITABLE && !!this.sourceBlock_ &&
- this.sourceBlock_.isEditable();
-};
-
-/**
- * Check whether this field should be serialized by the XML renderer.
- * Handles the logic for backwards compatibility and incongruous states.
- * @return {boolean} Whether this field should be serialized or not.
- */
-Field.prototype.isSerializable = function() {
- let isSerializable = false;
- if (this.name) {
- if (this.SERIALIZABLE) {
- isSerializable = true;
- } else if (this.EDITABLE) {
- console.warn(
- 'Detected an editable field that was not serializable.' +
- ' Please define SERIALIZABLE property as true on all editable custom' +
- ' fields. Proceeding with serialization.');
- isSerializable = true;
- }
- }
- return isSerializable;
-};
-
-/**
- * Gets whether this editable field is visible or not.
- * @return {boolean} True if visible.
- */
-Field.prototype.isVisible = function() {
- return this.visible_;
-};
-
-/**
- * Sets whether this editable field is visible or not. Should only be called
- * by input.setVisible.
- * @param {boolean} visible True if visible.
- * @package
- */
-Field.prototype.setVisible = function(visible) {
- if (this.visible_ === visible) {
- return;
- }
- this.visible_ = visible;
- const root = this.getSvgRoot();
- if (root) {
- root.style.display = visible ? 'block' : 'none';
- }
-};
-
-/**
- * Sets a new validation function for editable fields, or clears a previously
- * set validator.
- *
- * The validator function takes in the new field value, and returns
- * validated value. The validated value could be the input value, a modified
- * version of the input value, or null to abort the change.
- *
- * If the function does not return anything (or returns undefined) the new
- * value is accepted as valid. This is to allow for fields using the
- * validated function as a field-level change event notification.
- *
- * @param {Function} handler The validator function
- * or null to clear a previous validator.
- */
-Field.prototype.setValidator = function(handler) {
- this.validator_ = handler;
-};
-
-/**
- * Gets the validation function for editable fields, or null if not set.
- * @return {?Function} Validation function, or null.
- */
-Field.prototype.getValidator = function() {
- return this.validator_;
-};
-
-/**
- * Gets the group element for this editable field.
- * Used for measuring the size and for positioning.
- * @return {!SVGGElement} The group element.
- */
-Field.prototype.getSvgRoot = function() {
- return /** @type {!SVGGElement} */ (this.fieldGroup_);
-};
-
-/**
- * Updates the field to match the colour/style of the block. Should only be
- * called by BlockSvg.applyColour().
- * @package
- */
-Field.prototype.applyColour = function() {
- // Non-abstract sub-classes may wish to implement this. See FieldDropdown.
-};
-
-/**
- * Used by getSize() to move/resize any DOM elements, and get the new size.
- *
- * All rendering that has an effect on the size/shape of the block should be
- * done here, and should be triggered by getSize().
- * @protected
- */
-Field.prototype.render_ = function() {
- if (this.textContent_) {
- this.textContent_.nodeValue = this.getDisplayText_();
- }
- this.updateSize_();
-};
-
-/**
- * Show an editor when the field is clicked only if the field is clickable.
- * @param {Event=} opt_e Optional mouse event that triggered the field to open,
- * or undefined if triggered programmatically.
- * @package
- */
-Field.prototype.showEditor = function(opt_e) {
- if (this.isClickable()) {
- this.showEditor_(opt_e);
- }
-};
-
-/**
- * Updates the size of the field based on the text.
- * @param {number=} opt_margin margin to use when positioning the text element.
- * @protected
- */
-Field.prototype.updateSize_ = function(opt_margin) {
- const constants = this.getConstants();
- const xOffset = opt_margin !== undefined ?
- opt_margin :
- (this.borderRect_ ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0);
- let totalWidth = xOffset * 2;
- let totalHeight = constants.FIELD_TEXT_HEIGHT;
-
- let contentWidth = 0;
- if (this.textElement_) {
- contentWidth = dom.getFastTextWidth(
- this.textElement_, constants.FIELD_TEXT_FONTSIZE,
- constants.FIELD_TEXT_FONTWEIGHT, constants.FIELD_TEXT_FONTFAMILY);
- totalWidth += contentWidth;
- }
- if (this.borderRect_) {
- totalHeight = Math.max(totalHeight, constants.FIELD_BORDER_RECT_HEIGHT);
- }
-
- this.size_.height = totalHeight;
- this.size_.width = totalWidth;
-
- this.positionTextElement_(xOffset, contentWidth);
- this.positionBorderRect_();
-};
-
-/**
- * Position a field's text element after a size change. This handles both LTR
- * and RTL positioning.
- * @param {number} xOffset x offset to use when positioning the text element.
- * @param {number} contentWidth The content width.
- * @protected
- */
-Field.prototype.positionTextElement_ = function(xOffset, contentWidth) {
- if (!this.textElement_) {
- return;
- }
- const constants = this.getConstants();
- const halfHeight = this.size_.height / 2;
-
- this.textElement_.setAttribute(
- 'x',
- this.sourceBlock_.RTL ? this.size_.width - contentWidth - xOffset :
- xOffset);
- this.textElement_.setAttribute(
- 'y',
- constants.FIELD_TEXT_BASELINE_CENTER ? halfHeight :
- halfHeight -
- constants.FIELD_TEXT_HEIGHT / 2 + constants.FIELD_TEXT_BASELINE);
-};
-
-/**
- * Position a field's border rect after a size change.
- * @protected
- */
-Field.prototype.positionBorderRect_ = function() {
- if (!this.borderRect_) {
- return;
- }
- this.borderRect_.setAttribute('width', this.size_.width);
- this.borderRect_.setAttribute('height', this.size_.height);
- this.borderRect_.setAttribute(
- 'rx', this.getConstants().FIELD_BORDER_RECT_RADIUS);
- this.borderRect_.setAttribute(
- 'ry', this.getConstants().FIELD_BORDER_RECT_RADIUS);
-};
-
-
-/**
- * Returns the height and width of the field.
- *
- * This should *in general* be the only place render_ gets called from.
- * @return {!Size} Height and width.
- */
-Field.prototype.getSize = function() {
- if (!this.isVisible()) {
- return new Size(0, 0);
- }
-
- if (this.isDirty_) {
- this.render_();
- this.isDirty_ = false;
- } else if (this.visible_ && this.size_.width === 0) {
- // If the field is not visible the width will be 0 as well, one of the
- // problems with the old system.
- console.warn(
- 'Deprecated use of setting size_.width to 0 to rerender a' +
- ' field. Set field.isDirty_ to true instead.');
- this.render_();
- }
- return this.size_;
-};
-
-/**
- * Returns the bounding box of the rendered field, accounting for workspace
- * scaling.
- * @return {!Rect} An object with top, bottom, left, and right in
- * pixels relative to the top left corner of the page (window coordinates).
- * @package
- */
-Field.prototype.getScaledBBox = function() {
- let scaledWidth;
- let scaledHeight;
- let xy;
- if (!this.borderRect_) {
- // Browsers are inconsistent in what they return for a bounding box.
- // - Webkit / Blink: fill-box / object bounding box
- // - Gecko / Triden / EdgeHTML: stroke-box
- const bBox = this.sourceBlock_.getHeightWidth();
- const scale = this.sourceBlock_.workspace.scale;
- xy = this.getAbsoluteXY_();
- scaledWidth = bBox.width * scale;
- scaledHeight = bBox.height * scale;
-
- if (userAgent.GECKO) {
- xy.x += 1.5 * scale;
- xy.y += 1.5 * scale;
- scaledWidth += 1 * scale;
- scaledHeight += 1 * scale;
- } else {
- if (!userAgent.EDGE && !userAgent.IE) {
- xy.x -= 0.5 * scale;
- xy.y -= 0.5 * scale;
- }
- scaledWidth += 1 * scale;
- scaledHeight += 1 * scale;
- }
- } else {
- const bBox = this.borderRect_.getBoundingClientRect();
- xy = style.getPageOffset(this.borderRect_);
- scaledWidth = bBox.width;
- scaledHeight = bBox.height;
- }
- return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth);
-};
-
-/**
- * Get the text from this field to display on the block. May differ from
- * ``getText`` due to ellipsis, and other formatting.
- * @return {string} Text to display.
- * @protected
- */
-Field.prototype.getDisplayText_ = function() {
- let text = this.getText();
- if (!text) {
- // Prevent the field from disappearing if empty.
- return Field.NBSP;
- }
- if (text.length > this.maxDisplayLength) {
- // Truncate displayed string and add an ellipsis ('...').
- text = text.substring(0, this.maxDisplayLength - 2) + '\u2026';
- }
- // Replace whitespace with non-breaking spaces so the text doesn't collapse.
- text = text.replace(/\s/g, Field.NBSP);
- if (this.sourceBlock_ && this.sourceBlock_.RTL) {
- // The SVG is LTR, force text to be RTL.
- text += '\u200F';
- }
- return text;
-};
-
-/**
- * Get the text from this field.
- * @return {string} Current text.
- */
-Field.prototype.getText = function() {
- if (this.getText_) {
- const text = this.getText_.call(this);
- if (text !== null) {
- return String(text);
- }
- }
- return String(this.getValue());
-};
-
-/**
- * Force a rerender of the block that this field is installed on, which will
- * rerender this field and adjust for any sizing changes.
- * Other fields on the same block will not rerender, because their sizes have
- * already been recorded.
- * @package
- */
-Field.prototype.markDirty = function() {
- this.isDirty_ = true;
- this.constants_ = null;
-};
-
-/**
- * Force a rerender of the block that this field is installed on, which will
- * rerender this field and adjust for any sizing changes.
- * Other fields on the same block will not rerender, because their sizes have
- * already been recorded.
- * @package
- */
-Field.prototype.forceRerender = function() {
- this.isDirty_ = true;
- if (this.sourceBlock_ && this.sourceBlock_.rendered) {
- this.sourceBlock_.render();
- this.sourceBlock_.bumpNeighbours();
- this.updateMarkers_();
- }
-};
-
-/**
- * Used to change the value of the field. Handles validation and events.
- * Subclasses should override doClassValidation_ and doValueUpdate_ rather
- * than this method.
- * @param {*} newValue New value.
- */
-Field.prototype.setValue = function(newValue) {
- const doLogging = false;
- if (newValue === null) {
- doLogging && console.log('null, return');
- // Not a valid value to check.
- return;
- }
-
- let validatedValue = this.doClassValidation_(newValue);
- // Class validators might accidentally forget to return, we'll ignore that.
- newValue = this.processValidation_(newValue, validatedValue);
- if (newValue instanceof Error) {
- doLogging && console.log('invalid class validation, return');
- return;
- }
-
- const localValidator = this.getValidator();
- if (localValidator) {
- validatedValue = localValidator.call(this, newValue);
- // Local validators might accidentally forget to return, we'll ignore that.
- newValue = this.processValidation_(newValue, validatedValue);
- if (newValue instanceof Error) {
- doLogging && console.log('invalid local validation, return');
- return;
- }
- }
- const source = this.sourceBlock_;
- if (source && source.disposed) {
- doLogging && console.log('source disposed, return');
- return;
- }
- const oldValue = this.getValue();
- if (oldValue === newValue) {
- doLogging && console.log('same, doValueUpdate_, return');
- this.doValueUpdate_(newValue);
- return;
- }
-
- this.doValueUpdate_(newValue);
- if (source && eventUtils.isEnabled()) {
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
- source, 'field', this.name || null, oldValue, newValue));
- }
- if (this.isDirty_) {
- this.forceRerender();
- }
- doLogging && console.log(this.value_);
-};
-
-/**
- * Process the result of validation.
- * @param {*} newValue New value.
- * @param {*} validatedValue Validated value.
- * @return {*} New value, or an Error object.
- * @private
- */
-Field.prototype.processValidation_ = function(newValue, validatedValue) {
- if (validatedValue === null) {
- this.doValueInvalid_(newValue);
- if (this.isDirty_) {
- this.forceRerender();
- }
- return Error();
- }
- if (validatedValue !== undefined) {
- newValue = validatedValue;
- }
- return newValue;
-};
-
-/**
- * Get the current value of the field.
- * @return {*} Current value.
- */
-Field.prototype.getValue = function() {
- return this.value_;
-};
-
-/**
- * Used to validate a value. Returns input by default. Can be overridden by
- * subclasses, see FieldDropdown.
- * @param {*=} opt_newValue The value to be validated.
- * @return {*} The validated value, same as input by default.
- * @protected
- */
-Field.prototype.doClassValidation_ = function(opt_newValue) {
- if (opt_newValue === null || opt_newValue === undefined) {
- return null;
- }
- return opt_newValue;
-};
-
-/**
- * Used to update the value of a field. Can be overridden by subclasses to do
- * custom storage of values/updating of external things.
- * @param {*} newValue The value to be saved.
- * @protected
- */
-Field.prototype.doValueUpdate_ = function(newValue) {
- this.value_ = newValue;
- this.isDirty_ = true;
-};
-
-/**
- * Used to notify the field an invalid value was input. Can be overridden by
- * subclasses, see FieldTextInput.
- * No-op by default.
- * @param {*} _invalidValue The input value that was determined to be invalid.
- * @protected
- */
-Field.prototype.doValueInvalid_ = function(_invalidValue) {
- // NOP
-};
-
-/**
- * Handle a mouse down event on a field.
- * @param {!Event} e Mouse down event.
- * @protected
- */
-Field.prototype.onMouseDown_ = function(e) {
- if (!this.sourceBlock_ || !this.sourceBlock_.workspace) {
- return;
- }
- const gesture = this.sourceBlock_.workspace.getGesture(e);
- if (gesture) {
- gesture.setStartField(this);
- }
-};
-
-/**
- * Sets the tooltip for this field.
- * @param {?Tooltip.TipInfo} newTip The
- * text for the tooltip, a function that returns the text for the tooltip, a
- * parent object whose tooltip will be used, or null to display the tooltip
- * of the parent block. To not display a tooltip pass the empty string.
- */
-Field.prototype.setTooltip = function(newTip) {
- if (!newTip && newTip !== '') { // If null or undefined.
- newTip = this.sourceBlock_;
- }
- const clickTarget = this.getClickTarget_();
- if (clickTarget) {
- clickTarget.tooltip = newTip;
- } else {
- // Field has not been initialized yet.
- this.tooltip_ = newTip;
- }
-};
-
-/**
- * Returns the tooltip text for this field.
- * @return {string} The tooltip text for this field.
- */
-Field.prototype.getTooltip = function() {
- const clickTarget = this.getClickTarget_();
- if (clickTarget) {
- return Tooltip.getTooltipOfObject(clickTarget);
- }
- // Field has not been initialized yet. Return stashed this.tooltip_ value.
- return Tooltip.getTooltipOfObject({tooltip: this.tooltip_});
-};
-
-/**
- * The element to bind the click handler to. If not set explicitly, defaults
- * to the SVG root of the field. When this element is
- * clicked on an editable field, the editor will open.
- * @return {!Element} Element to bind click handler to.
- * @protected
- */
-Field.prototype.getClickTarget_ = function() {
- return this.clickTarget_ || this.getSvgRoot();
-};
-
-/**
- * Return the absolute coordinates of the top-left corner of this field.
- * The origin (0,0) is the top-left corner of the page body.
- * @return {!Coordinate} Object with .x and .y properties.
- * @protected
- */
-Field.prototype.getAbsoluteXY_ = function() {
- return style.getPageOffset(
- /** @type {!SVGRectElement} */ (this.getClickTarget_()));
-};
-
-/**
- * Whether this field references any Blockly variables. If true it may need to
- * be handled differently during serialization and deserialization. Subclasses
- * may override this.
- * @return {boolean} True if this field has any variable references.
- * @package
- */
-Field.prototype.referencesVariables = function() {
- return false;
-};
-
-/**
- * Search through the list of inputs and their fields in order to find the
- * parent input of a field.
- * @return {Input} The input that the field belongs to.
- * @package
- */
-Field.prototype.getParentInput = function() {
- let parentInput = null;
- const block = this.sourceBlock_;
- const inputs = block.inputList;
-
- for (let idx = 0; idx < block.inputList.length; idx++) {
- const input = inputs[idx];
- const fieldRows = input.fieldRow;
- for (let j = 0; j < fieldRows.length; j++) {
- if (fieldRows[j] === this) {
- parentInput = input;
- break;
- }
- }
- }
- return parentInput;
-};
-
-/**
- * Returns whether or not we should flip the field in RTL.
- * @return {boolean} True if we should flip in RTL.
- */
-Field.prototype.getFlipRtl = function() {
- return false;
-};
-
-/**
- * Returns whether or not the field is tab navigable.
- * @return {boolean} True if the field is tab navigable.
- */
-Field.prototype.isTabNavigable = function() {
- return false;
-};
-
-/**
- * Handles the given keyboard shortcut.
- * @param {!ShortcutRegistry.KeyboardShortcut} _shortcut The shortcut to be
- * handled.
- * @return {boolean} True if the shortcut has been handled, false otherwise.
- * @public
- */
-Field.prototype.onShortcut = function(_shortcut) {
- return false;
-};
-
-/**
- * Add the cursor SVG to this fields SVG group.
- * @param {SVGElement} cursorSvg The SVG root of the cursor to be added to the
- * field group.
- * @package
- */
-Field.prototype.setCursorSvg = function(cursorSvg) {
- if (!cursorSvg) {
- this.cursorSvg_ = null;
- return;
- }
-
- this.fieldGroup_.appendChild(cursorSvg);
- this.cursorSvg_ = cursorSvg;
-};
-
-/**
- * Add the marker SVG to this fields SVG group.
- * @param {SVGElement} markerSvg The SVG root of the marker to be added to the
- * field group.
- * @package
- */
-Field.prototype.setMarkerSvg = function(markerSvg) {
- if (!markerSvg) {
- this.markerSvg_ = null;
- return;
- }
-
- this.fieldGroup_.appendChild(markerSvg);
- this.markerSvg_ = markerSvg;
-};
-
-/**
- * Redraw any attached marker or cursor svgs if needed.
- * @protected
- */
-Field.prototype.updateMarkers_ = function() {
- const workspace =
- /** @type {!WorkspaceSvg} */ (this.sourceBlock_.workspace);
- if (workspace.keyboardAccessibilityMode && this.cursorSvg_) {
- workspace.getCursor().draw();
- }
- if (workspace.keyboardAccessibilityMode && this.markerSvg_) {
- // TODO(#4592): Update all markers on the field.
- workspace.getMarker(MarkerManager.LOCAL_MARKER).draw();
- }
-};
+Field.SKIP_SETUP = new Sentinel();
exports.Field = Field;
diff --git a/core/field_angle.js b/core/field_angle.js
index 8454f7900..ed7bf07c0 100644
--- a/core/field_angle.js
+++ b/core/field_angle.js
@@ -19,110 +19,493 @@ const Css = goog.require('Blockly.Css');
const WidgetDiv = goog.require('Blockly.WidgetDiv');
const browserEvents = goog.require('Blockly.browserEvents');
const dom = goog.require('Blockly.utils.dom');
+const dropDownDiv = goog.require('Blockly.dropDownDiv');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
const math = goog.require('Blockly.utils.math');
-const object = goog.require('Blockly.utils.object');
const userAgent = goog.require('Blockly.utils.userAgent');
-const {DropDownDiv} = goog.require('Blockly.DropDownDiv');
+const {Field} = goog.require('Blockly.Field');
const {FieldTextInput} = goog.require('Blockly.FieldTextInput');
const {KeyCodes} = goog.require('Blockly.utils.KeyCodes');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
const {Svg} = goog.require('Blockly.utils.Svg');
/**
* Class for an editable angle field.
- * @param {string|number=} opt_value The initial value of the field. Should cast
- * to a number. Defaults to 0.
- * @param {Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a number & returns a
- * validated number, or null to abort the change.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation}
- * for a list of properties this parameter supports.
* @extends {FieldTextInput}
- * @constructor
* @alias Blockly.FieldAngle
*/
-const FieldAngle = function(opt_value, opt_validator, opt_config) {
+class FieldAngle extends FieldTextInput {
/**
- * Should the angle increase as the angle picker is moved clockwise (true)
- * or counterclockwise (false)
- * @see FieldAngle.CLOCKWISE
- * @type {boolean}
+ * @param {(string|number|!Sentinel)=} opt_value The initial value of
+ * the field. Should cast to a number. Defaults to 0.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a number & returns a
+ * validated number, or null to abort the change.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation}
+ * for a list of properties this parameter supports.
+ */
+ constructor(opt_value, opt_validator, opt_config) {
+ super(Field.SKIP_SETUP);
+
+ /**
+ * Should the angle increase as the angle picker is moved clockwise (true)
+ * or counterclockwise (false)
+ * @see FieldAngle.CLOCKWISE
+ * @type {boolean}
+ * @private
+ */
+ this.clockwise_ = FieldAngle.CLOCKWISE;
+
+ /**
+ * The offset of zero degrees (and all other angles).
+ * @see FieldAngle.OFFSET
+ * @type {number}
+ * @private
+ */
+ this.offset_ = FieldAngle.OFFSET;
+
+ /**
+ * The maximum angle to allow before wrapping.
+ * @see FieldAngle.WRAP
+ * @type {number}
+ * @private
+ */
+ this.wrap_ = FieldAngle.WRAP;
+
+ /**
+ * The amount to round angles to when using a mouse or keyboard nav input.
+ * @see FieldAngle.ROUND
+ * @type {number}
+ * @private
+ */
+ this.round_ = FieldAngle.ROUND;
+
+ /**
+ * The angle picker's SVG element.
+ * @type {?SVGElement}
+ * @private
+ */
+ this.editor_ = null;
+
+ /**
+ * The angle picker's gauge path depending on the value.
+ * @type {?SVGElement}
+ */
+ this.gauge_ = null;
+
+ /**
+ * The angle picker's line drawn representing the value's angle.
+ * @type {?SVGElement}
+ */
+ this.line_ = null;
+
+ /**
+ * The degree symbol for this field.
+ * @type {SVGTSpanElement}
+ * @protected
+ */
+ this.symbol_ = null;
+
+ /**
+ * Wrapper click event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.clickWrapper_ = null;
+
+ /**
+ * Surface click event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.clickSurfaceWrapper_ = null;
+
+ /**
+ * Surface mouse move event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.moveSurfaceWrapper_ = null;
+
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
+
+ if (opt_value === Field.SKIP_SETUP) return;
+ if (opt_config) this.configure_(opt_config);
+ this.setValue(opt_value);
+ if (opt_validator) this.setValidator(opt_validator);
+ }
+
+ /**
+ * Configure the field based on the given map of options.
+ * @param {!Object} config A map of options to configure the field based on.
+ * @protected
+ * @override
+ */
+ configure_(config) {
+ super.configure_(config);
+
+ switch (config['mode']) {
+ case 'compass':
+ this.clockwise_ = true;
+ this.offset_ = 90;
+ break;
+ case 'protractor':
+ // This is the default mode, so we could do nothing. But just to
+ // future-proof, we'll set it anyway.
+ this.clockwise_ = false;
+ this.offset_ = 0;
+ break;
+ }
+
+ // Allow individual settings to override the mode setting.
+ const clockwise = config['clockwise'];
+ if (typeof clockwise === 'boolean') {
+ this.clockwise_ = clockwise;
+ }
+
+ // If these are passed as null then we should leave them on the default.
+ let offset = config['offset'];
+ if (offset !== null) {
+ offset = Number(offset);
+ if (!isNaN(offset)) {
+ this.offset_ = offset;
+ }
+ }
+ let wrap = config['wrap'];
+ if (wrap !== null) {
+ wrap = Number(wrap);
+ if (!isNaN(wrap)) {
+ this.wrap_ = wrap;
+ }
+ }
+ let round = config['round'];
+ if (round !== null) {
+ round = Number(round);
+ if (!isNaN(round)) {
+ this.round_ = round;
+ }
+ }
+ }
+
+ /**
+ * Create the block UI for this field.
+ * @package
+ */
+ initView() {
+ super.initView();
+ // Add the degree symbol to the left of the number, even in RTL (issue
+ // #2380)
+ this.symbol_ = dom.createSvgElement(Svg.TSPAN, {}, null);
+ this.symbol_.appendChild(document.createTextNode('\u00B0'));
+ this.textElement_.appendChild(this.symbol_);
+ }
+
+ /**
+ * Updates the graph when the field rerenders.
+ * @protected
+ * @override
+ */
+ render_() {
+ super.render_();
+ this.updateGraph_();
+ }
+
+ /**
+ * Create and show the angle field's editor.
+ * @param {Event=} opt_e Optional mouse event that triggered the field to
+ * open, or undefined if triggered programmatically.
+ * @protected
+ */
+ showEditor_(opt_e) {
+ // Mobile browsers have issues with in-line textareas (focus & keyboards).
+ const noFocus = userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD;
+ super.showEditor_(opt_e, noFocus);
+
+ this.dropdownCreate_();
+ dropDownDiv.getContentDiv().appendChild(this.editor_);
+
+ dropDownDiv.setColour(
+ this.sourceBlock_.style.colourPrimary,
+ this.sourceBlock_.style.colourTertiary);
+
+ dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
+
+ this.updateGraph_();
+ }
+
+ /**
+ * Create the angle dropdown editor.
* @private
*/
- this.clockwise_ = FieldAngle.CLOCKWISE;
+ dropdownCreate_() {
+ const svg = dom.createSvgElement(
+ Svg.SVG, {
+ 'xmlns': dom.SVG_NS,
+ 'xmlns:html': dom.HTML_NS,
+ 'xmlns:xlink': dom.XLINK_NS,
+ 'version': '1.1',
+ 'height': (FieldAngle.HALF * 2) + 'px',
+ 'width': (FieldAngle.HALF * 2) + 'px',
+ 'style': 'touch-action: none',
+ },
+ null);
+ const circle = dom.createSvgElement(
+ Svg.CIRCLE, {
+ 'cx': FieldAngle.HALF,
+ 'cy': FieldAngle.HALF,
+ 'r': FieldAngle.RADIUS,
+ 'class': 'blocklyAngleCircle',
+ },
+ svg);
+ this.gauge_ =
+ dom.createSvgElement(Svg.PATH, {'class': 'blocklyAngleGauge'}, svg);
+ this.line_ = dom.createSvgElement(
+ Svg.LINE, {
+ 'x1': FieldAngle.HALF,
+ 'y1': FieldAngle.HALF,
+ 'class': 'blocklyAngleLine',
+ },
+ svg);
+ // Draw markers around the edge.
+ for (let angle = 0; angle < 360; angle += 15) {
+ dom.createSvgElement(
+ Svg.LINE, {
+ 'x1': FieldAngle.HALF + FieldAngle.RADIUS,
+ 'y1': FieldAngle.HALF,
+ 'x2': FieldAngle.HALF + FieldAngle.RADIUS -
+ (angle % 45 === 0 ? 10 : 5),
+ 'y2': FieldAngle.HALF,
+ 'class': 'blocklyAngleMarks',
+ 'transform': 'rotate(' + angle + ',' + FieldAngle.HALF + ',' +
+ FieldAngle.HALF + ')',
+ },
+ svg);
+ }
+
+ // The angle picker is different from other fields in that it updates on
+ // mousemove even if it's not in the middle of a drag. In future we may
+ // change this behaviour.
+ this.clickWrapper_ =
+ browserEvents.conditionalBind(svg, 'click', this, this.hide_);
+ // On touch devices, the picker's value is only updated with a drag. Add
+ // a click handler on the drag surface to update the value if the surface
+ // is clicked.
+ this.clickSurfaceWrapper_ = browserEvents.conditionalBind(
+ circle, 'click', this, this.onMouseMove_, true, true);
+ this.moveSurfaceWrapper_ = browserEvents.conditionalBind(
+ circle, 'mousemove', this, this.onMouseMove_, true, true);
+ this.editor_ = svg;
+ }
/**
- * The offset of zero degrees (and all other angles).
- * @see FieldAngle.OFFSET
- * @type {number}
+ * Disposes of events and DOM-references belonging to the angle editor.
* @private
*/
- this.offset_ = FieldAngle.OFFSET;
+ dropdownDispose_() {
+ if (this.clickWrapper_) {
+ browserEvents.unbind(this.clickWrapper_);
+ this.clickWrapper_ = null;
+ }
+ if (this.clickSurfaceWrapper_) {
+ browserEvents.unbind(this.clickSurfaceWrapper_);
+ this.clickSurfaceWrapper_ = null;
+ }
+ if (this.moveSurfaceWrapper_) {
+ browserEvents.unbind(this.moveSurfaceWrapper_);
+ this.moveSurfaceWrapper_ = null;
+ }
+ this.gauge_ = null;
+ this.line_ = null;
+ }
/**
- * The maximum angle to allow before wrapping.
- * @see FieldAngle.WRAP
- * @type {number}
+ * Hide the editor.
* @private
*/
- this.wrap_ = FieldAngle.WRAP;
+ hide_() {
+ dropDownDiv.hideIfOwner(this);
+ WidgetDiv.hide();
+ }
/**
- * The amount to round angles to when using a mouse or keyboard nav input.
- * @see FieldAngle.ROUND
- * @type {number}
+ * Set the angle to match the mouse's position.
+ * @param {!Event} e Mouse move event.
+ * @protected
+ */
+ onMouseMove_(e) {
+ // Calculate angle.
+ const bBox = this.gauge_.ownerSVGElement.getBoundingClientRect();
+ const dx = e.clientX - bBox.left - FieldAngle.HALF;
+ const dy = e.clientY - bBox.top - FieldAngle.HALF;
+ let angle = Math.atan(-dy / dx);
+ if (isNaN(angle)) {
+ // This shouldn't happen, but let's not let this error propagate further.
+ return;
+ }
+ angle = math.toDegrees(angle);
+ // 0: East, 90: North, 180: West, 270: South.
+ if (dx < 0) {
+ angle += 180;
+ } else if (dy > 0) {
+ angle += 360;
+ }
+
+ // Do offsetting.
+ if (this.clockwise_) {
+ angle = this.offset_ + 360 - angle;
+ } else {
+ angle = 360 - (this.offset_ - angle);
+ }
+
+ this.displayMouseOrKeyboardValue_(angle);
+ }
+
+ /**
+ * Handles and displays values that are input via mouse or arrow key input.
+ * These values need to be rounded and wrapped before being displayed so
+ * that the text input's value is appropriate.
+ * @param {number} angle New angle.
* @private
*/
- this.round_ = FieldAngle.ROUND;
-
- FieldAngle.superClass_.constructor.call(
- this, opt_value, opt_validator, opt_config);
+ displayMouseOrKeyboardValue_(angle) {
+ if (this.round_) {
+ angle = Math.round(angle / this.round_) * this.round_;
+ }
+ angle = this.wrapValue_(angle);
+ if (angle !== this.value_) {
+ this.setEditorValue_(angle);
+ }
+ }
/**
- * The angle picker's SVG element.
- * @type {?SVGElement}
+ * Redraw the graph with the current angle.
* @private
*/
- this.editor_ = null;
+ updateGraph_() {
+ if (!this.gauge_) {
+ return;
+ }
+ // Always display the input (i.e. getText) even if it is invalid.
+ let angleDegrees = Number(this.getText()) + this.offset_;
+ angleDegrees %= 360;
+ let angleRadians = math.toRadians(angleDegrees);
+ const path = ['M ', FieldAngle.HALF, ',', FieldAngle.HALF];
+ let x2 = FieldAngle.HALF;
+ let y2 = FieldAngle.HALF;
+ if (!isNaN(angleRadians)) {
+ const clockwiseFlag = Number(this.clockwise_);
+ const angle1 = math.toRadians(this.offset_);
+ const x1 = Math.cos(angle1) * FieldAngle.RADIUS;
+ const y1 = Math.sin(angle1) * -FieldAngle.RADIUS;
+ if (clockwiseFlag) {
+ angleRadians = 2 * angle1 - angleRadians;
+ }
+ x2 += Math.cos(angleRadians) * FieldAngle.RADIUS;
+ y2 -= Math.sin(angleRadians) * FieldAngle.RADIUS;
+ // Don't ask how the flag calculations work. They just do.
+ let largeFlag =
+ Math.abs(Math.floor((angleRadians - angle1) / Math.PI) % 2);
+ if (clockwiseFlag) {
+ largeFlag = 1 - largeFlag;
+ }
+ path.push(
+ ' l ', x1, ',', y1, ' A ', FieldAngle.RADIUS, ',', FieldAngle.RADIUS,
+ ' 0 ', largeFlag, ' ', clockwiseFlag, ' ', x2, ',', y2, ' z');
+ }
+ this.gauge_.setAttribute('d', path.join(''));
+ this.line_.setAttribute('x2', x2);
+ this.line_.setAttribute('y2', y2);
+ }
/**
- * The angle picker's gauge path depending on the value.
- * @type {?SVGElement}
+ * Handle key down to the editor.
+ * @param {!Event} e Keyboard event.
+ * @protected
+ * @override
*/
- this.gauge_ = null;
+ onHtmlInputKeyDown_(e) {
+ super.onHtmlInputKeyDown_(e);
+
+ let multiplier;
+ if (e.keyCode === KeyCodes.LEFT) {
+ // decrement (increment in RTL)
+ multiplier = this.sourceBlock_.RTL ? 1 : -1;
+ } else if (e.keyCode === KeyCodes.RIGHT) {
+ // increment (decrement in RTL)
+ multiplier = this.sourceBlock_.RTL ? -1 : 1;
+ } else if (e.keyCode === KeyCodes.DOWN) {
+ // decrement
+ multiplier = -1;
+ } else if (e.keyCode === KeyCodes.UP) {
+ // increment
+ multiplier = 1;
+ }
+ if (multiplier) {
+ const value = /** @type {number} */ (this.getValue());
+ this.displayMouseOrKeyboardValue_(value + (multiplier * this.round_));
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
/**
- * The angle picker's line drawn representing the value's angle.
- * @type {?SVGElement}
+ * Ensure that the input value is a valid angle.
+ * @param {*=} opt_newValue The input value.
+ * @return {?number} A valid angle, or null if invalid.
+ * @protected
+ * @override
*/
- this.line_ = null;
+ doClassValidation_(opt_newValue) {
+ const value = Number(opt_newValue);
+ if (isNaN(value) || !isFinite(value)) {
+ return null;
+ }
+ return this.wrapValue_(value);
+ }
/**
- * Wrapper click event data.
- * @type {?browserEvents.Data}
+ * Wraps the value so that it is in the range (-360 + wrap, wrap).
+ * @param {number} value The value to wrap.
+ * @return {number} The wrapped value.
* @private
*/
- this.clickWrapper_ = null;
+ wrapValue_(value) {
+ value %= 360;
+ if (value < 0) {
+ value += 360;
+ }
+ if (value > this.wrap_) {
+ value -= 360;
+ }
+ return value;
+ }
/**
- * Surface click event data.
- * @type {?browserEvents.Data}
- * @private
+ * Construct a FieldAngle from a JSON arg object.
+ * @param {!Object} options A JSON object with options (angle).
+ * @return {!FieldAngle} The new field instance.
+ * @package
+ * @nocollapse
+ * @override
*/
- this.clickSurfaceWrapper_ = null;
-
- /**
- * Surface mouse move event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.moveSurfaceWrapper_ = null;
-};
-object.inherits(FieldAngle, FieldTextInput);
-
+ static fromJson(options) {
+ // `this` might be a subclass of FieldAngle if that class doesn't override
+ // the static fromJson method.
+ return new this(options['angle'], undefined, options);
+ }
+}
/**
* The default value for this field.
@@ -131,26 +514,6 @@ object.inherits(FieldAngle, FieldTextInput);
*/
FieldAngle.prototype.DEFAULT_VALUE = 0;
-/**
- * Construct a FieldAngle from a JSON arg object.
- * @param {!Object} options A JSON object with options (angle).
- * @return {!FieldAngle} The new field instance.
- * @package
- * @nocollapse
- */
-FieldAngle.fromJson = function(options) {
- // `this` might be a subclass of FieldAngle if that class doesn't override
- // the static fromJson method.
- return new this(options['angle'], undefined, options);
-};
-
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable.
- * @type {boolean}
- */
-FieldAngle.prototype.SERIALIZABLE = true;
-
/**
* The default amount to round angles to when using a mouse or keyboard nav
* input. Must be a positive integer to support keyboard navigation.
@@ -193,377 +556,34 @@ FieldAngle.WRAP = 360;
*/
FieldAngle.RADIUS = FieldAngle.HALF - 1;
-/**
- * Configure the field based on the given map of options.
- * @param {!Object} config A map of options to configure the field based on.
- * @protected
- * @override
- */
-FieldAngle.prototype.configure_ = function(config) {
- FieldAngle.superClass_.configure_.call(this, config);
-
- switch (config['mode']) {
- case 'compass':
- this.clockwise_ = true;
- this.offset_ = 90;
- break;
- case 'protractor':
- // This is the default mode, so we could do nothing. But just to
- // future-proof, we'll set it anyway.
- this.clockwise_ = false;
- this.offset_ = 0;
- break;
- }
-
- // Allow individual settings to override the mode setting.
- const clockwise = config['clockwise'];
- if (typeof clockwise === 'boolean') {
- this.clockwise_ = clockwise;
- }
-
- // If these are passed as null then we should leave them on the default.
- let offset = config['offset'];
- if (offset !== null) {
- offset = Number(offset);
- if (!isNaN(offset)) {
- this.offset_ = offset;
- }
- }
- let wrap = config['wrap'];
- if (wrap !== null) {
- wrap = Number(wrap);
- if (!isNaN(wrap)) {
- this.wrap_ = wrap;
- }
- }
- let round = config['round'];
- if (round !== null) {
- round = Number(round);
- if (!isNaN(round)) {
- this.round_ = round;
- }
- }
-};
-
-/**
- * Create the block UI for this field.
- * @package
- */
-FieldAngle.prototype.initView = function() {
- FieldAngle.superClass_.initView.call(this);
- // Add the degree symbol to the left of the number, even in RTL (issue #2380)
- this.symbol_ = dom.createSvgElement(Svg.TSPAN, {}, null);
- this.symbol_.appendChild(document.createTextNode('\u00B0'));
- this.textElement_.appendChild(this.symbol_);
-};
-
-/**
- * Updates the graph when the field rerenders.
- * @protected
- * @override
- */
-FieldAngle.prototype.render_ = function() {
- FieldAngle.superClass_.render_.call(this);
- this.updateGraph_();
-};
-
-/**
- * Create and show the angle field's editor.
- * @param {Event=} opt_e Optional mouse event that triggered the field to open,
- * or undefined if triggered programmatically.
- * @protected
- */
-FieldAngle.prototype.showEditor_ = function(opt_e) {
- // Mobile browsers have issues with in-line textareas (focus & keyboards).
- const noFocus = userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD;
- FieldAngle.superClass_.showEditor_.call(this, opt_e, noFocus);
-
- this.dropdownCreate_();
- DropDownDiv.getContentDiv().appendChild(this.editor_);
-
- DropDownDiv.setColour(
- this.sourceBlock_.style.colourPrimary,
- this.sourceBlock_.style.colourTertiary);
-
- DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
-
- this.updateGraph_();
-};
-
-/**
- * Create the angle dropdown editor.
- * @private
- */
-FieldAngle.prototype.dropdownCreate_ = function() {
- const svg = dom.createSvgElement(
- Svg.SVG, {
- 'xmlns': dom.SVG_NS,
- 'xmlns:html': dom.HTML_NS,
- 'xmlns:xlink': dom.XLINK_NS,
- 'version': '1.1',
- 'height': (FieldAngle.HALF * 2) + 'px',
- 'width': (FieldAngle.HALF * 2) + 'px',
- 'style': 'touch-action: none',
- },
- null);
- const circle = dom.createSvgElement(
- Svg.CIRCLE, {
- 'cx': FieldAngle.HALF,
- 'cy': FieldAngle.HALF,
- 'r': FieldAngle.RADIUS,
- 'class': 'blocklyAngleCircle',
- },
- svg);
- this.gauge_ =
- dom.createSvgElement(Svg.PATH, {'class': 'blocklyAngleGauge'}, svg);
- this.line_ = dom.createSvgElement(
- Svg.LINE, {
- 'x1': FieldAngle.HALF,
- 'y1': FieldAngle.HALF,
- 'class': 'blocklyAngleLine',
- },
- svg);
- // Draw markers around the edge.
- for (let angle = 0; angle < 360; angle += 15) {
- dom.createSvgElement(
- Svg.LINE, {
- 'x1': FieldAngle.HALF + FieldAngle.RADIUS,
- 'y1': FieldAngle.HALF,
- 'x2':
- FieldAngle.HALF + FieldAngle.RADIUS - (angle % 45 === 0 ? 10 : 5),
- 'y2': FieldAngle.HALF,
- 'class': 'blocklyAngleMarks',
- 'transform': 'rotate(' + angle + ',' + FieldAngle.HALF + ',' +
- FieldAngle.HALF + ')',
- },
- svg);
- }
-
- // The angle picker is different from other fields in that it updates on
- // mousemove even if it's not in the middle of a drag. In future we may
- // change this behaviour.
- this.clickWrapper_ =
- browserEvents.conditionalBind(svg, 'click', this, this.hide_);
- // On touch devices, the picker's value is only updated with a drag. Add
- // a click handler on the drag surface to update the value if the surface
- // is clicked.
- this.clickSurfaceWrapper_ = browserEvents.conditionalBind(
- circle, 'click', this, this.onMouseMove_, true, true);
- this.moveSurfaceWrapper_ = browserEvents.conditionalBind(
- circle, 'mousemove', this, this.onMouseMove_, true, true);
- this.editor_ = svg;
-};
-
-/**
- * Disposes of events and DOM-references belonging to the angle editor.
- * @private
- */
-FieldAngle.prototype.dropdownDispose_ = function() {
- if (this.clickWrapper_) {
- browserEvents.unbind(this.clickWrapper_);
- this.clickWrapper_ = null;
- }
- if (this.clickSurfaceWrapper_) {
- browserEvents.unbind(this.clickSurfaceWrapper_);
- this.clickSurfaceWrapper_ = null;
- }
- if (this.moveSurfaceWrapper_) {
- browserEvents.unbind(this.moveSurfaceWrapper_);
- this.moveSurfaceWrapper_ = null;
- }
- this.gauge_ = null;
- this.line_ = null;
-};
-
-/**
- * Hide the editor.
- * @private
- */
-FieldAngle.prototype.hide_ = function() {
- DropDownDiv.hideIfOwner(this);
- WidgetDiv.hide();
-};
-
-/**
- * Set the angle to match the mouse's position.
- * @param {!Event} e Mouse move event.
- * @protected
- */
-FieldAngle.prototype.onMouseMove_ = function(e) {
- // Calculate angle.
- const bBox = this.gauge_.ownerSVGElement.getBoundingClientRect();
- const dx = e.clientX - bBox.left - FieldAngle.HALF;
- const dy = e.clientY - bBox.top - FieldAngle.HALF;
- let angle = Math.atan(-dy / dx);
- if (isNaN(angle)) {
- // This shouldn't happen, but let's not let this error propagate further.
- return;
- }
- angle = math.toDegrees(angle);
- // 0: East, 90: North, 180: West, 270: South.
- if (dx < 0) {
- angle += 180;
- } else if (dy > 0) {
- angle += 360;
- }
-
- // Do offsetting.
- if (this.clockwise_) {
- angle = this.offset_ + 360 - angle;
- } else {
- angle = 360 - (this.offset_ - angle);
- }
-
- this.displayMouseOrKeyboardValue_(angle);
-};
-
-/**
- * Handles and displays values that are input via mouse or arrow key input.
- * These values need to be rounded and wrapped before being displayed so
- * that the text input's value is appropriate.
- * @param {number} angle New angle.
- * @private
- */
-FieldAngle.prototype.displayMouseOrKeyboardValue_ = function(angle) {
- if (this.round_) {
- angle = Math.round(angle / this.round_) * this.round_;
- }
- angle = this.wrapValue_(angle);
- if (angle !== this.value_) {
- this.setEditorValue_(angle);
- }
-};
-
-/**
- * Redraw the graph with the current angle.
- * @private
- */
-FieldAngle.prototype.updateGraph_ = function() {
- if (!this.gauge_) {
- return;
- }
- // Always display the input (i.e. getText) even if it is invalid.
- let angleDegrees = Number(this.getText()) + this.offset_;
- angleDegrees %= 360;
- let angleRadians = math.toRadians(angleDegrees);
- const path = ['M ', FieldAngle.HALF, ',', FieldAngle.HALF];
- let x2 = FieldAngle.HALF;
- let y2 = FieldAngle.HALF;
- if (!isNaN(angleRadians)) {
- const clockwiseFlag = Number(this.clockwise_);
- const angle1 = math.toRadians(this.offset_);
- const x1 = Math.cos(angle1) * FieldAngle.RADIUS;
- const y1 = Math.sin(angle1) * -FieldAngle.RADIUS;
- if (clockwiseFlag) {
- angleRadians = 2 * angle1 - angleRadians;
- }
- x2 += Math.cos(angleRadians) * FieldAngle.RADIUS;
- y2 -= Math.sin(angleRadians) * FieldAngle.RADIUS;
- // Don't ask how the flag calculations work. They just do.
- let largeFlag = Math.abs(Math.floor((angleRadians - angle1) / Math.PI) % 2);
- if (clockwiseFlag) {
- largeFlag = 1 - largeFlag;
- }
- path.push(
- ' l ', x1, ',', y1, ' A ', FieldAngle.RADIUS, ',', FieldAngle.RADIUS,
- ' 0 ', largeFlag, ' ', clockwiseFlag, ' ', x2, ',', y2, ' z');
- }
- this.gauge_.setAttribute('d', path.join(''));
- this.line_.setAttribute('x2', x2);
- this.line_.setAttribute('y2', y2);
-};
-
-/**
- * Handle key down to the editor.
- * @param {!Event} e Keyboard event.
- * @protected
- * @override
- */
-FieldAngle.prototype.onHtmlInputKeyDown_ = function(e) {
- FieldAngle.superClass_.onHtmlInputKeyDown_.call(this, e);
-
- let multiplier;
- if (e.keyCode === KeyCodes.LEFT) {
- // decrement (increment in RTL)
- multiplier = this.sourceBlock_.RTL ? 1 : -1;
- } else if (e.keyCode === KeyCodes.RIGHT) {
- // increment (decrement in RTL)
- multiplier = this.sourceBlock_.RTL ? -1 : 1;
- } else if (e.keyCode === KeyCodes.DOWN) {
- // decrement
- multiplier = -1;
- } else if (e.keyCode === KeyCodes.UP) {
- // increment
- multiplier = 1;
- }
- if (multiplier) {
- const value = /** @type {number} */ (this.getValue());
- this.displayMouseOrKeyboardValue_(value + (multiplier * this.round_));
- e.preventDefault();
- e.stopPropagation();
- }
-};
-
-/**
- * Ensure that the input value is a valid angle.
- * @param {*=} opt_newValue The input value.
- * @return {?number} A valid angle, or null if invalid.
- * @protected
- * @override
- */
-FieldAngle.prototype.doClassValidation_ = function(opt_newValue) {
- const value = Number(opt_newValue);
- if (isNaN(value) || !isFinite(value)) {
- return null;
- }
- return this.wrapValue_(value);
-};
-
-/**
- * Wraps the value so that it is in the range (-360 + wrap, wrap).
- * @param {number} value The value to wrap.
- * @return {number} The wrapped value.
- * @private
- */
-FieldAngle.prototype.wrapValue_ = function(value) {
- value %= 360;
- if (value < 0) {
- value += 360;
- }
- if (value > this.wrap_) {
- value -= 360;
- }
- return value;
-};
-
/**
* CSS for angle field. See css.js for use.
*/
Css.register(`
- .blocklyAngleCircle {
- stroke: #444;
- stroke-width: 1;
- fill: #ddd;
- fill-opacity: .8;
- }
+.blocklyAngleCircle {
+ stroke: #444;
+ stroke-width: 1;
+ fill: #ddd;
+ fill-opacity: .8;
+}
- .blocklyAngleMarks {
- stroke: #444;
- stroke-width: 1;
- }
+.blocklyAngleMarks {
+ stroke: #444;
+ stroke-width: 1;
+}
- .blocklyAngleGauge {
- fill: #f88;
- fill-opacity: .8;
- pointer-events: none;
- }
+.blocklyAngleGauge {
+ fill: #f88;
+ fill-opacity: .8;
+ pointer-events: none;
+}
- .blocklyAngleLine {
- stroke: #f00;
- stroke-width: 2;
- stroke-linecap: round;
- pointer-events: none;
- }
+.blocklyAngleLine {
+ stroke: #f00;
+ stroke-width: 2;
+ stroke-linecap: round;
+ pointer-events: none;
+}
`);
fieldRegistry.register('field_angle', FieldAngle);
diff --git a/core/field_checkbox.js b/core/field_checkbox.js
index ddbe38711..457b01ed8 100644
--- a/core/field_checkbox.js
+++ b/core/field_checkbox.js
@@ -17,41 +17,224 @@ goog.module('Blockly.FieldCheckbox');
const dom = goog.require('Blockly.utils.dom');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
const {Field} = goog.require('Blockly.Field');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
/** @suppress {extraRequire} */
goog.require('Blockly.Events.BlockChange');
/**
* Class for a checkbox field.
- * @param {string|boolean=} opt_value The initial value of the field. Should
- * either be 'TRUE', 'FALSE' or a boolean. Defaults to 'FALSE'.
- * @param {Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a value ('TRUE' or 'FALSE') &
- * returns a validated value ('TRUE' or 'FALSE'), or null to abort the
- * change.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/checkbox#creation}
- * for a list of properties this parameter supports.
* @extends {Field}
- * @constructor
* @alias Blockly.FieldCheckbox
*/
-const FieldCheckbox = function(opt_value, opt_validator, opt_config) {
+class FieldCheckbox extends Field {
/**
- * Character for the check mark. Used to apply a different check mark
- * character to individual fields.
- * @type {?string}
+ * @param {(string|boolean|!Sentinel)=} opt_value The initial value of
+ * the field. Should either be 'TRUE', 'FALSE' or a boolean. Defaults to
+ * 'FALSE'.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a value ('TRUE' or 'FALSE') &
+ * returns a validated value ('TRUE' or 'FALSE'), or null to abort the
+ * change.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/checkbox#creation}
+ * for a list of properties this parameter supports.
+ */
+ constructor(opt_value, opt_validator, opt_config) {
+ super(Field.SKIP_SETUP);
+
+ /**
+ * Character for the check mark. Used to apply a different check mark
+ * character to individual fields.
+ * @type {string}
+ * @private
+ */
+ this.checkChar_ = FieldCheckbox.CHECK_CHAR;
+
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
+
+ /**
+ * Mouse cursor style when over the hotspot that initiates editability.
+ * @type {string}
+ */
+ this.CURSOR = 'default';
+
+ if (opt_value === Field.SKIP_SETUP) return;
+ if (opt_config) this.configure_(opt_config);
+ this.setValue(opt_value);
+ if (opt_validator) this.setValidator(opt_validator);
+ }
+
+ /**
+ * Configure the field based on the given map of options.
+ * @param {!Object} config A map of options to configure the field based on.
+ * @protected
+ * @override
+ */
+ configure_(config) {
+ super.configure_(config);
+ if (config['checkCharacter']) {
+ this.checkChar_ = config['checkCharacter'];
+ }
+ }
+
+ /**
+ * Saves this field's value.
+ * @return {*} The boolean value held by this field.
+ * @override
+ * @package
+ */
+ saveState() {
+ const legacyState = this.saveLegacyState(FieldCheckbox);
+ if (legacyState !== null) {
+ return legacyState;
+ }
+ return this.getValueBoolean();
+ }
+
+ /**
+ * Create the block UI for this checkbox.
+ * @package
+ */
+ initView() {
+ super.initView();
+
+ dom.addClass(
+ /** @type {!SVGTextElement} **/ (this.textElement_), 'blocklyCheckbox');
+ this.textElement_.style.display = this.value_ ? 'block' : 'none';
+ }
+
+ /**
+ * @override
+ */
+ render_() {
+ if (this.textContent_) {
+ this.textContent_.nodeValue = this.getDisplayText_();
+ }
+ this.updateSize_(this.getConstants().FIELD_CHECKBOX_X_OFFSET);
+ }
+
+ /**
+ * @override
+ */
+ getDisplayText_() {
+ return this.checkChar_;
+ }
+
+ /**
+ * Set the character used for the check mark.
+ * @param {?string} character The character to use for the check mark, or
+ * null to use the default.
+ */
+ setCheckCharacter(character) {
+ this.checkChar_ = character || FieldCheckbox.CHECK_CHAR;
+ this.forceRerender();
+ }
+
+ /**
+ * Toggle the state of the checkbox on click.
+ * @protected
+ */
+ showEditor_() {
+ this.setValue(!this.value_);
+ }
+
+ /**
+ * Ensure that the input value is valid ('TRUE' or 'FALSE').
+ * @param {*=} opt_newValue The input value.
+ * @return {?string} A valid value ('TRUE' or 'FALSE), or null if invalid.
+ * @protected
+ */
+ doClassValidation_(opt_newValue) {
+ if (opt_newValue === true || opt_newValue === 'TRUE') {
+ return 'TRUE';
+ }
+ if (opt_newValue === false || opt_newValue === 'FALSE') {
+ return 'FALSE';
+ }
+ return null;
+ }
+
+ /**
+ * Update the value of the field, and update the checkElement.
+ * @param {*} newValue The value to be saved. The default validator guarantees
+ * that this is a either 'TRUE' or 'FALSE'.
+ * @protected
+ */
+ doValueUpdate_(newValue) {
+ this.value_ = this.convertValueToBool_(newValue);
+ // Update visual.
+ if (this.textElement_) {
+ this.textElement_.style.display = this.value_ ? 'block' : 'none';
+ }
+ }
+
+ /**
+ * Get the value of this field, either 'TRUE' or 'FALSE'.
+ * @return {string} The value of this field.
+ */
+ getValue() {
+ return this.value_ ? 'TRUE' : 'FALSE';
+ }
+
+ /**
+ * Get the boolean value of this field.
+ * @return {boolean} The boolean value of this field.
+ */
+ getValueBoolean() {
+ return /** @type {boolean} */ (this.value_);
+ }
+
+ /**
+ * Get the text of this field. Used when the block is collapsed.
+ * @return {string} Text representing the value of this field
+ * ('true' or 'false').
+ */
+ getText() {
+ return String(this.convertValueToBool_(this.value_));
+ }
+
+ /**
+ * Convert a value into a pure boolean.
+ *
+ * Converts 'TRUE' to true and 'FALSE' to false correctly, everything else
+ * is cast to a boolean.
+ * @param {*} value The value to convert.
+ * @return {boolean} The converted value.
* @private
*/
- this.checkChar_ = null;
+ convertValueToBool_(value) {
+ if (typeof value === 'string') {
+ return value === 'TRUE';
+ } else {
+ return !!value;
+ }
+ }
- FieldCheckbox.superClass_.constructor.call(
- this, opt_value, opt_validator, opt_config);
-};
-object.inherits(FieldCheckbox, Field);
+ /**
+ * Construct a FieldCheckbox from a JSON arg object.
+ * @param {!Object} options A JSON object with options (checked).
+ * @return {!FieldCheckbox} The new field instance.
+ * @package
+ * @nocollapse
+ */
+ static fromJson(options) {
+ // `this` might be a subclass of FieldCheckbox if that class doesn't
+ // 'override' the static fromJson method.
+ return new this(options['checked'], undefined, options);
+ }
+}
/**
* The default value for this field.
@@ -60,19 +243,6 @@ object.inherits(FieldCheckbox, Field);
*/
FieldCheckbox.prototype.DEFAULT_VALUE = false;
-/**
- * Construct a FieldCheckbox from a JSON arg object.
- * @param {!Object} options A JSON object with options (checked).
- * @return {!FieldCheckbox} The new field instance.
- * @package
- * @nocollapse
- */
-FieldCheckbox.fromJson = function(options) {
- // `this` might be a subclass of FieldCheckbox if that class doesn't override
- // the static fromJson method.
- return new this(options['checked'], undefined, options);
-};
-
/**
* Default character for the checkmark.
* @type {string}
@@ -80,164 +250,6 @@ FieldCheckbox.fromJson = function(options) {
*/
FieldCheckbox.CHECK_CHAR = '\u2713';
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable.
- * @type {boolean}
- */
-FieldCheckbox.prototype.SERIALIZABLE = true;
-
-/**
- * Mouse cursor style when over the hotspot that initiates editability.
- */
-FieldCheckbox.prototype.CURSOR = 'default';
-
-/**
- * Configure the field based on the given map of options.
- * @param {!Object} config A map of options to configure the field based on.
- * @protected
- * @override
- */
-FieldCheckbox.prototype.configure_ = function(config) {
- FieldCheckbox.superClass_.configure_.call(this, config);
- if (config['checkCharacter']) {
- this.checkChar_ = config['checkCharacter'];
- }
-};
-
-/**
- * Saves this field's value.
- * @return {*} The boolean value held by this field.
- * @override
- * @package
- */
-FieldCheckbox.prototype.saveState = function() {
- const legacyState = this.saveLegacyState(FieldCheckbox);
- if (legacyState !== null) {
- return legacyState;
- }
- return this.getValueBoolean();
-};
-
-/**
- * Create the block UI for this checkbox.
- * @package
- */
-FieldCheckbox.prototype.initView = function() {
- FieldCheckbox.superClass_.initView.call(this);
-
- dom.addClass(
- /** @type {!SVGTextElement} **/ (this.textElement_), 'blocklyCheckbox');
- this.textElement_.style.display = this.value_ ? 'block' : 'none';
-};
-
-/**
- * @override
- */
-FieldCheckbox.prototype.render_ = function() {
- if (this.textContent_) {
- this.textContent_.nodeValue = this.getDisplayText_();
- }
- this.updateSize_(this.getConstants().FIELD_CHECKBOX_X_OFFSET);
-};
-
-/**
- * @override
- */
-FieldCheckbox.prototype.getDisplayText_ = function() {
- return this.checkChar_ || FieldCheckbox.CHECK_CHAR;
-};
-
-/**
- * Set the character used for the check mark.
- * @param {?string} character The character to use for the check mark, or
- * null to use the default.
- */
-FieldCheckbox.prototype.setCheckCharacter = function(character) {
- this.checkChar_ = character;
- this.forceRerender();
-};
-
-/**
- * Toggle the state of the checkbox on click.
- * @protected
- */
-FieldCheckbox.prototype.showEditor_ = function() {
- this.setValue(!this.value_);
-};
-
-/**
- * Ensure that the input value is valid ('TRUE' or 'FALSE').
- * @param {*=} opt_newValue The input value.
- * @return {?string} A valid value ('TRUE' or 'FALSE), or null if invalid.
- * @protected
- */
-FieldCheckbox.prototype.doClassValidation_ = function(opt_newValue) {
- if (opt_newValue === true || opt_newValue === 'TRUE') {
- return 'TRUE';
- }
- if (opt_newValue === false || opt_newValue === 'FALSE') {
- return 'FALSE';
- }
- return null;
-};
-
-/**
- * Update the value of the field, and update the checkElement.
- * @param {*} newValue The value to be saved. The default validator guarantees
- * that this is a either 'TRUE' or 'FALSE'.
- * @protected
- */
-FieldCheckbox.prototype.doValueUpdate_ = function(newValue) {
- this.value_ = this.convertValueToBool_(newValue);
- // Update visual.
- if (this.textElement_) {
- this.textElement_.style.display = this.value_ ? 'block' : 'none';
- }
-};
-
-/**
- * Get the value of this field, either 'TRUE' or 'FALSE'.
- * @return {string} The value of this field.
- */
-FieldCheckbox.prototype.getValue = function() {
- return this.value_ ? 'TRUE' : 'FALSE';
-};
-
-/**
- * Get the boolean value of this field.
- * @return {boolean} The boolean value of this field.
- */
-FieldCheckbox.prototype.getValueBoolean = function() {
- return /** @type {boolean} */ (this.value_);
-};
-
-/**
- * Get the text of this field. Used when the block is collapsed.
- * @return {string} Text representing the value of this field
- * ('true' or 'false').
- */
-FieldCheckbox.prototype.getText = function() {
- return String(this.convertValueToBool_(this.value_));
-};
-
-/**
- * Convert a value into a pure boolean.
- *
- * Converts 'TRUE' to true and 'FALSE' to false correctly, everything else
- * is cast to a boolean.
- * @param {*} value The value to convert.
- * @return {boolean} The converted value.
- * @private
- */
-FieldCheckbox.prototype.convertValueToBool_ = function(value) {
- if (typeof value === 'string') {
- return value === 'TRUE';
- } else {
- return !!value;
- }
-};
-
fieldRegistry.register('field_checkbox', FieldCheckbox);
exports.FieldCheckbox = FieldCheckbox;
diff --git a/core/field_colour.js b/core/field_colour.js
index 4b4c037a9..02095f25b 100644
--- a/core/field_colour.js
+++ b/core/field_colour.js
@@ -20,12 +20,13 @@ const aria = goog.require('Blockly.utils.aria');
const browserEvents = goog.require('Blockly.browserEvents');
const colour = goog.require('Blockly.utils.colour');
const dom = goog.require('Blockly.utils.dom');
+const dropDownDiv = goog.require('Blockly.dropDownDiv');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
const idGenerator = goog.require('Blockly.utils.idGenerator');
-const object = goog.require('Blockly.utils.object');
-const {DropDownDiv} = goog.require('Blockly.DropDownDiv');
const {Field} = goog.require('Blockly.Field');
const {KeyCodes} = goog.require('Blockly.utils.KeyCodes');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
const {Size} = goog.require('Blockly.utils.Size');
/** @suppress {extraRequire} */
goog.require('Blockly.Events.BlockChange');
@@ -33,219 +34,539 @@ goog.require('Blockly.Events.BlockChange');
/**
* Class for a colour input field.
- * @param {string=} opt_value The initial value of the field. Should be in
- * '#rrggbb' format. Defaults to the first value in the default colour array.
- * @param {Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a colour string & returns a
- * validated colour string ('#rrggbb' format), or null to abort the
- * change.Blockly.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour}
- * for a list of properties this parameter supports.
* @extends {Field}
- * @constructor
* @alias Blockly.FieldColour
*/
-const FieldColour = function(opt_value, opt_validator, opt_config) {
- FieldColour.superClass_.constructor.call(
- this, opt_value, opt_validator, opt_config);
-
+class FieldColour extends Field {
/**
- * The field's colour picker element.
- * @type {?Element}
- * @private
+ * @param {(string|!Sentinel)=} opt_value The initial value of the
+ * field. Should be in '#rrggbb' format. Defaults to the first value in
+ * the default colour array.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a colour string & returns a
+ * validated colour string ('#rrggbb' format), or null to abort the
+ * change.Blockly.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour}
+ * for a list of properties this parameter supports.
*/
- this.picker_ = null;
+ constructor(opt_value, opt_validator, opt_config) {
+ super(Field.SKIP_SETUP);
- /**
- * Index of the currently highlighted element.
- * @type {?number}
- * @private
- */
- this.highlightedIndex_ = null;
+ /**
+ * The field's colour picker element.
+ * @type {?Element}
+ * @private
+ */
+ this.picker_ = null;
- /**
- * Mouse click event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onClickWrapper_ = null;
+ /**
+ * Index of the currently highlighted element.
+ * @type {?number}
+ * @private
+ */
+ this.highlightedIndex_ = null;
- /**
- * Mouse move event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onMouseMoveWrapper_ = null;
+ /**
+ * Mouse click event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onClickWrapper_ = null;
- /**
- * Mouse enter event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onMouseEnterWrapper_ = null;
+ /**
+ * Mouse move event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onMouseMoveWrapper_ = null;
- /**
- * Mouse leave event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onMouseLeaveWrapper_ = null;
+ /**
+ * Mouse enter event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onMouseEnterWrapper_ = null;
- /**
- * Key down event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onKeyDownWrapper_ = null;
-};
-object.inherits(FieldColour, Field);
+ /**
+ * Mouse leave event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onMouseLeaveWrapper_ = null;
-/**
- * Construct a FieldColour from a JSON arg object.
- * @param {!Object} options A JSON object with options (colour).
- * @return {!FieldColour} The new field instance.
- * @package
- * @nocollapse
- */
-FieldColour.fromJson = function(options) {
- // `this` might be a subclass of FieldColour if that class doesn't override
- // the static fromJson method.
- return new this(options['colour'], undefined, options);
-};
+ /**
+ * Key down event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onKeyDownWrapper_ = null;
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable.
- * @type {boolean}
- */
-FieldColour.prototype.SERIALIZABLE = true;
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
-/**
- * Mouse cursor style when over the hotspot that initiates the editor.
- */
-FieldColour.prototype.CURSOR = 'default';
+ /**
+ * Mouse cursor style when over the hotspot that initiates the editor.
+ * @type {string}
+ */
+ this.CURSOR = 'default';
-/**
- * Used to tell if the field needs to be rendered the next time the block is
- * rendered. Colour fields are statically sized, and only need to be
- * rendered at initialization.
- * @type {boolean}
- * @protected
- */
-FieldColour.prototype.isDirty_ = false;
+ /**
+ * Used to tell if the field needs to be rendered the next time the block is
+ * rendered. Colour fields are statically sized, and only need to be
+ * rendered at initialization.
+ * @type {boolean}
+ * @protected
+ */
+ this.isDirty_ = false;
-/**
- * Array of colours used by this field. If null, use the global list.
- * @type {Array}
- * @private
- */
-FieldColour.prototype.colours_ = null;
+ /**
+ * Array of colours used by this field. If null, use the global list.
+ * @type {Array}
+ * @private
+ */
+ this.colours_ = null;
-/**
- * Array of colour tooltips used by this field. If null, use the global list.
- * @type {Array}
- * @private
- */
-FieldColour.prototype.titles_ = null;
+ /**
+ * Array of colour tooltips used by this field. If null, use the global
+ * list.
+ * @type {Array}
+ * @private
+ */
+ this.titles_ = null;
-/**
- * Number of colour columns used by this field. If 0, use the global setting.
- * By default use the global constants for columns.
- * @type {number}
- * @private
- */
-FieldColour.prototype.columns_ = 0;
+ /**
+ * Number of colour columns used by this field. If 0, use the global
+ * setting. By default use the global constants for columns.
+ * @type {number}
+ * @private
+ */
+ this.columns_ = 0;
-/**
- * Configure the field based on the given map of options.
- * @param {!Object} config A map of options to configure the field based on.
- * @protected
- * @override
- */
-FieldColour.prototype.configure_ = function(config) {
- FieldColour.superClass_.configure_.call(this, config);
- if (config['colourOptions']) {
- this.colours_ = config['colourOptions'];
- this.titles_ = config['colourTitles'];
+ if (opt_value === Field.SKIP_SETUP) return;
+ if (opt_config) this.configure_(opt_config);
+ this.setValue(opt_value);
+ if (opt_validator) this.setValidator(opt_validator);
}
- if (config['columns']) {
- this.columns_ = config['columns'];
- }
-};
-/**
- * Create the block UI for this colour field.
- * @package
- */
-FieldColour.prototype.initView = function() {
- this.size_ = new Size(
- this.getConstants().FIELD_COLOUR_DEFAULT_WIDTH,
- this.getConstants().FIELD_COLOUR_DEFAULT_HEIGHT);
- if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) {
- this.createBorderRect_();
- this.borderRect_.style['fillOpacity'] = '1';
- } else {
- this.clickTarget_ = this.sourceBlock_.getSvgRoot();
- }
-};
-
-/**
- * @override
- */
-FieldColour.prototype.applyColour = function() {
- if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) {
- if (this.borderRect_) {
- this.borderRect_.style.fill = /** @type {string} */ (this.getValue());
+ /**
+ * Configure the field based on the given map of options.
+ * @param {!Object} config A map of options to configure the field based on.
+ * @protected
+ * @override
+ */
+ configure_(config) {
+ super.configure_(config);
+ if (config['colourOptions']) {
+ this.colours_ = config['colourOptions'];
+ this.titles_ = config['colourTitles'];
+ }
+ if (config['columns']) {
+ this.columns_ = config['columns'];
}
- } else {
- this.sourceBlock_.pathObject.svgPath.setAttribute('fill', this.getValue());
- this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff');
}
-};
-/**
- * Ensure that the input value is a valid colour.
- * @param {*=} opt_newValue The input value.
- * @return {?string} A valid colour, or null if invalid.
- * @protected
- */
-FieldColour.prototype.doClassValidation_ = function(opt_newValue) {
- if (typeof opt_newValue !== 'string') {
- return null;
+ /**
+ * Create the block UI for this colour field.
+ * @package
+ */
+ initView() {
+ this.size_ = new Size(
+ this.getConstants().FIELD_COLOUR_DEFAULT_WIDTH,
+ this.getConstants().FIELD_COLOUR_DEFAULT_HEIGHT);
+ if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) {
+ this.createBorderRect_();
+ this.borderRect_.style['fillOpacity'] = '1';
+ } else {
+ this.clickTarget_ = this.sourceBlock_.getSvgRoot();
+ }
}
- return colour.parse(opt_newValue);
-};
-/**
- * Update the value of this colour field, and update the displayed colour.
- * @param {*} newValue The value to be saved. The default validator guarantees
- * that this is a colour in '#rrggbb' format.
- * @protected
- */
-FieldColour.prototype.doValueUpdate_ = function(newValue) {
- this.value_ = newValue;
- if (this.borderRect_) {
- this.borderRect_.style.fill = /** @type {string} */ (newValue);
- } else if (this.sourceBlock_ && this.sourceBlock_.rendered) {
- this.sourceBlock_.pathObject.svgPath.setAttribute('fill', newValue);
- this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff');
+ /**
+ * @override
+ */
+ applyColour() {
+ if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) {
+ if (this.borderRect_) {
+ this.borderRect_.style.fill = /** @type {string} */ (this.getValue());
+ }
+ } else {
+ this.sourceBlock_.pathObject.svgPath.setAttribute(
+ 'fill', this.getValue());
+ this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff');
+ }
}
-};
-/**
- * Get the text for this field. Used when the block is collapsed.
- * @return {string} Text representing the value of this field.
- */
-FieldColour.prototype.getText = function() {
- let colour = /** @type {string} */ (this.value_);
- // Try to use #rgb format if possible, rather than #rrggbb.
- if (/^#(.)\1(.)\2(.)\3$/.test(colour)) {
- colour = '#' + colour[1] + colour[3] + colour[5];
+ /**
+ * Ensure that the input value is a valid colour.
+ * @param {*=} opt_newValue The input value.
+ * @return {?string} A valid colour, or null if invalid.
+ * @protected
+ */
+ doClassValidation_(opt_newValue) {
+ if (typeof opt_newValue !== 'string') {
+ return null;
+ }
+ return colour.parse(opt_newValue);
}
- return colour;
-};
+
+ /**
+ * Update the value of this colour field, and update the displayed colour.
+ * @param {*} newValue The value to be saved. The default validator guarantees
+ * that this is a colour in '#rrggbb' format.
+ * @protected
+ */
+ doValueUpdate_(newValue) {
+ this.value_ = newValue;
+ if (this.borderRect_) {
+ this.borderRect_.style.fill = /** @type {string} */ (newValue);
+ } else if (this.sourceBlock_ && this.sourceBlock_.rendered) {
+ this.sourceBlock_.pathObject.svgPath.setAttribute('fill', newValue);
+ this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff');
+ }
+ }
+
+ /**
+ * Get the text for this field. Used when the block is collapsed.
+ * @return {string} Text representing the value of this field.
+ */
+ getText() {
+ let colour = /** @type {string} */ (this.value_);
+ // Try to use #rgb format if possible, rather than #rrggbb.
+ if (/^#(.)\1(.)\2(.)\3$/.test(colour)) {
+ colour = '#' + colour[1] + colour[3] + colour[5];
+ }
+ return colour;
+ }
+
+ /**
+ * Set a custom colour grid for this field.
+ * @param {Array} colours Array of colours for this block,
+ * or null to use default (FieldColour.COLOURS).
+ * @param {Array=} opt_titles Optional array of colour tooltips,
+ * or null to use default (FieldColour.TITLES).
+ * @return {!FieldColour} Returns itself (for method chaining).
+ */
+ setColours(colours, opt_titles) {
+ this.colours_ = colours;
+ if (opt_titles) {
+ this.titles_ = opt_titles;
+ }
+ return this;
+ }
+
+ /**
+ * Set a custom grid size for this field.
+ * @param {number} columns Number of columns for this block,
+ * or 0 to use default (FieldColour.COLUMNS).
+ * @return {!FieldColour} Returns itself (for method chaining).
+ */
+ setColumns(columns) {
+ this.columns_ = columns;
+ return this;
+ }
+
+ /**
+ * Create and show the colour field's editor.
+ * @protected
+ */
+ showEditor_() {
+ this.dropdownCreate_();
+ dropDownDiv.getContentDiv().appendChild(this.picker_);
+
+ dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
+
+ // Focus so we can start receiving keyboard events.
+ this.picker_.focus({preventScroll: true});
+ }
+
+ /**
+ * Handle a click on a colour cell.
+ * @param {!MouseEvent} e Mouse event.
+ * @private
+ */
+ onClick_(e) {
+ const cell = /** @type {!Element} */ (e.target);
+ const colour = cell && cell.label;
+ if (colour !== null) {
+ this.setValue(colour);
+ dropDownDiv.hideIfOwner(this);
+ }
+ }
+
+ /**
+ * Handle a key down event. Navigate around the grid with the
+ * arrow keys. Enter selects the highlighted colour.
+ * @param {!KeyboardEvent} e Keyboard event.
+ * @private
+ */
+ onKeyDown_(e) {
+ let handled = false;
+ if (e.keyCode === KeyCodes.UP) {
+ this.moveHighlightBy_(0, -1);
+ handled = true;
+ } else if (e.keyCode === KeyCodes.DOWN) {
+ this.moveHighlightBy_(0, 1);
+ handled = true;
+ } else if (e.keyCode === KeyCodes.LEFT) {
+ this.moveHighlightBy_(-1, 0);
+ handled = true;
+ } else if (e.keyCode === KeyCodes.RIGHT) {
+ this.moveHighlightBy_(1, 0);
+ handled = true;
+ } else if (e.keyCode === KeyCodes.ENTER) {
+ // Select the highlighted colour.
+ const highlighted = this.getHighlighted_();
+ if (highlighted) {
+ const colour = highlighted && highlighted.label;
+ if (colour !== null) {
+ this.setValue(colour);
+ }
+ }
+ dropDownDiv.hideWithoutAnimation();
+ handled = true;
+ }
+ if (handled) {
+ e.stopPropagation();
+ }
+ }
+
+ /**
+ * Move the currently highlighted position by dx and dy.
+ * @param {number} dx Change of x
+ * @param {number} dy Change of y
+ * @private
+ */
+ moveHighlightBy_(dx, dy) {
+ const colours = this.colours_ || FieldColour.COLOURS;
+ const columns = this.columns_ || FieldColour.COLUMNS;
+
+ // Get the current x and y coordinates
+ let x = this.highlightedIndex_ % columns;
+ let y = Math.floor(this.highlightedIndex_ / columns);
+
+ // Add the offset
+ x += dx;
+ y += dy;
+
+ if (dx < 0) {
+ // Move left one grid cell, even in RTL.
+ // Loop back to the end of the previous row if we have room.
+ if (x < 0 && y > 0) {
+ x = columns - 1;
+ y--;
+ } else if (x < 0) {
+ x = 0;
+ }
+ } else if (dx > 0) {
+ // Move right one grid cell, even in RTL.
+ // Loop to the start of the next row, if there's room.
+ if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) {
+ x = 0;
+ y++;
+ } else if (x > columns - 1) {
+ x--;
+ }
+ } else if (dy < 0) {
+ // Move up one grid cell, stop at the top.
+ if (y < 0) {
+ y = 0;
+ }
+ } else if (dy > 0) {
+ // Move down one grid cell, stop at the bottom.
+ if (y > Math.floor(colours.length / columns) - 1) {
+ y = Math.floor(colours.length / columns) - 1;
+ }
+ }
+
+ // Move the highlight to the new coordinates.
+ const cell =
+ /** @type {!Element} */ (this.picker_.childNodes[y].childNodes[x]);
+ const index = (y * columns) + x;
+ this.setHighlightedCell_(cell, index);
+ }
+
+ /**
+ * Handle a mouse move event. Highlight the hovered colour.
+ * @param {!MouseEvent} e Mouse event.
+ * @private
+ */
+ onMouseMove_(e) {
+ const cell = /** @type {!Element} */ (e.target);
+ const index = cell && Number(cell.getAttribute('data-index'));
+ if (index !== null && index !== this.highlightedIndex_) {
+ this.setHighlightedCell_(cell, index);
+ }
+ }
+
+ /**
+ * Handle a mouse enter event. Focus the picker.
+ * @private
+ */
+ onMouseEnter_() {
+ this.picker_.focus({preventScroll: true});
+ }
+
+ /**
+ * Handle a mouse leave event. Blur the picker and unhighlight
+ * the currently highlighted colour.
+ * @private
+ */
+ onMouseLeave_() {
+ this.picker_.blur();
+ const highlighted = this.getHighlighted_();
+ if (highlighted) {
+ dom.removeClass(highlighted, 'blocklyColourHighlighted');
+ }
+ }
+
+ /**
+ * Returns the currently highlighted item (if any).
+ * @return {?HTMLElement} Highlighted item (null if none).
+ * @private
+ */
+ getHighlighted_() {
+ const columns = this.columns_ || FieldColour.COLUMNS;
+ const x = this.highlightedIndex_ % columns;
+ const y = Math.floor(this.highlightedIndex_ / columns);
+ const row = this.picker_.childNodes[y];
+ if (!row) {
+ return null;
+ }
+ const col = /** @type {HTMLElement} */ (row.childNodes[x]);
+ return col;
+ }
+
+ /**
+ * Update the currently highlighted cell.
+ * @param {!Element} cell the new cell to highlight
+ * @param {number} index the index of the new cell
+ * @private
+ */
+ setHighlightedCell_(cell, index) {
+ // Unhighlight the current item.
+ const highlighted = this.getHighlighted_();
+ if (highlighted) {
+ dom.removeClass(highlighted, 'blocklyColourHighlighted');
+ }
+ // Highlight new item.
+ dom.addClass(cell, 'blocklyColourHighlighted');
+ // Set new highlighted index.
+ this.highlightedIndex_ = index;
+
+ // Update accessibility roles.
+ aria.setState(
+ /** @type {!Element} */ (this.picker_), aria.State.ACTIVEDESCENDANT,
+ cell.getAttribute('id'));
+ }
+
+ /**
+ * Create a colour picker dropdown editor.
+ * @private
+ */
+ dropdownCreate_() {
+ const columns = this.columns_ || FieldColour.COLUMNS;
+ const colours = this.colours_ || FieldColour.COLOURS;
+ const titles = this.titles_ || FieldColour.TITLES;
+ const selectedColour = this.getValue();
+ // Create the palette.
+ const table = document.createElement('table');
+ table.className = 'blocklyColourTable';
+ table.tabIndex = 0;
+ table.dir = 'ltr';
+ aria.setRole(table, aria.Role.GRID);
+ aria.setState(table, aria.State.EXPANDED, true);
+ aria.setState(
+ table, aria.State.ROWCOUNT, Math.floor(colours.length / columns));
+ aria.setState(table, aria.State.COLCOUNT, columns);
+ let row;
+ for (let i = 0; i < colours.length; i++) {
+ if (i % columns === 0) {
+ row = document.createElement('tr');
+ aria.setRole(row, aria.Role.ROW);
+ table.appendChild(row);
+ }
+ const cell = document.createElement('td');
+ row.appendChild(cell);
+ cell.label = colours[i]; // This becomes the value, if clicked.
+ cell.title = titles[i] || colours[i];
+ cell.id = idGenerator.getNextUniqueId();
+ cell.setAttribute('data-index', i);
+ aria.setRole(cell, aria.Role.GRIDCELL);
+ aria.setState(cell, aria.State.LABEL, colours[i]);
+ aria.setState(cell, aria.State.SELECTED, colours[i] === selectedColour);
+ cell.style.backgroundColor = colours[i];
+ if (colours[i] === selectedColour) {
+ cell.className = 'blocklyColourSelected';
+ this.highlightedIndex_ = i;
+ }
+ }
+
+ // Configure event handler on the table to listen for any event in a cell.
+ this.onClickWrapper_ = browserEvents.conditionalBind(
+ table, 'click', this, this.onClick_, true);
+ this.onMouseMoveWrapper_ = browserEvents.conditionalBind(
+ table, 'mousemove', this, this.onMouseMove_, true);
+ this.onMouseEnterWrapper_ = browserEvents.conditionalBind(
+ table, 'mouseenter', this, this.onMouseEnter_, true);
+ this.onMouseLeaveWrapper_ = browserEvents.conditionalBind(
+ table, 'mouseleave', this, this.onMouseLeave_, true);
+ this.onKeyDownWrapper_ =
+ browserEvents.conditionalBind(table, 'keydown', this, this.onKeyDown_);
+
+ this.picker_ = table;
+ }
+
+ /**
+ * Disposes of events and DOM-references belonging to the colour editor.
+ * @private
+ */
+ dropdownDispose_() {
+ if (this.onClickWrapper_) {
+ browserEvents.unbind(this.onClickWrapper_);
+ this.onClickWrapper_ = null;
+ }
+ if (this.onMouseMoveWrapper_) {
+ browserEvents.unbind(this.onMouseMoveWrapper_);
+ this.onMouseMoveWrapper_ = null;
+ }
+ if (this.onMouseEnterWrapper_) {
+ browserEvents.unbind(this.onMouseEnterWrapper_);
+ this.onMouseEnterWrapper_ = null;
+ }
+ if (this.onMouseLeaveWrapper_) {
+ browserEvents.unbind(this.onMouseLeaveWrapper_);
+ this.onMouseLeaveWrapper_ = null;
+ }
+ if (this.onKeyDownWrapper_) {
+ browserEvents.unbind(this.onKeyDownWrapper_);
+ this.onKeyDownWrapper_ = null;
+ }
+ this.picker_ = null;
+ this.highlightedIndex_ = null;
+ }
+
+ /**
+ * Construct a FieldColour from a JSON arg object.
+ * @param {!Object} options A JSON object with options (colour).
+ * @return {!FieldColour} The new field instance.
+ * @package
+ * @nocollapse
+ */
+ static fromJson(options) {
+ // `this` might be a subclass of FieldColour if that class doesn't override
+ // the static fromJson method.
+ return new this(options['colour'], undefined, options);
+ }
+}
/**
* An array of colour strings for the palette.
@@ -357,345 +678,38 @@ FieldColour.TITLES = [];
*/
FieldColour.COLUMNS = 7;
-/**
- * Set a custom colour grid for this field.
- * @param {Array} colours Array of colours for this block,
- * or null to use default (FieldColour.COLOURS).
- * @param {Array=} opt_titles Optional array of colour tooltips,
- * or null to use default (FieldColour.TITLES).
- * @return {!FieldColour} Returns itself (for method chaining).
- */
-FieldColour.prototype.setColours = function(colours, opt_titles) {
- this.colours_ = colours;
- if (opt_titles) {
- this.titles_ = opt_titles;
- }
- return this;
-};
-
-/**
- * Set a custom grid size for this field.
- * @param {number} columns Number of columns for this block,
- * or 0 to use default (FieldColour.COLUMNS).
- * @return {!FieldColour} Returns itself (for method chaining).
- */
-FieldColour.prototype.setColumns = function(columns) {
- this.columns_ = columns;
- return this;
-};
-
-/**
- * Create and show the colour field's editor.
- * @protected
- */
-FieldColour.prototype.showEditor_ = function() {
- this.dropdownCreate_();
- DropDownDiv.getContentDiv().appendChild(this.picker_);
-
- DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
-
- // Focus so we can start receiving keyboard events.
- this.picker_.focus({preventScroll: true});
-};
-
-/**
- * Handle a click on a colour cell.
- * @param {!MouseEvent} e Mouse event.
- * @private
- */
-FieldColour.prototype.onClick_ = function(e) {
- const cell = /** @type {!Element} */ (e.target);
- const colour = cell && cell.label;
- if (colour !== null) {
- this.setValue(colour);
- DropDownDiv.hideIfOwner(this);
- }
-};
-
-/**
- * Handle a key down event. Navigate around the grid with the
- * arrow keys. Enter selects the highlighted colour.
- * @param {!KeyboardEvent} e Keyboard event.
- * @private
- */
-FieldColour.prototype.onKeyDown_ = function(e) {
- let handled = false;
- if (e.keyCode === KeyCodes.UP) {
- this.moveHighlightBy_(0, -1);
- handled = true;
- } else if (e.keyCode === KeyCodes.DOWN) {
- this.moveHighlightBy_(0, 1);
- handled = true;
- } else if (e.keyCode === KeyCodes.LEFT) {
- this.moveHighlightBy_(-1, 0);
- handled = true;
- } else if (e.keyCode === KeyCodes.RIGHT) {
- this.moveHighlightBy_(1, 0);
- handled = true;
- } else if (e.keyCode === KeyCodes.ENTER) {
- // Select the highlighted colour.
- const highlighted = this.getHighlighted_();
- if (highlighted) {
- const colour = highlighted && highlighted.label;
- if (colour !== null) {
- this.setValue(colour);
- }
- }
- DropDownDiv.hideWithoutAnimation();
- handled = true;
- }
- if (handled) {
- e.stopPropagation();
- }
-};
-
-/**
- * Move the currently highlighted position by dx and dy.
- * @param {number} dx Change of x
- * @param {number} dy Change of y
- * @private
- */
-FieldColour.prototype.moveHighlightBy_ = function(dx, dy) {
- const colours = this.colours_ || FieldColour.COLOURS;
- const columns = this.columns_ || FieldColour.COLUMNS;
-
- // Get the current x and y coordinates
- let x = this.highlightedIndex_ % columns;
- let y = Math.floor(this.highlightedIndex_ / columns);
-
- // Add the offset
- x += dx;
- y += dy;
-
- if (dx < 0) {
- // Move left one grid cell, even in RTL.
- // Loop back to the end of the previous row if we have room.
- if (x < 0 && y > 0) {
- x = columns - 1;
- y--;
- } else if (x < 0) {
- x = 0;
- }
- } else if (dx > 0) {
- // Move right one grid cell, even in RTL.
- // Loop to the start of the next row, if there's room.
- if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) {
- x = 0;
- y++;
- } else if (x > columns - 1) {
- x--;
- }
- } else if (dy < 0) {
- // Move up one grid cell, stop at the top.
- if (y < 0) {
- y = 0;
- }
- } else if (dy > 0) {
- // Move down one grid cell, stop at the bottom.
- if (y > Math.floor(colours.length / columns) - 1) {
- y = Math.floor(colours.length / columns) - 1;
- }
- }
-
- // Move the highlight to the new coordinates.
- const cell =
- /** @type {!Element} */ (this.picker_.childNodes[y].childNodes[x]);
- const index = (y * columns) + x;
- this.setHighlightedCell_(cell, index);
-};
-
-/**
- * Handle a mouse move event. Highlight the hovered colour.
- * @param {!MouseEvent} e Mouse event.
- * @private
- */
-FieldColour.prototype.onMouseMove_ = function(e) {
- const cell = /** @type {!Element} */ (e.target);
- const index = cell && Number(cell.getAttribute('data-index'));
- if (index !== null && index !== this.highlightedIndex_) {
- this.setHighlightedCell_(cell, index);
- }
-};
-
-/**
- * Handle a mouse enter event. Focus the picker.
- * @private
- */
-FieldColour.prototype.onMouseEnter_ = function() {
- this.picker_.focus({preventScroll: true});
-};
-
-/**
- * Handle a mouse leave event. Blur the picker and unhighlight
- * the currently highlighted colour.
- * @private
- */
-FieldColour.prototype.onMouseLeave_ = function() {
- this.picker_.blur();
- const highlighted = this.getHighlighted_();
- if (highlighted) {
- dom.removeClass(highlighted, 'blocklyColourHighlighted');
- }
-};
-
-/**
- * Returns the currently highlighted item (if any).
- * @return {?HTMLElement} Highlighted item (null if none).
- * @private
- */
-FieldColour.prototype.getHighlighted_ = function() {
- const columns = this.columns_ || FieldColour.COLUMNS;
- const x = this.highlightedIndex_ % columns;
- const y = Math.floor(this.highlightedIndex_ / columns);
- const row = this.picker_.childNodes[y];
- if (!row) {
- return null;
- }
- const col = /** @type {HTMLElement} */ (row.childNodes[x]);
- return col;
-};
-
-/**
- * Update the currently highlighted cell.
- * @param {!Element} cell the new cell to highlight
- * @param {number} index the index of the new cell
- * @private
- */
-FieldColour.prototype.setHighlightedCell_ = function(cell, index) {
- // Unhighlight the current item.
- const highlighted = this.getHighlighted_();
- if (highlighted) {
- dom.removeClass(highlighted, 'blocklyColourHighlighted');
- }
- // Highlight new item.
- dom.addClass(cell, 'blocklyColourHighlighted');
- // Set new highlighted index.
- this.highlightedIndex_ = index;
-
- // Update accessibility roles.
- aria.setState(
- /** @type {!Element} */ (this.picker_), aria.State.ACTIVEDESCENDANT,
- cell.getAttribute('id'));
-};
-
-/**
- * Create a colour picker dropdown editor.
- * @private
- */
-FieldColour.prototype.dropdownCreate_ = function() {
- const columns = this.columns_ || FieldColour.COLUMNS;
- const colours = this.colours_ || FieldColour.COLOURS;
- const titles = this.titles_ || FieldColour.TITLES;
- const selectedColour = this.getValue();
- // Create the palette.
- const table = document.createElement('table');
- table.className = 'blocklyColourTable';
- table.tabIndex = 0;
- table.dir = 'ltr';
- aria.setRole(table, aria.Role.GRID);
- aria.setState(table, aria.State.EXPANDED, true);
- aria.setState(
- table, aria.State.ROWCOUNT, Math.floor(colours.length / columns));
- aria.setState(table, aria.State.COLCOUNT, columns);
- let row;
- for (let i = 0; i < colours.length; i++) {
- if (i % columns === 0) {
- row = document.createElement('tr');
- aria.setRole(row, aria.Role.ROW);
- table.appendChild(row);
- }
- const cell = document.createElement('td');
- row.appendChild(cell);
- cell.label = colours[i]; // This becomes the value, if clicked.
- cell.title = titles[i] || colours[i];
- cell.id = idGenerator.getNextUniqueId();
- cell.setAttribute('data-index', i);
- aria.setRole(cell, aria.Role.GRIDCELL);
- aria.setState(cell, aria.State.LABEL, colours[i]);
- aria.setState(cell, aria.State.SELECTED, colours[i] === selectedColour);
- cell.style.backgroundColor = colours[i];
- if (colours[i] === selectedColour) {
- cell.className = 'blocklyColourSelected';
- this.highlightedIndex_ = i;
- }
- }
-
- // Configure event handler on the table to listen for any event in a cell.
- this.onClickWrapper_ =
- browserEvents.conditionalBind(table, 'click', this, this.onClick_, true);
- this.onMouseMoveWrapper_ = browserEvents.conditionalBind(
- table, 'mousemove', this, this.onMouseMove_, true);
- this.onMouseEnterWrapper_ = browserEvents.conditionalBind(
- table, 'mouseenter', this, this.onMouseEnter_, true);
- this.onMouseLeaveWrapper_ = browserEvents.conditionalBind(
- table, 'mouseleave', this, this.onMouseLeave_, true);
- this.onKeyDownWrapper_ =
- browserEvents.conditionalBind(table, 'keydown', this, this.onKeyDown_);
-
- this.picker_ = table;
-};
-
-/**
- * Disposes of events and DOM-references belonging to the colour editor.
- * @private
- */
-FieldColour.prototype.dropdownDispose_ = function() {
- if (this.onClickWrapper_) {
- browserEvents.unbind(this.onClickWrapper_);
- this.onClickWrapper_ = null;
- }
- if (this.onMouseMoveWrapper_) {
- browserEvents.unbind(this.onMouseMoveWrapper_);
- this.onMouseMoveWrapper_ = null;
- }
- if (this.onMouseEnterWrapper_) {
- browserEvents.unbind(this.onMouseEnterWrapper_);
- this.onMouseEnterWrapper_ = null;
- }
- if (this.onMouseLeaveWrapper_) {
- browserEvents.unbind(this.onMouseLeaveWrapper_);
- this.onMouseLeaveWrapper_ = null;
- }
- if (this.onKeyDownWrapper_) {
- browserEvents.unbind(this.onKeyDownWrapper_);
- this.onKeyDownWrapper_ = null;
- }
- this.picker_ = null;
- this.highlightedIndex_ = null;
-};
-
/**
* CSS for colour picker. See css.js for use.
*/
Css.register(`
- .blocklyColourTable {
- border-collapse: collapse;
- display: block;
- outline: none;
- padding: 1px;
- }
+.blocklyColourTable {
+ border-collapse: collapse;
+ display: block;
+ outline: none;
+ padding: 1px;
+}
- .blocklyColourTable>tr>td {
- border: .5px solid #888;
- box-sizing: border-box;
- cursor: pointer;
- display: inline-block;
- height: 20px;
- padding: 0;
- width: 20px;
- }
+.blocklyColourTable>tr>td {
+ border: .5px solid #888;
+ box-sizing: border-box;
+ cursor: pointer;
+ display: inline-block;
+ height: 20px;
+ padding: 0;
+ width: 20px;
+}
- .blocklyColourTable>tr>td.blocklyColourHighlighted {
- border-color: #eee;
- box-shadow: 2px 2px 7px 2px rgba(0,0,0,.3);
- position: relative;
- }
+.blocklyColourTable>tr>td.blocklyColourHighlighted {
+ border-color: #eee;
+ box-shadow: 2px 2px 7px 2px rgba(0,0,0,.3);
+ position: relative;
+}
- .blocklyColourSelected, .blocklyColourSelected:hover {
- border-color: #eee !important;
- outline: 1px solid #333;
- position: relative;
- }
+.blocklyColourSelected, .blocklyColourSelected:hover {
+ border-color: #eee !important;
+ outline: 1px solid #333;
+ position: relative;
+}
`);
fieldRegistry.register('field_colour', FieldColour);
diff --git a/core/field_dropdown.js b/core/field_dropdown.js
index 618ecd7b6..98d2c14d7 100644
--- a/core/field_dropdown.js
+++ b/core/field_dropdown.js
@@ -21,121 +21,691 @@ goog.module('Blockly.FieldDropdown');
const aria = goog.require('Blockly.utils.aria');
const dom = goog.require('Blockly.utils.dom');
+const dropDownDiv = goog.require('Blockly.dropDownDiv');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
const userAgent = goog.require('Blockly.utils.userAgent');
const utilsString = goog.require('Blockly.utils.string');
const {Coordinate} = goog.require('Blockly.utils.Coordinate');
-const {DropDownDiv} = goog.require('Blockly.DropDownDiv');
const {Field} = goog.require('Blockly.Field');
const {MenuItem} = goog.require('Blockly.MenuItem');
const {Menu} = goog.require('Blockly.Menu');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
const {Svg} = goog.require('Blockly.utils.Svg');
/**
* Class for an editable dropdown field.
- * @param {(!Array|!Function)} menuGenerator A non-empty array of
- * options for a dropdown list, or a function which generates these options.
- * @param {Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a language-neutral dropdown
- * option & returns a validated language-neutral dropdown option, or null to
- * abort the change.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation}
- * for a list of properties this parameter supports.
* @extends {Field}
- * @constructor
- * @throws {TypeError} If `menuGenerator` options are incorrectly structured.
* @alias Blockly.FieldDropdown
*/
-const FieldDropdown = function(menuGenerator, opt_validator, opt_config) {
- if (typeof menuGenerator !== 'function') {
- validateOptions(menuGenerator);
+class FieldDropdown extends Field {
+ /**
+ * @param {(!Array|!Function|!Sentinel)} menuGenerator
+ * A non-empty array of options for a dropdown list, or a function which
+ * generates these options.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a language-neutral dropdown
+ * option & returns a validated language-neutral dropdown option, or null
+ * to abort the change.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation}
+ * for a list of properties this parameter supports.
+ * @throws {TypeError} If `menuGenerator` options are incorrectly structured.
+ */
+ constructor(menuGenerator, opt_validator, opt_config) {
+ super(Field.SKIP_SETUP);
+
+ /**
+ * A reference to the currently selected menu item.
+ * @type {?MenuItem}
+ * @private
+ */
+ this.selectedMenuItem_ = null;
+
+ /**
+ * The dropdown menu.
+ * @type {?Menu}
+ * @protected
+ */
+ this.menu_ = null;
+
+ /**
+ * SVG image element if currently selected option is an image, or null.
+ * @type {?SVGImageElement}
+ * @private
+ */
+ this.imageElement_ = null;
+
+ /**
+ * Tspan based arrow element.
+ * @type {?SVGTSpanElement}
+ * @private
+ */
+ this.arrow_ = null;
+
+ /**
+ * SVG based arrow element.
+ * @type {?SVGElement}
+ * @private
+ */
+ this.svgArrow_ = null;
+
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
+
+ /**
+ * Mouse cursor style when over the hotspot that initiates the editor.
+ * @type {string}
+ */
+ this.CURSOR = 'default';
+
+
+ // If we pass SKIP_SETUP, don't do *anything* with the menu generator.
+ if (menuGenerator === Field.SKIP_SETUP) return;
+
+ if (Array.isArray(menuGenerator)) {
+ validateOptions(menuGenerator);
+ }
+
+ /**
+ * An array of options for a dropdown list,
+ * or a function which generates these options.
+ * @type {(!Array|!function(this:FieldDropdown): !Array)}
+ * @protected
+ */
+ this.menuGenerator_ =
+ /**
+ * @type {(!Array|
+ * !function(this:FieldDropdown):!Array)}
+ */
+ (menuGenerator);
+
+ /**
+ * A cache of the most recently generated options.
+ * @type {Array>}
+ * @private
+ */
+ this.generatedOptions_ = null;
+
+ /**
+ * The prefix field label, of common words set after options are trimmed.
+ * @type {?string}
+ * @package
+ */
+ this.prefixField = null;
+
+ /**
+ * The suffix field label, of common words set after options are trimmed.
+ * @type {?string}
+ * @package
+ */
+ this.suffixField = null;
+
+ this.trimOptions_();
+
+ /**
+ * The currently selected option. The field is initialized with the
+ * first option selected.
+ * @type {!Array}
+ * @private
+ */
+ this.selectedOption_ = this.getOptions(false)[0];
+
+ if (opt_config) this.configure_(opt_config);
+ this.setValue(this.selectedOption_[1]);
+ if (opt_validator) this.setValidator(opt_validator);
}
/**
- * An array of options for a dropdown list,
- * or a function which generates these options.
- * @type {(!Array|
- * !function(this:FieldDropdown): !Array)}
- * @protected
- */
- this.menuGenerator_ = menuGenerator;
-
- /**
- * A cache of the most recently generated options.
- * @type {Array>}
- * @private
- */
- this.generatedOptions_ = null;
-
- /**
- * The prefix field label, of common words set after options are trimmed.
- * @type {?string}
+ * Sets the field's value based on the given XML element. Should only be
+ * called by Blockly.Xml.
+ * @param {!Element} fieldElement The element containing info about the
+ * field's state.
* @package
*/
- this.prefixField = null;
+ fromXml(fieldElement) {
+ if (this.isOptionListDynamic()) {
+ this.getOptions(false);
+ }
+ this.setValue(fieldElement.textContent);
+ }
/**
- * The suffix field label, of common words set after options are trimmed.
- * @type {?string}
+ * Sets the field's value based on the given state.
+ * @param {*} state The state to apply to the dropdown field.
+ * @override
* @package
*/
- this.suffixField = null;
-
- this.trimOptions_();
+ loadState(state) {
+ if (this.loadLegacyState(FieldDropdown, state)) {
+ return;
+ }
+ if (this.isOptionListDynamic()) {
+ this.getOptions(false);
+ }
+ this.setValue(state);
+ }
/**
- * The currently selected option. The field is initialized with the
- * first option selected.
- * @type {!Object}
- * @private
+ * Create the block UI for this dropdown.
+ * @package
*/
- this.selectedOption_ = this.getOptions(false)[0];
+ initView() {
+ if (this.shouldAddBorderRect_()) {
+ this.createBorderRect_();
+ } else {
+ this.clickTarget_ = this.sourceBlock_.getSvgRoot();
+ }
+ this.createTextElement_();
- // Call parent's constructor.
- FieldDropdown.superClass_.constructor.call(
- this, this.selectedOption_[1], opt_validator, opt_config);
+ this.imageElement_ = dom.createSvgElement(Svg.IMAGE, {}, this.fieldGroup_);
+
+ if (this.getConstants().FIELD_DROPDOWN_SVG_ARROW) {
+ this.createSVGArrow_();
+ } else {
+ this.createTextArrow_();
+ }
+
+ if (this.borderRect_) {
+ dom.addClass(this.borderRect_, 'blocklyDropdownRect');
+ }
+ }
/**
- * A reference to the currently selected menu item.
- * @type {?MenuItem}
- * @private
- */
- this.selectedMenuItem_ = null;
-
- /**
- * The dropdown menu.
- * @type {?Menu}
+ * Whether or not the dropdown should add a border rect.
+ * @return {boolean} True if the dropdown field should add a border rect.
* @protected
*/
- this.menu_ = null;
+ shouldAddBorderRect_() {
+ return !this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW ||
+ (this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW &&
+ !this.sourceBlock_.isShadow());
+ }
/**
- * SVG image element if currently selected option is an image, or null.
- * @type {?SVGImageElement}
- * @private
+ * Create a tspan based arrow.
+ * @protected
*/
- this.imageElement_ = null;
+ createTextArrow_() {
+ this.arrow_ = dom.createSvgElement(Svg.TSPAN, {}, this.textElement_);
+ this.arrow_.appendChild(document.createTextNode(
+ this.sourceBlock_.RTL ? FieldDropdown.ARROW_CHAR + ' ' :
+ ' ' + FieldDropdown.ARROW_CHAR));
+ if (this.sourceBlock_.RTL) {
+ this.textElement_.insertBefore(this.arrow_, this.textContent_);
+ } else {
+ this.textElement_.appendChild(this.arrow_);
+ }
+ }
/**
- * Tspan based arrow element.
- * @type {?SVGTSpanElement}
- * @private
+ * Create an SVG based arrow.
+ * @protected
*/
- this.arrow_ = null;
+ createSVGArrow_() {
+ this.svgArrow_ = dom.createSvgElement(
+ Svg.IMAGE, {
+ 'height': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',
+ 'width': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',
+ },
+ this.fieldGroup_);
+ this.svgArrow_.setAttributeNS(
+ dom.XLINK_NS, 'xlink:href',
+ this.getConstants().FIELD_DROPDOWN_SVG_ARROW_DATAURI);
+ }
/**
- * SVG based arrow element.
- * @type {?SVGElement}
+ * Create a dropdown menu under the text.
+ * @param {Event=} opt_e Optional mouse event that triggered the field to
+ * open, or undefined if triggered programmatically.
+ * @protected
+ */
+ showEditor_(opt_e) {
+ this.dropdownCreate_();
+ if (opt_e && typeof opt_e.clientX === 'number') {
+ this.menu_.openingCoords = new Coordinate(opt_e.clientX, opt_e.clientY);
+ } else {
+ this.menu_.openingCoords = null;
+ }
+
+ // Remove any pre-existing elements in the dropdown.
+ dropDownDiv.clearContent();
+ // Element gets created in render.
+ this.menu_.render(dropDownDiv.getContentDiv());
+ const menuElement = /** @type {!Element} */ (this.menu_.getElement());
+ dom.addClass(menuElement, 'blocklyDropdownMenu');
+
+ if (this.getConstants().FIELD_DROPDOWN_COLOURED_DIV) {
+ const primaryColour = (this.sourceBlock_.isShadow()) ?
+ this.sourceBlock_.getParent().getColour() :
+ this.sourceBlock_.getColour();
+ const borderColour = (this.sourceBlock_.isShadow()) ?
+ this.sourceBlock_.getParent().style.colourTertiary :
+ this.sourceBlock_.style.colourTertiary;
+ dropDownDiv.setColour(primaryColour, borderColour);
+ }
+
+ dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
+
+ // Focusing needs to be handled after the menu is rendered and positioned.
+ // Otherwise it will cause a page scroll to get the misplaced menu in
+ // view. See issue #1329.
+ this.menu_.focus();
+
+ if (this.selectedMenuItem_) {
+ this.menu_.setHighlighted(this.selectedMenuItem_);
+ }
+
+ this.applyColour();
+ }
+
+ /**
+ * Create the dropdown editor.
* @private
*/
- this.svgArrow_ = null;
-};
-object.inherits(FieldDropdown, Field);
+ dropdownCreate_() {
+ const menu = new Menu();
+ menu.setRole(aria.Role.LISTBOX);
+ this.menu_ = menu;
+
+ const options = this.getOptions(false);
+ this.selectedMenuItem_ = null;
+ for (let i = 0; i < options.length; i++) {
+ let content = options[i][0]; // Human-readable text or image.
+ const value = options[i][1]; // Language-neutral value.
+ if (typeof content === 'object') {
+ // An image, not text.
+ const image = new Image(content['width'], content['height']);
+ image.src = content['src'];
+ image.alt = content['alt'] || '';
+ content = image;
+ }
+ const menuItem = new MenuItem(content, value);
+ menuItem.setRole(aria.Role.OPTION);
+ menuItem.setRightToLeft(this.sourceBlock_.RTL);
+ menuItem.setCheckable(true);
+ menu.addChild(menuItem);
+ menuItem.setChecked(value === this.value_);
+ if (value === this.value_) {
+ this.selectedMenuItem_ = menuItem;
+ }
+ menuItem.onAction(this.handleMenuActionEvent_, this);
+ }
+ }
+
+ /**
+ * Disposes of events and DOM-references belonging to the dropdown editor.
+ * @private
+ */
+ dropdownDispose_() {
+ if (this.menu_) {
+ this.menu_.dispose();
+ }
+ this.menu_ = null;
+ this.selectedMenuItem_ = null;
+ this.applyColour();
+ }
+
+ /**
+ * Handle an action in the dropdown menu.
+ * @param {!MenuItem} menuItem The MenuItem selected within menu.
+ * @private
+ */
+ handleMenuActionEvent_(menuItem) {
+ dropDownDiv.hideIfOwner(this, true);
+ this.onItemSelected_(/** @type {!Menu} */ (this.menu_), menuItem);
+ }
+
+ /**
+ * Handle the selection of an item in the dropdown menu.
+ * @param {!Menu} menu The Menu component clicked.
+ * @param {!MenuItem} menuItem The MenuItem selected within menu.
+ * @protected
+ */
+ onItemSelected_(menu, menuItem) {
+ this.setValue(menuItem.getValue());
+ }
+
+ /**
+ * Factor out common words in statically defined options.
+ * Create prefix and/or suffix labels.
+ * @private
+ */
+ trimOptions_() {
+ const options = this.menuGenerator_;
+ if (!Array.isArray(options)) {
+ return;
+ }
+ let hasImages = false;
+
+ // Localize label text and image alt text.
+ for (let i = 0; i < options.length; i++) {
+ const label = options[i][0];
+ if (typeof label === 'string') {
+ options[i][0] = parsing.replaceMessageReferences(label);
+ } else {
+ if (label.alt !== null) {
+ options[i][0].alt = parsing.replaceMessageReferences(label.alt);
+ }
+ hasImages = true;
+ }
+ }
+ if (hasImages || options.length < 2) {
+ return; // Do nothing if too few items or at least one label is an image.
+ }
+ const strings = [];
+ for (let i = 0; i < options.length; i++) {
+ strings.push(options[i][0]);
+ }
+ const shortest = utilsString.shortestStringLength(strings);
+ const prefixLength = utilsString.commonWordPrefix(strings, shortest);
+ const suffixLength = utilsString.commonWordSuffix(strings, shortest);
+ if (!prefixLength && !suffixLength) {
+ return;
+ }
+ if (shortest <= prefixLength + suffixLength) {
+ // One or more strings will entirely vanish if we proceed. Abort.
+ return;
+ }
+ if (prefixLength) {
+ this.prefixField = strings[0].substring(0, prefixLength - 1);
+ }
+ if (suffixLength) {
+ this.suffixField = strings[0].substr(1 - suffixLength);
+ }
+
+ this.menuGenerator_ =
+ FieldDropdown.applyTrim_(options, prefixLength, suffixLength);
+ }
+
+ /**
+ * @return {boolean} True if the option list is generated by a function.
+ * Otherwise false.
+ */
+ isOptionListDynamic() {
+ return typeof this.menuGenerator_ === 'function';
+ }
+
+ /**
+ * Return a list of the options for this dropdown.
+ * @param {boolean=} opt_useCache For dynamic options, whether or not to use
+ * the cached options or to re-generate them.
+ * @return {!Array} A non-empty array of option tuples:
+ * (human-readable text or image, language-neutral name).
+ * @throws {TypeError} If generated options are incorrectly structured.
+ */
+ getOptions(opt_useCache) {
+ if (this.isOptionListDynamic()) {
+ if (!this.generatedOptions_ || !opt_useCache) {
+ this.generatedOptions_ = this.menuGenerator_.call(this);
+ validateOptions(this.generatedOptions_);
+ }
+ return this.generatedOptions_;
+ }
+ return /** @type {!Array>} */ (this.menuGenerator_);
+ }
+
+ /**
+ * Ensure that the input value is a valid language-neutral option.
+ * @param {*=} opt_newValue The input value.
+ * @return {?string} A valid language-neutral option, or null if invalid.
+ * @protected
+ */
+ doClassValidation_(opt_newValue) {
+ let isValueValid = false;
+ const options = this.getOptions(true);
+ for (let i = 0, option; (option = options[i]); i++) {
+ // Options are tuples of human-readable text and language-neutral values.
+ if (option[1] === opt_newValue) {
+ isValueValid = true;
+ break;
+ }
+ }
+ if (!isValueValid) {
+ if (this.sourceBlock_) {
+ console.warn(
+ 'Cannot set the dropdown\'s value to an unavailable option.' +
+ ' Block type: ' + this.sourceBlock_.type +
+ ', Field name: ' + this.name + ', Value: ' + opt_newValue);
+ }
+ return null;
+ }
+ return /** @type {string} */ (opt_newValue);
+ }
+
+ /**
+ * Update the value of this dropdown field.
+ * @param {*} newValue The value to be saved. The default validator guarantees
+ * that this is one of the valid dropdown options.
+ * @protected
+ */
+ doValueUpdate_(newValue) {
+ super.doValueUpdate_(newValue);
+ const options = this.getOptions(true);
+ for (let i = 0, option; (option = options[i]); i++) {
+ if (option[1] === this.value_) {
+ this.selectedOption_ = option;
+ }
+ }
+ }
+
+ /**
+ * Updates the dropdown arrow to match the colour/style of the block.
+ * @package
+ */
+ applyColour() {
+ if (this.borderRect_) {
+ this.borderRect_.setAttribute(
+ 'stroke', this.sourceBlock_.style.colourTertiary);
+ if (this.menu_) {
+ this.borderRect_.setAttribute(
+ 'fill', this.sourceBlock_.style.colourTertiary);
+ } else {
+ this.borderRect_.setAttribute('fill', 'transparent');
+ }
+ }
+ // Update arrow's colour.
+ if (this.sourceBlock_ && this.arrow_) {
+ if (this.sourceBlock_.isShadow()) {
+ this.arrow_.style.fill = this.sourceBlock_.style.colourSecondary;
+ } else {
+ this.arrow_.style.fill = this.sourceBlock_.style.colourPrimary;
+ }
+ }
+ }
+
+ /**
+ * Draws the border with the correct width.
+ * @protected
+ */
+ render_() {
+ // Hide both elements.
+ this.textContent_.nodeValue = '';
+ this.imageElement_.style.display = 'none';
+
+ // Show correct element.
+ const option = this.selectedOption_ && this.selectedOption_[0];
+ if (option && typeof option === 'object') {
+ this.renderSelectedImage_(
+ /** @type {!ImageProperties} */ (option));
+ } else {
+ this.renderSelectedText_();
+ }
+
+ this.positionBorderRect_();
+ }
+
+ /**
+ * Renders the selected option, which must be an image.
+ * @param {!ImageProperties} imageJson Selected
+ * option that must be an image.
+ * @private
+ */
+ renderSelectedImage_(imageJson) {
+ this.imageElement_.style.display = '';
+ this.imageElement_.setAttributeNS(
+ dom.XLINK_NS, 'xlink:href', imageJson.src);
+ this.imageElement_.setAttribute('height', imageJson.height);
+ this.imageElement_.setAttribute('width', imageJson.width);
+
+ const imageHeight = Number(imageJson.height);
+ const imageWidth = Number(imageJson.width);
+
+ // Height and width include the border rect.
+ const hasBorder = !!this.borderRect_;
+ const height = Math.max(
+ hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
+ imageHeight + IMAGE_Y_PADDING);
+ const xPadding =
+ hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0;
+ let arrowWidth = 0;
+ if (this.svgArrow_) {
+ arrowWidth = this.positionSVGArrow_(
+ imageWidth + xPadding,
+ height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2);
+ } else {
+ arrowWidth = dom.getFastTextWidth(
+ /** @type {!SVGTSpanElement} */ (this.arrow_),
+ this.getConstants().FIELD_TEXT_FONTSIZE,
+ this.getConstants().FIELD_TEXT_FONTWEIGHT,
+ this.getConstants().FIELD_TEXT_FONTFAMILY);
+ }
+ this.size_.width = imageWidth + arrowWidth + xPadding * 2;
+ this.size_.height = height;
+
+ let arrowX = 0;
+ if (this.sourceBlock_.RTL) {
+ const imageX = xPadding + arrowWidth;
+ this.imageElement_.setAttribute('x', imageX);
+ } else {
+ arrowX = imageWidth + arrowWidth;
+ this.textElement_.setAttribute('text-anchor', 'end');
+ this.imageElement_.setAttribute('x', xPadding);
+ }
+ this.imageElement_.setAttribute('y', height / 2 - imageHeight / 2);
+
+ this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth);
+ }
+
+ /**
+ * Renders the selected option, which must be text.
+ * @private
+ */
+ renderSelectedText_() {
+ // Retrieves the selected option to display through getText_.
+ this.textContent_.nodeValue = this.getDisplayText_();
+ dom.addClass(
+ /** @type {!Element} */ (this.textElement_), 'blocklyDropdownText');
+ this.textElement_.setAttribute('text-anchor', 'start');
+
+ // Height and width include the border rect.
+ const hasBorder = !!this.borderRect_;
+ const height = Math.max(
+ hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
+ this.getConstants().FIELD_TEXT_HEIGHT);
+ const textWidth = dom.getFastTextWidth(
+ this.textElement_, this.getConstants().FIELD_TEXT_FONTSIZE,
+ this.getConstants().FIELD_TEXT_FONTWEIGHT,
+ this.getConstants().FIELD_TEXT_FONTFAMILY);
+ const xPadding =
+ hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0;
+ let arrowWidth = 0;
+ if (this.svgArrow_) {
+ arrowWidth = this.positionSVGArrow_(
+ textWidth + xPadding,
+ height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2);
+ }
+ this.size_.width = textWidth + arrowWidth + xPadding * 2;
+ this.size_.height = height;
+
+ this.positionTextElement_(xPadding, textWidth);
+ }
+
+ /**
+ * Position a drop-down arrow at the appropriate location at render-time.
+ * @param {number} x X position the arrow is being rendered at, in px.
+ * @param {number} y Y position the arrow is being rendered at, in px.
+ * @return {number} Amount of space the arrow is taking up, in px.
+ * @private
+ */
+ positionSVGArrow_(x, y) {
+ if (!this.svgArrow_) {
+ return 0;
+ }
+ const hasBorder = !!this.borderRect_;
+ const xPadding =
+ hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0;
+ const textPadding = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_PADDING;
+ const svgArrowSize = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE;
+ const arrowX = this.sourceBlock_.RTL ? xPadding : x + textPadding;
+ this.svgArrow_.setAttribute(
+ 'transform', 'translate(' + arrowX + ',' + y + ')');
+ return svgArrowSize + textPadding;
+ }
+
+ /**
+ * Use the `getText_` developer hook to override the field's text
+ * representation. Get the selected option text. If the selected option is an
+ * image we return the image alt text.
+ * @return {?string} Selected option text.
+ * @protected
+ * @override
+ */
+ getText_() {
+ if (!this.selectedOption_) {
+ return null;
+ }
+ const option = this.selectedOption_[0];
+ if (typeof option === 'object') {
+ return option['alt'];
+ }
+ return option;
+ }
+
+ /**
+ * Construct a FieldDropdown from a JSON arg object.
+ * @param {!Object} options A JSON object with options (options).
+ * @return {!FieldDropdown} The new field instance.
+ * @package
+ * @nocollapse
+ */
+ static fromJson(options) {
+ // `this` might be a subclass of FieldDropdown if that class doesn't
+ // override the static fromJson method.
+ return new this(options['options'], undefined, options);
+ }
+
+ /**
+ * Use the calculated prefix and suffix lengths to trim all of the options in
+ * the given array.
+ * @param {!Array} options Array of option tuples:
+ * (human-readable text or image, language-neutral name).
+ * @param {number} prefixLength The length of the common prefix.
+ * @param {number} suffixLength The length of the common suffix
+ * @return {!Array} A new array with all of the option text trimmed.
+ */
+ static applyTrim_(options, prefixLength, suffixLength) {
+ const newOptions = [];
+ // Remove the prefix and suffix from the options.
+ for (let i = 0; i < options.length; i++) {
+ let text = options[i][0];
+ const value = options[i][1];
+ text = text.substring(prefixLength, text.length - suffixLength);
+ newOptions[i] = [text, value];
+ }
+ return newOptions;
+ }
+}
/**
* Dropdown image properties.
@@ -146,57 +716,7 @@ object.inherits(FieldDropdown, Field);
* height:number
* }}
*/
-FieldDropdown.ImageProperties;
-
-/**
- * Construct a FieldDropdown from a JSON arg object.
- * @param {!Object} options A JSON object with options (options).
- * @return {!FieldDropdown} The new field instance.
- * @package
- * @nocollapse
- */
-FieldDropdown.fromJson = function(options) {
- // `this` might be a subclass of FieldDropdown if that class doesn't override
- // the static fromJson method.
- return new this(options['options'], undefined, options);
-};
-
-/**
- * Sets the field's value based on the given XML element. Should only be
- * called by Blockly.Xml.
- * @param {!Element} fieldElement The element containing info about the
- * field's state.
- * @package
- */
-FieldDropdown.prototype.fromXml = function(fieldElement) {
- if (this.isOptionListDynamic()) {
- this.getOptions(false);
- }
- this.setValue(fieldElement.textContent);
-};
-
-/**
- * Sets the field's value based on the given state.
- * @param {*} state The state to apply to the dropdown field.
- * @override
- * @package
- */
-FieldDropdown.prototype.loadState = function(state) {
- if (this.loadLegacyState(FieldDropdown, state)) {
- return;
- }
- if (this.isOptionListDynamic()) {
- this.getOptions(false);
- }
- this.setValue(state);
-};
-
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable.
- * @type {boolean}
- */
-FieldDropdown.prototype.SERIALIZABLE = true;
+let ImageProperties; // eslint-disable-line no-unused-vars
/**
* Horizontal distance that a checkmark overhangs the dropdown.
@@ -228,507 +748,6 @@ const IMAGE_Y_PADDING = IMAGE_Y_OFFSET * 2;
*/
FieldDropdown.ARROW_CHAR = userAgent.ANDROID ? '\u25BC' : '\u25BE';
-/**
- * Mouse cursor style when over the hotspot that initiates the editor.
- */
-FieldDropdown.prototype.CURSOR = 'default';
-
-/**
- * Create the block UI for this dropdown.
- * @package
- */
-FieldDropdown.prototype.initView = function() {
- if (this.shouldAddBorderRect_()) {
- this.createBorderRect_();
- } else {
- this.clickTarget_ = this.sourceBlock_.getSvgRoot();
- }
- this.createTextElement_();
-
- this.imageElement_ = dom.createSvgElement(Svg.IMAGE, {}, this.fieldGroup_);
-
- if (this.getConstants().FIELD_DROPDOWN_SVG_ARROW) {
- this.createSVGArrow_();
- } else {
- this.createTextArrow_();
- }
-
- if (this.borderRect_) {
- dom.addClass(this.borderRect_, 'blocklyDropdownRect');
- }
-};
-
-/**
- * Whether or not the dropdown should add a border rect.
- * @return {boolean} True if the dropdown field should add a border rect.
- * @protected
- */
-FieldDropdown.prototype.shouldAddBorderRect_ = function() {
- return !this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW ||
- (this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW &&
- !this.sourceBlock_.isShadow());
-};
-
-/**
- * Create a tspan based arrow.
- * @protected
- */
-FieldDropdown.prototype.createTextArrow_ = function() {
- this.arrow_ = dom.createSvgElement(Svg.TSPAN, {}, this.textElement_);
- this.arrow_.appendChild(document.createTextNode(
- this.sourceBlock_.RTL ? FieldDropdown.ARROW_CHAR + ' ' :
- ' ' + FieldDropdown.ARROW_CHAR));
- if (this.sourceBlock_.RTL) {
- this.textElement_.insertBefore(this.arrow_, this.textContent_);
- } else {
- this.textElement_.appendChild(this.arrow_);
- }
-};
-
-/**
- * Create an SVG based arrow.
- * @protected
- */
-FieldDropdown.prototype.createSVGArrow_ = function() {
- this.svgArrow_ = dom.createSvgElement(
- Svg.IMAGE, {
- 'height': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',
- 'width': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',
- },
- this.fieldGroup_);
- this.svgArrow_.setAttributeNS(
- dom.XLINK_NS, 'xlink:href',
- this.getConstants().FIELD_DROPDOWN_SVG_ARROW_DATAURI);
-};
-
-/**
- * Create a dropdown menu under the text.
- * @param {Event=} opt_e Optional mouse event that triggered the field to open,
- * or undefined if triggered programmatically.
- * @protected
- */
-FieldDropdown.prototype.showEditor_ = function(opt_e) {
- this.dropdownCreate_();
- if (opt_e && typeof opt_e.clientX === 'number') {
- this.menu_.openingCoords = new Coordinate(opt_e.clientX, opt_e.clientY);
- } else {
- this.menu_.openingCoords = null;
- }
-
- // Remove any pre-existing elements in the dropdown.
- DropDownDiv.clearContent();
- // Element gets created in render.
- this.menu_.render(DropDownDiv.getContentDiv());
- const menuElement = /** @type {!Element} */ (this.menu_.getElement());
- dom.addClass(menuElement, 'blocklyDropdownMenu');
-
- if (this.getConstants().FIELD_DROPDOWN_COLOURED_DIV) {
- const primaryColour = (this.sourceBlock_.isShadow()) ?
- this.sourceBlock_.getParent().getColour() :
- this.sourceBlock_.getColour();
- const borderColour = (this.sourceBlock_.isShadow()) ?
- this.sourceBlock_.getParent().style.colourTertiary :
- this.sourceBlock_.style.colourTertiary;
- DropDownDiv.setColour(primaryColour, borderColour);
- }
-
- DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
-
- // Focusing needs to be handled after the menu is rendered and positioned.
- // Otherwise it will cause a page scroll to get the misplaced menu in
- // view. See issue #1329.
- this.menu_.focus();
-
- if (this.selectedMenuItem_) {
- this.menu_.setHighlighted(this.selectedMenuItem_);
- }
-
- this.applyColour();
-};
-
-/**
- * Create the dropdown editor.
- * @private
- */
-FieldDropdown.prototype.dropdownCreate_ = function() {
- const menu = new Menu();
- menu.setRole(aria.Role.LISTBOX);
- this.menu_ = menu;
-
- const options = this.getOptions(false);
- this.selectedMenuItem_ = null;
- for (let i = 0; i < options.length; i++) {
- let content = options[i][0]; // Human-readable text or image.
- const value = options[i][1]; // Language-neutral value.
- if (typeof content === 'object') {
- // An image, not text.
- const image = new Image(content['width'], content['height']);
- image.src = content['src'];
- image.alt = content['alt'] || '';
- content = image;
- }
- const menuItem = new MenuItem(content, value);
- menuItem.setRole(aria.Role.OPTION);
- menuItem.setRightToLeft(this.sourceBlock_.RTL);
- menuItem.setCheckable(true);
- menu.addChild(menuItem);
- menuItem.setChecked(value === this.value_);
- if (value === this.value_) {
- this.selectedMenuItem_ = menuItem;
- }
- menuItem.onAction(this.handleMenuActionEvent_, this);
- }
-};
-
-/**
- * Disposes of events and DOM-references belonging to the dropdown editor.
- * @private
- */
-FieldDropdown.prototype.dropdownDispose_ = function() {
- if (this.menu_) {
- this.menu_.dispose();
- }
- this.menu_ = null;
- this.selectedMenuItem_ = null;
- this.applyColour();
-};
-
-/**
- * Handle an action in the dropdown menu.
- * @param {!MenuItem} menuItem The MenuItem selected within menu.
- * @private
- */
-FieldDropdown.prototype.handleMenuActionEvent_ = function(menuItem) {
- DropDownDiv.hideIfOwner(this, true);
- this.onItemSelected_(/** @type {!Menu} */ (this.menu_), menuItem);
-};
-
-/**
- * Handle the selection of an item in the dropdown menu.
- * @param {!Menu} menu The Menu component clicked.
- * @param {!MenuItem} menuItem The MenuItem selected within menu.
- * @protected
- */
-FieldDropdown.prototype.onItemSelected_ = function(menu, menuItem) {
- this.setValue(menuItem.getValue());
-};
-
-/**
- * Factor out common words in statically defined options.
- * Create prefix and/or suffix labels.
- * @private
- */
-FieldDropdown.prototype.trimOptions_ = function() {
- const options = this.menuGenerator_;
- if (!Array.isArray(options)) {
- return;
- }
- let hasImages = false;
-
- // Localize label text and image alt text.
- for (let i = 0; i < options.length; i++) {
- const label = options[i][0];
- if (typeof label === 'string') {
- options[i][0] = parsing.replaceMessageReferences(label);
- } else {
- if (label.alt !== null) {
- options[i][0].alt = parsing.replaceMessageReferences(label.alt);
- }
- hasImages = true;
- }
- }
- if (hasImages || options.length < 2) {
- return; // Do nothing if too few items or at least one label is an image.
- }
- const strings = [];
- for (let i = 0; i < options.length; i++) {
- strings.push(options[i][0]);
- }
- const shortest = utilsString.shortestStringLength(strings);
- const prefixLength = utilsString.commonWordPrefix(strings, shortest);
- const suffixLength = utilsString.commonWordSuffix(strings, shortest);
- if (!prefixLength && !suffixLength) {
- return;
- }
- if (shortest <= prefixLength + suffixLength) {
- // One or more strings will entirely vanish if we proceed. Abort.
- return;
- }
- if (prefixLength) {
- this.prefixField = strings[0].substring(0, prefixLength - 1);
- }
- if (suffixLength) {
- this.suffixField = strings[0].substr(1 - suffixLength);
- }
-
- this.menuGenerator_ =
- FieldDropdown.applyTrim_(options, prefixLength, suffixLength);
-};
-
-/**
- * Use the calculated prefix and suffix lengths to trim all of the options in
- * the given array.
- * @param {!Array} options Array of option tuples:
- * (human-readable text or image, language-neutral name).
- * @param {number} prefixLength The length of the common prefix.
- * @param {number} suffixLength The length of the common suffix
- * @return {!Array} A new array with all of the option text trimmed.
- */
-FieldDropdown.applyTrim_ = function(options, prefixLength, suffixLength) {
- const newOptions = [];
- // Remove the prefix and suffix from the options.
- for (let i = 0; i < options.length; i++) {
- let text = options[i][0];
- const value = options[i][1];
- text = text.substring(prefixLength, text.length - suffixLength);
- newOptions[i] = [text, value];
- }
- return newOptions;
-};
-
-/**
- * @return {boolean} True if the option list is generated by a function.
- * Otherwise false.
- */
-FieldDropdown.prototype.isOptionListDynamic = function() {
- return typeof this.menuGenerator_ === 'function';
-};
-
-/**
- * Return a list of the options for this dropdown.
- * @param {boolean=} opt_useCache For dynamic options, whether or not to use the
- * cached options or to re-generate them.
- * @return {!Array} A non-empty array of option tuples:
- * (human-readable text or image, language-neutral name).
- * @throws {TypeError} If generated options are incorrectly structured.
- */
-FieldDropdown.prototype.getOptions = function(opt_useCache) {
- if (this.isOptionListDynamic()) {
- if (!this.generatedOptions_ || !opt_useCache) {
- this.generatedOptions_ = this.menuGenerator_.call(this);
- validateOptions(this.generatedOptions_);
- }
- return this.generatedOptions_;
- }
- return /** @type {!Array>} */ (this.menuGenerator_);
-};
-
-/**
- * Ensure that the input value is a valid language-neutral option.
- * @param {*=} opt_newValue The input value.
- * @return {?string} A valid language-neutral option, or null if invalid.
- * @protected
- */
-FieldDropdown.prototype.doClassValidation_ = function(opt_newValue) {
- let isValueValid = false;
- const options = this.getOptions(true);
- for (let i = 0, option; (option = options[i]); i++) {
- // Options are tuples of human-readable text and language-neutral values.
- if (option[1] === opt_newValue) {
- isValueValid = true;
- break;
- }
- }
- if (!isValueValid) {
- if (this.sourceBlock_) {
- console.warn(
- 'Cannot set the dropdown\'s value to an unavailable option.' +
- ' Block type: ' + this.sourceBlock_.type +
- ', Field name: ' + this.name + ', Value: ' + opt_newValue);
- }
- return null;
- }
- return /** @type {string} */ (opt_newValue);
-};
-
-/**
- * Update the value of this dropdown field.
- * @param {*} newValue The value to be saved. The default validator guarantees
- * that this is one of the valid dropdown options.
- * @protected
- */
-FieldDropdown.prototype.doValueUpdate_ = function(newValue) {
- FieldDropdown.superClass_.doValueUpdate_.call(this, newValue);
- const options = this.getOptions(true);
- for (let i = 0, option; (option = options[i]); i++) {
- if (option[1] === this.value_) {
- this.selectedOption_ = option;
- }
- }
-};
-
-/**
- * Updates the dropdown arrow to match the colour/style of the block.
- * @package
- */
-FieldDropdown.prototype.applyColour = function() {
- if (this.borderRect_) {
- this.borderRect_.setAttribute(
- 'stroke', this.sourceBlock_.style.colourTertiary);
- if (this.menu_) {
- this.borderRect_.setAttribute(
- 'fill', this.sourceBlock_.style.colourTertiary);
- } else {
- this.borderRect_.setAttribute('fill', 'transparent');
- }
- }
- // Update arrow's colour.
- if (this.sourceBlock_ && this.arrow_) {
- if (this.sourceBlock_.isShadow()) {
- this.arrow_.style.fill = this.sourceBlock_.style.colourSecondary;
- } else {
- this.arrow_.style.fill = this.sourceBlock_.style.colourPrimary;
- }
- }
-};
-
-/**
- * Draws the border with the correct width.
- * @protected
- */
-FieldDropdown.prototype.render_ = function() {
- // Hide both elements.
- this.textContent_.nodeValue = '';
- this.imageElement_.style.display = 'none';
-
- // Show correct element.
- const option = this.selectedOption_ && this.selectedOption_[0];
- if (option && typeof option === 'object') {
- this.renderSelectedImage_(
- /** @type {!FieldDropdown.ImageProperties} */ (option));
- } else {
- this.renderSelectedText_();
- }
-
- this.positionBorderRect_();
-};
-
-/**
- * Renders the selected option, which must be an image.
- * @param {!FieldDropdown.ImageProperties} imageJson Selected
- * option that must be an image.
- * @private
- */
-FieldDropdown.prototype.renderSelectedImage_ = function(imageJson) {
- this.imageElement_.style.display = '';
- this.imageElement_.setAttributeNS(dom.XLINK_NS, 'xlink:href', imageJson.src);
- this.imageElement_.setAttribute('height', imageJson.height);
- this.imageElement_.setAttribute('width', imageJson.width);
-
- const imageHeight = Number(imageJson.height);
- const imageWidth = Number(imageJson.width);
-
- // Height and width include the border rect.
- const hasBorder = !!this.borderRect_;
- const height = Math.max(
- hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
- imageHeight + IMAGE_Y_PADDING);
- const xPadding =
- hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0;
- let arrowWidth = 0;
- if (this.svgArrow_) {
- arrowWidth = this.positionSVGArrow_(
- imageWidth + xPadding,
- height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2);
- } else {
- arrowWidth = dom.getFastTextWidth(
- /** @type {!SVGTSpanElement} */ (this.arrow_),
- this.getConstants().FIELD_TEXT_FONTSIZE,
- this.getConstants().FIELD_TEXT_FONTWEIGHT,
- this.getConstants().FIELD_TEXT_FONTFAMILY);
- }
- this.size_.width = imageWidth + arrowWidth + xPadding * 2;
- this.size_.height = height;
-
- let arrowX = 0;
- if (this.sourceBlock_.RTL) {
- const imageX = xPadding + arrowWidth;
- this.imageElement_.setAttribute('x', imageX);
- } else {
- arrowX = imageWidth + arrowWidth;
- this.textElement_.setAttribute('text-anchor', 'end');
- this.imageElement_.setAttribute('x', xPadding);
- }
- this.imageElement_.setAttribute('y', height / 2 - imageHeight / 2);
-
- this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth);
-};
-
-/**
- * Renders the selected option, which must be text.
- * @private
- */
-FieldDropdown.prototype.renderSelectedText_ = function() {
- // Retrieves the selected option to display through getText_.
- this.textContent_.nodeValue = this.getDisplayText_();
- dom.addClass(
- /** @type {!Element} */ (this.textElement_), 'blocklyDropdownText');
- this.textElement_.setAttribute('text-anchor', 'start');
-
- // Height and width include the border rect.
- const hasBorder = !!this.borderRect_;
- const height = Math.max(
- hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
- this.getConstants().FIELD_TEXT_HEIGHT);
- const textWidth = dom.getFastTextWidth(
- this.textElement_, this.getConstants().FIELD_TEXT_FONTSIZE,
- this.getConstants().FIELD_TEXT_FONTWEIGHT,
- this.getConstants().FIELD_TEXT_FONTFAMILY);
- const xPadding =
- hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0;
- let arrowWidth = 0;
- if (this.svgArrow_) {
- arrowWidth = this.positionSVGArrow_(
- textWidth + xPadding,
- height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2);
- }
- this.size_.width = textWidth + arrowWidth + xPadding * 2;
- this.size_.height = height;
-
- this.positionTextElement_(xPadding, textWidth);
-};
-
-/**
- * Position a drop-down arrow at the appropriate location at render-time.
- * @param {number} x X position the arrow is being rendered at, in px.
- * @param {number} y Y position the arrow is being rendered at, in px.
- * @return {number} Amount of space the arrow is taking up, in px.
- * @private
- */
-FieldDropdown.prototype.positionSVGArrow_ = function(x, y) {
- if (!this.svgArrow_) {
- return 0;
- }
- const hasBorder = !!this.borderRect_;
- const xPadding =
- hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0;
- const textPadding = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_PADDING;
- const svgArrowSize = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE;
- const arrowX = this.sourceBlock_.RTL ? xPadding : x + textPadding;
- this.svgArrow_.setAttribute(
- 'transform', 'translate(' + arrowX + ',' + y + ')');
- return svgArrowSize + textPadding;
-};
-
-/**
- * Use the `getText_` developer hook to override the field's text
- * representation. Get the selected option text. If the selected option is an
- * image we return the image alt text.
- * @return {?string} Selected option text.
- * @protected
- * @override
- */
-FieldDropdown.prototype.getText_ = function() {
- if (!this.selectedOption_) {
- return null;
- }
- const option = this.selectedOption_[0];
- if (typeof option === 'object') {
- return option['alt'];
- }
- return option;
-};
-
/**
* Validates the data structure to be processed as an options list.
* @param {?} options The proposed dropdown options.
diff --git a/core/field_image.js b/core/field_image.js
index 25cf1c5d1..fde679911 100644
--- a/core/field_image.js
+++ b/core/field_image.js
@@ -17,108 +17,272 @@ goog.module('Blockly.FieldImage');
const dom = goog.require('Blockly.utils.dom');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
const {Field} = goog.require('Blockly.Field');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
const {Size} = goog.require('Blockly.utils.Size');
const {Svg} = goog.require('Blockly.utils.Svg');
/**
* Class for an image on a block.
- * @param {string} src The URL of the image.
- * @param {!(string|number)} width Width of the image.
- * @param {!(string|number)} height Height of the image.
- * @param {string=} opt_alt Optional alt text for when block is collapsed.
- * @param {function(!FieldImage)=} opt_onClick Optional function to be
- * called when the image is clicked. If opt_onClick is defined, opt_alt must
- * also be defined.
- * @param {boolean=} opt_flipRtl Whether to flip the icon in RTL.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/image#creation}
- * for a list of properties this parameter supports.
* @extends {Field}
- * @constructor
* @alias Blockly.FieldImage
*/
-const FieldImage = function(
- src, width, height, opt_alt, opt_onClick, opt_flipRtl, opt_config) {
- // Return early.
- if (!src) {
- throw Error('Src value of an image field is required');
- }
- src = parsing.replaceMessageReferences(src);
- const imageHeight = Number(parsing.replaceMessageReferences(height));
- const imageWidth = Number(parsing.replaceMessageReferences(width));
- if (isNaN(imageHeight) || isNaN(imageWidth)) {
- throw Error(
- 'Height and width values of an image field must cast to' +
- ' numbers.');
- }
- if (imageHeight <= 0 || imageWidth <= 0) {
- throw Error(
- 'Height and width values of an image field must be greater' +
- ' than 0.');
- }
-
- // Initialize configurable properties.
+class FieldImage extends Field {
/**
- * Whether to flip this image in RTL.
- * @type {boolean}
- * @private
+ * @param {string|!Sentinel} src The URL of the image.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {!(string|number)} width Width of the image.
+ * @param {!(string|number)} height Height of the image.
+ * @param {string=} opt_alt Optional alt text for when block is collapsed.
+ * @param {function(!FieldImage)=} opt_onClick Optional function to be
+ * called when the image is clicked. If opt_onClick is defined, opt_alt
+ * must also be defined.
+ * @param {boolean=} opt_flipRtl Whether to flip the icon in RTL.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/image#creation}
+ * for a list of properties this parameter supports.
*/
- this.flipRtl_ = false;
+ constructor(
+ src, width, height, opt_alt, opt_onClick, opt_flipRtl, opt_config) {
+ super(Field.SKIP_SETUP);
- /**
- * Alt text of this image.
- * @type {string}
- * @private
- */
- this.altText_ = '';
+ // Return early.
+ if (!src) {
+ throw Error('Src value of an image field is required');
+ }
+ const imageHeight = Number(parsing.replaceMessageReferences(height));
+ const imageWidth = Number(parsing.replaceMessageReferences(width));
+ if (isNaN(imageHeight) || isNaN(imageWidth)) {
+ throw Error(
+ 'Height and width values of an image field must cast to' +
+ ' numbers.');
+ }
+ if (imageHeight <= 0 || imageWidth <= 0) {
+ throw Error(
+ 'Height and width values of an image field must be greater' +
+ ' than 0.');
+ }
- FieldImage.superClass_.constructor.call(this, src, null, opt_config);
+ /**
+ * The size of the area rendered by the field.
+ * @type {Size}
+ * @protected
+ * @override
+ */
+ this.size_ = new Size(imageWidth, imageHeight + FieldImage.Y_PADDING);
- if (!opt_config) { // If the config wasn't passed, do old configuration.
- this.flipRtl_ = !!opt_flipRtl;
- this.altText_ = parsing.replaceMessageReferences(opt_alt) || '';
+ /**
+ * Store the image height, since it is different from the field height.
+ * @type {number}
+ * @private
+ */
+ this.imageHeight_ = imageHeight;
+
+ /**
+ * The function to be called when this field is clicked.
+ * @type {?function(!FieldImage)}
+ * @private
+ */
+ this.clickHandler_ = null;
+
+ if (typeof opt_onClick === 'function') {
+ this.clickHandler_ = opt_onClick;
+ }
+
+ /**
+ * The rendered field's image element.
+ * @type {SVGImageElement}
+ * @private
+ */
+ this.imageElement_ = null;
+
+ /**
+ * Editable fields usually show some sort of UI indicating they are
+ * editable. This field should not.
+ * @type {boolean}
+ * @const
+ */
+ this.EDITABLE = false;
+
+ /**
+ * Used to tell if the field needs to be rendered the next time the block is
+ * rendered. Image fields are statically sized, and only need to be
+ * rendered at initialization.
+ * @type {boolean}
+ * @protected
+ */
+ this.isDirty_ = false;
+
+ /**
+ * Whether to flip this image in RTL.
+ * @type {boolean}
+ * @private
+ */
+ this.flipRtl_ = false;
+
+ /**
+ * Alt text of this image.
+ * @type {string}
+ * @private
+ */
+ this.altText_ = '';
+
+ if (src === Field.SKIP_SETUP) return;
+
+ if (opt_config) {
+ this.configure_(opt_config);
+ } else {
+ this.flipRtl_ = !!opt_flipRtl;
+ this.altText_ = parsing.replaceMessageReferences(opt_alt) || '';
+ }
+ this.setValue(parsing.replaceMessageReferences(src));
}
- // Initialize other properties.
/**
- * The size of the area rendered by the field.
- * @type {Size}
+ * Configure the field based on the given map of options.
+ * @param {!Object} config A map of options to configure the field based on.
* @protected
* @override
*/
- this.size_ = new Size(imageWidth, imageHeight + FieldImage.Y_PADDING);
-
- /**
- * Store the image height, since it is different from the field height.
- * @type {number}
- * @private
- */
- this.imageHeight_ = imageHeight;
-
- /**
- * The function to be called when this field is clicked.
- * @type {?function(!FieldImage)}
- * @private
- */
- this.clickHandler_ = null;
-
- if (typeof opt_onClick === 'function') {
- this.clickHandler_ = opt_onClick;
+ configure_(config) {
+ super.configure_(config);
+ this.flipRtl_ = !!config['flipRtl'];
+ this.altText_ = parsing.replaceMessageReferences(config['alt']) || '';
}
/**
- * The rendered field's image element.
- * @type {SVGImageElement}
- * @private
+ * Create the block UI for this image.
+ * @package
*/
- this.imageElement_ = null;
-};
-object.inherits(FieldImage, Field);
+ initView() {
+ this.imageElement_ = dom.createSvgElement(
+ Svg.IMAGE, {
+ 'height': this.imageHeight_ + 'px',
+ 'width': this.size_.width + 'px',
+ 'alt': this.altText_,
+ },
+ this.fieldGroup_);
+ this.imageElement_.setAttributeNS(
+ dom.XLINK_NS, 'xlink:href', /** @type {string} */ (this.value_));
+
+ if (this.clickHandler_) {
+ this.imageElement_.style.cursor = 'pointer';
+ }
+ }
+
+ /**
+ * @override
+ */
+ updateSize_() {
+ // NOP
+ }
+
+ /**
+ * Ensure that the input value (the source URL) is a string.
+ * @param {*=} opt_newValue The input value.
+ * @return {?string} A string, or null if invalid.
+ * @protected
+ */
+ doClassValidation_(opt_newValue) {
+ if (typeof opt_newValue !== 'string') {
+ return null;
+ }
+ return opt_newValue;
+ }
+
+ /**
+ * Update the value of this image field, and update the displayed image.
+ * @param {*} newValue The value to be saved. The default validator guarantees
+ * that this is a string.
+ * @protected
+ */
+ doValueUpdate_(newValue) {
+ this.value_ = newValue;
+ if (this.imageElement_) {
+ this.imageElement_.setAttributeNS(
+ dom.XLINK_NS, 'xlink:href', String(this.value_));
+ }
+ }
+
+ /**
+ * Get whether to flip this image in RTL
+ * @return {boolean} True if we should flip in RTL.
+ * @override
+ */
+ getFlipRtl() {
+ return this.flipRtl_;
+ }
+
+ /**
+ * Set the alt text of this image.
+ * @param {?string} alt New alt text.
+ * @public
+ */
+ setAlt(alt) {
+ if (alt === this.altText_) {
+ return;
+ }
+ this.altText_ = alt || '';
+ if (this.imageElement_) {
+ this.imageElement_.setAttribute('alt', this.altText_);
+ }
+ }
+
+ /**
+ * If field click is called, and click handler defined,
+ * call the handler.
+ * @protected
+ */
+ showEditor_() {
+ if (this.clickHandler_) {
+ this.clickHandler_(this);
+ }
+ }
+
+ /**
+ * Set the function that is called when this image is clicked.
+ * @param {?function(!FieldImage)} func The function that is called
+ * when the image is clicked, or null to remove.
+ */
+ setOnClickHandler(func) {
+ this.clickHandler_ = func;
+ }
+
+ /**
+ * Use the `getText_` developer hook to override the field's text
+ * representation.
+ * Return the image alt text instead.
+ * @return {?string} The image alt text.
+ * @protected
+ * @override
+ */
+ getText_() {
+ return this.altText_;
+ }
+
+ /**
+ * Construct a FieldImage from a JSON arg object,
+ * dereferencing any string table references.
+ * @param {!Object} options A JSON object with options (src, width, height,
+ * alt, and flipRtl).
+ * @return {!FieldImage} The new field instance.
+ * @package
+ * @nocollapse
+ */
+ static fromJson(options) {
+ // `this` might be a subclass of FieldImage if that class doesn't override
+ // the static fromJson method.
+ return new this(
+ options['src'], options['width'], options['height'], undefined,
+ undefined, undefined, options);
+ }
+}
/**
* The default value for this field.
@@ -127,23 +291,6 @@ object.inherits(FieldImage, Field);
*/
FieldImage.prototype.DEFAULT_VALUE = '';
-/**
- * Construct a FieldImage from a JSON arg object,
- * dereferencing any string table references.
- * @param {!Object} options A JSON object with options (src, width, height,
- * alt, and flipRtl).
- * @return {!FieldImage} The new field instance.
- * @package
- * @nocollapse
- */
-FieldImage.fromJson = function(options) {
- // `this` might be a subclass of FieldImage if that class doesn't override
- // the static fromJson method.
- return new this(
- options['src'], options['width'], options['height'], undefined, undefined,
- undefined, options);
-};
-
/**
* Vertical padding below the image, which is included in the reported height of
* the field.
@@ -152,144 +299,6 @@ FieldImage.fromJson = function(options) {
*/
FieldImage.Y_PADDING = 1;
-/**
- * Editable fields usually show some sort of UI indicating they are
- * editable. This field should not.
- * @type {boolean}
- */
-FieldImage.prototype.EDITABLE = false;
-
-/**
- * Used to tell if the field needs to be rendered the next time the block is
- * rendered. Image fields are statically sized, and only need to be
- * rendered at initialization.
- * @type {boolean}
- * @protected
- */
-FieldImage.prototype.isDirty_ = false;
-
-/**
- * Configure the field based on the given map of options.
- * @param {!Object} config A map of options to configure the field based on.
- * @protected
- * @override
- */
-FieldImage.prototype.configure_ = function(config) {
- FieldImage.superClass_.configure_.call(this, config);
- this.flipRtl_ = !!config['flipRtl'];
- this.altText_ = parsing.replaceMessageReferences(config['alt']) || '';
-};
-
-/**
- * Create the block UI for this image.
- * @package
- */
-FieldImage.prototype.initView = function() {
- this.imageElement_ = dom.createSvgElement(
- Svg.IMAGE, {
- 'height': this.imageHeight_ + 'px',
- 'width': this.size_.width + 'px',
- 'alt': this.altText_,
- },
- this.fieldGroup_);
- this.imageElement_.setAttributeNS(
- dom.XLINK_NS, 'xlink:href', /** @type {string} */ (this.value_));
-
- if (this.clickHandler_) {
- this.imageElement_.style.cursor = 'pointer';
- }
-};
-
-/**
- * @override
- */
-FieldImage.prototype.updateSize_ = function() {
- // NOP
-};
-
-/**
- * Ensure that the input value (the source URL) is a string.
- * @param {*=} opt_newValue The input value.
- * @return {?string} A string, or null if invalid.
- * @protected
- */
-FieldImage.prototype.doClassValidation_ = function(opt_newValue) {
- if (typeof opt_newValue !== 'string') {
- return null;
- }
- return opt_newValue;
-};
-
-/**
- * Update the value of this image field, and update the displayed image.
- * @param {*} newValue The value to be saved. The default validator guarantees
- * that this is a string.
- * @protected
- */
-FieldImage.prototype.doValueUpdate_ = function(newValue) {
- this.value_ = newValue;
- if (this.imageElement_) {
- this.imageElement_.setAttributeNS(
- dom.XLINK_NS, 'xlink:href', String(this.value_));
- }
-};
-
-/**
- * Get whether to flip this image in RTL
- * @return {boolean} True if we should flip in RTL.
- * @override
- */
-FieldImage.prototype.getFlipRtl = function() {
- return this.flipRtl_;
-};
-
-/**
- * Set the alt text of this image.
- * @param {?string} alt New alt text.
- * @public
- */
-FieldImage.prototype.setAlt = function(alt) {
- if (alt === this.altText_) {
- return;
- }
- this.altText_ = alt || '';
- if (this.imageElement_) {
- this.imageElement_.setAttribute('alt', this.altText_);
- }
-};
-
-/**
- * If field click is called, and click handler defined,
- * call the handler.
- * @protected
- */
-FieldImage.prototype.showEditor_ = function() {
- if (this.clickHandler_) {
- this.clickHandler_(this);
- }
-};
-
-/**
- * Set the function that is called when this image is clicked.
- * @param {?function(!FieldImage)} func The function that is called
- * when the image is clicked, or null to remove.
- */
-FieldImage.prototype.setOnClickHandler = function(func) {
- this.clickHandler_ = func;
-};
-
-/**
- * Use the `getText_` developer hook to override the field's text
- * representation.
- * Return the image alt text instead.
- * @return {?string} The image alt text.
- * @protected
- * @override
- */
-FieldImage.prototype.getText_ = function() {
- return this.altText_;
-};
-
fieldRegistry.register('field_image', FieldImage);
exports.FieldImage = FieldImage;
diff --git a/core/field_label.js b/core/field_label.js
index b9195d803..f30c0de82 100644
--- a/core/field_label.js
+++ b/core/field_label.js
@@ -19,39 +19,123 @@ goog.module('Blockly.FieldLabel');
const dom = goog.require('Blockly.utils.dom');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
const {Field} = goog.require('Blockly.Field');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
/**
* Class for a non-editable, non-serializable text field.
- * @param {string=} opt_value The initial value of the field. Should cast to a
- * string. Defaults to an empty string if null or undefined.
- * @param {string=} opt_class Optional CSS class for the field's text.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label#creation}
- * for a list of properties this parameter supports.
* @extends {Field}
- * @constructor
* @alias Blockly.FieldLabel
*/
-const FieldLabel = function(opt_value, opt_class, opt_config) {
+class FieldLabel extends Field {
/**
- * The html class name to use for this field.
- * @type {?string}
- * @private
+ * @param {(string|!Sentinel)=} opt_value The initial value of the
+ * field. Should cast to a string. Defaults to an empty string if null or
+ * undefined.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {string=} opt_class Optional CSS class for the field's text.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label#creation}
+ * for a list of properties this parameter supports.
*/
- this.class_ = null;
+ constructor(opt_value, opt_class, opt_config) {
+ super(Field.SKIP_SETUP);
- FieldLabel.superClass_.constructor.call(this, opt_value, null, opt_config);
+ /**
+ * The html class name to use for this field.
+ * @type {?string}
+ * @private
+ */
+ this.class_ = null;
- if (!opt_config) { // If the config was not passed use old configuration.
- this.class_ = opt_class || null;
+ /**
+ * Editable fields usually show some sort of UI indicating they are
+ * editable. This field should not.
+ * @type {boolean}
+ */
+ this.EDITABLE = false;
+
+ if (opt_value === Field.SKIP_SETUP) return;
+ if (opt_config) {
+ this.configure_(opt_config);
+ } else {
+ this.class_ = opt_class || null;
+ }
+ this.setValue(opt_value);
}
-};
-object.inherits(FieldLabel, Field);
+
+ /**
+ * @override
+ */
+ configure_(config) {
+ super.configure_(config);
+ this.class_ = config['class'];
+ }
+
+ /**
+ * Create block UI for this label.
+ * @package
+ */
+ initView() {
+ this.createTextElement_();
+ if (this.class_) {
+ dom.addClass(
+ /** @type {!SVGTextElement} */ (this.textElement_), this.class_);
+ }
+ }
+
+ /**
+ * Ensure that the input value casts to a valid string.
+ * @param {*=} opt_newValue The input value.
+ * @return {?string} A valid string, or null if invalid.
+ * @protected
+ */
+ doClassValidation_(opt_newValue) {
+ if (opt_newValue === null || opt_newValue === undefined) {
+ return null;
+ }
+ return String(opt_newValue);
+ }
+
+ /**
+ * Set the CSS class applied to the field's textElement_.
+ * @param {?string} cssClass The new CSS class name, or null to remove.
+ */
+ setClass(cssClass) {
+ if (this.textElement_) {
+ // This check isn't necessary, but it's faster than letting removeClass
+ // figure it out.
+ if (this.class_) {
+ dom.removeClass(this.textElement_, this.class_);
+ }
+ if (cssClass) {
+ dom.addClass(this.textElement_, cssClass);
+ }
+ }
+ this.class_ = cssClass;
+ }
+
+ /**
+ * Construct a FieldLabel from a JSON arg object,
+ * dereferencing any string table references.
+ * @param {!Object} options A JSON object with options (text, and class).
+ * @return {!FieldLabel} The new field instance.
+ * @package
+ * @nocollapse
+ */
+ static fromJson(options) {
+ const text = parsing.replaceMessageReferences(options['text']);
+ // `this` might be a subclass of FieldLabel if that class doesn't override
+ // the static fromJson method.
+ return new this(text, undefined, options);
+ }
+}
/**
* The default value for this field.
@@ -60,79 +144,6 @@ object.inherits(FieldLabel, Field);
*/
FieldLabel.prototype.DEFAULT_VALUE = '';
-/**
- * Construct a FieldLabel from a JSON arg object,
- * dereferencing any string table references.
- * @param {!Object} options A JSON object with options (text, and class).
- * @return {!FieldLabel} The new field instance.
- * @package
- * @nocollapse
- */
-FieldLabel.fromJson = function(options) {
- const text = parsing.replaceMessageReferences(options['text']);
- // `this` might be a subclass of FieldLabel if that class doesn't override
- // the static fromJson method.
- return new this(text, undefined, options);
-};
-
-/**
- * Editable fields usually show some sort of UI indicating they are
- * editable. This field should not.
- * @type {boolean}
- */
-FieldLabel.prototype.EDITABLE = false;
-
-/**
- * @override
- */
-FieldLabel.prototype.configure_ = function(config) {
- FieldLabel.superClass_.configure_.call(this, config);
- this.class_ = config['class'];
-};
-
-/**
- * Create block UI for this label.
- * @package
- */
-FieldLabel.prototype.initView = function() {
- this.createTextElement_();
- if (this.class_) {
- dom.addClass(
- /** @type {!SVGTextElement} */ (this.textElement_), this.class_);
- }
-};
-
-/**
- * Ensure that the input value casts to a valid string.
- * @param {*=} opt_newValue The input value.
- * @return {?string} A valid string, or null if invalid.
- * @protected
- */
-FieldLabel.prototype.doClassValidation_ = function(opt_newValue) {
- if (opt_newValue === null || opt_newValue === undefined) {
- return null;
- }
- return String(opt_newValue);
-};
-
-/**
- * Set the CSS class applied to the field's textElement_.
- * @param {?string} cssClass The new CSS class name, or null to remove.
- */
-FieldLabel.prototype.setClass = function(cssClass) {
- if (this.textElement_) {
- // This check isn't necessary, but it's faster than letting removeClass
- // figure it out.
- if (this.class_) {
- dom.removeClass(this.textElement_, this.class_);
- }
- if (cssClass) {
- dom.addClass(this.textElement_, cssClass);
- }
- }
- this.class_ = cssClass;
-};
-
fieldRegistry.register('field_label', FieldLabel);
exports.FieldLabel = FieldLabel;
diff --git a/core/field_label_serializable.js b/core/field_label_serializable.js
index 4c4990f03..e243648fd 100644
--- a/core/field_label_serializable.js
+++ b/core/field_label_serializable.js
@@ -20,59 +20,60 @@
goog.module('Blockly.FieldLabelSerializable');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
const {FieldLabel} = goog.require('Blockly.FieldLabel');
/**
* Class for a non-editable, serializable text field.
- * @param {*} opt_value The initial value of the field. Should cast to a
- * string. Defaults to an empty string if null or undefined.
- * @param {string=} opt_class Optional CSS class for the field's text.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label-serializable#creation}
- * for a list of properties this parameter supports.
* @extends {FieldLabel}
- * @constructor
- *
* @alias Blockly.FieldLabelSerializable
*/
-const FieldLabelSerializable = function(opt_value, opt_class, opt_config) {
- FieldLabelSerializable.superClass_.constructor.call(
- this, opt_value, opt_class, opt_config);
-};
-object.inherits(FieldLabelSerializable, FieldLabel);
+class FieldLabelSerializable extends FieldLabel {
+ /**
+ * @param {string=} opt_value The initial value of the field. Should cast to a
+ * string. Defaults to an empty string if null or undefined.
+ * @param {string=} opt_class Optional CSS class for the field's text.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label-serializable#creation}
+ * for a list of properties this parameter supports.
+ */
+ constructor(opt_value, opt_class, opt_config) {
+ super(String(opt_value ?? ''), opt_class, opt_config);
-/**
- * Construct a FieldLabelSerializable from a JSON arg object,
- * dereferencing any string table references.
- * @param {!Object} options A JSON object with options (text, and class).
- * @return {!FieldLabelSerializable} The new field instance.
- * @package
- * @nocollapse
- */
-FieldLabelSerializable.fromJson = function(options) {
- const text = parsing.replaceMessageReferences(options['text']);
- // `this` might be a subclass of FieldLabelSerializable if that class doesn't
- // override the static fromJson method.
- return new this(text, undefined, options);
-};
+ /**
+ * Editable fields usually show some sort of UI indicating they are
+ * editable. This field should not.
+ * @type {boolean}
+ */
+ this.EDITABLE = false;
-/**
- * Editable fields usually show some sort of UI indicating they are
- * editable. This field should not.
- * @type {boolean}
- */
-FieldLabelSerializable.prototype.EDITABLE = false;
+ /**
+ * Serializable fields are saved by the XML renderer, non-serializable
+ * fields are not. This field should be serialized, but only edited
+ * programmatically.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
+ }
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. This field should be serialized, but only edited programmatically.
- * @type {boolean}
- */
-FieldLabelSerializable.prototype.SERIALIZABLE = true;
+ /**
+ * Construct a FieldLabelSerializable from a JSON arg object,
+ * dereferencing any string table references.
+ * @param {!Object} options A JSON object with options (text, and class).
+ * @return {!FieldLabelSerializable} The new field instance.
+ * @package
+ * @nocollapse
+ * @override
+ */
+ static fromJson(options) {
+ const text = parsing.replaceMessageReferences(options['text']);
+ // `this` might be a subclass of FieldLabelSerializable if that class
+ // doesn't override the static fromJson method.
+ return new this(text, undefined, options);
+ }
+}
fieldRegistry.register('field_label_serializable', FieldLabelSerializable);
diff --git a/core/field_multilineinput.js b/core/field_multilineinput.js
index 5496bdef8..0e9ec36e5 100644
--- a/core/field_multilineinput.js
+++ b/core/field_multilineinput.js
@@ -20,428 +20,442 @@ const WidgetDiv = goog.require('Blockly.WidgetDiv');
const aria = goog.require('Blockly.utils.aria');
const dom = goog.require('Blockly.utils.dom');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
const userAgent = goog.require('Blockly.utils.userAgent');
const {FieldTextInput} = goog.require('Blockly.FieldTextInput');
const {Field} = goog.require('Blockly.Field');
const {KeyCodes} = goog.require('Blockly.utils.KeyCodes');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
const {Svg} = goog.require('Blockly.utils.Svg');
/**
* Class for an editable text area field.
- * @param {string=} opt_value The initial content of the field. Should cast to a
- * string. Defaults to an empty string if null or undefined.
- * @param {Function=} opt_validator An optional function that is called
- * to validate any constraints on what the user entered. Takes the new
- * text as an argument and returns either the accepted text, a replacement
- * text, or null to abort the change.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation}
- * for a list of properties this parameter supports.
* @extends {FieldTextInput}
- * @constructor
* @alias Blockly.FieldMultilineInput
*/
-const FieldMultilineInput = function(opt_value, opt_validator, opt_config) {
- FieldMultilineInput.superClass_.constructor.call(
- this, opt_value, opt_validator, opt_config);
-
+class FieldMultilineInput extends FieldTextInput {
/**
- * The SVG group element that will contain a text element for each text row
- * when initialized.
- * @type {SVGGElement}
+ * @param {(string|!Sentinel)=} opt_value The initial content of the
+ * field. Should cast to a string. Defaults to an empty string if null or
+ * undefined.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {Function=} opt_validator An optional function that is called
+ * to validate any constraints on what the user entered. Takes the new
+ * text as an argument and returns either the accepted text, a replacement
+ * text, or null to abort the change.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation}
+ * for a list of properties this parameter supports.
*/
- this.textGroup_ = null;
+ constructor(opt_value, opt_validator, opt_config) {
+ super(Field.SKIP_SETUP);
+
+ /**
+ * The SVG group element that will contain a text element for each text row
+ * when initialized.
+ * @type {SVGGElement}
+ */
+ this.textGroup_ = null;
+
+ /**
+ * Defines the maximum number of lines of field.
+ * If exceeded, scrolling functionality is enabled.
+ * @type {number}
+ * @protected
+ */
+ this.maxLines_ = Infinity;
+
+ /**
+ * Whether Y overflow is currently occurring.
+ * @type {boolean}
+ * @protected
+ */
+ this.isOverflowedY_ = false;
+
+ if (opt_value === Field.SKIP_SETUP) return;
+ if (opt_config) this.configure_(opt_config);
+ this.setValue(opt_value);
+ if (opt_validator) this.setValidator(opt_validator);
+ }
/**
- * Defines the maximum number of lines of field.
- * If exceeded, scrolling functionality is enabled.
- * @type {number}
+ * @override
+ */
+ configure_(config) {
+ super.configure_(config);
+ config.maxLines && this.setMaxLines(config.maxLines);
+ }
+
+ /**
+ * Serializes this field's value to XML. Should only be called by Blockly.Xml.
+ * @param {!Element} fieldElement The element to populate with info about the
+ * field's state.
+ * @return {!Element} The element containing info about the field's state.
+ * @package
+ */
+ toXml(fieldElement) {
+ // Replace '\n' characters with HTML-escaped equivalent '
'. This is
+ // needed so the plain-text representation of the XML produced by
+ // `Blockly.Xml.domToText` will appear on a single line (this is a
+ // limitation of the plain-text format).
+ fieldElement.textContent = this.getValue().replace(/\n/g, '
');
+ return fieldElement;
+ }
+
+ /**
+ * Sets the field's value based on the given XML element. Should only be
+ * called by Blockly.Xml.
+ * @param {!Element} fieldElement The element containing info about the
+ * field's state.
+ * @package
+ */
+ fromXml(fieldElement) {
+ this.setValue(fieldElement.textContent.replace(/
/g, '\n'));
+ }
+
+ /**
+ * Saves this field's value.
+ * @return {*} The state of this field.
+ * @package
+ */
+ saveState() {
+ const legacyState = this.saveLegacyState(FieldMultilineInput);
+ if (legacyState !== null) {
+ return legacyState;
+ }
+ return this.getValue();
+ }
+
+ /**
+ * Sets the field's value based on the given state.
+ * @param {*} state The state of the variable to assign to this variable
+ * field.
+ * @override
+ * @package
+ */
+ loadState(state) {
+ if (this.loadLegacyState(Field, state)) {
+ return;
+ }
+ this.setValue(state);
+ }
+
+ /**
+ * Create the block UI for this field.
+ * @package
+ */
+ initView() {
+ this.createBorderRect_();
+ this.textGroup_ = dom.createSvgElement(
+ Svg.G, {
+ 'class': 'blocklyEditableText',
+ },
+ this.fieldGroup_);
+ }
+
+ /**
+ * Get the text from this field as displayed on screen. May differ from
+ * getText due to ellipsis, and other formatting.
+ * @return {string} Currently displayed text.
+ * @protected
+ * @override
+ */
+ getDisplayText_() {
+ let textLines = this.getText();
+ if (!textLines) {
+ // Prevent the field from disappearing if empty.
+ return Field.NBSP;
+ }
+ const lines = textLines.split('\n');
+ textLines = '';
+ const displayLinesNumber =
+ this.isOverflowedY_ ? this.maxLines_ : lines.length;
+ for (let i = 0; i < displayLinesNumber; i++) {
+ let text = lines[i];
+ if (text.length > this.maxDisplayLength) {
+ // Truncate displayed string and add an ellipsis ('...').
+ text = text.substring(0, this.maxDisplayLength - 4) + '...';
+ } else if (this.isOverflowedY_ && i === displayLinesNumber - 1) {
+ text = text.substring(0, text.length - 3) + '...';
+ }
+ // Replace whitespace with non-breaking spaces so the text doesn't
+ // collapse.
+ text = text.replace(/\s/g, Field.NBSP);
+
+ textLines += text;
+ if (i !== displayLinesNumber - 1) {
+ textLines += '\n';
+ }
+ }
+ if (this.sourceBlock_.RTL) {
+ // The SVG is LTR, force value to be RTL.
+ textLines += '\u200F';
+ }
+ return textLines;
+ }
+
+ /**
+ * Called by setValue if the text input is valid. Updates the value of the
+ * field, and updates the text of the field if it is not currently being
+ * edited (i.e. handled by the htmlInput_). Is being redefined here to update
+ * overflow state of the field.
+ * @param {*} newValue The value to be saved. The default validator guarantees
+ * that this is a string.
* @protected
*/
- this.maxLines_ = Infinity;
+ doValueUpdate_(newValue) {
+ super.doValueUpdate_(newValue);
+ this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_;
+ }
/**
- * Whether Y overflow is currently occurring.
- * @type {boolean}
+ * Updates the text of the textElement.
* @protected
*/
- this.isOverflowedY_ = false;
-};
-object.inherits(FieldMultilineInput, FieldTextInput);
-
-/**
- * @override
- */
-FieldMultilineInput.prototype.configure_ = function(config) {
- FieldMultilineInput.superClass_.configure_.call(this, config);
- config.maxLines && this.setMaxLines(config.maxLines);
-};
-
-/**
- * Construct a FieldMultilineInput from a JSON arg object,
- * dereferencing any string table references.
- * @param {!Object} options A JSON object with options (text, and spellcheck).
- * @return {!FieldMultilineInput} The new field instance.
- * @package
- * @nocollapse
- */
-FieldMultilineInput.fromJson = function(options) {
- const text = parsing.replaceMessageReferences(options['text']);
- // `this` might be a subclass of FieldMultilineInput if that class doesn't
- // override the static fromJson method.
- return new this(text, undefined, options);
-};
-
-/**
- * Serializes this field's value to XML. Should only be called by Blockly.Xml.
- * @param {!Element} fieldElement The element to populate with info about the
- * field's state.
- * @return {!Element} The element containing info about the field's state.
- * @package
- */
-FieldMultilineInput.prototype.toXml = function(fieldElement) {
- // Replace '\n' characters with HTML-escaped equivalent '
'. This is
- // needed so the plain-text representation of the XML produced by
- // `Blockly.Xml.domToText` will appear on a single line (this is a limitation
- // of the plain-text format).
- fieldElement.textContent = this.getValue().replace(/\n/g, '
');
- return fieldElement;
-};
-
-/**
- * Sets the field's value based on the given XML element. Should only be
- * called by Blockly.Xml.
- * @param {!Element} fieldElement The element containing info about the
- * field's state.
- * @package
- */
-FieldMultilineInput.prototype.fromXml = function(fieldElement) {
- this.setValue(fieldElement.textContent.replace(/
/g, '\n'));
-};
-
-/**
- * Saves this field's value.
- * @return {*} The state of this field.
- * @package
- */
-FieldMultilineInput.prototype.saveState = function() {
- const legacyState = this.saveLegacyState(FieldMultilineInput);
- if (legacyState !== null) {
- return legacyState;
- }
- return this.getValue();
-};
-
-/**
- * Sets the field's value based on the given state.
- * @param {*} state The state of the variable to assign to this variable field.
- * @override
- * @package
- */
-FieldMultilineInput.prototype.loadState = function(state) {
- if (this.loadLegacyState(Field, state)) {
- return;
- }
- this.setValue(state);
-};
-
-/**
- * Create the block UI for this field.
- * @package
- */
-FieldMultilineInput.prototype.initView = function() {
- this.createBorderRect_();
- this.textGroup_ = dom.createSvgElement(
- Svg.G, {
- 'class': 'blocklyEditableText',
- },
- this.fieldGroup_);
-};
-
-/**
- * Get the text from this field as displayed on screen. May differ from getText
- * due to ellipsis, and other formatting.
- * @return {string} Currently displayed text.
- * @protected
- * @override
- */
-FieldMultilineInput.prototype.getDisplayText_ = function() {
- let textLines = this.getText();
- if (!textLines) {
- // Prevent the field from disappearing if empty.
- return Field.NBSP;
- }
- const lines = textLines.split('\n');
- textLines = '';
- const displayLinesNumber =
- this.isOverflowedY_ ? this.maxLines_ : lines.length;
- for (let i = 0; i < displayLinesNumber; i++) {
- let text = lines[i];
- if (text.length > this.maxDisplayLength) {
- // Truncate displayed string and add an ellipsis ('...').
- text = text.substring(0, this.maxDisplayLength - 4) + '...';
- } else if (this.isOverflowedY_ && i === displayLinesNumber - 1) {
- text = text.substring(0, text.length - 3) + '...';
+ render_() {
+ // Remove all text group children.
+ let currentChild;
+ while ((currentChild = this.textGroup_.firstChild)) {
+ this.textGroup_.removeChild(currentChild);
}
- // Replace whitespace with non-breaking spaces so the text doesn't collapse.
- text = text.replace(/\s/g, Field.NBSP);
- textLines += text;
- if (i !== displayLinesNumber - 1) {
- textLines += '\n';
+ // Add in text elements into the group.
+ const lines = this.getDisplayText_().split('\n');
+ let y = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const lineHeight = this.getConstants().FIELD_TEXT_HEIGHT +
+ this.getConstants().FIELD_BORDER_RECT_Y_PADDING;
+ const span = dom.createSvgElement(
+ Svg.TEXT, {
+ 'class': 'blocklyText blocklyMultilineText',
+ 'x': this.getConstants().FIELD_BORDER_RECT_X_PADDING,
+ 'y': y + this.getConstants().FIELD_BORDER_RECT_Y_PADDING,
+ 'dy': this.getConstants().FIELD_TEXT_BASELINE,
+ },
+ this.textGroup_);
+ span.appendChild(document.createTextNode(lines[i]));
+ y += lineHeight;
+ }
+
+ if (this.isBeingEdited_) {
+ const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_);
+ if (this.isOverflowedY_) {
+ dom.addClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
+ } else {
+ dom.removeClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
+ }
+ }
+
+ this.updateSize_();
+
+ if (this.isBeingEdited_) {
+ if (this.sourceBlock_.RTL) {
+ // in RTL, we need to let the browser reflow before resizing
+ // in order to get the correct bounding box of the borderRect
+ // avoiding issue #2777.
+ setTimeout(this.resizeEditor_.bind(this), 0);
+ } else {
+ this.resizeEditor_();
+ }
+ const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_);
+ if (!this.isTextValid_) {
+ dom.addClass(htmlInput, 'blocklyInvalidInput');
+ aria.setState(htmlInput, aria.State.INVALID, true);
+ } else {
+ dom.removeClass(htmlInput, 'blocklyInvalidInput');
+ aria.setState(htmlInput, aria.State.INVALID, false);
+ }
}
}
- if (this.sourceBlock_.RTL) {
- // The SVG is LTR, force value to be RTL.
- textLines += '\u200F';
- }
- return textLines;
-};
-/**
- * Called by setValue if the text input is valid. Updates the value of the
- * field, and updates the text of the field if it is not currently being
- * edited (i.e. handled by the htmlInput_). Is being redefined here to update
- * overflow state of the field.
- * @param {*} newValue The value to be saved. The default validator guarantees
- * that this is a string.
- * @protected
- */
-FieldMultilineInput.prototype.doValueUpdate_ = function(newValue) {
- FieldMultilineInput.superClass_.doValueUpdate_.call(this, newValue);
- this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_;
-};
+ /**
+ * Updates the size of the field based on the text.
+ * @protected
+ */
+ updateSize_() {
+ const nodes = this.textGroup_.childNodes;
+ let totalWidth = 0;
+ let totalHeight = 0;
+ for (let i = 0; i < nodes.length; i++) {
+ const tspan = /** @type {!Element} */ (nodes[i]);
+ const textWidth = dom.getTextWidth(tspan);
+ if (textWidth > totalWidth) {
+ totalWidth = textWidth;
+ }
+ totalHeight += this.getConstants().FIELD_TEXT_HEIGHT +
+ (i > 0 ? this.getConstants().FIELD_BORDER_RECT_Y_PADDING : 0);
+ }
+ if (this.isBeingEdited_) {
+ // The default width is based on the longest line in the display text,
+ // but when it's being edited, width should be calculated based on the
+ // absolute longest line, even if it would be truncated after editing.
+ // Otherwise we would get wrong editor width when there are more
+ // lines than this.maxLines_.
+ const actualEditorLines = this.value_.split('\n');
+ const dummyTextElement = dom.createSvgElement(
+ Svg.TEXT, {'class': 'blocklyText blocklyMultilineText'});
+ const fontSize = this.getConstants().FIELD_TEXT_FONTSIZE;
+ const fontWeight = this.getConstants().FIELD_TEXT_FONTWEIGHT;
+ const fontFamily = this.getConstants().FIELD_TEXT_FONTFAMILY;
-/**
- * Updates the text of the textElement.
- * @protected
- */
-FieldMultilineInput.prototype.render_ = function() {
- // Remove all text group children.
- let currentChild;
- while ((currentChild = this.textGroup_.firstChild)) {
- this.textGroup_.removeChild(currentChild);
+ for (let i = 0; i < actualEditorLines.length; i++) {
+ if (actualEditorLines[i].length > this.maxDisplayLength) {
+ actualEditorLines[i] =
+ actualEditorLines[i].substring(0, this.maxDisplayLength);
+ }
+ dummyTextElement.textContent = actualEditorLines[i];
+ const lineWidth = dom.getFastTextWidth(
+ dummyTextElement, fontSize, fontWeight, fontFamily);
+ if (lineWidth > totalWidth) {
+ totalWidth = lineWidth;
+ }
+ }
+
+ const scrollbarWidth =
+ this.htmlInput_.offsetWidth - this.htmlInput_.clientWidth;
+ totalWidth += scrollbarWidth;
+ }
+ if (this.borderRect_) {
+ totalHeight += this.getConstants().FIELD_BORDER_RECT_Y_PADDING * 2;
+ totalWidth += this.getConstants().FIELD_BORDER_RECT_X_PADDING * 2;
+ this.borderRect_.setAttribute('width', totalWidth);
+ this.borderRect_.setAttribute('height', totalHeight);
+ }
+ this.size_.width = totalWidth;
+ this.size_.height = totalHeight;
+
+ this.positionBorderRect_();
}
- // Add in text elements into the group.
- const lines = this.getDisplayText_().split('\n');
- let y = 0;
- for (let i = 0; i < lines.length; i++) {
+ /**
+ * Show the inline free-text editor on top of the text.
+ * Overrides the default behaviour to force rerender in order to
+ * correct block size, based on editor text.
+ * @param {Event=} _opt_e Optional mouse event that triggered the field to
+ * open, or undefined if triggered programmatically.
+ * @param {boolean=} opt_quietInput True if editor should be created without
+ * focus. Defaults to false.
+ * @override
+ */
+ showEditor_(_opt_e, opt_quietInput) {
+ super.showEditor_(_opt_e, opt_quietInput);
+ this.forceRerender();
+ }
+
+ /**
+ * Create the text input editor widget.
+ * @return {!HTMLTextAreaElement} The newly created text input editor.
+ * @protected
+ */
+ widgetCreate_() {
+ const div = WidgetDiv.getDiv();
+ const scale = this.workspace_.getScale();
+
+ const htmlInput =
+ /** @type {HTMLTextAreaElement} */ (document.createElement('textarea'));
+ htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput';
+ htmlInput.setAttribute('spellcheck', this.spellcheck_);
+ const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt';
+ div.style.fontSize = fontSize;
+ htmlInput.style.fontSize = fontSize;
+ const borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px';
+ htmlInput.style.borderRadius = borderRadius;
+ const paddingX = this.getConstants().FIELD_BORDER_RECT_X_PADDING * scale;
+ const paddingY =
+ this.getConstants().FIELD_BORDER_RECT_Y_PADDING * scale / 2;
+ htmlInput.style.padding = paddingY + 'px ' + paddingX + 'px ' + paddingY +
+ 'px ' + paddingX + 'px';
const lineHeight = this.getConstants().FIELD_TEXT_HEIGHT +
this.getConstants().FIELD_BORDER_RECT_Y_PADDING;
- const span = dom.createSvgElement(
- Svg.TEXT, {
- 'class': 'blocklyText blocklyMultilineText',
- 'x': this.getConstants().FIELD_BORDER_RECT_X_PADDING,
- 'y': y + this.getConstants().FIELD_BORDER_RECT_Y_PADDING,
- 'dy': this.getConstants().FIELD_TEXT_BASELINE,
- },
- this.textGroup_);
- span.appendChild(document.createTextNode(lines[i]));
- y += lineHeight;
- }
+ htmlInput.style.lineHeight = (lineHeight * scale) + 'px';
- if (this.isBeingEdited_) {
- const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_);
- if (this.isOverflowedY_) {
- dom.addClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
- } else {
- dom.removeClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
- }
- }
+ div.appendChild(htmlInput);
- this.updateSize_();
-
- if (this.isBeingEdited_) {
- if (this.sourceBlock_.RTL) {
- // in RTL, we need to let the browser reflow before resizing
- // in order to get the correct bounding box of the borderRect
- // avoiding issue #2777.
+ htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
+ htmlInput.untypedDefaultValue_ = this.value_;
+ htmlInput.oldValue_ = null;
+ if (userAgent.GECKO) {
+ // In FF, ensure the browser reflows before resizing to avoid issue #2777.
setTimeout(this.resizeEditor_.bind(this), 0);
} else {
this.resizeEditor_();
}
- const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_);
- if (!this.isTextValid_) {
- dom.addClass(htmlInput, 'blocklyInvalidInput');
- aria.setState(htmlInput, aria.State.INVALID, true);
- } else {
- dom.removeClass(htmlInput, 'blocklyInvalidInput');
- aria.setState(htmlInput, aria.State.INVALID, false);
+
+ this.bindInputEvents_(htmlInput);
+
+ return htmlInput;
+ }
+
+ /**
+ * Sets the maxLines config for this field.
+ * @param {number} maxLines Defines the maximum number of lines allowed,
+ * before scrolling functionality is enabled.
+ */
+ setMaxLines(maxLines) {
+ if (typeof maxLines === 'number' && maxLines > 0 &&
+ maxLines !== this.maxLines_) {
+ this.maxLines_ = maxLines;
+ this.forceRerender();
}
}
-};
-/**
- * Updates the size of the field based on the text.
- * @protected
- */
-FieldMultilineInput.prototype.updateSize_ = function() {
- const nodes = this.textGroup_.childNodes;
- let totalWidth = 0;
- let totalHeight = 0;
- for (let i = 0; i < nodes.length; i++) {
- const tspan = /** @type {!Element} */ (nodes[i]);
- const textWidth = dom.getTextWidth(tspan);
- if (textWidth > totalWidth) {
- totalWidth = textWidth;
+ /**
+ * Returns the maxLines config of this field.
+ * @return {number} The maxLines config value.
+ */
+ getMaxLines() {
+ return this.maxLines_;
+ }
+
+ /**
+ * Handle key down to the editor. Override the text input definition of this
+ * so as to not close the editor when enter is typed in.
+ * @param {!Event} e Keyboard event.
+ * @protected
+ */
+ onHtmlInputKeyDown_(e) {
+ if (e.keyCode !== KeyCodes.ENTER) {
+ super.onHtmlInputKeyDown_(e);
}
- totalHeight += this.getConstants().FIELD_TEXT_HEIGHT +
- (i > 0 ? this.getConstants().FIELD_BORDER_RECT_Y_PADDING : 0);
- }
- if (this.isBeingEdited_) {
- // The default width is based on the longest line in the display text,
- // but when it's being edited, width should be calculated based on the
- // absolute longest line, even if it would be truncated after editing.
- // Otherwise we would get wrong editor width when there are more
- // lines than this.maxLines_.
- const actualEditorLines = this.value_.split('\n');
- const dummyTextElement = dom.createSvgElement(
- Svg.TEXT, {'class': 'blocklyText blocklyMultilineText'});
- const fontSize = this.getConstants().FIELD_TEXT_FONTSIZE;
- const fontWeight = this.getConstants().FIELD_TEXT_FONTWEIGHT;
- const fontFamily = this.getConstants().FIELD_TEXT_FONTFAMILY;
-
- for (let i = 0; i < actualEditorLines.length; i++) {
- if (actualEditorLines[i].length > this.maxDisplayLength) {
- actualEditorLines[i] =
- actualEditorLines[i].substring(0, this.maxDisplayLength);
- }
- dummyTextElement.textContent = actualEditorLines[i];
- const lineWidth = dom.getFastTextWidth(
- dummyTextElement, fontSize, fontWeight, fontFamily);
- if (lineWidth > totalWidth) {
- totalWidth = lineWidth;
- }
- }
-
- const scrollbarWidth =
- this.htmlInput_.offsetWidth - this.htmlInput_.clientWidth;
- totalWidth += scrollbarWidth;
- }
- if (this.borderRect_) {
- totalHeight += this.getConstants().FIELD_BORDER_RECT_Y_PADDING * 2;
- totalWidth += this.getConstants().FIELD_BORDER_RECT_X_PADDING * 2;
- this.borderRect_.setAttribute('width', totalWidth);
- this.borderRect_.setAttribute('height', totalHeight);
- }
- this.size_.width = totalWidth;
- this.size_.height = totalHeight;
-
- this.positionBorderRect_();
-};
-
-/**
- * Show the inline free-text editor on top of the text.
- * Overrides the default behaviour to force rerender in order to
- * correct block size, based on editor text.
- * @param {Event=} _opt_e Optional mouse event that triggered the field to open,
- * or undefined if triggered programmatically.
- * @param {boolean=} opt_quietInput True if editor should be created without
- * focus. Defaults to false.
- * @override
- */
-FieldMultilineInput.prototype.showEditor_ = function(_opt_e, opt_quietInput) {
- FieldMultilineInput.superClass_.showEditor_.call(
- this, _opt_e, opt_quietInput);
- this.forceRerender();
-};
-
-/**
- * Create the text input editor widget.
- * @return {!HTMLTextAreaElement} The newly created text input editor.
- * @protected
- */
-FieldMultilineInput.prototype.widgetCreate_ = function() {
- const div = WidgetDiv.getDiv();
- const scale = this.workspace_.getScale();
-
- const htmlInput =
- /** @type {HTMLTextAreaElement} */ (document.createElement('textarea'));
- htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput';
- htmlInput.setAttribute('spellcheck', this.spellcheck_);
- const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt';
- div.style.fontSize = fontSize;
- htmlInput.style.fontSize = fontSize;
- const borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px';
- htmlInput.style.borderRadius = borderRadius;
- const paddingX = this.getConstants().FIELD_BORDER_RECT_X_PADDING * scale;
- const paddingY = this.getConstants().FIELD_BORDER_RECT_Y_PADDING * scale / 2;
- htmlInput.style.padding =
- paddingY + 'px ' + paddingX + 'px ' + paddingY + 'px ' + paddingX + 'px';
- const lineHeight = this.getConstants().FIELD_TEXT_HEIGHT +
- this.getConstants().FIELD_BORDER_RECT_Y_PADDING;
- htmlInput.style.lineHeight = (lineHeight * scale) + 'px';
-
- div.appendChild(htmlInput);
-
- htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
- htmlInput.untypedDefaultValue_ = this.value_;
- htmlInput.oldValue_ = null;
- if (userAgent.GECKO) {
- // In FF, ensure the browser reflows before resizing to avoid issue #2777.
- setTimeout(this.resizeEditor_.bind(this), 0);
- } else {
- this.resizeEditor_();
}
- this.bindInputEvents_(htmlInput);
-
- return htmlInput;
-};
-
-/**
- * Sets the maxLines config for this field.
- * @param {number} maxLines Defines the maximum number of lines allowed,
- * before scrolling functionality is enabled.
- */
-FieldMultilineInput.prototype.setMaxLines = function(maxLines) {
- if (typeof maxLines === 'number' && maxLines > 0 &&
- maxLines !== this.maxLines_) {
- this.maxLines_ = maxLines;
- this.forceRerender();
+ /**
+ * Construct a FieldMultilineInput from a JSON arg object,
+ * dereferencing any string table references.
+ * @param {!Object} options A JSON object with options (text, and spellcheck).
+ * @return {!FieldMultilineInput} The new field instance.
+ * @package
+ * @nocollapse
+ * @override
+ */
+ static fromJson(options) {
+ const text = parsing.replaceMessageReferences(options['text']);
+ // `this` might be a subclass of FieldMultilineInput if that class doesn't
+ // override the static fromJson method.
+ return new this(text, undefined, options);
}
-};
-
-/**
- * Returns the maxLines config of this field.
- * @return {number} The maxLines config value.
- */
-FieldMultilineInput.prototype.getMaxLines = function() {
- return this.maxLines_;
-};
-
-/**
- * Handle key down to the editor. Override the text input definition of this
- * so as to not close the editor when enter is typed in.
- * @param {!Event} e Keyboard event.
- * @protected
- */
-FieldMultilineInput.prototype.onHtmlInputKeyDown_ = function(e) {
- if (e.keyCode !== KeyCodes.ENTER) {
- FieldMultilineInput.superClass_.onHtmlInputKeyDown_.call(this, e);
- }
-};
+}
/**
* CSS for multiline field. See css.js for use.
*/
Css.register(`
- .blocklyHtmlTextAreaInput {
- font-family: monospace;
- resize: none;
- overflow: hidden;
- height: 100%;
- text-align: left;
- }
+.blocklyHtmlTextAreaInput {
+ font-family: monospace;
+ resize: none;
+ overflow: hidden;
+ height: 100%;
+ text-align: left;
+}
- .blocklyHtmlTextAreaInputOverflowedY {
- overflow-y: scroll;
- }
+.blocklyHtmlTextAreaInputOverflowedY {
+ overflow-y: scroll;
+}
`);
fieldRegistry.register('field_multilinetext', FieldMultilineInput);
diff --git a/core/field_number.js b/core/field_number.js
index afa9ee89b..6e19cfee4 100644
--- a/core/field_number.js
+++ b/core/field_number.js
@@ -17,67 +17,316 @@ goog.module('Blockly.FieldNumber');
const aria = goog.require('Blockly.utils.aria');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
+const {Field} = goog.require('Blockly.Field');
const {FieldTextInput} = goog.require('Blockly.FieldTextInput');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
/**
* Class for an editable number field.
- * @param {string|number=} opt_value The initial value of the field. Should cast
- * to a number. Defaults to 0.
- * @param {?(string|number)=} opt_min Minimum value.
- * @param {?(string|number)=} opt_max Maximum value.
- * @param {?(string|number)=} opt_precision Precision for value.
- * @param {?Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a number & returns a validated
- * number, or null to abort the change.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/number#creation}
- * for a list of properties this parameter supports.
* @extends {FieldTextInput}
- * @constructor
* @alias Blockly.FieldNumber
*/
-const FieldNumber = function(
- opt_value, opt_min, opt_max, opt_precision, opt_validator, opt_config) {
+class FieldNumber extends FieldTextInput {
/**
- * The minimum value this number field can contain.
- * @type {number}
- * @protected
+ * @param {(string|number|!Sentinel)=} opt_value The initial value of
+ * the field. Should cast to a number. Defaults to 0.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {?(string|number)=} opt_min Minimum value. Will only be used if
+ * opt_config is not provided.
+ * @param {?(string|number)=} opt_max Maximum value. Will only be used if
+ * opt_config is not provided.
+ * @param {?(string|number)=} opt_precision Precision for value. Will only be
+ * used if opt_config is not provided.
+ * @param {?Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a number & returns a validated
+ * number, or null to abort the change.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/number#creation}
+ * for a list of properties this parameter supports.
*/
- this.min_ = -Infinity;
+ constructor(
+ opt_value, opt_min, opt_max, opt_precision, opt_validator, opt_config) {
+ // Pass SENTINEL so that we can define properties before value validation.
+ super(Field.SKIP_SETUP);
+
+ /**
+ * The minimum value this number field can contain.
+ * @type {number}
+ * @protected
+ */
+ this.min_ = -Infinity;
+
+ /**
+ * The maximum value this number field can contain.
+ * @type {number}
+ * @protected
+ */
+ this.max_ = Infinity;
+
+ /**
+ * The multiple to which this fields value is rounded.
+ * @type {number}
+ * @protected
+ */
+ this.precision_ = 0;
+
+ /**
+ * The number of decimal places to allow, or null to allow any number of
+ * decimal digits.
+ * @type {?number}
+ * @private
+ */
+ this.decimalPlaces_ = null;
+
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
+
+ if (opt_value === Field.SKIP_SETUP) return;
+ if (opt_config) {
+ this.configure_(opt_config);
+ } else {
+ this.setConstraints(opt_min, opt_max, opt_precision);
+ }
+ this.setValue(opt_value);
+ if (opt_validator) this.setValidator(opt_validator);
+ }
/**
- * The maximum value this number field can contain.
- * @type {number}
+ * Configure the field based on the given map of options.
+ * @param {!Object} config A map of options to configure the field based on.
* @protected
+ * @override
*/
- this.max_ = Infinity;
+ configure_(config) {
+ super.configure_(config);
+ this.setMinInternal_(config['min']);
+ this.setMaxInternal_(config['max']);
+ this.setPrecisionInternal_(config['precision']);
+ }
/**
- * The multiple to which this fields value is rounded.
- * @type {number}
- * @protected
+ * Set the maximum, minimum and precision constraints on this field.
+ * Any of these properties may be undefined or NaN to be disabled.
+ * Setting precision (usually a power of 10) enforces a minimum step between
+ * values. That is, the user's value will rounded to the closest multiple of
+ * precision. The least significant digit place is inferred from the
+ * precision. Integers values can be enforces by choosing an integer
+ * precision.
+ * @param {?(number|string|undefined)} min Minimum value.
+ * @param {?(number|string|undefined)} max Maximum value.
+ * @param {?(number|string|undefined)} precision Precision for value.
*/
- this.precision_ = 0;
+ setConstraints(min, max, precision) {
+ this.setMinInternal_(min);
+ this.setMaxInternal_(max);
+ this.setPrecisionInternal_(precision);
+ this.setValue(this.getValue());
+ }
/**
- * The number of decimal places to allow, or null to allow any number of
- * decimal digits.
- * @type {?number}
+ * Sets the minimum value this field can contain. Updates the value to
+ * reflect.
+ * @param {?(number|string|undefined)} min Minimum value.
+ */
+ setMin(min) {
+ this.setMinInternal_(min);
+ this.setValue(this.getValue());
+ }
+
+ /**
+ * Sets the minimum value this field can contain. Called internally to avoid
+ * value updates.
+ * @param {?(number|string|undefined)} min Minimum value.
* @private
*/
- this.decimalPlaces_ = null;
-
- FieldNumber.superClass_.constructor.call(
- this, opt_value, opt_validator, opt_config);
-
- if (!opt_config) { // Only do one kind of configuration or the other.
- this.setConstraints(opt_min, opt_max, opt_precision);
+ setMinInternal_(min) {
+ if (min == null) {
+ this.min_ = -Infinity;
+ } else {
+ min = Number(min);
+ if (!isNaN(min)) {
+ this.min_ = min;
+ }
+ }
}
-};
-object.inherits(FieldNumber, FieldTextInput);
+
+ /**
+ * Returns the current minimum value this field can contain. Default is
+ * -Infinity.
+ * @return {number} The current minimum value this field can contain.
+ */
+ getMin() {
+ return this.min_;
+ }
+
+ /**
+ * Sets the maximum value this field can contain. Updates the value to
+ * reflect.
+ * @param {?(number|string|undefined)} max Maximum value.
+ */
+ setMax(max) {
+ this.setMaxInternal_(max);
+ this.setValue(this.getValue());
+ }
+
+ /**
+ * Sets the maximum value this field can contain. Called internally to avoid
+ * value updates.
+ * @param {?(number|string|undefined)} max Maximum value.
+ * @private
+ */
+ setMaxInternal_(max) {
+ if (max == null) {
+ this.max_ = Infinity;
+ } else {
+ max = Number(max);
+ if (!isNaN(max)) {
+ this.max_ = max;
+ }
+ }
+ }
+
+ /**
+ * Returns the current maximum value this field can contain. Default is
+ * Infinity.
+ * @return {number} The current maximum value this field can contain.
+ */
+ getMax() {
+ return this.max_;
+ }
+
+ /**
+ * Sets the precision of this field's value, i.e. the number to which the
+ * value is rounded. Updates the field to reflect.
+ * @param {?(number|string|undefined)} precision The number to which the
+ * field's value is rounded.
+ */
+ setPrecision(precision) {
+ this.setPrecisionInternal_(precision);
+ this.setValue(this.getValue());
+ }
+
+ /**
+ * Sets the precision of this field's value. Called internally to avoid
+ * value updates.
+ * @param {?(number|string|undefined)} precision The number to which the
+ * field's value is rounded.
+ * @private
+ */
+ setPrecisionInternal_(precision) {
+ this.precision_ = Number(precision) || 0;
+ let precisionString = String(this.precision_);
+ if (precisionString.indexOf('e') !== -1) {
+ // String() is fast. But it turns .0000001 into '1e-7'.
+ // Use the much slower toLocaleString to access all the digits.
+ precisionString =
+ this.precision_.toLocaleString('en-US', {maximumFractionDigits: 20});
+ }
+ const decimalIndex = precisionString.indexOf('.');
+ if (decimalIndex === -1) {
+ // If the precision is 0 (float) allow any number of decimals,
+ // otherwise allow none.
+ this.decimalPlaces_ = precision ? 0 : null;
+ } else {
+ this.decimalPlaces_ = precisionString.length - decimalIndex - 1;
+ }
+ }
+
+ /**
+ * Returns the current precision of this field. The precision being the
+ * number to which the field's value is rounded. A precision of 0 means that
+ * the value is not rounded.
+ * @return {number} The number to which this field's value is rounded.
+ */
+ getPrecision() {
+ return this.precision_;
+ }
+
+ /**
+ * Ensure that the input value is a valid number (must fulfill the
+ * constraints placed on the field).
+ * @param {*=} opt_newValue The input value.
+ * @return {?number} A valid number, or null if invalid.
+ * @protected
+ * @override
+ */
+ doClassValidation_(opt_newValue) {
+ if (opt_newValue === null) {
+ return null;
+ }
+ // Clean up text.
+ let newValue = String(opt_newValue);
+ // TODO: Handle cases like 'ten', '1.203,14', etc.
+ // 'O' is sometimes mistaken for '0' by inexperienced users.
+ newValue = newValue.replace(/O/ig, '0');
+ // Strip out thousands separators.
+ newValue = newValue.replace(/,/g, '');
+ // Ignore case of 'Infinity'.
+ newValue = newValue.replace(/infinity/i, 'Infinity');
+
+ // Clean up number.
+ let n = Number(newValue || 0);
+ if (isNaN(n)) {
+ // Invalid number.
+ return null;
+ }
+ // Get the value in range.
+ n = Math.min(Math.max(n, this.min_), this.max_);
+ // Round to nearest multiple of precision.
+ if (this.precision_ && isFinite(n)) {
+ n = Math.round(n / this.precision_) * this.precision_;
+ }
+ // Clean up floating point errors.
+ if (this.decimalPlaces_ !== null) {
+ n = Number(n.toFixed(this.decimalPlaces_));
+ }
+ return n;
+ }
+
+ /**
+ * Create the number input editor widget.
+ * @return {!HTMLElement} The newly created number input editor.
+ * @protected
+ * @override
+ */
+ widgetCreate_() {
+ const htmlInput = super.widgetCreate_();
+
+ // Set the accessibility state
+ if (this.min_ > -Infinity) {
+ aria.setState(htmlInput, aria.State.VALUEMIN, this.min_);
+ }
+ if (this.max_ < Infinity) {
+ aria.setState(htmlInput, aria.State.VALUEMAX, this.max_);
+ }
+ return htmlInput;
+ }
+
+ /**
+ * Construct a FieldNumber from a JSON arg object.
+ * @param {!Object} options A JSON object with options (value, min, max, and
+ * precision).
+ * @return {!FieldNumber} The new field instance.
+ * @package
+ * @nocollapse
+ * @override
+ */
+ static fromJson(options) {
+ // `this` might be a subclass of FieldNumber if that class doesn't override
+ // the static fromJson method.
+ return new this(
+ options['value'], undefined, undefined, undefined, undefined, options);
+ }
+}
/**
* The default value for this field.
@@ -86,236 +335,6 @@ object.inherits(FieldNumber, FieldTextInput);
*/
FieldNumber.prototype.DEFAULT_VALUE = 0;
-/**
- * Construct a FieldNumber from a JSON arg object.
- * @param {!Object} options A JSON object with options (value, min, max, and
- * precision).
- * @return {!FieldNumber} The new field instance.
- * @package
- * @nocollapse
- */
-FieldNumber.fromJson = function(options) {
- // `this` might be a subclass of FieldNumber if that class doesn't override
- // the static fromJson method.
- return new this(
- options['value'], undefined, undefined, undefined, undefined, options);
-};
-
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable.
- * @type {boolean}
- */
-FieldNumber.prototype.SERIALIZABLE = true;
-
-/**
- * Configure the field based on the given map of options.
- * @param {!Object} config A map of options to configure the field based on.
- * @protected
- * @override
- */
-FieldNumber.prototype.configure_ = function(config) {
- FieldNumber.superClass_.configure_.call(this, config);
- this.setMinInternal_(config['min']);
- this.setMaxInternal_(config['max']);
- this.setPrecisionInternal_(config['precision']);
-};
-
-/**
- * Set the maximum, minimum and precision constraints on this field.
- * Any of these properties may be undefined or NaN to be disabled.
- * Setting precision (usually a power of 10) enforces a minimum step between
- * values. That is, the user's value will rounded to the closest multiple of
- * precision. The least significant digit place is inferred from the precision.
- * Integers values can be enforces by choosing an integer precision.
- * @param {?(number|string|undefined)} min Minimum value.
- * @param {?(number|string|undefined)} max Maximum value.
- * @param {?(number|string|undefined)} precision Precision for value.
- */
-FieldNumber.prototype.setConstraints = function(min, max, precision) {
- this.setMinInternal_(min);
- this.setMaxInternal_(max);
- this.setPrecisionInternal_(precision);
- this.setValue(this.getValue());
-};
-
-/**
- * Sets the minimum value this field can contain. Updates the value to reflect.
- * @param {?(number|string|undefined)} min Minimum value.
- */
-FieldNumber.prototype.setMin = function(min) {
- this.setMinInternal_(min);
- this.setValue(this.getValue());
-};
-
-/**
- * Sets the minimum value this field can contain. Called internally to avoid
- * value updates.
- * @param {?(number|string|undefined)} min Minimum value.
- * @private
- */
-FieldNumber.prototype.setMinInternal_ = function(min) {
- if (min == null) {
- this.min_ = -Infinity;
- } else {
- min = Number(min);
- if (!isNaN(min)) {
- this.min_ = min;
- }
- }
-};
-
-/**
- * Returns the current minimum value this field can contain. Default is
- * -Infinity.
- * @return {number} The current minimum value this field can contain.
- */
-FieldNumber.prototype.getMin = function() {
- return this.min_;
-};
-
-/**
- * Sets the maximum value this field can contain. Updates the value to reflect.
- * @param {?(number|string|undefined)} max Maximum value.
- */
-FieldNumber.prototype.setMax = function(max) {
- this.setMaxInternal_(max);
- this.setValue(this.getValue());
-};
-
-/**
- * Sets the maximum value this field can contain. Called internally to avoid
- * value updates.
- * @param {?(number|string|undefined)} max Maximum value.
- * @private
- */
-FieldNumber.prototype.setMaxInternal_ = function(max) {
- if (max == null) {
- this.max_ = Infinity;
- } else {
- max = Number(max);
- if (!isNaN(max)) {
- this.max_ = max;
- }
- }
-};
-
-/**
- * Returns the current maximum value this field can contain. Default is
- * Infinity.
- * @return {number} The current maximum value this field can contain.
- */
-FieldNumber.prototype.getMax = function() {
- return this.max_;
-};
-
-/**
- * Sets the precision of this field's value, i.e. the number to which the
- * value is rounded. Updates the field to reflect.
- * @param {?(number|string|undefined)} precision The number to which the
- * field's value is rounded.
- */
-FieldNumber.prototype.setPrecision = function(precision) {
- this.setPrecisionInternal_(precision);
- this.setValue(this.getValue());
-};
-
-/**
- * Sets the precision of this field's value. Called internally to avoid
- * value updates.
- * @param {?(number|string|undefined)} precision The number to which the
- * field's value is rounded.
- * @private
- */
-FieldNumber.prototype.setPrecisionInternal_ = function(precision) {
- this.precision_ = Number(precision) || 0;
- let precisionString = String(this.precision_);
- if (precisionString.indexOf('e') !== -1) {
- // String() is fast. But it turns .0000001 into '1e-7'.
- // Use the much slower toLocaleString to access all the digits.
- precisionString =
- this.precision_.toLocaleString('en-US', {maximumFractionDigits: 20});
- }
- const decimalIndex = precisionString.indexOf('.');
- if (decimalIndex === -1) {
- // If the precision is 0 (float) allow any number of decimals,
- // otherwise allow none.
- this.decimalPlaces_ = precision ? 0 : null;
- } else {
- this.decimalPlaces_ = precisionString.length - decimalIndex - 1;
- }
-};
-
-/**
- * Returns the current precision of this field. The precision being the
- * number to which the field's value is rounded. A precision of 0 means that
- * the value is not rounded.
- * @return {number} The number to which this field's value is rounded.
- */
-FieldNumber.prototype.getPrecision = function() {
- return this.precision_;
-};
-
-/**
- * Ensure that the input value is a valid number (must fulfill the
- * constraints placed on the field).
- * @param {*=} opt_newValue The input value.
- * @return {?number} A valid number, or null if invalid.
- * @protected
- * @override
- */
-FieldNumber.prototype.doClassValidation_ = function(opt_newValue) {
- if (opt_newValue === null) {
- return null;
- }
- // Clean up text.
- let newValue = String(opt_newValue);
- // TODO: Handle cases like 'ten', '1.203,14', etc.
- // 'O' is sometimes mistaken for '0' by inexperienced users.
- newValue = newValue.replace(/O/ig, '0');
- // Strip out thousands separators.
- newValue = newValue.replace(/,/g, '');
- // Ignore case of 'Infinity'.
- newValue = newValue.replace(/infinity/i, 'Infinity');
-
- // Clean up number.
- let n = Number(newValue || 0);
- if (isNaN(n)) {
- // Invalid number.
- return null;
- }
- // Get the value in range.
- n = Math.min(Math.max(n, this.min_), this.max_);
- // Round to nearest multiple of precision.
- if (this.precision_ && isFinite(n)) {
- n = Math.round(n / this.precision_) * this.precision_;
- }
- // Clean up floating point errors.
- if (this.decimalPlaces_ !== null) {
- n = Number(n.toFixed(this.decimalPlaces_));
- }
- return n;
-};
-
-/**
- * Create the number input editor widget.
- * @return {!HTMLElement} The newly created number input editor.
- * @protected
- * @override
- */
-FieldNumber.prototype.widgetCreate_ = function() {
- const htmlInput = FieldNumber.superClass_.widgetCreate_.call(this);
-
- // Set the accessibility state
- if (this.min_ > -Infinity) {
- aria.setState(htmlInput, aria.State.VALUEMIN, this.min_);
- }
- if (this.max_ < Infinity) {
- aria.setState(htmlInput, aria.State.VALUEMAX, this.max_);
- }
- return htmlInput;
-};
-
fieldRegistry.register('field_number', FieldNumber);
exports.FieldNumber = FieldNumber;
diff --git a/core/field_textinput.js b/core/field_textinput.js
index dfb6f055f..25a8a15f5 100644
--- a/core/field_textinput.js
+++ b/core/field_textinput.js
@@ -20,19 +20,20 @@ const aria = goog.require('Blockly.utils.aria');
const browserEvents = goog.require('Blockly.browserEvents');
const dialog = goog.require('Blockly.dialog');
const dom = goog.require('Blockly.utils.dom');
+const dropDownDiv = goog.require('Blockly.dropDownDiv');
const eventUtils = goog.require('Blockly.Events.utils');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
-const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
const userAgent = goog.require('Blockly.utils.userAgent');
/* eslint-disable-next-line no-unused-vars */
const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
const {Coordinate} = goog.require('Blockly.utils.Coordinate');
-const {DropDownDiv} = goog.require('Blockly.DropDownDiv');
const {Field} = goog.require('Blockly.Field');
const {KeyCodes} = goog.require('Blockly.utils.KeyCodes');
const {Msg} = goog.require('Blockly.Msg');
/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
+/* eslint-disable-next-line no-unused-vars */
const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg');
/** @suppress {extraRequire} */
goog.require('Blockly.Events.BlockChange');
@@ -40,65 +41,565 @@ goog.require('Blockly.Events.BlockChange');
/**
* Class for an editable text field.
- * @param {string=} opt_value The initial value of the field. Should cast to a
- * string. Defaults to an empty string if null or undefined.
- * @param {?Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a string & returns a validated
- * string, or null to abort the change.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/text-input#creation}
- * for a list of properties this parameter supports.
- * @extends {Field}
- * @constructor
* @alias Blockly.FieldTextInput
*/
-const FieldTextInput = function(opt_value, opt_validator, opt_config) {
+class FieldTextInput extends Field {
/**
- * Allow browser to spellcheck this field.
- * @type {boolean}
+ * @param {(string|!Sentinel)=} opt_value The initial value of the
+ * field. Should cast to a string. Defaults to an empty string if null or
+ * undefined.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {?Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a string & returns a validated
+ * string, or null to abort the change.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/text-input#creation}
+ * for a list of properties this parameter supports.
+ */
+ constructor(opt_value, opt_validator, opt_config) {
+ super(Field.SKIP_SETUP);
+
+ /**
+ * Allow browser to spellcheck this field.
+ * @type {boolean}
+ * @protected
+ */
+ this.spellcheck_ = true;
+
+ /**
+ * The HTML input element.
+ * @type {HTMLElement}
+ * @protected
+ */
+ this.htmlInput_ = null;
+
+ /**
+ * True if the field's value is currently being edited via the UI.
+ * @type {boolean}
+ * @private
+ */
+ this.isBeingEdited_ = false;
+
+ /**
+ * True if the value currently displayed in the field's editory UI is valid.
+ * @type {boolean}
+ * @private
+ */
+ this.isTextValid_ = false;
+
+ /**
+ * Key down event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onKeyDownWrapper_ = null;
+
+ /**
+ * Key input event data.
+ * @type {?browserEvents.Data}
+ * @private
+ */
+ this.onKeyInputWrapper_ = null;
+
+ /**
+ * Whether the field should consider the whole parent block to be its click
+ * target.
+ * @type {?boolean}
+ */
+ this.fullBlockClickTarget_ = false;
+
+ /**
+ * The workspace that this field belongs to.
+ * @type {?WorkspaceSvg}
+ * @protected
+ */
+ this.workspace_ = null;
+
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
+
+ /**
+ * Mouse cursor style when over the hotspot that initiates the editor.
+ * @type {string}
+ */
+ this.CURSOR = 'text';
+
+ if (opt_value === Field.SKIP_SETUP) return;
+ if (opt_config) this.configure_(opt_config);
+ this.setValue(opt_value);
+ if (opt_validator) this.setValidator(opt_validator);
+ }
+
+ /**
+ * @override
+ */
+ configure_(config) {
+ super.configure_(config);
+ if (typeof config['spellcheck'] === 'boolean') {
+ this.spellcheck_ = config['spellcheck'];
+ }
+ }
+
+ /**
+ * @override
+ */
+ initView() {
+ if (this.getConstants().FULL_BLOCK_FIELDS) {
+ // Step one: figure out if this is the only field on this block.
+ // Rendering is quite different in that case.
+ let nFields = 0;
+ let nConnections = 0;
+
+ // Count the number of fields, excluding text fields
+ for (let i = 0, input; (input = this.sourceBlock_.inputList[i]); i++) {
+ for (let j = 0; (input.fieldRow[j]); j++) {
+ nFields++;
+ }
+ if (input.connection) {
+ nConnections++;
+ }
+ }
+ // The special case is when this is the only non-label field on the block
+ // and it has an output but no inputs.
+ this.fullBlockClickTarget_ =
+ nFields <= 1 && this.sourceBlock_.outputConnection && !nConnections;
+ } else {
+ this.fullBlockClickTarget_ = false;
+ }
+
+ if (this.fullBlockClickTarget_) {
+ this.clickTarget_ = this.sourceBlock_.getSvgRoot();
+ } else {
+ this.createBorderRect_();
+ }
+ this.createTextElement_();
+ }
+
+ /**
+ * Ensure that the input value casts to a valid string.
+ * @param {*=} opt_newValue The input value.
+ * @return {*} A valid string, or null if invalid.
* @protected
*/
- this.spellcheck_ = true;
-
- FieldTextInput.superClass_.constructor.call(
- this, opt_value, opt_validator, opt_config);
+ doClassValidation_(opt_newValue) {
+ if (opt_newValue === null || opt_newValue === undefined) {
+ return null;
+ }
+ return String(opt_newValue);
+ }
/**
- * The HTML input element.
- * @type {HTMLElement}
- */
- this.htmlInput_ = null;
-
- /**
- * Key down event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onKeyDownWrapper_ = null;
-
- /**
- * Key input event data.
- * @type {?browserEvents.Data}
- * @private
- */
- this.onKeyInputWrapper_ = null;
-
- /**
- * Whether the field should consider the whole parent block to be its click
- * target.
- * @type {?boolean}
- */
- this.fullBlockClickTarget_ = false;
-
- /**
- * The workspace that this field belongs to.
- * @type {?WorkspaceSvg}
+ * Called by setValue if the text input is not valid. If the field is
+ * currently being edited it reverts value of the field to the previous
+ * value while allowing the display text to be handled by the htmlInput_.
+ * @param {*} _invalidValue The input value that was determined to be invalid.
+ * This is not used by the text input because its display value is stored
+ * on the htmlInput_.
* @protected
*/
- this.workspace_ = null;
-};
-object.inherits(FieldTextInput, Field);
+ doValueInvalid_(_invalidValue) {
+ if (this.isBeingEdited_) {
+ this.isTextValid_ = false;
+ const oldValue = this.value_;
+ // Revert value when the text becomes invalid.
+ this.value_ = this.htmlInput_.untypedDefaultValue_;
+ if (this.sourceBlock_ && eventUtils.isEnabled()) {
+ eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
+ this.sourceBlock_, 'field', this.name || null, oldValue,
+ this.value_));
+ }
+ }
+ }
+
+ /**
+ * Called by setValue if the text input is valid. Updates the value of the
+ * field, and updates the text of the field if it is not currently being
+ * edited (i.e. handled by the htmlInput_).
+ * @param {*} newValue The value to be saved. The default validator guarantees
+ * that this is a string.
+ * @protected
+ */
+ doValueUpdate_(newValue) {
+ this.isTextValid_ = true;
+ this.value_ = newValue;
+ if (!this.isBeingEdited_) {
+ // This should only occur if setValue is triggered programmatically.
+ this.isDirty_ = true;
+ }
+ }
+
+ /**
+ * Updates text field to match the colour/style of the block.
+ * @package
+ */
+ applyColour() {
+ if (this.sourceBlock_ && this.getConstants().FULL_BLOCK_FIELDS) {
+ if (this.borderRect_) {
+ this.borderRect_.setAttribute(
+ 'stroke', this.sourceBlock_.style.colourTertiary);
+ } else {
+ this.sourceBlock_.pathObject.svgPath.setAttribute(
+ 'fill', this.getConstants().FIELD_BORDER_RECT_COLOUR);
+ }
+ }
+ }
+
+ /**
+ * Updates the colour of the htmlInput given the current validity of the
+ * field's value.
+ * @protected
+ */
+ render_() {
+ super.render_();
+ // This logic is done in render_ rather than doValueInvalid_ or
+ // doValueUpdate_ so that the code is more centralized.
+ if (this.isBeingEdited_) {
+ this.resizeEditor_();
+ const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_);
+ if (!this.isTextValid_) {
+ dom.addClass(htmlInput, 'blocklyInvalidInput');
+ aria.setState(htmlInput, aria.State.INVALID, true);
+ } else {
+ dom.removeClass(htmlInput, 'blocklyInvalidInput');
+ aria.setState(htmlInput, aria.State.INVALID, false);
+ }
+ }
+ }
+
+ /**
+ * Set whether this field is spellchecked by the browser.
+ * @param {boolean} check True if checked.
+ */
+ setSpellcheck(check) {
+ if (check === this.spellcheck_) {
+ return;
+ }
+ this.spellcheck_ = check;
+ if (this.htmlInput_) {
+ this.htmlInput_.setAttribute('spellcheck', this.spellcheck_);
+ }
+ }
+
+ /**
+ * Show the inline free-text editor on top of the text.
+ * @param {Event=} _opt_e Optional mouse event that triggered the field to
+ * open, or undefined if triggered programmatically.
+ * @param {boolean=} opt_quietInput True if editor should be created without
+ * focus. Defaults to false.
+ * @protected
+ */
+ showEditor_(_opt_e, opt_quietInput) {
+ this.workspace_ = (/** @type {!BlockSvg} */ (this.sourceBlock_)).workspace;
+ const quietInput = opt_quietInput || false;
+ if (!quietInput &&
+ (userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD)) {
+ this.showPromptEditor_();
+ } else {
+ this.showInlineEditor_(quietInput);
+ }
+ }
+
+ /**
+ * Create and show a text input editor that is a prompt (usually a popup).
+ * Mobile browsers have issues with in-line textareas (focus and keyboards).
+ * @private
+ */
+ showPromptEditor_() {
+ dialog.prompt(Msg['CHANGE_VALUE_TITLE'], this.getText(), function(text) {
+ // Text is null if user pressed cancel button.
+ if (text !== null) {
+ this.setValue(this.getValueFromEditorText_(text));
+ }
+ }.bind(this));
+ }
+
+ /**
+ * Create and show a text input editor that sits directly over the text input.
+ * @param {boolean} quietInput True if editor should be created without
+ * focus.
+ * @private
+ */
+ showInlineEditor_(quietInput) {
+ WidgetDiv.show(this, this.sourceBlock_.RTL, this.widgetDispose_.bind(this));
+ this.htmlInput_ = this.widgetCreate_();
+ this.isBeingEdited_ = true;
+
+ if (!quietInput) {
+ this.htmlInput_.focus({preventScroll: true});
+ this.htmlInput_.select();
+ }
+ }
+
+ /**
+ * Create the text input editor widget.
+ * @return {!HTMLElement} The newly created text input editor.
+ * @protected
+ */
+ widgetCreate_() {
+ eventUtils.setGroup(true);
+ const div = WidgetDiv.getDiv();
+
+ dom.addClass(this.getClickTarget_(), 'editing');
+
+ const htmlInput =
+ /** @type {HTMLInputElement} */ (document.createElement('input'));
+ htmlInput.className = 'blocklyHtmlInput';
+ htmlInput.setAttribute('spellcheck', this.spellcheck_);
+ const scale = this.workspace_.getScale();
+ const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt';
+ div.style.fontSize = fontSize;
+ htmlInput.style.fontSize = fontSize;
+ let borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px';
+
+ if (this.fullBlockClickTarget_) {
+ const bBox = this.getScaledBBox();
+
+ // Override border radius.
+ borderRadius = (bBox.bottom - bBox.top) / 2 + 'px';
+ // Pull stroke colour from the existing shadow block
+ const strokeColour = this.sourceBlock_.getParent() ?
+ this.sourceBlock_.getParent().style.colourTertiary :
+ this.sourceBlock_.style.colourTertiary;
+ htmlInput.style.border = (1 * scale) + 'px solid ' + strokeColour;
+ div.style.borderRadius = borderRadius;
+ div.style.transition = 'box-shadow 0.25s ease 0s';
+ if (this.getConstants().FIELD_TEXTINPUT_BOX_SHADOW) {
+ div.style.boxShadow =
+ 'rgba(255, 255, 255, 0.3) 0 0 0 ' + (4 * scale) + 'px';
+ }
+ }
+ htmlInput.style.borderRadius = borderRadius;
+
+ div.appendChild(htmlInput);
+
+ htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
+ htmlInput.untypedDefaultValue_ = this.value_;
+ htmlInput.oldValue_ = null;
+
+ this.resizeEditor_();
+
+ this.bindInputEvents_(htmlInput);
+
+ return htmlInput;
+ }
+
+ /**
+ * Closes the editor, saves the results, and disposes of any events or
+ * DOM-references belonging to the editor.
+ * @protected
+ */
+ widgetDispose_() {
+ // Non-disposal related things that we do when the editor closes.
+ this.isBeingEdited_ = false;
+ this.isTextValid_ = true;
+ // Make sure the field's node matches the field's internal value.
+ this.forceRerender();
+ this.onFinishEditing_(this.value_);
+ eventUtils.setGroup(false);
+
+ // Actual disposal.
+ this.unbindInputEvents_();
+ const style = WidgetDiv.getDiv().style;
+ style.width = 'auto';
+ style.height = 'auto';
+ style.fontSize = '';
+ style.transition = '';
+ style.boxShadow = '';
+ this.htmlInput_ = null;
+
+ dom.removeClass(this.getClickTarget_(), 'editing');
+ }
+
+ /**
+ * A callback triggered when the user is done editing the field via the UI.
+ * @param {*} _value The new value of the field.
+ */
+ onFinishEditing_(_value) {
+ // NOP by default.
+ // TODO(#2496): Support people passing a func into the field.
+ }
+
+ /**
+ * Bind handlers for user input on the text input field's editor.
+ * @param {!HTMLElement} htmlInput The htmlInput to which event
+ * handlers will be bound.
+ * @protected
+ */
+ bindInputEvents_(htmlInput) {
+ // Trap Enter without IME and Esc to hide.
+ this.onKeyDownWrapper_ = browserEvents.conditionalBind(
+ htmlInput, 'keydown', this, this.onHtmlInputKeyDown_);
+ // Resize after every input change.
+ this.onKeyInputWrapper_ = browserEvents.conditionalBind(
+ htmlInput, 'input', this, this.onHtmlInputChange_);
+ }
+
+ /**
+ * Unbind handlers for user input and workspace size changes.
+ * @protected
+ */
+ unbindInputEvents_() {
+ if (this.onKeyDownWrapper_) {
+ browserEvents.unbind(this.onKeyDownWrapper_);
+ this.onKeyDownWrapper_ = null;
+ }
+ if (this.onKeyInputWrapper_) {
+ browserEvents.unbind(this.onKeyInputWrapper_);
+ this.onKeyInputWrapper_ = null;
+ }
+ }
+
+ /**
+ * Handle key down to the editor.
+ * @param {!Event} e Keyboard event.
+ * @protected
+ */
+ onHtmlInputKeyDown_(e) {
+ if (e.keyCode === KeyCodes.ENTER) {
+ WidgetDiv.hide();
+ dropDownDiv.hideWithoutAnimation();
+ } else if (e.keyCode === KeyCodes.ESC) {
+ this.setValue(this.htmlInput_.untypedDefaultValue_);
+ WidgetDiv.hide();
+ dropDownDiv.hideWithoutAnimation();
+ } else if (e.keyCode === KeyCodes.TAB) {
+ WidgetDiv.hide();
+ dropDownDiv.hideWithoutAnimation();
+ this.sourceBlock_.tab(this, !e.shiftKey);
+ e.preventDefault();
+ }
+ }
+
+ /**
+ * Handle a change to the editor.
+ * @param {!Event} _e Keyboard event.
+ * @private
+ */
+ onHtmlInputChange_(_e) {
+ const text = this.htmlInput_.value;
+ if (text !== this.htmlInput_.oldValue_) {
+ this.htmlInput_.oldValue_ = text;
+
+ const value = this.getValueFromEditorText_(text);
+ this.setValue(value);
+ this.forceRerender();
+ this.resizeEditor_();
+ }
+ }
+
+ /**
+ * Set the HTML input value and the field's internal value. The difference
+ * between this and ``setValue`` is that this also updates the HTML input
+ * value whilst editing.
+ * @param {*} newValue New value.
+ * @protected
+ */
+ setEditorValue_(newValue) {
+ this.isDirty_ = true;
+ if (this.isBeingEdited_) {
+ // In the case this method is passed an invalid value, we still
+ // pass it through the transformation method `getEditorText` to deal
+ // with. Otherwise, the internal field's state will be inconsistent
+ // with what's shown to the user.
+ this.htmlInput_.value = this.getEditorText_(newValue);
+ }
+ this.setValue(newValue);
+ }
+
+ /**
+ * Resize the editor to fit the text.
+ * @protected
+ */
+ resizeEditor_() {
+ const div = WidgetDiv.getDiv();
+ const bBox = this.getScaledBBox();
+ div.style.width = bBox.right - bBox.left + 'px';
+ div.style.height = bBox.bottom - bBox.top + 'px';
+
+ // In RTL mode block fields and LTR input fields the left edge moves,
+ // whereas the right edge is fixed. Reposition the editor.
+ const x = this.sourceBlock_.RTL ? bBox.right - div.offsetWidth : bBox.left;
+ const xy = new Coordinate(x, bBox.top);
+
+ div.style.left = xy.x + 'px';
+ div.style.top = xy.y + 'px';
+ }
+
+ /**
+ * Returns whether or not the field is tab navigable.
+ * @return {boolean} True if the field is tab navigable.
+ * @override
+ */
+ isTabNavigable() {
+ return true;
+ }
+
+ /**
+ * Use the `getText_` developer hook to override the field's text
+ * representation. When we're currently editing, return the current HTML value
+ * instead. Otherwise, return null which tells the field to use the default
+ * behaviour (which is a string cast of the field's value).
+ * @return {?string} The HTML value if we're editing, otherwise null.
+ * @protected
+ * @override
+ */
+ getText_() {
+ if (this.isBeingEdited_ && this.htmlInput_) {
+ // We are currently editing, return the HTML input value instead.
+ return this.htmlInput_.value;
+ }
+ return null;
+ }
+
+ /**
+ * Transform the provided value into a text to show in the HTML input.
+ * Override this method if the field's HTML input representation is different
+ * than the field's value. This should be coupled with an override of
+ * `getValueFromEditorText_`.
+ * @param {*} value The value stored in this field.
+ * @return {string} The text to show on the HTML input.
+ * @protected
+ */
+ getEditorText_(value) {
+ return String(value);
+ }
+
+ /**
+ * Transform the text received from the HTML input into a value to store
+ * in this field.
+ * Override this method if the field's HTML input representation is different
+ * than the field's value. This should be coupled with an override of
+ * `getEditorText_`.
+ * @param {string} text Text received from the HTML input.
+ * @return {*} The value to store.
+ * @protected
+ */
+ getValueFromEditorText_(text) {
+ return text;
+ }
+
+ /**
+ * Construct a FieldTextInput from a JSON arg object,
+ * dereferencing any string table references.
+ * @param {!Object} options A JSON object with options (text, and spellcheck).
+ * @return {!FieldTextInput} The new field instance.
+ * @package
+ * @nocollapse
+ */
+ static fromJson(options) {
+ const text = parsing.replaceMessageReferences(options['text']);
+ // `this` might be a subclass of FieldTextInput if that class doesn't
+ // override the static fromJson method.
+ return new this(text, undefined, options);
+ }
+}
/**
* The default value for this field.
@@ -107,481 +608,12 @@ object.inherits(FieldTextInput, Field);
*/
FieldTextInput.prototype.DEFAULT_VALUE = '';
-/**
- * Construct a FieldTextInput from a JSON arg object,
- * dereferencing any string table references.
- * @param {!Object} options A JSON object with options (text, and spellcheck).
- * @return {!FieldTextInput} The new field instance.
- * @package
- * @nocollapse
- */
-FieldTextInput.fromJson = function(options) {
- const text = parsing.replaceMessageReferences(options['text']);
- // `this` might be a subclass of FieldTextInput if that class doesn't override
- // the static fromJson method.
- return new this(text, undefined, options);
-};
-
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable.
- * @type {boolean}
- */
-FieldTextInput.prototype.SERIALIZABLE = true;
-
/**
* Pixel size of input border radius.
* Should match blocklyText's border-radius in CSS.
*/
FieldTextInput.BORDERRADIUS = 4;
-/**
- * Mouse cursor style when over the hotspot that initiates the editor.
- */
-FieldTextInput.prototype.CURSOR = 'text';
-
-/**
- * @override
- */
-FieldTextInput.prototype.configure_ = function(config) {
- FieldTextInput.superClass_.configure_.call(this, config);
- if (typeof config['spellcheck'] === 'boolean') {
- this.spellcheck_ = config['spellcheck'];
- }
-};
-
-/**
- * @override
- */
-FieldTextInput.prototype.initView = function() {
- if (this.getConstants().FULL_BLOCK_FIELDS) {
- // Step one: figure out if this is the only field on this block.
- // Rendering is quite different in that case.
- let nFields = 0;
- let nConnections = 0;
-
- // Count the number of fields, excluding text fields
- for (let i = 0, input; (input = this.sourceBlock_.inputList[i]); i++) {
- for (let j = 0; (input.fieldRow[j]); j++) {
- nFields++;
- }
- if (input.connection) {
- nConnections++;
- }
- }
- // The special case is when this is the only non-label field on the block
- // and it has an output but no inputs.
- this.fullBlockClickTarget_ =
- nFields <= 1 && this.sourceBlock_.outputConnection && !nConnections;
- } else {
- this.fullBlockClickTarget_ = false;
- }
-
- if (this.fullBlockClickTarget_) {
- this.clickTarget_ = this.sourceBlock_.getSvgRoot();
- } else {
- this.createBorderRect_();
- }
- this.createTextElement_();
-};
-
-/**
- * Ensure that the input value casts to a valid string.
- * @param {*=} opt_newValue The input value.
- * @return {*} A valid string, or null if invalid.
- * @protected
- */
-FieldTextInput.prototype.doClassValidation_ = function(opt_newValue) {
- if (opt_newValue === null || opt_newValue === undefined) {
- return null;
- }
- return String(opt_newValue);
-};
-
-/**
- * Called by setValue if the text input is not valid. If the field is
- * currently being edited it reverts value of the field to the previous
- * value while allowing the display text to be handled by the htmlInput_.
- * @param {*} _invalidValue The input value that was determined to be invalid.
- * This is not used by the text input because its display value is stored on
- * the htmlInput_.
- * @protected
- */
-FieldTextInput.prototype.doValueInvalid_ = function(_invalidValue) {
- if (this.isBeingEdited_) {
- this.isTextValid_ = false;
- const oldValue = this.value_;
- // Revert value when the text becomes invalid.
- this.value_ = this.htmlInput_.untypedDefaultValue_;
- if (this.sourceBlock_ && eventUtils.isEnabled()) {
- eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))(
- this.sourceBlock_, 'field', this.name || null, oldValue,
- this.value_));
- }
- }
-};
-
-/**
- * Called by setValue if the text input is valid. Updates the value of the
- * field, and updates the text of the field if it is not currently being
- * edited (i.e. handled by the htmlInput_).
- * @param {*} newValue The value to be saved. The default validator guarantees
- * that this is a string.
- * @protected
- */
-FieldTextInput.prototype.doValueUpdate_ = function(newValue) {
- this.isTextValid_ = true;
- this.value_ = newValue;
- if (!this.isBeingEdited_) {
- // This should only occur if setValue is triggered programmatically.
- this.isDirty_ = true;
- }
-};
-
-/**
- * Updates text field to match the colour/style of the block.
- * @package
- */
-FieldTextInput.prototype.applyColour = function() {
- if (this.sourceBlock_ && this.getConstants().FULL_BLOCK_FIELDS) {
- if (this.borderRect_) {
- this.borderRect_.setAttribute(
- 'stroke', this.sourceBlock_.style.colourTertiary);
- } else {
- this.sourceBlock_.pathObject.svgPath.setAttribute(
- 'fill', this.getConstants().FIELD_BORDER_RECT_COLOUR);
- }
- }
-};
-
-/**
- * Updates the colour of the htmlInput given the current validity of the
- * field's value.
- * @protected
- */
-FieldTextInput.prototype.render_ = function() {
- FieldTextInput.superClass_.render_.call(this);
- // This logic is done in render_ rather than doValueInvalid_ or
- // doValueUpdate_ so that the code is more centralized.
- if (this.isBeingEdited_) {
- this.resizeEditor_();
- const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_);
- if (!this.isTextValid_) {
- dom.addClass(htmlInput, 'blocklyInvalidInput');
- aria.setState(htmlInput, aria.State.INVALID, true);
- } else {
- dom.removeClass(htmlInput, 'blocklyInvalidInput');
- aria.setState(htmlInput, aria.State.INVALID, false);
- }
- }
-};
-
-/**
- * Set whether this field is spellchecked by the browser.
- * @param {boolean} check True if checked.
- */
-FieldTextInput.prototype.setSpellcheck = function(check) {
- if (check === this.spellcheck_) {
- return;
- }
- this.spellcheck_ = check;
- if (this.htmlInput_) {
- this.htmlInput_.setAttribute('spellcheck', this.spellcheck_);
- }
-};
-
-/**
- * Show the inline free-text editor on top of the text.
- * @param {Event=} _opt_e Optional mouse event that triggered the field to open,
- * or undefined if triggered programmatically.
- * @param {boolean=} opt_quietInput True if editor should be created without
- * focus. Defaults to false.
- * @protected
- */
-FieldTextInput.prototype.showEditor_ = function(_opt_e, opt_quietInput) {
- this.workspace_ = (/** @type {!BlockSvg} */ (this.sourceBlock_)).workspace;
- const quietInput = opt_quietInput || false;
- if (!quietInput &&
- (userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD)) {
- this.showPromptEditor_();
- } else {
- this.showInlineEditor_(quietInput);
- }
-};
-
-/**
- * Create and show a text input editor that is a prompt (usually a popup).
- * Mobile browsers have issues with in-line textareas (focus and keyboards).
- * @private
- */
-FieldTextInput.prototype.showPromptEditor_ = function() {
- dialog.prompt(Msg['CHANGE_VALUE_TITLE'], this.getText(), function(text) {
- // Text is null if user pressed cancel button.
- if (text !== null) {
- this.setValue(this.getValueFromEditorText_(text));
- }
- }.bind(this));
-};
-
-/**
- * Create and show a text input editor that sits directly over the text input.
- * @param {boolean} quietInput True if editor should be created without
- * focus.
- * @private
- */
-FieldTextInput.prototype.showInlineEditor_ = function(quietInput) {
- WidgetDiv.show(this, this.sourceBlock_.RTL, this.widgetDispose_.bind(this));
- this.htmlInput_ = this.widgetCreate_();
- this.isBeingEdited_ = true;
-
- if (!quietInput) {
- this.htmlInput_.focus({preventScroll: true});
- this.htmlInput_.select();
- }
-};
-
-/**
- * Create the text input editor widget.
- * @return {!HTMLElement} The newly created text input editor.
- * @protected
- */
-FieldTextInput.prototype.widgetCreate_ = function() {
- eventUtils.setGroup(true);
- const div = WidgetDiv.getDiv();
-
- dom.addClass(this.getClickTarget_(), 'editing');
-
- const htmlInput =
- /** @type {HTMLInputElement} */ (document.createElement('input'));
- htmlInput.className = 'blocklyHtmlInput';
- htmlInput.setAttribute('spellcheck', this.spellcheck_);
- const scale = this.workspace_.getScale();
- const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt';
- div.style.fontSize = fontSize;
- htmlInput.style.fontSize = fontSize;
- let borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px';
-
- if (this.fullBlockClickTarget_) {
- const bBox = this.getScaledBBox();
-
- // Override border radius.
- borderRadius = (bBox.bottom - bBox.top) / 2 + 'px';
- // Pull stroke colour from the existing shadow block
- const strokeColour = this.sourceBlock_.getParent() ?
- this.sourceBlock_.getParent().style.colourTertiary :
- this.sourceBlock_.style.colourTertiary;
- htmlInput.style.border = (1 * scale) + 'px solid ' + strokeColour;
- div.style.borderRadius = borderRadius;
- div.style.transition = 'box-shadow 0.25s ease 0s';
- if (this.getConstants().FIELD_TEXTINPUT_BOX_SHADOW) {
- div.style.boxShadow =
- 'rgba(255, 255, 255, 0.3) 0 0 0 ' + (4 * scale) + 'px';
- }
- }
- htmlInput.style.borderRadius = borderRadius;
-
- div.appendChild(htmlInput);
-
- htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
- htmlInput.untypedDefaultValue_ = this.value_;
- htmlInput.oldValue_ = null;
-
- this.resizeEditor_();
-
- this.bindInputEvents_(htmlInput);
-
- return htmlInput;
-};
-
-/**
- * Closes the editor, saves the results, and disposes of any events or
- * DOM-references belonging to the editor.
- * @protected
- */
-FieldTextInput.prototype.widgetDispose_ = function() {
- // Non-disposal related things that we do when the editor closes.
- this.isBeingEdited_ = false;
- this.isTextValid_ = true;
- // Make sure the field's node matches the field's internal value.
- this.forceRerender();
- // TODO(#2496): Make this less of a hack.
- if (this.onFinishEditing_) {
- this.onFinishEditing_(this.value_);
- }
- eventUtils.setGroup(false);
-
- // Actual disposal.
- this.unbindInputEvents_();
- const style = WidgetDiv.getDiv().style;
- style.width = 'auto';
- style.height = 'auto';
- style.fontSize = '';
- style.transition = '';
- style.boxShadow = '';
- this.htmlInput_ = null;
-
- dom.removeClass(this.getClickTarget_(), 'editing');
-};
-
-/**
- * Bind handlers for user input on the text input field's editor.
- * @param {!HTMLElement} htmlInput The htmlInput to which event
- * handlers will be bound.
- * @protected
- */
-FieldTextInput.prototype.bindInputEvents_ = function(htmlInput) {
- // Trap Enter without IME and Esc to hide.
- this.onKeyDownWrapper_ = browserEvents.conditionalBind(
- htmlInput, 'keydown', this, this.onHtmlInputKeyDown_);
- // Resize after every input change.
- this.onKeyInputWrapper_ = browserEvents.conditionalBind(
- htmlInput, 'input', this, this.onHtmlInputChange_);
-};
-
-/**
- * Unbind handlers for user input and workspace size changes.
- * @protected
- */
-FieldTextInput.prototype.unbindInputEvents_ = function() {
- if (this.onKeyDownWrapper_) {
- browserEvents.unbind(this.onKeyDownWrapper_);
- this.onKeyDownWrapper_ = null;
- }
- if (this.onKeyInputWrapper_) {
- browserEvents.unbind(this.onKeyInputWrapper_);
- this.onKeyInputWrapper_ = null;
- }
-};
-
-/**
- * Handle key down to the editor.
- * @param {!Event} e Keyboard event.
- * @protected
- */
-FieldTextInput.prototype.onHtmlInputKeyDown_ = function(e) {
- if (e.keyCode === KeyCodes.ENTER) {
- WidgetDiv.hide();
- DropDownDiv.hideWithoutAnimation();
- } else if (e.keyCode === KeyCodes.ESC) {
- this.setValue(this.htmlInput_.untypedDefaultValue_);
- WidgetDiv.hide();
- DropDownDiv.hideWithoutAnimation();
- } else if (e.keyCode === KeyCodes.TAB) {
- WidgetDiv.hide();
- DropDownDiv.hideWithoutAnimation();
- this.sourceBlock_.tab(this, !e.shiftKey);
- e.preventDefault();
- }
-};
-
-/**
- * Handle a change to the editor.
- * @param {!Event} _e Keyboard event.
- * @private
- */
-FieldTextInput.prototype.onHtmlInputChange_ = function(_e) {
- const text = this.htmlInput_.value;
- if (text !== this.htmlInput_.oldValue_) {
- this.htmlInput_.oldValue_ = text;
-
- const value = this.getValueFromEditorText_(text);
- this.setValue(value);
- this.forceRerender();
- this.resizeEditor_();
- }
-};
-
-/**
- * Set the HTML input value and the field's internal value. The difference
- * between this and ``setValue`` is that this also updates the HTML input
- * value whilst editing.
- * @param {*} newValue New value.
- * @protected
- */
-FieldTextInput.prototype.setEditorValue_ = function(newValue) {
- this.isDirty_ = true;
- if (this.isBeingEdited_) {
- // In the case this method is passed an invalid value, we still
- // pass it through the transformation method `getEditorText` to deal
- // with. Otherwise, the internal field's state will be inconsistent
- // with what's shown to the user.
- this.htmlInput_.value = this.getEditorText_(newValue);
- }
- this.setValue(newValue);
-};
-
-/**
- * Resize the editor to fit the text.
- * @protected
- */
-FieldTextInput.prototype.resizeEditor_ = function() {
- const div = WidgetDiv.getDiv();
- const bBox = this.getScaledBBox();
- div.style.width = bBox.right - bBox.left + 'px';
- div.style.height = bBox.bottom - bBox.top + 'px';
-
- // In RTL mode block fields and LTR input fields the left edge moves,
- // whereas the right edge is fixed. Reposition the editor.
- const x = this.sourceBlock_.RTL ? bBox.right - div.offsetWidth : bBox.left;
- const xy = new Coordinate(x, bBox.top);
-
- div.style.left = xy.x + 'px';
- div.style.top = xy.y + 'px';
-};
-
-/**
- * Returns whether or not the field is tab navigable.
- * @return {boolean} True if the field is tab navigable.
- * @override
- */
-FieldTextInput.prototype.isTabNavigable = function() {
- return true;
-};
-
-/**
- * Use the `getText_` developer hook to override the field's text
- * representation. When we're currently editing, return the current HTML value
- * instead. Otherwise, return null which tells the field to use the default
- * behaviour (which is a string cast of the field's value).
- * @return {?string} The HTML value if we're editing, otherwise null.
- * @protected
- * @override
- */
-FieldTextInput.prototype.getText_ = function() {
- if (this.isBeingEdited_ && this.htmlInput_) {
- // We are currently editing, return the HTML input value instead.
- return this.htmlInput_.value;
- }
- return null;
-};
-
-/**
- * Transform the provided value into a text to show in the HTML input.
- * Override this method if the field's HTML input representation is different
- * than the field's value. This should be coupled with an override of
- * `getValueFromEditorText_`.
- * @param {*} value The value stored in this field.
- * @return {string} The text to show on the HTML input.
- * @protected
- */
-FieldTextInput.prototype.getEditorText_ = function(value) {
- return String(value);
-};
-
-/**
- * Transform the text received from the HTML input into a value to store
- * in this field.
- * Override this method if the field's HTML input representation is different
- * than the field's value. This should be coupled with an override of
- * `getEditorText_`.
- * @param {string} text Text received from the HTML input.
- * @return {*} The value to store.
- * @protected
- */
-FieldTextInput.prototype.getValueFromEditorText_ = function(text) {
- return text;
-};
-
fieldRegistry.register('field_input', FieldTextInput);
exports.FieldTextInput = FieldTextInput;
diff --git a/core/field_variable.js b/core/field_variable.js
index af43f02a5..bca4d5203 100644
--- a/core/field_variable.js
+++ b/core/field_variable.js
@@ -19,16 +19,18 @@ const Variables = goog.require('Blockly.Variables');
const Xml = goog.require('Blockly.Xml');
const fieldRegistry = goog.require('Blockly.fieldRegistry');
const internalConstants = goog.require('Blockly.internalConstants');
-const object = goog.require('Blockly.utils.object');
const parsing = goog.require('Blockly.utils.parsing');
/* eslint-disable-next-line no-unused-vars */
const {Block} = goog.requireType('Blockly.Block');
+const {Field} = goog.require('Blockly.Field');
const {FieldDropdown} = goog.require('Blockly.FieldDropdown');
/* eslint-disable-next-line no-unused-vars */
const {MenuItem} = goog.requireType('Blockly.MenuItem');
/* eslint-disable-next-line no-unused-vars */
const {Menu} = goog.requireType('Blockly.Menu');
const {Msg} = goog.require('Blockly.Msg');
+/* eslint-disable-next-line no-unused-vars */
+const {Sentinel} = goog.requireType('Blockly.utils.Sentinel');
const {Size} = goog.require('Blockly.utils.Size');
const {VariableModel} = goog.require('Blockly.VariableModel');
/** @suppress {extraRequire} */
@@ -37,483 +39,519 @@ goog.require('Blockly.Events.BlockChange');
/**
* Class for a variable's dropdown field.
- * @param {?string} varName The default name for the variable. If null,
- * a unique variable name will be generated.
- * @param {Function=} opt_validator A function that is called to validate
- * changes to the field's value. Takes in a variable ID & returns a
- * validated variable ID, or null to abort the change.
- * @param {Array=} opt_variableTypes A list of the types of variables
- * to include in the dropdown.
- * @param {string=} opt_defaultType The type of variable to create if this
- * field's value is not explicitly set. Defaults to ''.
- * @param {Object=} opt_config A map of options used to configure the field.
- * See the [field creation documentation]{@link
- * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/variable#creation}
- * for a list of properties this parameter supports.
* @extends {FieldDropdown}
- * @constructor
* @alias Blockly.FieldVariable
*/
-const FieldVariable = function(
- varName, opt_validator, opt_variableTypes, opt_defaultType, opt_config) {
- // The FieldDropdown constructor expects the field's initial value to be
- // the first entry in the menu generator, which it may or may not be.
- // Just do the relevant parts of the constructor.
+class FieldVariable extends FieldDropdown {
+ /**
+ * @param {?string|!Sentinel} varName The default name for the variable.
+ * If null, a unique variable name will be generated.
+ * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
+ * subclasses that want to handle configuration and setting the field
+ * value after their own constructors have run).
+ * @param {Function=} opt_validator A function that is called to validate
+ * changes to the field's value. Takes in a variable ID & returns a
+ * validated variable ID, or null to abort the change.
+ * @param {Array=} opt_variableTypes A list of the types of variables
+ * to include in the dropdown. Will only be used if opt_config is not
+ * provided.
+ * @param {string=} opt_defaultType The type of variable to create if this
+ * field's value is not explicitly set. Defaults to ''. Will only be used
+ * if opt_config is not provided.
+ * @param {Object=} opt_config A map of options used to configure the field.
+ * See the [field creation documentation]{@link
+ * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/variable#creation}
+ * for a list of properties this parameter supports.
+ */
+ constructor(
+ varName, opt_validator, opt_variableTypes, opt_defaultType, opt_config) {
+ super(Field.SKIP_SETUP);
+
+ /**
+ * An array of options for a dropdown list,
+ * or a function which generates these options.
+ * @type {(!Array|
+ * !function(this:FieldDropdown): !Array)}
+ * @protected
+ */
+ this.menuGenerator_ = FieldVariable.dropdownCreate;
+
+ /**
+ * The initial variable name passed to this field's constructor, or an
+ * empty string if a name wasn't provided. Used to create the initial
+ * variable.
+ * @type {string}
+ */
+ this.defaultVariableName = typeof varName === 'string' ? varName : '';
+
+ /**
+ * The type of the default variable for this field.
+ * @type {string}
+ * @private
+ */
+ this.defaultType_ = '';
+
+ /**
+ * All of the types of variables that will be available in this field's
+ * dropdown.
+ * @type {?Array}
+ */
+ this.variableTypes = [];
+
+ /**
+ * The size of the area rendered by the field.
+ * @type {Size}
+ * @protected
+ * @override
+ */
+ this.size_ = new Size(0, 0);
+
+ /**
+ * The variable model associated with this field.
+ * @type {?VariableModel}
+ * @private
+ */
+ this.variable_ = null;
+
+ /**
+ * Serializable fields are saved by the serializer, non-serializable fields
+ * are not. Editable fields should also be serializable.
+ * @type {boolean}
+ */
+ this.SERIALIZABLE = true;
+
+ if (varName === Field.SKIP_SETUP) return;
+
+ if (opt_config) {
+ this.configure_(opt_config);
+ } else {
+ this.setTypes_(opt_variableTypes, opt_defaultType);
+ }
+ if (opt_validator) this.setValidator(opt_validator);
+ }
/**
- * An array of options for a dropdown list,
- * or a function which generates these options.
- * @type {(!Array|
- * !function(this:FieldDropdown): !Array)}
+ * Configure the field based on the given map of options.
+ * @param {!Object} config A map of options to configure the field based on.
* @protected
*/
- this.menuGenerator_ = FieldVariable.dropdownCreate;
+ configure_(config) {
+ super.configure_(config);
+ this.setTypes_(config['variableTypes'], config['defaultType']);
+ }
/**
- * The initial variable name passed to this field's constructor, or an
- * empty string if a name wasn't provided. Used to create the initial
- * variable.
- * @type {string}
+ * Initialize the model for this field if it has not already been initialized.
+ * If the value has not been set to a variable by the first render, we make up
+ * a variable rather than let the value be invalid.
+ * @package
*/
- this.defaultVariableName = typeof varName === 'string' ? varName : '';
+ initModel() {
+ if (this.variable_) {
+ return; // Initialization already happened.
+ }
+ const variable = Variables.getOrCreateVariablePackage(
+ this.sourceBlock_.workspace, null, this.defaultVariableName,
+ this.defaultType_);
+
+ // Don't call setValue because we don't want to cause a rerender.
+ this.doValueUpdate_(variable.getId());
+ }
/**
- * The size of the area rendered by the field.
- * @type {Size}
- * @protected
* @override
*/
- this.size_ = new Size(0, 0);
-
- opt_config && this.configure_(opt_config);
- opt_validator && this.setValidator(opt_validator);
-
- if (!opt_config) { // Only do one kind of configuration or the other.
- this.setTypes_(opt_variableTypes, opt_defaultType);
- }
-};
-object.inherits(FieldVariable, FieldDropdown);
-
-/**
- * Construct a FieldVariable from a JSON arg object,
- * dereferencing any string table references.
- * @param {!Object} options A JSON object with options (variable,
- * variableTypes, and defaultType).
- * @return {!FieldVariable} The new field instance.
- * @package
- * @nocollapse
- */
-FieldVariable.fromJson = function(options) {
- const varName = parsing.replaceMessageReferences(options['variable']);
- // `this` might be a subclass of FieldVariable if that class doesn't override
- // the static fromJson method.
- return new this(varName, undefined, undefined, undefined, options);
-};
-
-/**
- * Serializable fields are saved by the XML renderer, non-serializable fields
- * are not. Editable fields should also be serializable.
- * @type {boolean}
- */
-FieldVariable.prototype.SERIALIZABLE = true;
-
-/**
- * Configure the field based on the given map of options.
- * @param {!Object} config A map of options to configure the field based on.
- * @protected
- */
-FieldVariable.prototype.configure_ = function(config) {
- FieldVariable.superClass_.configure_.call(this, config);
- this.setTypes_(config['variableTypes'], config['defaultType']);
-};
-
-/**
- * Initialize the model for this field if it has not already been initialized.
- * If the value has not been set to a variable by the first render, we make up a
- * variable rather than let the value be invalid.
- * @package
- */
-FieldVariable.prototype.initModel = function() {
- if (this.variable_) {
- return; // Initialization already happened.
- }
- const variable = Variables.getOrCreateVariablePackage(
- this.sourceBlock_.workspace, null, this.defaultVariableName,
- this.defaultType_);
-
- // Don't call setValue because we don't want to cause a rerender.
- this.doValueUpdate_(variable.getId());
-};
-
-/**
- * @override
- */
-FieldVariable.prototype.shouldAddBorderRect_ = function() {
- return FieldVariable.superClass_.shouldAddBorderRect_.call(this) &&
- (!this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW ||
- this.sourceBlock_.type !== 'variables_get');
-};
-
-/**
- * Initialize this field based on the given XML.
- * @param {!Element} fieldElement The element containing information about the
- * variable field's state.
- */
-FieldVariable.prototype.fromXml = function(fieldElement) {
- const id = fieldElement.getAttribute('id');
- const variableName = fieldElement.textContent;
- // 'variabletype' should be lowercase, but until July 2019 it was sometimes
- // recorded as 'variableType'. Thus we need to check for both.
- const variableType = fieldElement.getAttribute('variabletype') ||
- fieldElement.getAttribute('variableType') || '';
-
- const variable = Variables.getOrCreateVariablePackage(
- this.sourceBlock_.workspace, id, variableName, variableType);
-
- // This should never happen :)
- if (variableType !== null && variableType !== variable.type) {
- throw Error(
- 'Serialized variable type with id \'' + variable.getId() +
- '\' had type ' + variable.type + ', and ' +
- 'does not match variable field that references it: ' +
- Xml.domToText(fieldElement) + '.');
+ shouldAddBorderRect_() {
+ return super.shouldAddBorderRect_() &&
+ (!this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW ||
+ this.sourceBlock_.type !== 'variables_get');
}
- this.setValue(variable.getId());
-};
+ /**
+ * Initialize this field based on the given XML.
+ * @param {!Element} fieldElement The element containing information about the
+ * variable field's state.
+ */
+ fromXml(fieldElement) {
+ const id = fieldElement.getAttribute('id');
+ const variableName = fieldElement.textContent;
+ // 'variabletype' should be lowercase, but until July 2019 it was sometimes
+ // recorded as 'variableType'. Thus we need to check for both.
+ const variableType = fieldElement.getAttribute('variabletype') ||
+ fieldElement.getAttribute('variableType') || '';
-/**
- * Serialize this field to XML.
- * @param {!Element} fieldElement The element to populate with info about the
- * field's state.
- * @return {!Element} The element containing info about the field's state.
- */
-FieldVariable.prototype.toXml = function(fieldElement) {
- // Make sure the variable is initialized.
- this.initModel();
+ const variable = Variables.getOrCreateVariablePackage(
+ this.sourceBlock_.workspace, id, variableName, variableType);
- fieldElement.id = this.variable_.getId();
- fieldElement.textContent = this.variable_.name;
- if (this.variable_.type) {
- fieldElement.setAttribute('variabletype', this.variable_.type);
- }
- return fieldElement;
-};
-
-/**
- * Saves this field's value.
- * @param {boolean=} doFullSerialization If true, the variable field will
- * serialize the full state of the field being referenced (ie ID, name,
- * and type) rather than just a reference to it (ie ID).
- * @return {*} The state of the variable field.
- * @override
- * @package
- */
-FieldVariable.prototype.saveState = function(doFullSerialization) {
- const legacyState = this.saveLegacyState(FieldVariable);
- if (legacyState !== null) {
- return legacyState;
- }
- // Make sure the variable is initialized.
- this.initModel();
- const state = {'id': this.variable_.getId()};
- if (doFullSerialization) {
- state['name'] = this.variable_.name;
- state['type'] = this.variable_.type;
- }
- return state;
-};
-
-/**
- * Sets the field's value based on the given state.
- * @param {*} state The state of the variable to assign to this variable field.
- * @override
- * @package
- */
-FieldVariable.prototype.loadState = function(state) {
- if (this.loadLegacyState(FieldVariable, state)) {
- return;
- }
- // This is necessary so that blocks in the flyout can have custom var names.
- const variable = Variables.getOrCreateVariablePackage(
- this.sourceBlock_.workspace, state['id'] || null, state['name'],
- state['type'] || '');
- this.setValue(variable.getId());
-};
-
-/**
- * Attach this field to a block.
- * @param {!Block} block The block containing this field.
- */
-FieldVariable.prototype.setSourceBlock = function(block) {
- if (block.isShadow()) {
- throw Error('Variable fields are not allowed to exist on shadow blocks.');
- }
- FieldVariable.superClass_.setSourceBlock.call(this, block);
-};
-
-/**
- * Get the variable's ID.
- * @return {string} Current variable's ID.
- */
-FieldVariable.prototype.getValue = function() {
- return this.variable_ ? this.variable_.getId() : null;
-};
-
-/**
- * Get the text from this field, which is the selected variable's name.
- * @return {string} The selected variable's name, or the empty string if no
- * variable is selected.
- */
-FieldVariable.prototype.getText = function() {
- return this.variable_ ? this.variable_.name : '';
-};
-
-/**
- * Get the variable model for the selected variable.
- * Not guaranteed to be in the variable map on the workspace (e.g. if accessed
- * after the variable has been deleted).
- * @return {?VariableModel} The selected variable, or null if none was
- * selected.
- * @package
- */
-FieldVariable.prototype.getVariable = function() {
- return this.variable_;
-};
-
-/**
- * Gets the validation function for this field, or null if not set.
- * Returns null if the variable is not set, because validators should not
- * run on the initial setValue call, because the field won't be attached to
- * a block and workspace at that point.
- * @return {?Function} Validation function, or null.
- */
-FieldVariable.prototype.getValidator = function() {
- // Validators shouldn't operate on the initial setValue call.
- // Normally this is achieved by calling setValidator after setValue, but
- // this is not a possibility with variable fields.
- if (this.variable_) {
- return this.validator_;
- }
- return null;
-};
-
-/**
- * Ensure that the ID belongs to a valid variable of an allowed type.
- * @param {*=} opt_newValue The ID of the new variable to set.
- * @return {?string} The validated ID, or null if invalid.
- * @protected
- */
-FieldVariable.prototype.doClassValidation_ = function(opt_newValue) {
- if (opt_newValue === null) {
- return null;
- }
- const newId = /** @type {string} */ (opt_newValue);
- const variable = Variables.getVariable(this.sourceBlock_.workspace, newId);
- if (!variable) {
- console.warn(
- 'Variable id doesn\'t point to a real variable! ' +
- 'ID was ' + newId);
- return null;
- }
- // Type Checks.
- const type = variable.type;
- if (!this.typeIsAllowed_(type)) {
- console.warn('Variable type doesn\'t match this field! Type was ' + type);
- return null;
- }
- return newId;
-};
-
-/**
- * Update the value of this variable field, as well as its variable and text.
- *
- * The variable ID should be valid at this point, but if a variable field
- * validator returns a bad ID, this could break.
- * @param {*} newId The value to be saved.
- * @protected
- */
-FieldVariable.prototype.doValueUpdate_ = function(newId) {
- this.variable_ = Variables.getVariable(
- this.sourceBlock_.workspace, /** @type {string} */ (newId));
- FieldVariable.superClass_.doValueUpdate_.call(this, newId);
-};
-
-/**
- * Check whether the given variable type is allowed on this field.
- * @param {string} type The type to check.
- * @return {boolean} True if the type is in the list of allowed types.
- * @private
- */
-FieldVariable.prototype.typeIsAllowed_ = function(type) {
- const typeList = this.getVariableTypes_();
- if (!typeList) {
- return true; // If it's null, all types are valid.
- }
- for (let i = 0; i < typeList.length; i++) {
- if (type === typeList[i]) {
- return true;
+ // This should never happen :)
+ if (variableType !== null && variableType !== variable.type) {
+ throw Error(
+ 'Serialized variable type with id \'' + variable.getId() +
+ '\' had type ' + variable.type + ', and ' +
+ 'does not match variable field that references it: ' +
+ Xml.domToText(fieldElement) + '.');
}
- }
- return false;
-};
-/**
- * Return a list of variable types to include in the dropdown.
- * @return {!Array} Array of variable types.
- * @throws {Error} if variableTypes is an empty array.
- * @private
- */
-FieldVariable.prototype.getVariableTypes_ = function() {
- // TODO (#1513): Try to avoid calling this every time the field is edited.
- let variableTypes = this.variableTypes;
- if (variableTypes === null) {
- // If variableTypes is null, return all variable types.
- if (this.sourceBlock_ && this.sourceBlock_.workspace) {
- return this.sourceBlock_.workspace.getVariableTypes();
+ this.setValue(variable.getId());
+ }
+
+ /**
+ * Serialize this field to XML.
+ * @param {!Element} fieldElement The element to populate with info about the
+ * field's state.
+ * @return {!Element} The element containing info about the field's state.
+ */
+ toXml(fieldElement) {
+ // Make sure the variable is initialized.
+ this.initModel();
+
+ fieldElement.id = this.variable_.getId();
+ fieldElement.textContent = this.variable_.name;
+ if (this.variable_.type) {
+ fieldElement.setAttribute('variabletype', this.variable_.type);
}
+ return fieldElement;
}
- variableTypes = variableTypes || [''];
- if (variableTypes.length === 0) {
- // Throw an error if variableTypes is an empty list.
- const name = this.getText();
- throw Error(
- '\'variableTypes\' of field variable ' + name + ' was an empty list');
- }
- return variableTypes;
-};
-/**
- * Parse the optional arguments representing the allowed variable types and the
- * default variable type.
- * @param {Array=} opt_variableTypes A list of the types of variables
- * to include in the dropdown. If null or undefined, variables of all types
- * will be displayed in the dropdown.
- * @param {string=} opt_defaultType The type of the variable to create if this
- * field's value is not explicitly set. Defaults to ''.
- * @private
- */
-FieldVariable.prototype.setTypes_ = function(
- opt_variableTypes, opt_defaultType) {
- // If you expected that the default type would be the same as the only entry
- // in the variable types array, tell the Blockly team by commenting on #1499.
- const defaultType = opt_defaultType || '';
- let variableTypes;
- // Set the allowable variable types. Null means all types on the workspace.
- if (opt_variableTypes === null || opt_variableTypes === undefined) {
- variableTypes = null;
- } else if (Array.isArray(opt_variableTypes)) {
- variableTypes = opt_variableTypes;
- // Make sure the default type is valid.
- let isInArray = false;
- for (let i = 0; i < variableTypes.length; i++) {
- if (variableTypes[i] === defaultType) {
- isInArray = true;
+ /**
+ * Saves this field's value.
+ * @param {boolean=} doFullSerialization If true, the variable field will
+ * serialize the full state of the field being referenced (ie ID, name,
+ * and type) rather than just a reference to it (ie ID).
+ * @return {*} The state of the variable field.
+ * @override
+ * @package
+ */
+ saveState(doFullSerialization) {
+ const legacyState = this.saveLegacyState(FieldVariable);
+ if (legacyState !== null) {
+ return legacyState;
+ }
+ // Make sure the variable is initialized.
+ this.initModel();
+ const state = {'id': this.variable_.getId()};
+ if (doFullSerialization) {
+ state['name'] = this.variable_.name;
+ state['type'] = this.variable_.type;
+ }
+ return state;
+ }
+
+ /**
+ * Sets the field's value based on the given state.
+ * @param {*} state The state of the variable to assign to this variable
+ * field.
+ * @override
+ * @package
+ */
+ loadState(state) {
+ if (this.loadLegacyState(FieldVariable, state)) {
+ return;
+ }
+ // This is necessary so that blocks in the flyout can have custom var names.
+ const variable = Variables.getOrCreateVariablePackage(
+ this.sourceBlock_.workspace, state['id'] || null, state['name'],
+ state['type'] || '');
+ this.setValue(variable.getId());
+ }
+
+ /**
+ * Attach this field to a block.
+ * @param {!Block} block The block containing this field.
+ */
+ setSourceBlock(block) {
+ if (block.isShadow()) {
+ throw Error('Variable fields are not allowed to exist on shadow blocks.');
+ }
+ super.setSourceBlock(block);
+ }
+
+ /**
+ * Get the variable's ID.
+ * @return {?string} Current variable's ID.
+ */
+ getValue() {
+ return this.variable_ ? this.variable_.getId() : null;
+ }
+
+ /**
+ * Get the text from this field, which is the selected variable's name.
+ * @return {string} The selected variable's name, or the empty string if no
+ * variable is selected.
+ */
+ getText() {
+ return this.variable_ ? this.variable_.name : '';
+ }
+
+ /**
+ * Get the variable model for the selected variable.
+ * Not guaranteed to be in the variable map on the workspace (e.g. if accessed
+ * after the variable has been deleted).
+ * @return {?VariableModel} The selected variable, or null if none was
+ * selected.
+ * @package
+ */
+ getVariable() {
+ return this.variable_;
+ }
+
+ /**
+ * Gets the validation function for this field, or null if not set.
+ * Returns null if the variable is not set, because validators should not
+ * run on the initial setValue call, because the field won't be attached to
+ * a block and workspace at that point.
+ * @return {?Function} Validation function, or null.
+ */
+ getValidator() {
+ // Validators shouldn't operate on the initial setValue call.
+ // Normally this is achieved by calling setValidator after setValue, but
+ // this is not a possibility with variable fields.
+ if (this.variable_) {
+ return this.validator_;
+ }
+ return null;
+ }
+
+ /**
+ * Ensure that the ID belongs to a valid variable of an allowed type.
+ * @param {*=} opt_newValue The ID of the new variable to set.
+ * @return {?string} The validated ID, or null if invalid.
+ * @protected
+ */
+ doClassValidation_(opt_newValue) {
+ if (opt_newValue === null) {
+ return null;
+ }
+ const newId = /** @type {string} */ (opt_newValue);
+ const variable = Variables.getVariable(this.sourceBlock_.workspace, newId);
+ if (!variable) {
+ console.warn(
+ 'Variable id doesn\'t point to a real variable! ' +
+ 'ID was ' + newId);
+ return null;
+ }
+ // Type Checks.
+ const type = variable.type;
+ if (!this.typeIsAllowed_(type)) {
+ console.warn(
+ 'Variable type doesn\'t match this field! Type was ' + type);
+ return null;
+ }
+ return newId;
+ }
+
+ /**
+ * Update the value of this variable field, as well as its variable and text.
+ *
+ * The variable ID should be valid at this point, but if a variable field
+ * validator returns a bad ID, this could break.
+ * @param {*} newId The value to be saved.
+ * @protected
+ */
+ doValueUpdate_(newId) {
+ this.variable_ = Variables.getVariable(
+ this.sourceBlock_.workspace, /** @type {string} */ (newId));
+ super.doValueUpdate_(newId);
+ }
+
+ /**
+ * Check whether the given variable type is allowed on this field.
+ * @param {string} type The type to check.
+ * @return {boolean} True if the type is in the list of allowed types.
+ * @private
+ */
+ typeIsAllowed_(type) {
+ const typeList = this.getVariableTypes_();
+ if (!typeList) {
+ return true; // If it's null, all types are valid.
+ }
+ for (let i = 0; i < typeList.length; i++) {
+ if (type === typeList[i]) {
+ return true;
}
}
- if (!isInArray) {
+ return false;
+ }
+
+ /**
+ * Return a list of variable types to include in the dropdown.
+ * @return {!Array} Array of variable types.
+ * @throws {Error} if variableTypes is an empty array.
+ * @private
+ */
+ getVariableTypes_() {
+ // TODO (#1513): Try to avoid calling this every time the field is edited.
+ let variableTypes = this.variableTypes;
+ if (variableTypes === null) {
+ // If variableTypes is null, return all variable types.
+ if (this.sourceBlock_ && this.sourceBlock_.workspace) {
+ return this.sourceBlock_.workspace.getVariableTypes();
+ }
+ }
+ variableTypes = variableTypes || [''];
+ if (variableTypes.length === 0) {
+ // Throw an error if variableTypes is an empty list.
+ const name = this.getText();
throw Error(
- 'Invalid default type \'' + defaultType + '\' in ' +
- 'the definition of a FieldVariable');
+ '\'variableTypes\' of field variable ' + name + ' was an empty list');
}
- } else {
- throw Error(
- '\'variableTypes\' was not an array in the definition of ' +
- 'a FieldVariable');
+ return variableTypes;
}
- // Only update the field once all checks pass.
- this.defaultType_ = defaultType;
- this.variableTypes = variableTypes;
-};
-/**
- * Refreshes the name of the variable by grabbing the name of the model.
- * Used when a variable gets renamed, but the ID stays the same. Should only
- * be called by the block.
- * @package
- */
-FieldVariable.prototype.refreshVariableName = function() {
- this.forceRerender();
-};
-
-/**
- * Return a sorted list of variable names for variable dropdown menus.
- * Include a special option at the end for creating a new variable name.
- * @return {!Array} Array of variable names/id tuples.
- * @this {FieldVariable}
- */
-FieldVariable.dropdownCreate = function() {
- if (!this.variable_) {
- throw Error(
- 'Tried to call dropdownCreate on a variable field with no' +
- ' variable selected.');
- }
- const name = this.getText();
- let variableModelList = [];
- if (this.sourceBlock_ && this.sourceBlock_.workspace) {
- const variableTypes = this.getVariableTypes_();
- // Get a copy of the list, so that adding rename and new variable options
- // doesn't modify the workspace's list.
- for (let i = 0; i < variableTypes.length; i++) {
- const variableType = variableTypes[i];
- const variables =
- this.sourceBlock_.workspace.getVariablesOfType(variableType);
- variableModelList = variableModelList.concat(variables);
+ /**
+ * Parse the optional arguments representing the allowed variable types and
+ * the default variable type.
+ * @param {Array=} opt_variableTypes A list of the types of variables
+ * to include in the dropdown. If null or undefined, variables of all
+ * types will be displayed in the dropdown.
+ * @param {string=} opt_defaultType The type of the variable to create if this
+ * field's value is not explicitly set. Defaults to ''.
+ * @private
+ */
+ setTypes_(opt_variableTypes, opt_defaultType) {
+ // If you expected that the default type would be the same as the only entry
+ // in the variable types array, tell the Blockly team by commenting on
+ // #1499.
+ const defaultType = opt_defaultType || '';
+ let variableTypes;
+ // Set the allowable variable types. Null means all types on the workspace.
+ if (opt_variableTypes === null || opt_variableTypes === undefined) {
+ variableTypes = null;
+ } else if (Array.isArray(opt_variableTypes)) {
+ variableTypes = opt_variableTypes;
+ // Make sure the default type is valid.
+ let isInArray = false;
+ for (let i = 0; i < variableTypes.length; i++) {
+ if (variableTypes[i] === defaultType) {
+ isInArray = true;
+ }
+ }
+ if (!isInArray) {
+ throw Error(
+ 'Invalid default type \'' + defaultType + '\' in ' +
+ 'the definition of a FieldVariable');
+ }
+ } else {
+ throw Error(
+ '\'variableTypes\' was not an array in the definition of ' +
+ 'a FieldVariable');
}
- }
- variableModelList.sort(VariableModel.compareByName);
-
- const options = [];
- for (let i = 0; i < variableModelList.length; i++) {
- // Set the UUID as the internal representation of the variable.
- options[i] = [variableModelList[i].name, variableModelList[i].getId()];
- }
- options.push([Msg['RENAME_VARIABLE'], internalConstants.RENAME_VARIABLE_ID]);
- if (Msg['DELETE_VARIABLE']) {
- options.push([
- Msg['DELETE_VARIABLE'].replace('%1', name),
- internalConstants.DELETE_VARIABLE_ID,
- ]);
+ // Only update the field once all checks pass.
+ this.defaultType_ = defaultType;
+ this.variableTypes = variableTypes;
}
- return options;
-};
+ /**
+ * Refreshes the name of the variable by grabbing the name of the model.
+ * Used when a variable gets renamed, but the ID stays the same. Should only
+ * be called by the block.
+ * @override
+ * @package
+ */
+ refreshVariableName() {
+ this.forceRerender();
+ }
-/**
- * Handle the selection of an item in the variable dropdown menu.
- * Special case the 'Rename variable...' and 'Delete variable...' options.
- * In the rename case, prompt the user for a new name.
- * @param {!Menu} menu The Menu component clicked.
- * @param {!MenuItem} menuItem The MenuItem selected within menu.
- * @protected
- */
-FieldVariable.prototype.onItemSelected_ = function(menu, menuItem) {
- const id = menuItem.getValue();
- // Handle special cases.
- if (this.sourceBlock_ && this.sourceBlock_.workspace) {
- if (id === internalConstants.RENAME_VARIABLE_ID) {
- // Rename variable.
- Variables.renameVariable(this.sourceBlock_.workspace, this.variable_);
- return;
- } else if (id === internalConstants.DELETE_VARIABLE_ID) {
- // Delete variable.
- this.sourceBlock_.workspace.deleteVariableById(this.variable_.getId());
- return;
+ /**
+ * Handle the selection of an item in the variable dropdown menu.
+ * Special case the 'Rename variable...' and 'Delete variable...' options.
+ * In the rename case, prompt the user for a new name.
+ * @param {!Menu} menu The Menu component clicked.
+ * @param {!MenuItem} menuItem The MenuItem selected within menu.
+ * @protected
+ */
+ onItemSelected_(menu, menuItem) {
+ const id = menuItem.getValue();
+ // Handle special cases.
+ if (this.sourceBlock_ && this.sourceBlock_.workspace) {
+ if (id === internalConstants.RENAME_VARIABLE_ID) {
+ // Rename variable.
+ Variables.renameVariable(
+ this.sourceBlock_.workspace,
+ /** @type {!VariableModel} */ (this.variable_));
+ return;
+ } else if (id === internalConstants.DELETE_VARIABLE_ID) {
+ // Delete variable.
+ this.sourceBlock_.workspace.deleteVariableById(this.variable_.getId());
+ return;
+ }
}
+ // Handle unspecial case.
+ this.setValue(id);
}
- // Handle unspecial case.
- this.setValue(id);
-};
-/**
- * Overrides referencesVariables(), indicating this field refers to a variable.
- * @return {boolean} True.
- * @package
- * @override
- */
-FieldVariable.prototype.referencesVariables = function() {
- return true;
-};
+ /**
+ * Overrides referencesVariables(), indicating this field refers to a
+ * variable.
+ * @return {boolean} True.
+ * @package
+ * @override
+ */
+ referencesVariables() {
+ return true;
+ }
+
+ /**
+ * Construct a FieldVariable from a JSON arg object,
+ * dereferencing any string table references.
+ * @param {!Object} options A JSON object with options (variable,
+ * variableTypes, and defaultType).
+ * @return {!FieldVariable} The new field instance.
+ * @package
+ * @nocollapse
+ * @override
+ */
+ static fromJson(options) {
+ const varName = parsing.replaceMessageReferences(options['variable']);
+ // `this` might be a subclass of FieldVariable if that class doesn't
+ // override the static fromJson method.
+ return new this(varName, undefined, undefined, undefined, options);
+ }
+
+ /**
+ * Return a sorted list of variable names for variable dropdown menus.
+ * Include a special option at the end for creating a new variable name.
+ * @return {!Array} Array of variable names/id tuples.
+ * @this {FieldVariable}
+ */
+ static dropdownCreate() {
+ if (!this.variable_) {
+ throw Error(
+ 'Tried to call dropdownCreate on a variable field with no' +
+ ' variable selected.');
+ }
+ const name = this.getText();
+ let variableModelList = [];
+ if (this.sourceBlock_ && this.sourceBlock_.workspace) {
+ const variableTypes = this.getVariableTypes_();
+ // Get a copy of the list, so that adding rename and new variable options
+ // doesn't modify the workspace's list.
+ for (let i = 0; i < variableTypes.length; i++) {
+ const variableType = variableTypes[i];
+ const variables =
+ this.sourceBlock_.workspace.getVariablesOfType(variableType);
+ variableModelList = variableModelList.concat(variables);
+ }
+ }
+ variableModelList.sort(VariableModel.compareByName);
+
+ const options = [];
+ for (let i = 0; i < variableModelList.length; i++) {
+ // Set the UUID as the internal representation of the variable.
+ options[i] = [variableModelList[i].name, variableModelList[i].getId()];
+ }
+ options.push(
+ [Msg['RENAME_VARIABLE'], internalConstants.RENAME_VARIABLE_ID]);
+ if (Msg['DELETE_VARIABLE']) {
+ options.push([
+ Msg['DELETE_VARIABLE'].replace('%1', name),
+ internalConstants.DELETE_VARIABLE_ID,
+ ]);
+ }
+
+ return options;
+ }
+}
fieldRegistry.register('field_variable', FieldVariable);
diff --git a/core/flyout_base.js b/core/flyout_base.js
index d1484ffa8..ef44c4236 100644
--- a/core/flyout_base.js
+++ b/core/flyout_base.js
@@ -24,7 +24,6 @@ const common = goog.require('Blockly.common');
const dom = goog.require('Blockly.utils.dom');
const eventUtils = goog.require('Blockly.Events.utils');
const idGenerator = goog.require('Blockly.utils.idGenerator');
-const object = goog.require('Blockly.utils.object');
const toolbox = goog.require('Blockly.utils.toolbox');
/* eslint-disable-next-line no-unused-vars */
const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
@@ -59,1076 +58,1130 @@ goog.require('Blockly.blockRendering');
/**
* Class for a flyout.
- * @param {!Options} workspaceOptions Dictionary of options for the
- * workspace.
- * @constructor
* @abstract
* @implements {IFlyout}
* @extends {DeleteArea}
* @alias Blockly.Flyout
*/
-const Flyout = function(workspaceOptions) {
- Flyout.superClass_.constructor.call(this);
- workspaceOptions.setMetrics = this.setMetrics_.bind(this);
-
+class Flyout extends DeleteArea {
/**
- * @type {!WorkspaceSvg}
- * @protected
+ * @param {!Options} workspaceOptions Dictionary of options for the
+ * workspace.
*/
- this.workspace_ = new WorkspaceSvg(workspaceOptions);
- this.workspace_.setMetricsManager(
- new FlyoutMetricsManager(this.workspace_, this));
+ constructor(workspaceOptions) {
+ super();
+ workspaceOptions.setMetrics = this.setMetrics_.bind(this);
- this.workspace_.isFlyout = true;
- // Keep the workspace visibility consistent with the flyout's visibility.
- this.workspace_.setVisible(this.isVisible_);
+ /**
+ * @type {!WorkspaceSvg}
+ * @protected
+ */
+ this.workspace_ = new WorkspaceSvg(workspaceOptions);
+ this.workspace_.setMetricsManager(
+ new FlyoutMetricsManager(this.workspace_, this));
- /**
- * The unique id for this component that is used to register with the
- * ComponentManager.
- * @type {string}
- */
- this.id = idGenerator.genUid();
+ this.workspace_.isFlyout = true;
+ // Keep the workspace visibility consistent with the flyout's visibility.
+ this.workspace_.setVisible(this.isVisible_);
- /**
- * Is RTL vs LTR.
- * @type {boolean}
- */
- this.RTL = !!workspaceOptions.RTL;
+ /**
+ * The unique id for this component that is used to register with the
+ * ComponentManager.
+ * @type {string}
+ */
+ this.id = idGenerator.genUid();
- /**
- * Whether the flyout should be laid out horizontally or not.
- * @type {boolean}
- * @package
- */
- this.horizontalLayout = false;
+ /**
+ * Is RTL vs LTR.
+ * @type {boolean}
+ */
+ this.RTL = !!workspaceOptions.RTL;
- /**
- * Position of the toolbox and flyout relative to the workspace.
- * @type {number}
- * @protected
- */
- this.toolboxPosition_ = workspaceOptions.toolboxPosition;
+ /**
+ * Whether the flyout should be laid out horizontally or not.
+ * @type {boolean}
+ * @package
+ */
+ this.horizontalLayout = false;
- /**
- * Opaque data that can be passed to Blockly.unbindEvent_.
- * @type {!Array}
- * @private
- */
- this.eventWrappers_ = [];
+ /**
+ * Position of the toolbox and flyout relative to the workspace.
+ * @type {number}
+ * @protected
+ */
+ this.toolboxPosition_ = workspaceOptions.toolboxPosition;
- /**
- * List of background mats that lurk behind each block to catch clicks
- * landing in the blocks' lakes and bays.
- * @type {!Array}
- * @private
- */
- this.mats_ = [];
+ /**
+ * Opaque data that can be passed to Blockly.unbindEvent_.
+ * @type {!Array}
+ * @private
+ */
+ this.eventWrappers_ = [];
- /**
- * List of visible buttons.
- * @type {!Array}
- * @protected
- */
- this.buttons_ = [];
+ /**
+ * Function that will be registered as a change listener on the workspace
+ * to reflow when blocks in the flyout workspace change.
+ * @type {?Function}
+ * @private
+ */
+ this.reflowWrapper_ = null;
- /**
- * List of event listeners.
- * @type {!Array}
- * @private
- */
- this.listeners_ = [];
-
- /**
- * List of blocks that should always be disabled.
- * @type {!Array}
- * @private
- */
- this.permanentlyDisabled_ = [];
-
- /**
- * Width of output tab.
- * @type {number}
- * @protected
- * @const
- */
- this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH;
-
- /**
- * The target workspace
- * @type {?WorkspaceSvg}
- * @package
- */
- this.targetWorkspace = null;
-
- /**
- * A list of blocks that can be reused.
- * @type {!Array}
- * @private
- */
- this.recycledBlocks_ = [];
-};
-object.inherits(Flyout, DeleteArea);
-
-/**
- * Does the flyout automatically close when a block is created?
- * @type {boolean}
- */
-Flyout.prototype.autoClose = true;
-
-/**
- * Whether the flyout is visible.
- * @type {boolean}
- * @private
- */
-Flyout.prototype.isVisible_ = false;
-
-/**
- * Whether the workspace containing this flyout is visible.
- * @type {boolean}
- * @private
- */
-Flyout.prototype.containerVisible_ = true;
-
-/**
- * Corner radius of the flyout background.
- * @type {number}
- * @const
- */
-Flyout.prototype.CORNER_RADIUS = 8;
-
-/**
- * Margin around the edges of the blocks in the flyout.
- * @type {number}
- * @const
- */
-Flyout.prototype.MARGIN = Flyout.prototype.CORNER_RADIUS;
-
-// TODO: Move GAP_X and GAP_Y to their appropriate files.
-
-/**
- * Gap between items in horizontal flyouts. Can be overridden with the "sep"
- * element.
- * @const {number}
- */
-Flyout.prototype.GAP_X = Flyout.prototype.MARGIN * 3;
-
-/**
- * Gap between items in vertical flyouts. Can be overridden with the "sep"
- * element.
- * @const {number}
- */
-Flyout.prototype.GAP_Y = Flyout.prototype.MARGIN * 3;
-
-/**
- * Top/bottom padding between scrollbar and edge of flyout background.
- * @type {number}
- * @const
- */
-Flyout.prototype.SCROLLBAR_MARGIN = 2.5;
-
-/**
- * Width of flyout.
- * @type {number}
- * @protected
- */
-Flyout.prototype.width_ = 0;
-
-/**
- * Height of flyout.
- * @type {number}
- * @protected
- */
-Flyout.prototype.height_ = 0;
-
-/**
- * Range of a drag angle from a flyout considered "dragging toward workspace".
- * Drags that are within the bounds of this many degrees from the orthogonal
- * line to the flyout edge are considered to be "drags toward the workspace".
- * Example:
- * Flyout Edge Workspace
- * [block] / <-within this angle, drags "toward workspace" |
- * [block] ---- orthogonal to flyout boundary ---- |
- * [block] \ |
- * The angle is given in degrees from the orthogonal.
- *
- * This is used to know when to create a new block and when to scroll the
- * flyout. Setting it to 360 means that all drags create a new block.
- * @type {number}
- * @protected
- */
-Flyout.prototype.dragAngleRange_ = 70;
-
-/**
- * Creates the flyout's DOM. Only needs to be called once. The flyout can
- * either exist as its own SVG element or be a g element nested inside a
- * separate SVG element.
- * @param {string|
- * !Svg|
- * !Svg} tagName The type of tag to
- * put the flyout in. This should be