From fa14e9d6de5e22f204acf5055e7612889ca8ffdf Mon Sep 17 00:00:00 2001 From: Beka Westberg Date: Mon, 28 Feb 2022 09:48:37 -0800 Subject: [PATCH] refactor: convert blocksvg and block to ES6 classes (#5952) * refact: move super call to top of block svg * refact: run conversion script on block svg and block * fix: make debug build happy * fix: tests * style: format * fix: cleanup from rebase * fix: use new.target instead of a new parameter * fix: add more overridden casted methods to BlockSvg * style: fix typos * style: move override tags to the end of JSDoc * fix: cleanup from rebase --- core/block.js | 3669 ++++++++++++------------ core/block_dragger.js | 3 +- core/block_svg.js | 3049 ++++++++++---------- core/comment.js | 1 - core/contextmenu.js | 5 +- core/contextmenu_items.js | 3 +- core/events/events_block_change.js | 7 +- core/extensions.js | 4 +- core/flyout_base.js | 10 +- core/flyout_horizontal.js | 4 +- core/flyout_vertical.js | 4 +- core/mutator.js | 6 +- core/procedures.js | 28 +- core/rendered_connection.js | 28 +- core/renderers/common/i_path_object.js | 4 +- core/renderers/common/path_object.js | 4 +- core/serialization/blocks.js | 19 +- core/workspace_svg.js | 14 +- core/xml.js | 25 +- scripts/gulpfiles/chunks.json | 2 +- tests/deps.js | 2 +- tests/mocha/extensions_test.js | 4 +- 22 files changed, 3546 insertions(+), 3349 deletions(-) diff --git a/core/block.js b/core/block.js index 3c6a7541453..134ce88ab48 100644 --- a/core/block.js +++ b/core/block.js @@ -64,2111 +64,2174 @@ 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. - * @alias Blockly.Block + * @unrestricted */ -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.'); +class Block { + /** + * @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. + * @alias Blockly.Block + */ + 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.'); + } + + /** + * 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 method called by the default mutator UI which allows the + * block to "explode" itself into smaller sub blocks. This function should + * return a "top block" which is the main block in the mutator workspace + * that sub-blocks connect to. + * @type {undefined|?function():!Block} + */ + this.decompose = undefined; + + /** + * An optional method called by the deafult mutator UI which allows the + * block to configure itself based on the configuration of sub-blocks in + * the mutator workspace. This function should accept the "top block" which + * was returned by `decompose` as a paramter. + * @type {undefined|?function(!Block)} + */ + this.compose = undefined; + + /** + * 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(!Block)} + */ + this.saveConnections = 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_(); } /** - * Optional text data that round-trips between blocks and XML. - * Has no effect. May be used by 3rd parties for meta information. - * @type {?string} + * Calls the init() function and handles associated event firing, etc. + * @protected */ - this.data = null; + 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(); + + try { + // Call an initialization function, if it exists. + if (typeof this.init === 'function') { + eventUtils.setRecordUndo(false); + this.init(); + eventUtils.setRecordUndo(initialUndoFlag); + } + + // 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); + } + + // 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); + } + } /** - * Has this block been disposed of? - * @type {boolean} - * @package + * 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} */ - this.disposed = false; + dispose(healStack, _animate) { + 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; + } + } /** - * 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 + * 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 */ - this.hue_ = null; + 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(); + } + } + } + } /** - * Colour of the block in '#RRGGBB' format. - * @type {string} - * @protected + * 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. */ - this.colour_ = '#000000'; + unplug(opt_healStack) { + if (this.outputConnection) { + this.unplugFromRow_(opt_healStack); + } + if (this.previousConnection) { + this.unplugFromStack_(opt_healStack); + } + } /** - * Name of the block style. - * @type {string} - * @protected + * 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 */ - this.styleName_ = ''; + 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); + } + } /** - * An optional method called during initialization. - * @type {undefined|?function()} + * 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 */ - this.init = undefined; + 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; + } /** - * 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} + * 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 */ - this.mutationToDom = undefined; + 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); + } + } + } /** - * 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)} + * Returns all connections originating from this block. + * @param {boolean} _all If true, return all connections even hidden ones. + * @return {!Array} Array of connections. + * @package */ - this.domToMutation = undefined; + 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; + } /** - * 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(): *} + * 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 */ - this.saveExtraState = undefined; + lastConnectionInStack(ignoreShadows) { + let nextConnection = this.nextConnection; + while (nextConnection) { + const nextBlock = nextConnection.targetBlock(); + if (!nextBlock || (ignoreShadows && nextBlock.isShadow())) { + return nextConnection; + } + nextConnection = nextBlock.nextConnection; + } + return null; + } /** - * 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(*)} + * Bump unconnected blocks out of alignment. Two blocks which aren't actually + * connected should not coincidentally line up on screen. */ - this.loadExtraState = undefined; + bumpNeighbours() { + // noop. + } /** - * An optional property for suppressing adding STATEMENT_PREFIX and - * STATEMENT_SUFFIX to generated code. - * @type {?boolean} + * 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. */ - this.suppressPrefixSuffix = false; + getParent() { + return this.parentBlock_; + } /** - * 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} + * 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. */ - this.getDeveloperVariables = undefined; + getInputWithBlock(block) { + for (let i = 0, input; (input = this.inputList[i]); i++) { + if (input.connection && input.connection.targetBlock() === block) { + return input; + } + } + return null; + } - /** @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 + * 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. */ - this.disabled = false; - /** @type {!Tooltip.TipInfo} */ - this.tooltip = ''; - /** @type {boolean} */ - this.contextMenu = true; + 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; + } /** - * @type {Block} - * @protected + * Return the next statement block directly connected to this block. + * @return {?Block} The next statement block or null. */ - this.parentBlock_ = null; + getNextBlock() { + return this.nextConnection && this.nextConnection.targetBlock(); + } /** - * @type {!Array} - * @protected + * Returns the block connected to the previous connection. + * @return {?Block} The previous statement block or null. */ - this.childBlocks_ = []; + getPreviousBlock() { + return this.previousConnection && this.previousConnection.targetBlock(); + } /** - * @type {boolean} - * @private + * 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 */ - this.deletable_ = true; + 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; + } /** - * @type {boolean} - * @private + * 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. */ - this.movable_ = true; + getRootBlock() { + let rootBlock; + let block = this; + do { + rootBlock = block; + block = rootBlock.parentBlock_; + } while (block); + return rootBlock; + } /** - * @type {boolean} - * @private + * 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 */ - this.editable_ = true; + getTopStackBlock() { + let block = this; + let previous; + do { + previous = block.getPreviousBlock(); + } while (previous && previous.getNextBlock() === block && + (block = previous)); + return block; + } /** - * @type {boolean} - * @private + * 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. */ - this.isShadow_ = false; + 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; + } /** - * @type {boolean} - * @protected + * Set parent of this block to be a new block or null. + * @param {Block} newParent New parent block. + * @package */ - this.collapsed_ = false; + 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); + } + } /** - * @type {?number} - * @protected + * 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. */ - this.outputShape_ = null; + 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; + } /** - * A string representing the comment attached to this block. - * @type {string|Comment} - * @deprecated August 2019. Use getCommentText instead. + * Get whether this block is deletable or not. + * @return {boolean} True if deletable. */ - this.comment = null; + isDeletable() { + return this.deletable_ && !this.isShadow_ && + !(this.workspace && this.workspace.options.readOnly); + } /** - * A model of the comment attached to this block. - * @type {!Block.CommentModel} - * @package + * Set whether this block is deletable or not. + * @param {boolean} deletable True if deletable. */ - this.commentModel = {text: null, pinned: false, size: new Size(160, 80)}; + setDeletable(deletable) { + this.deletable_ = deletable; + } /** - * The block's position in workspace units. (0, 0) is at the workspace's - * origin; scale does not change this value. - * @type {!Coordinate} - * @private + * Get whether this block is movable or not. + * @return {boolean} True if movable. */ - 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; + isMovable() { + return this.movable_ && !this.isShadow_ && + !(this.workspace && this.workspace.options.readOnly); + } /** - * True if this block is an insertion marker. - * @type {boolean} - * @protected + * Set whether this block is movable or not. + * @param {boolean} movable True if movable. */ - this.isInsertionMarker_ = false; + setMovable(movable) { + this.movable_ = movable; + } /** - * Name of the type of hat. - * @type {string|undefined} + * 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. */ - this.hat = undefined; + isDuplicatable() { + if (!this.workspace.hasBlockLimits()) { + return true; + } + return this.workspace.isCapacityAvailable( + common.getBlockTypeCounts(this, true)); + } - /** @type {?boolean} */ - this.rendered = null; + /** + * Get whether this block is a shadow block or not. + * @return {boolean} True if a shadow. + */ + isShadow() { + return this.isShadow_; + } /** - * String for block help, or function that returns a URL. Null for no help. - * @type {string|Function} + * Set whether this block is a shadow block or not. + * @param {boolean} shadow True if a shadow. + * @package */ - this.helpUrl = null; + setShadow(shadow) { + this.isShadow_ = shadow; + } /** - * A bound callback function to use when the parent workspace changes. - * @type {?function(Abstract)} - * @private + * Get whether this block is an insertion marker block or not. + * @return {boolean} True if an insertion marker. */ - this.onchangeWrapper_ = null; + isInsertionMarker() { + return this.isInsertionMarker_; + } /** - * A count of statement inputs on the block. - * @type {number} + * 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 */ - 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); + setInsertionMarker(insertionMarker) { + this.isInsertionMarker_ = insertionMarker; } - workspace.addTopBlock(this); - workspace.addTypedBlock(this); - - // 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); + /** + * Get whether this block is editable or not. + * @return {boolean} True if editable. + */ + isEditable() { + return this.editable_ && + !(this.workspace && this.workspace.options.readOnly); } - 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); + /** + * 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(); + } } + } - // Fire a create event. - if (eventUtils.isEnabled()) { - eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(this)); + /** + * 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.'); } - } finally { - if (!existingGroup) { - eventUtils.setGroup(false); + for (let i = 0; i < otherConnections.length; i++) { + if (otherConnections[i] === conn) { + return connections[i]; + } } - // In case init threw, recordUndo flag should still be reset. - eventUtils.setRecordUndo(initialUndoFlag); + return null; } - // 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); + /** + * 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; } -}; -/** - * @typedef {{ - * text:?string, - * pinned:boolean, - * size:Size - * }} - */ -Block.CommentModel; + /** + * 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; + } -/** - * 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; + /** + * Returns the tooltip text for this block. + * @return {!string} The tooltip text for this block. + */ + getTooltip() { + return Tooltip.getTooltipOfObject(this); + } -/** - * The language-neutral ID given to the collapsed input. - * @const {string} - */ -Block.COLLAPSED_INPUT_NAME = constants.COLLAPSED_INPUT_NAME; + /** + * Get the colour of a block. + * @return {string} #RRGGBB string. + */ + getColour() { + return this.colour_; + } -/** - * The language-neutral ID given to the collapsed field. - * @const {string} - */ -Block.COLLAPSED_FIELD_NAME = constants.COLLAPSED_FIELD_NAME; + /** + * Get the name of the block style. + * @return {string} Name of the block style. + */ + getStyleName() { + return this.styleName_; + } -/** - * 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} - */ -Block.prototype.dispose = function(healStack, _animate) { - if (!this.workspace) { - // Already deleted. - return; + /** + * Get the HSV hue value of a block. Null if hue not set. + * @return {?number} Hue value (0-360). + */ + getHue() { + return this.hue_; } - // Terminate onchange event calls. - if (this.onchangeWrapper_) { - this.workspace.removeChangeListener(this.onchangeWrapper_); + + /** + * 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; } - this.unplug(healStack); - if (eventUtils.isEnabled()) { - eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_DELETE))(this)); + /** + * Set the style and colour values of a block. + * @param {string} blockStyleName Name of the block style. + */ + setStyle(blockStyleName) { + this.styleName_ = blockStyleName; } - 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; + /** + * 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.'); } - - // 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); + if (this.onchangeWrapper_) { + this.workspace.removeChangeListener(this.onchangeWrapper_); } + this.onchange = onchangeFn; + if (this.onchange) { + this.onchangeWrapper_ = onchangeFn.bind(this); + this.workspace.addChangeListener(this.onchangeWrapper_); + } + } - // First, dispose of all my children. - for (let i = this.childBlocks_.length - 1; i >= 0; i--) { - this.childBlocks_[i].dispose(false); + /** + * 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'); } - // 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(); + for (let j = 0, field; (field = input.fieldRow[j]); j++) { + if (field.name === name) { + return field; + } + } } - } finally { - eventUtils.enable(); - this.disposed = true; + return null; } -}; -/** - * 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(); + /** + * 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; } -}; -/** - * 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); + /** + * 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; } -}; -/** - * 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(); + /** + * 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(); + } + } + } } - // Return early in obvious cases. - if (!parentConnection || !opt_healStack) { - return; + /** + * 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); + } + } + } } - 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; + /** + * 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; } - 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); + /** + * 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); } -}; -/** - * 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. + /** + * 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; } - 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); + /** + * 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; + } } } - 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; + /** + * 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; + } } - 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; + /** + * 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; } } - 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; + /** + * 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; } - } 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; + // 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; + } } - } - 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); + 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; } - 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; + /** + * Set the block's output shape. + * @param {?number} outputShape Value representing an output shape. + */ + setOutputShape(outputShape) { + this.outputShape_ = outputShape; } - // 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.'); + /** + * Get the block's output shape. + * @return {?number} Value representing output shape if one exists. + */ + getOutputShape() { + return this.outputShape_; } - 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); + /** + * Get whether this block is enabled or not. + * @return {boolean} True if enabled. + */ + isEnabled() { + return !this.disabled; } - this.parentBlock_ = newParent; - if (newParent) { - // Add this block to the new parent's child list. - newParent.childBlocks_.push(this); - } else { - this.workspace.addTopBlock(this); + /** + * 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)); + } } -}; -/** - * 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)); + /** + * 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; } - 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; + /** + * Get whether the block is collapsed or not. + * @return {boolean} True if collapsed. + */ + isCollapsed() { + return this.collapsed_; } - 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(); + /** + * 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; } } -}; - -/** - * 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]; + /** + * 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); } - } - 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; + /** + * 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; } } - } - 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()); + // 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; + } } - } - } - 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); + 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(); } } } - } - 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(); + // 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); } } - } -}; -/** - * 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); + // 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; } -}; -/** - * 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(); + /** + * 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); } - 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.'); + /** + * 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); } - 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; + /** + * 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.'); } - if (!this.previousConnection) { - this.previousConnection = - this.makeConnection_(ConnectionType.PREVIOUS_STATEMENT); + + // 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; } - 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; + + 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); } - } -}; -/** - * 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; + // 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 (!this.nextConnection) { - this.nextConnection = this.makeConnection_(ConnectionType.NEXT_STATEMENT); + + if (json['inputsInline'] !== undefined) { + this.setInputsInline(json['inputsInline']); } - 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 output and previous/next connections. + if (json['output'] !== undefined) { + this.setOutput(true, json['output']); } - } -}; - -/** - * 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 (json['outputShape'] !== undefined) { + this.setOutputShape(json['outputShape']); } - if (!this.outputConnection) { - this.outputConnection = this.makeConnection_(ConnectionType.OUTPUT_VALUE); + if (json['previousStatement'] !== undefined) { + this.setPreviousStatement(true, json['previousStatement']); } - 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; + if (json['nextStatement'] !== undefined) { + this.setNextStatement(true, json['nextStatement']); } - } -}; - -/** - * 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; + if (json['tooltip'] !== undefined) { + const rawValue = json['tooltip']; + const localizedText = parsing.replaceMessageReferences(rawValue); + this.setTooltip(localizedText); } - } - 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; + if (json['enableContextMenu'] !== undefined) { + this.contextMenu = !!json['enableContextMenu']; } - } - 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; + 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. } - 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_; -}; + // Add the mutator to the block. + if (json['mutator'] !== undefined) { + Extensions.apply(json['mutator'], this, true); + } -/** - * 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; + const extensionNames = json['extensions']; + if (Array.isArray(extensionNames)) { + for (let j = 0; j < extensionNames.length; j++) { + Extensions.apply(extensionNames[j], this, false); + } + } } -}; - -/** - * 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; + /** + * 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); + } + } + } + } /** - * 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. + * 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 */ - function shouldAddParentheses(connection) { - let checks = connection.getCheck(); - if (!checks && connection.targetConnection) { - checks = connection.targetConnection.getCheck(); + jsonInitStyle_(json, warningPrefix) { + const blockStyleName = json['style']; + try { + this.setStyle(blockStyleName); + } catch (styleError) { + console.warn(warningPrefix + 'Style does not exist: ', blockStyleName); } - return !!checks && - (checks.indexOf('Boolean') !== -1 || checks.indexOf('Number') !== -1); } /** - * Check that we haven't circled back to the original root node. + * 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. */ - function checkRoot() { - if (node && node.getType() === rootNode.getType() && - node.getLocation() === rootNode.getLocation()) { - node = null; + mixin(mixinObj, opt_disableCheck) { + if (opt_disableCheck !== undefined && + typeof opt_disableCheck !== 'boolean') { + throw Error('opt_disableCheck must be a boolean if provided'); } - } - - // 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('('); + if (!opt_disableCheck) { + const overwrites = []; + for (const key in mixinObj) { + if (this[key] !== undefined) { + overwrites.push(key); } - break; } - case ASTNode.types.FIELD: { - const field = /** @type {Field} */ (node.getLocation()); - if (field.name !== constants.COLLAPSED_FIELD_NAME) { - text.push(field.getText()); - } - break; + if (overwrites.length) { + throw Error( + 'Mixin will overwrite block members: ' + + JSON.stringify(overwrites)); } } + object.mixin(this, mixinObj); + } - 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(')'); + /** + * 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']]); } - } - 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); + /** + * 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++; } - } - - // 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) + '...'; + if (visitedArgsCount !== argsCount) { + throw Error( + 'Block "' + this.type + '": ' + + 'Message does not reference all ' + argsCount + ' arg(s).'); } } - 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; - } + /** + * 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); + } - 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); - } + 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); + } - // 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++; + return elements; } - 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. + /** + * 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; } - // Add the mutator to the block. - if (json['mutator'] !== undefined) { - Extensions.apply(json['mutator'], this, true); - } + /** + * 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, + }; - const extensionNames = json['extensions']; - if (Array.isArray(extensionNames)) { - for (let j = 0; j < extensionNames.length; j++) { - Extensions.apply(extensionNames[j], this, false); + 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; } - } -}; -/** - * 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); + 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; } -}; -/** - * 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); + /** + * 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'; } -}; -/** - * 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); - } + /** + * 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, + }; } - if (overwrites.length) { - throw Error( - 'Mixin will overwrite block members: ' + JSON.stringify(overwrites)); + 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; } - 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]); + /** + * 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; } - 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']]); } } + if (inputIndex === -1) { + throw Error('Named input "' + name + '" not found.'); + } + if (refIndex === -1) { + throw Error('Reference input "' + refName + '" not found.'); + } + this.moveNumberedInputBefore(inputIndex, refIndex); } -}; -/** - * 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.'); + /** + * 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 (visitedArgsHash[token]) { - throw Error( - 'Block "' + this.type + '": ' + - 'Message index %' + token + ' duplicated.'); + if (inputIndex >= this.inputList.length) { + throw RangeError('Input index ' + inputIndex + ' out of bounds.'); } - visitedArgsHash[token] = true; - visitedArgsCount++; - } - if (visitedArgsCount !== argsCount) { - throw Error( - 'Block "' + this.type + '": ' + - 'Message does not reference all ' + argsCount + ' arg(s).'); + 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); } -}; -/** - * 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; + /** + * 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; } } - 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; + if (opt_quiet) { + return false; } - elements.push(dummyInput); + throw Error('Input not found: ' + name); } - 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; + /** + * 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; + } } - return this.fieldFromJson_(element['alt']); + // This input does not exist. + return null; } - 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; + /** + * 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(); } - if (element['check']) { - input.setCheck(element['check']); + /** + * Returns the comment on this block (or null if there is no comment). + * @return {?string} Block's comment. + */ + getCommentText() { + return this.commentModel.text; } - if (element['align']) { - const alignment = alignmentLookup[element['align'].toUpperCase()]; - if (alignment === undefined) { - console.warn(warningPrefix + 'Illegal align value: ', element['align']); - } else { - input.setAlign(alignment); + + /** + * 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. } - 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, - }; + /** + * 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. } - 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); + /** + * Give this block a mutator dialog. + * @param {Mutator} _mutator A mutator dialog instance or null to + * remove. + */ + setMutator(_mutator) { + // NOP. } - if (type === inputTypes.STATEMENT) { - this.statementInputCount++; + + /** + * 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_; } - 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; - } + /** + * 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); } - 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--; + /** + * 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); } - // 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; + /** + * 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_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; + if (!opt_shadowBlocksAreFilled && this.isShadow()) { + return false; } - } - // 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(); -}; + // 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; + } + } -/** - * 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; -}; + // Recursively check the next block after the current block. + const next = this.getNextBlock(); + if (next) { + return next.allInputsFilled(opt_shadowBlocksAreFilled); + } -/** - * 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; + return true; } - 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_; -}; + /** + * 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; + } +} /** - * Move a block by a relative offset. - * @param {number} dx Horizontal offset, in workspace units. - * @param {number} dy Vertical offset, in workspace units. + * @typedef {{ + * text:?string, + * pinned:boolean, + * size:Size + * }} */ -Block.prototype.moveBy = function(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); -}; +Block.CommentModel; /** - * 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 + * 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.makeConnection_ = function(type) { - return new Connection(this, type); -}; +Block.prototype.onchange; /** - * 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. + * The language-neutral ID given to the collapsed input. + * @const {string} */ -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; -}; +Block.COLLAPSED_INPUT_NAME = constants.COLLAPSED_INPUT_NAME; /** - * 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. + * The language-neutral ID given to the collapsed field. + * @const {string} */ -Block.prototype.toDevString = function() { - let msg = this.type ? '"' + this.type + '" block' : 'Block'; - if (this.id) { - msg += ' (id="' + this.id + '")'; - } - return msg; -}; +Block.COLLAPSED_FIELD_NAME = constants.COLLAPSED_FIELD_NAME; exports.Block = Block; diff --git a/core/block_dragger.js b/core/block_dragger.js index 88c1a177d62..896f099f7c2 100644 --- a/core/block_dragger.js +++ b/core/block_dragger.js @@ -460,7 +460,8 @@ const BlockDragger = class { 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(); diff --git a/core/block_svg.js b/core/block_svg.js index ffd6eb48f73..3b81d63f857 100644 --- a/core/block_svg.js +++ b/core/block_svg.js @@ -25,7 +25,6 @@ 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'); @@ -84,1718 +83,1804 @@ 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) { +class BlockSvg extends Block { /** - * 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} + * @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. + * @alias Blockly.BlockSvg */ - this.decompose = undefined; + 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 = undefined; + + /** + * 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 = undefined; + + /** + * An optional method for defining custom block context menu items. + * @type {undefined|?function(!Array)} + */ + this.customContextMenu = undefined; + + /** + * 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_(); + } /** - * 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)} + * Create and initialize the SVG representation of the block. + * May be called more than once. */ - this.compose = undefined; + 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); + } + } /** - * An optional method for defining custom block context menu items. - * @type {undefined|?function(!Array)} + * Get the secondary colour of a block. + * @return {?string} #RRGGBB string. */ - this.customContextMenu = undefined; + getColourSecondary() { + return this.style.colourSecondary; + } /** - * An property used internally to reference the block's rendering debugger. - * @type {?BlockRenderingDebug} - * @package + * Get the tertiary colour of a block. + * @return {?string} #RRGGBB string. */ - this.renderingDebugger = null; + getColourTertiary() { + return this.style.colourTertiary; + } /** - * Height of this block, not including any statement blocks above or below. - * Height is in workspace units. - * @type {number} + * Selects this block. Highlights the block visually and fires a select event + * if the block is not already selected. */ - this.height = 0; + 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(); + } /** - * Width of this block, including any connected value blocks. - * Width is in workspace units. - * @type {number} + * Unselects this block. Unhighlights the block and fires a select (false) + * event if the block is currently selected. */ - this.width = 0; + 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(); + } /** - * Map from IDs for warnings text to PIDs of functions to apply them. - * Used to be able to maintain multiple warnings. - * @type {Object} - * @private + * Returns a list of mutator, comment, and warning icons. + * @return {!Array} List of icons. */ - this.warningTextDb_ = null; + 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; + } /** - * Block's mutator icon (if any). - * @type {?Mutator} + * Sets the parent of this block to be a new block or null. + * @param {?Block} newParent New parent block. + * @package + * @override */ - this.mutator = null; + 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(); + } /** - * Block's comment icon (if any). - * @type {?Comment} - * @deprecated August 2019. Use getCommentIcon instead. + * 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. */ - this.comment = null; + 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); + } /** - * Block's comment icon (if any). - * @type {?Comment} - * @private + * Move a block by a relative offset. + * @param {number} dx Horizontal offset in workspace units. + * @param {number} dy Vertical offset in workspace units. */ - this.commentIcon_ = null; + 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(); + } /** - * Block's warning icon (if any). - * @type {?Warning} + * 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. */ - this.warning = null; - + translate(x, y) { + this.getSvgRoot().setAttribute( + 'transform', 'translate(' + x + ',' + y + ')'); + } - // Create core elements for the block. /** - * @type {!SVGGElement} - * @private + * 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.svgGroup_ = dom.createSvgElement(Svg.G, {}, null); - this.svgGroup_.translate_ = ''; + 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); + } + } /** - * A block style object. - * @type {!Theme.BlockStyle} + * Move a block to a position. + * @param {Coordinate} xy The position to move to in workspace units. */ - this.style = workspace.getRenderer().getConstants().getBlockStyle(null); + moveTo(xy) { + const curXY = this.getRelativeToSurfaceXY(); + this.moveBy(xy.x - curXY.x, xy.y - curXY.y); + } /** - * The renderer's path object. - * @type {IPathObject} + * 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 */ - this.pathObject = - workspace.getRenderer().makePathObject(this.svgGroup_, this.style); + 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()); + } - /** @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 + * 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 */ - this.renderIsInProgress_ = false; + 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_); + } + } /** - * Whether mousedown events have been bound yet. - * @type {boolean} + * Clear the block of transform="..." attributes. + * Used when the block is switching from 3d to 2d transform or vice versa. * @private */ - this.eventsInit_ = false; - - /** @type {!WorkspaceSvg} */ - this.workspace = workspace; - - /** @type {RenderedConnection} */ - this.outputConnection = null; - /** @type {RenderedConnection} */ - this.nextConnection = null; - /** @type {RenderedConnection} */ - this.previousConnection = null; + clearTransformAttributes_() { + this.getSvgRoot().removeAttribute('transform'); + } /** - * 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 + * Snap this block to the nearest grid point. */ - 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); - } -}; -object.inherits(BlockSvg, Block); - -/** - * Constant for identifying rows that are to be rendered inline. - * Don't collide with Blockly.inputTypes. - * @const - */ -BlockSvg.INLINE = -1; - -/** - * ID to give the "collapsed warnings" warning. Allows us to remove the - * "collapsed warnings" warning without removing any warnings that belong to - * the block. - * @type {string} - * @const - */ -BlockSvg.COLLAPSED_WARNING_ID = 'TEMP_COLLAPSED_WARNING_'; - -/** - * 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); + 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); + } } -}; - -/** - * 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(); + /** + * 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); } - 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(); -}; -/** - * 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); + /** + * 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(); + } } - 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; + /** + * 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. + } } - dom.startTextWidthCache(); - BlockSvg.superClass_.setParent.call(this, newParent); - dom.stopTextWidthCache(); + /** + * 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); + } + } - const svgRoot = this.getSvgRoot(); + if (!collapsed) { + this.updateDisabled(); + this.removeInput(collapsedInputName); + return; + } - // 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 icons = this.getIcons(); + for (let i = 0, icon; (icon = icons[i]); i++) { + icon.setVisible(false); + } - 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); + 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); } - 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; + /** + * 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 dragSurfaceGroup = this.useDragSurface_ ? - this.workspace.getBlockDragSurface().getGroup() : - null; + const nextNode = tabCursor.getCurNode(); + if (nextNode && nextNode !== currentNode) { + const nextField = /** @type {!Field} */ (nextNode.getLocation()); + nextField.showEditor(); - 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; + // Also move the cursor if we're in keyboard nav mode. + if (this.workspace.keyboardAccessibilityMode) { + this.workspace.getCursor().setCurNode(nextNode); } - 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 = /** @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); + /** + * 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); + } } - 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); - } -}; + /** + * 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); + } + } -/** - * 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); -}; + /** + * 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}); -/** - * 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()); -}; + // Allow the block to add or modify menuOptions. + if (this.customContextMenu) { + this.customContextMenu(menuOptions); + } -/** - * 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_); + return menuOptions; } -}; -/** - * 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'); -}; + /** + * Show the context menu for this block. + * @param {!Event} e Mouse event. + * @package + */ + showContextMenu(e) { + const menuOptions = this.generateContextMenu(); -/** - * 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); + if (menuOptions && menuOptions.length) { + ContextMenu.show(e, menuOptions, this.RTL); + ContextMenu.setCurrentBlock(this); + } } -}; -/** - * 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); -}; + /** + * 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(); + } -/** - * 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(); + // 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); + } } -}; -/** - * 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. + /** + * 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); + } } -}; -/** - * 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; + /** + * Set whether this block is movable or not. + * @param {boolean} movable True if movable. + */ + setMovable(movable) { + super.setMovable(movable); + this.pathObject.updateMovable(movable); + } - for (let i = 0, input; (input = this.inputList[i]); i++) { - if (input.name !== collapsedInputName) { - input.setVisible(!collapsed); + /** + * 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(); } } - if (!collapsed) { - this.updateDisabled(); - this.removeInput(collapsedInputName); - return; + /** + * 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(); } - const icons = this.getIcons(); - for (let i = 0, icon; (icon = icons[i]); i++) { - icon.setVisible(false); + /** + * 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); + } } - const text = this.toString(internalConstants.COLLAPSE_CHARS); - const field = this.getField(collapsedFieldName); - if (field) { - field.setValue(text); - return; + /** + * 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_; } - 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(); + /** + * 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 (forward) { - tabCursor.next(); - } else { - tabCursor.prev(); - } + if (animate && this.rendered) { + this.unplug(healStack); + blockAnimations.disposeUiEffect(this); + } + // Stop rerendering. + this.rendered = false; - const nextNode = tabCursor.getCurNode(); - if (nextNode && nextNode !== currentNode) { - const nextField = /** @type {!Field} */ (nextNode.getLocation()); - nextField.showEditor(); + // Clear pending warnings. + if (this.warningTextDb_) { + for (const n in this.warningTextDb_) { + clearTimeout(this.warningTextDb_[n]); + } + this.warningTextDb_ = null; + } - // Also move the cursor if we're in keyboard nav mode. - if (this.workspace.keyboardAccessibilityMode) { - this.workspace.getCursor().setCurNode(nextNode); + const icons = this.getIcons(); + for (let i = 0; i < icons.length; i++) { + icons[i].dispose(); } - } -}; + super.dispose(!!healStack); -/** - * 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); + dom.removeNode(this.svgGroup_); + blockWorkspace.resizeContents(); + // Sever JavaScript to DOM connections. + this.svgGroup_ = null; + dom.stopTextWidthCache(); } -}; -/** - * 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); + /** + * 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); } -}; -/** - * 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); + /** + * 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), + }; } - return menuOptions; -}; + /** + * Updates the colour of the block to match the block's state. + * @package + */ + applyColour() { + this.pathObject.applyColour(this); -/** - * Show the context menu for this block. - * @param {!Event} e Mouse event. - * @package - */ -BlockSvg.prototype.showContextMenu = function(e) { - const menuOptions = this.generateContextMenu(); + const icons = this.getIcons(); + for (let i = 0; i < icons.length; i++) { + icons[i].applyColour(); + } - if (menuOptions && menuOptions.length) { - ContextMenu.show(e, menuOptions, this.RTL); - ContextMenu.setCurrentBlock(this); + for (let x = 0, input; (input = this.inputList[x]); x++) { + for (let y = 0, field; (field = input.fieldRow[y]); y++) { + field.applyColour(); + } + } } -}; -/** - * 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); + /** + * 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(); + } + } } - const icons = this.getIcons(); - for (let i = 0; i < icons.length; i++) { - icons[i].computeIconLocation(); + + /** + * 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_; } - // Recurse through all blocks attached under this one. - for (let i = 0; i < this.childBlocks_.length; i++) { - this.childBlocks_[i].moveConnections(dx, dy); + /** + * 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(); + } } -}; -/** - * 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 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; + } -/** - * 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); -}; + 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); + } -/** - * 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(); + 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(); + } } -}; -/** - * 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(); -}; + /** + * 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 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. + /** + * 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(); + } + } } - this.isInsertionMarker_ = insertionMarker; - if (this.isInsertionMarker_) { - this.setColour( - this.workspace.getRenderer().getConstants().INSERTION_MARKER_COLOUR); - this.pathObject.updateInsertionMarker(true); + + /** + * 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); } -}; -/** - * 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_; -}; + /** + * 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); + } -/** - * 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; + /** + * 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); } - const icons = this.getIcons(); - for (let i = 0; i < icons.length; i++) { - icons[i].dispose(); + /** + * 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); } - BlockSvg.superClass_.dispose.call(this, !!healStack); - dom.removeNode(this.svgGroup_); - blockWorkspace.resizeContents(); - // Sever JavaScript to DOM connections. - this.svgGroup_ = null; - dom.stopTextWidthCache(); -}; + // Overrides of functions on Blockly.Block that take into account whether the -/** - * 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); -}; + // block has been rendered. -/** - * 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), - }; -}; + /** + * Get the colour of a block. + * @return {string} #RRGGBB string. + */ + getColour() { + return this.style.colourPrimary; + } -/** - * Updates the colour of the block to match the block's state. - * @package - */ -BlockSvg.prototype.applyColour = function() { - this.pathObject.applyColour(this); + /** + * 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; - const icons = this.getIcons(); - for (let i = 0; i < icons.length; i++) { - icons[i].applyColour(); + this.applyColour(); } - for (let x = 0, input; (input = this.inputList[x]); x++) { - for (let y = 0, field; (field = input.fieldRow[y]); y++) { - field.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); } } -}; -/** - * 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; + /** + * 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); } - for (let i = 0, child; (child = children[i]); i++) { - if (child.rendered) { - child.updateDisabled(); + + /** + * 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(); } } -}; -/** - * 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 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); -/** - * 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(); + if (this.rendered) { + this.render(); + 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; - } + /** + * 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); - 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 (this.rendered) { + this.render(); + this.bumpNeighbours(); } } - 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 value inputs are arranged horizontally or vertically. + * @param {boolean} newBoolean True if inputs are horizontal. + */ + setInputsInline(newBoolean) { + super.setInputsInline(newBoolean); -/** - * 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(); + if (this.rendered) { + this.render(); + this.bumpNeighbours(); } } -}; -/** - * 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); -}; + /** + * 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); -/** - * 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); -}; + if (this.rendered) { + this.render(); + // Removing an input will cause the block to change shape. + this.bumpNeighbours(); + } -/** - * 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); -}; + return removed; + } -/** - * 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); -}; + /** + * 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(); + } + } -// 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; -}; + /** + * 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); -/** - * 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_); + if (this.rendered) { + this.render(); + // Adding an input will cause the block to change shape. + this.bumpNeighbours(); + } + return input; + } - this.pathObject.setStyle(styleObj.style); - this.style = styleObj.style; - this.styleName_ = styleObj.name; + /** + * 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); + } + } - this.applyColour(); -}; + 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; + } -/** - * 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; + for (let i = 0; i < this.inputList.length; i++) { + const conn = + /** @type {!RenderedConnection} */ (this.inputList[i].connection); + if (conn) { + conn.setTracking(track); - this.applyColour(); - } else { - throw Error('Invalid style name: ' + blockStyleName); + // Pass tracking on down the chain. + const block = conn.targetBlock(); + if (block) { + block.setConnectionTracking(track); + } + } + } } -}; - -/** - * 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); + /** + * 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; + } - if (this.rendered) { - this.render(); - this.bumpNeighbours(); + /** + * 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)); } -}; -/** - * 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); + /** + * 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)); + } - if (this.rendered) { - this.render(); - this.bumpNeighbours(); + /** + * 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); } -}; -/** - * 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); + /** + * 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(); + } - if (this.rendered) { - this.render(); - this.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); + } + } + } + } + } } -}; -/** - * 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); + /** + * 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); - if (this.rendered) { - this.render(); - this.bumpNeighbours(); + setTimeout(function() { + eventUtils.setGroup(group); + block.bumpNeighbours(); + eventUtils.setGroup(false); + }, config.bumpDelay); } -}; -/** - * 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); + /** + * 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); + } + } - if (this.rendered) { - this.render(); - // Removing an input will cause the block to change shape. - this.bumpNeighbours(); + /** + * 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 removed; -}; + /** + * @return {?BlockSvg} The block (if any) that surrounds the current block. + * @override + */ + getSurroundParent() { + return /** @type {?BlockSvg} */ (super.getSurroundParent()); + } -/** - * 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); + /** + * @return {?BlockSvg} The next statement block or null. + * @override + */ + getNextBlock() { + return /** @type {?BlockSvg} */ (super.getNextBlock()); + } - if (this.rendered) { - this.render(); - // Moving an input will cause the block to change shape. - this.bumpNeighbours(); + /** + * @return {?BlockSvg} The previou statement block or null. + * @override + */ + getPreviousBlock() { + return /** @type {?BlockSvg} */ (super.getPreviousBlock()); } -}; -/** - * 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); + /** + * @return {?RenderedConnection} The first statement connection or null. + * @package + * @override + */ + getFirstStatementConnection() { + return /** @type {?RenderedConnection} */ ( + super.getFirstStatementConnection()); + } - if (this.rendered) { - this.render(); - // Adding an input will cause the block to change shape. - this.bumpNeighbours(); + /** + * @return {!BlockSvg} The top block in a stack. + * @override + */ + getTopStackBlock() { + return /** @type {!BlockSvg} */ (super.getTopStackBlock()); } - 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) { - /** @type {!RenderedConnection} */ (this.previousConnection) - .setTracking(track); + /** + * @param {boolean} ordered Sort the list if true. + * @return {!Array} Children of this block. + * @override + */ + getChildren(ordered) { + return /** @type {!Array} */ (super.getChildren(ordered)); } - if (this.outputConnection) { - /** @type {!RenderedConnection} */ (this.outputConnection) - .setTracking(track); + + /** + * @param {boolean} ordered Sort the list if true. + * @return {!Array} Descendants of this block. + * @override + */ + getDescendants(ordered) { + return /** @type {!Array} */ (super.getDescendants(ordered)); } - if (this.nextConnection) { - /** @type {!RenderedConnection} */ (this.nextConnection).setTracking(track); - const child = - /** @type {!RenderedConnection} */ (this.nextConnection).targetBlock(); - if (child) { - child.setConnectionTracking(track); - } + + /** + * @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)); } - 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; + /** + * 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()); } - for (let i = 0; i < this.inputList.length; i++) { - const conn = - /** @type {!RenderedConnection} */ (this.inputList[i].connection); - if (conn) { - conn.setTracking(track); + /** + * 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(); - // Pass tracking on down the chain. - const block = conn.targetBlock(); - if (block) { - block.setConnectionTracking(track); + 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; } } -}; -/** - * 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); + /** + * 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) { - myConnections.push(this.previousConnection); + this.previousConnection.moveToOffset(blockTL); } - if (this.nextConnection) { - myConnections.push(this.nextConnection); + if (this.outputConnection) { + this.outputConnection.moveToOffset(blockTL); } - if (all || !this.collapsed_) { - for (let i = 0, input; (input = this.inputList[i]); i++) { - if (input.connection) { - myConnections.push(input.connection); + + 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(); } } } - } - 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++) { - 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); - } - } + if (this.nextConnection) { + this.nextConnection.moveToOffset(blockTL); + if (this.nextConnection.isConnected()) { + this.nextConnection.tighten(); } } } -}; - -/** - * 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); - }, 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 - */ -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); + /** + * 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); } -}; - -/** - * 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. + /** + * 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); } - 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(); - } + /** + * 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); } - - dom.stopTextWidthCache(); - this.updateMarkers_(); - } finally { - this.renderIsInProgress_ = false; + return {height: height, width: width}; } -}; -/** - * 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 = - /** @type {!RenderedConnection} */ (this.inputList[i].connection); - if (conn) { - conn.moveToOffset(blockTL); - if (conn.isConnected()) { - conn.tighten(); - } - } + /** + * 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); } - if (this.nextConnection) { - this.nextConnection.moveToOffset(blockTL); - if (this.nextConnection.isConnected()) { - this.nextConnection.tighten(); - } + /** + * 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); } -}; - -/** - * 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 + * Constant for identifying rows that are to be rendered inline. + * Don't collide with Blockly.inputTypes. + * @const */ -BlockSvg.prototype.fadeForReplacement = function(add) { - this.pathObject.updateReplacementFade(add); -}; +BlockSvg.INLINE = -1; /** - * 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 + * ID to give the "collapsed warnings" warning. Allows us to remove the + * "collapsed warnings" warning without removing any warnings that belong to + * the block. + * @type {string} + * @const */ -BlockSvg.prototype.highlightShapeForInput = function(conn, add) { - this.pathObject.updateShapeForInputHighlight(conn, add); -}; +BlockSvg.COLLAPSED_WARNING_ID = 'TEMP_COLLAPSED_WARNING_'; exports.BlockSvg = BlockSvg; diff --git a/core/comment.js b/core/comment.js index 8f1efbe93d3..0502057bfe7 100644 --- a/core/comment.js +++ b/core/comment.js @@ -120,7 +120,6 @@ class Comment extends Icon { */ this.paragraphElement_ = null; - this.createIcon(); } diff --git a/core/contextmenu.js b/core/contextmenu.js index 6508ff3c960..907f24389cf 100644 --- a/core/contextmenu.js +++ b/core/contextmenu.js @@ -27,6 +27,8 @@ 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'); @@ -262,7 +264,8 @@ 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) { diff --git a/core/contextmenu_items.js b/core/contextmenu_items.js index aa0abbf5d25..0d0696f4241 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/events/events_block_change.js b/core/events/events_block_change.js index cf8b6d0e9df..305ed44886d 100644 --- a/core/events/events_block_change.js +++ b/core/events/events_block_change.js @@ -103,9 +103,12 @@ class BlockChange extends BlockBase { console.warn('Can\'t change non-existent block: ' + this.blockId); return; } - if (block.mutator) { + + // 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. - block.mutator.setVisible(false); + blockSvg.mutator.setVisible(false); } const value = forward ? this.newValue : this.oldValue; switch (this.element) { diff --git a/core/extensions.js b/core/extensions.js index 4043ba95ceb..a05e05d61fb 100644 --- a/core/extensions.js +++ b/core/extensions.js @@ -513,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/flyout_base.js b/core/flyout_base.js index 4490617e00b..89bb628e7a7 100644 --- a/core/flyout_base.js +++ b/core/flyout_base.js @@ -206,6 +206,14 @@ class Flyout extends DeleteArea { */ this.containerVisible_ = true; + /** + * A map from blocks to the rects which are beneath them to act as input + * targets. + * @type {!WeakMap} + * @private + */ + this.rectMap_ = new WeakMap(); + /** * Corner radius of the flyout background. * @type {number} @@ -1045,7 +1053,7 @@ class Flyout extends DeleteArea { // Add the rectangles under the blocks, so that the blocks' tooltips work. this.workspace_.getCanvas().insertBefore(rect, block.getSvgRoot()); - block.flyoutRect_ = rect; + this.rectMap_.set(block, rect); this.mats_[index] = rect; return rect; } diff --git a/core/flyout_horizontal.js b/core/flyout_horizontal.js index e09ced7f94b..56c25e9cef9 100644 --- a/core/flyout_horizontal.js +++ b/core/flyout_horizontal.js @@ -360,8 +360,8 @@ class HorizontalFlyout extends Flyout { if (this.height_ !== flyoutHeight) { for (let i = 0, block; (block = blocks[i]); i++) { - if (block.flyoutRect_) { - this.moveRectToBlock_(block.flyoutRect_, block); + if (this.rectMap_.has(block)) { + this.moveRectToBlock_(this.rectMap_.get(block), block); } } diff --git a/core/flyout_vertical.js b/core/flyout_vertical.js index 546124eeb0d..80a6d61d513 100644 --- a/core/flyout_vertical.js +++ b/core/flyout_vertical.js @@ -350,8 +350,8 @@ class VerticalFlyout extends Flyout { } block.moveBy(newX - oldX, 0); } - if (block.flyoutRect_) { - this.moveRectToBlock_(block.flyoutRect_, block); + if (this.rectMap_.has(block)) { + this.moveRectToBlock_(this.rectMap_.get(block), block); } } if (this.RTL) { diff --git a/core/mutator.js b/core/mutator.js index 0794e32ca64..9b6b2c80613 100644 --- a/core/mutator.js +++ b/core/mutator.js @@ -367,11 +367,9 @@ class Mutator extends Icon { // Save the initial connections, then listen for further changes. if (this.block_.saveConnections) { const thisRootBlock = this.rootBlock_; - const mutatorBlock = - /** @type {{saveConnections: function(!Block)}} */ (this.block_); - mutatorBlock.saveConnections(this.rootBlock_); + this.block_.saveConnections(thisRootBlock); this.sourceListener_ = function() { - mutatorBlock.saveConnections(thisRootBlock); + this.block_.saveConnections(thisRootBlock); }; this.block_.workspace.addChangeListener(this.sourceListener_); } diff --git a/core/procedures.js b/core/procedures.js index 308b1fff740..d7041f46db1 100644 --- a/core/procedures.js +++ b/core/procedures.js @@ -27,6 +27,8 @@ const {Block} = goog.requireType('Blockly.Block'); /* eslint-disable-next-line no-unused-vars */ const {BubbleOpen} = goog.requireType('Blockly.Events.BubbleOpen'); /* eslint-disable-next-line no-unused-vars */ +const {BlockSvg} = goog.requireType('Blockly.BlockSvg'); +/* eslint-disable-next-line no-unused-vars */ const {Field} = goog.requireType('Blockly.Field'); const {Msg} = goog.require('Blockly.Msg'); const {Names} = goog.require('Blockly.Names'); @@ -162,8 +164,9 @@ const isNameUsed = function(name, workspace, opt_exclude) { if (blocks[i] === opt_exclude) { continue; } - if (blocks[i].getProcedureDef) { - const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + // Assume it is a procedure block so we can check. + const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + if (procedureBlock.getProcedureDef) { const procName = procedureBlock.getProcedureDef(); if (Names.equals(procName[0], name)) { return true; @@ -193,8 +196,9 @@ const rename = function(name) { // Rename any callers. const blocks = this.getSourceBlock().workspace.getAllBlocks(false); for (let i = 0; i < blocks.length; i++) { - if (blocks[i].renameProcedure) { - const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + // Assume it is a procedure so we can check. + const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + if (procedureBlock.renameProcedure) { procedureBlock.renameProcedure( /** @type {string} */ (oldName), legalName); } @@ -335,13 +339,13 @@ const mutatorOpenListener = function(e) { return; } const workspaceId = /** @type {string} */ (bubbleEvent.workspaceId); - const block = - Workspace.getById(workspaceId).getBlockById(bubbleEvent.blockId); + const block = /** @type {!BlockSvg} */ + (Workspace.getById(workspaceId).getBlockById(bubbleEvent.blockId)); const type = block.type; if (type !== 'procedures_defnoreturn' && type !== 'procedures_defreturn') { return; } - const workspace = block.mutator.getWorkspace(); + const workspace = /** @type {!WorkspaceSvg} */ (block.mutator.getWorkspace()); updateMutatorFlyout(workspace); workspace.addChangeListener(mutatorChangeListener); }; @@ -376,8 +380,9 @@ const getCallers = function(name, workspace) { const blocks = workspace.getAllBlocks(false); // Iterate through every block and check the name. for (let i = 0; i < blocks.length; i++) { - if (blocks[i].getProcedureCall) { - const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + // Assume it is a procedure block so we can check. + const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + if (procedureBlock.getProcedureCall) { const procName = procedureBlock.getProcedureCall(); // Procedure name may be null if the block is only half-built. if (procName && Names.equals(procName, name)) { @@ -433,8 +438,9 @@ const getDefinition = function(name, workspace) { // rely on getProcedureDef. const blocks = workspace.getAllBlocks(false); for (let i = 0; i < blocks.length; i++) { - if (blocks[i].getProcedureDef) { - const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + // Assume it is a procedure block so we can check. + const procedureBlock = /** @type {!ProcedureBlock} */ (blocks[i]); + if (procedureBlock.getProcedureDef) { const tuple = procedureBlock.getProcedureDef(); if (tuple && Names.equals(tuple[0], name)) { return blocks[i]; // Can't use procedureBlock var due to type check. diff --git a/core/rendered_connection.js b/core/rendered_connection.js index 56010451a2b..4b0634deb0f 100644 --- a/core/rendered_connection.js +++ b/core/rendered_connection.js @@ -53,6 +53,9 @@ class RenderedConnection extends Connection { constructor(source, type) { super(source, type); + /** @type {!BlockSvg} */ + this.sourceBlock_; + /** * Connection database for connections of this type on the current * workspace. @@ -139,7 +142,7 @@ class RenderedConnection extends Connection { /** * Move the block(s) belonging to the connection to a point where they don't * visually interfere with the specified connection. - * @param {!Connection} staticConnection The connection to move away + * @param {!RenderedConnection} staticConnection The connection to move away * from. * @package */ @@ -436,7 +439,8 @@ class RenderedConnection extends Connection { setTimeout(function() { if (!block.isDisposed() && !block.getParent()) { eventUtils.setGroup(group); - this.bumpAwayFrom(otherConnection); + this.bumpAwayFrom( + /** @type {!RenderedConnection} */ (otherConnection)); eventUtils.setGroup(false); } }.bind(this), config.bumpDelay); @@ -452,15 +456,18 @@ class RenderedConnection extends Connection { */ disconnectInternal_(parentBlock, childBlock) { super.disconnectInternal_(parentBlock, childBlock); + const renderedParent = /** @type {!BlockSvg} */ (parentBlock); + const renderedChild = /** @type {!BlockSvg} */ (childBlock); + // Rerender the parent so that it may reflow. - if (parentBlock.rendered) { - parentBlock.render(); + if (renderedParent.rendered) { + renderedParent.render(); } - if (childBlock.rendered) { - childBlock.updateDisabled(); - childBlock.render(); + if (renderedChild.rendered) { + renderedChild.updateDisabled(); + renderedChild.render(); // Reset visibility, since the child is now a top block. - childBlock.getSvgRoot().style.display = 'block'; + renderedChild.getSvgRoot().style.display = 'block'; } } @@ -506,9 +513,12 @@ class RenderedConnection extends Connection { connect_(childConnection) { super.connect_(childConnection); + const renderedChildConnection = /** @type {!RenderedConnection} */ + (childConnection); + const parentConnection = this; const parentBlock = parentConnection.getSourceBlock(); - const childBlock = childConnection.getSourceBlock(); + const childBlock = renderedChildConnection.getSourceBlock(); const parentRendered = parentBlock.rendered; const childRendered = childBlock.rendered; diff --git a/core/renderers/common/i_path_object.js b/core/renderers/common/i_path_object.js index d8388e715b3..06939bb3c13 100644 --- a/core/renderers/common/i_path_object.js +++ b/core/renderers/common/i_path_object.js @@ -19,7 +19,7 @@ goog.module('Blockly.blockRendering.IPathObject'); /* eslint-disable-next-line no-unused-vars */ -const {Block} = goog.requireType('Blockly.Block'); +const {BlockSvg} = goog.requireType('Blockly.BlockSvg'); /* eslint-disable-next-line no-unused-vars */ const {ConstantProvider} = goog.requireType('Blockly.blockRendering.ConstantProvider'); /* eslint-disable-next-line no-unused-vars */ @@ -78,7 +78,7 @@ IPathObject.prototype.setPath; /** * Apply the stored colours to the block's path, taking into account whether * the paths belong to a shadow block. - * @param {!Block} block The source block. + * @param {!BlockSvg} block The source block. * @package */ IPathObject.prototype.applyColour; diff --git a/core/renderers/common/path_object.js b/core/renderers/common/path_object.js index 712e16bded7..3ffa21a0a2d 100644 --- a/core/renderers/common/path_object.js +++ b/core/renderers/common/path_object.js @@ -18,7 +18,7 @@ goog.module('Blockly.blockRendering.PathObject'); const dom = goog.require('Blockly.utils.dom'); /* eslint-disable-next-line no-unused-vars */ -const {Block} = goog.requireType('Blockly.Block'); +const {BlockSvg} = goog.requireType('Blockly.BlockSvg'); /* eslint-disable-next-line no-unused-vars */ const {Connection} = goog.requireType('Blockly.Connection'); /* eslint-disable-next-line no-unused-vars */ @@ -144,7 +144,7 @@ class PathObject { /** * Apply the stored colours to the block's path, taking into account whether * the paths belong to a shadow block. - * @param {!Block} block The source block. + * @param {!BlockSvg} block The source block. * @package */ applyColour(block) { diff --git a/core/serialization/blocks.js b/core/serialization/blocks.js index 150ea865fd3..2e3b76bbdc5 100644 --- a/core/serialization/blocks.js +++ b/core/serialization/blocks.js @@ -23,6 +23,8 @@ const serializationRegistry = goog.require('Blockly.serialization.registry'); const {BadConnectionCheck, MissingBlockType, MissingConnection, RealChildOfShadow} = goog.require('Blockly.serialization.exceptions'); /* 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'); // eslint-disable-next-line no-unused-vars const {Connection} = goog.requireType('Blockly.Connection'); // eslint-disable-next-line no-unused-vars @@ -350,9 +352,10 @@ const appendInternal = function(state, workspace, { // Adding connections to the connection db is expensive. This defers that // operation to decrease load time. if (workspace.rendered) { + const blockSvg = /** @type {!BlockSvg} */ (block); setTimeout(() => { - if (!block.disposed) { - block.setConnectionTracking(true); + if (!blockSvg.disposed) { + blockSvg.setConnectionTracking(true); } }, 1); } @@ -513,9 +516,10 @@ const loadIcons = function(block, state) { block.setCommentText(comment['text']); block.commentModel.pinned = comment['pinned']; block.commentModel.size = new Size(comment['width'], comment['height']); - if (comment['pinned'] && block.getCommentIcon && !block.isInFlyout) { + if (comment['pinned'] && block.rendered && !block.isInFlyout) { // Give the block a chance to be positioned and rendered before showing. - setTimeout(() => block.getCommentIcon().setVisible(true), 1); + const blockSvg = /** @type {!BlockSvg} */ (block); + setTimeout(() => blockSvg.getCommentIcon().setVisible(true), 1); } } }; @@ -607,12 +611,13 @@ const loadConnection = function(connection, connectionState) { */ const initBlock = function(block, rendered) { if (rendered) { + const blockSvg = /** @type {!BlockSvg} */ (block); // Adding connections to the connection db is expensive. This defers that // operation to decrease load time. - block.setConnectionTracking(false); + blockSvg.setConnectionTracking(false); - block.initSvg(); - block.render(false); + blockSvg.initSvg(); + blockSvg.render(false); } else { block.initModel(); } diff --git a/core/workspace_svg.js b/core/workspace_svg.js index 28849b0c37e..f95e61eb3b6 100644 --- a/core/workspace_svg.js +++ b/core/workspace_svg.js @@ -1563,14 +1563,14 @@ class WorkspaceSvg extends Workspace { let blockX = 0; let blockY = 0; if (xmlBlock) { - block = Xml.domToBlock(xmlBlock, this); + block = /** @type {!BlockSvg} */ (Xml.domToBlock(xmlBlock, this)); blockX = parseInt(xmlBlock.getAttribute('x'), 10); if (this.RTL) { blockX = -blockX; } blockY = parseInt(xmlBlock.getAttribute('y'), 10); } else if (jsonBlock) { - block = blocks.append(jsonBlock, this); + block = /** @type {!BlockSvg} */ (blocks.append(jsonBlock, this)); blockX = jsonBlock['x'] || 10; if (this.RTL) { blockX = this.getWidth() - blockX; @@ -2425,6 +2425,16 @@ class WorkspaceSvg extends Workspace { return /** @type {BlockSvg} */ (super.getBlockById(id)); } + /** + * Find all blocks in workspace. Blocks are optionally sorted + * by position; top to bottom (with slight LTR or RTL bias). + * @param {boolean} ordered Sort the list if true. + * @return {!Array} Array of blocks. + */ + getAllBlocks(ordered) { + return /** @type {!Array} */ (super.getAllBlocks(ordered)); + } + /** * Finds the top-level blocks and returns them. Blocks are optionally sorted * by position; top to bottom (with slight LTR or RTL bias). diff --git a/core/xml.js b/core/xml.js index f9c8b1c31fc..217b910f03c 100644 --- a/core/xml.js +++ b/core/xml.js @@ -21,6 +21,8 @@ const utilsXml = 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 {BlockSvg} = goog.requireType('Blockly.BlockSvg'); +/* eslint-disable-next-line no-unused-vars */ const {Connection} = goog.requireType('Blockly.Connection'); /* eslint-disable-next-line no-unused-vars */ const {Field} = goog.requireType('Blockly.Field'); @@ -599,10 +601,10 @@ const domToBlock = function(xmlBlock, workspace) { try { topBlock = domToBlockHeadless(xmlBlock, workspace); // Generate list of all blocks. - const blocks = topBlock.getDescendants(false); if (workspace.rendered) { - // Wait to track connections to speed up assembly. - topBlock.setConnectionTracking(false); + const topBlockSvg = /** @type {!BlockSvg} */ (topBlock); + const blocks = topBlock.getDescendants(false); + topBlockSvg.setConnectionTracking(false); // Render each block. for (let i = blocks.length - 1; i >= 0; i--) { blocks[i].initSvg(); @@ -613,15 +615,16 @@ const domToBlock = function(xmlBlock, workspace) { // Populating the connection database may be deferred until after the // blocks have rendered. setTimeout(function() { - if (!topBlock.disposed) { - topBlock.setConnectionTracking(true); + if (!topBlockSvg.disposed) { + topBlockSvg.setConnectionTracking(true); } }, 1); - topBlock.updateDisabled(); + topBlockSvg.updateDisabled(); // Allow the scrollbars to resize and move based on the new contents. // TODO(@picklesrus): #387. Remove when domToBlock avoids resizing. workspace.resizeContents(); } else { + const blocks = topBlock.getDescendants(false); for (let i = blocks.length - 1; i >= 0; i--) { blocks[i].initModel(); } @@ -778,8 +781,9 @@ const applyCommentTagNodes = function(xmlChildren, block) { } if (pinned && block.getCommentIcon && !block.isInFlyout) { + const blockSvg = /** @type {BlockSvg} */ (block); setTimeout(function() { - block.getCommentIcon().setVisible(true); + blockSvg.getCommentIcon().setVisible(true); }, 1); } } @@ -953,8 +957,11 @@ const domToBlockHeadless = function( applyNextTagNodes(xmlChildNameMap.next, workspace, block); if (shouldCallInitSvg) { - // InitSvg needs to be called after variable fields are loaded. - block.initSvg(); + // This shouldn't even be called here + // (ref: https://github.com/google/blockly/pull/4296#issuecomment-884226021 + // But the XML serializer/deserializer is iceboxed so I'm not going to fix + // it. + (/** @type {!BlockSvg} */ (block)).initSvg(); } const inline = xmlBlock.getAttribute('inline'); diff --git a/scripts/gulpfiles/chunks.json b/scripts/gulpfiles/chunks.json index 72c0d27c83a..789b9fa3a37 100644 --- a/scripts/gulpfiles/chunks.json +++ b/scripts/gulpfiles/chunks.json @@ -253,13 +253,13 @@ "./core/extensions.js", "./core/block.js", "./core/utils/string.js", - "./core/utils/object.js", "./core/dialog.js", "./core/utils/xml.js", "./core/events/events_var_base.js", "./core/events/events_var_create.js", "./core/variable_model.js", "./core/variables.js", + "./core/utils/object.js", "./core/events/events_abstract.js", "./core/registry.js", "./core/events/utils.js", diff --git a/tests/deps.js b/tests/deps.js index 3d6cad703dc..24df7a3c00e 100644 --- a/tests/deps.js +++ b/tests/deps.js @@ -12,7 +12,7 @@ goog.addDependency('../../core/block.js', ['Blockly.Block'], ['Blockly.ASTNode', goog.addDependency('../../core/block_animations.js', ['Blockly.blockAnimations'], ['Blockly.utils.Svg', 'Blockly.utils.dom'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/block_drag_surface.js', ['Blockly.BlockDragSurfaceSvg'], ['Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.svgMath'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/block_dragger.js', ['Blockly.BlockDragger'], ['Blockly.Events.BlockDrag', 'Blockly.Events.BlockMove', 'Blockly.Events.utils', 'Blockly.IBlockDragger', 'Blockly.InsertionMarkerManager', 'Blockly.blockAnimations', 'Blockly.bumpObjects', 'Blockly.common', 'Blockly.registry', 'Blockly.utils.Coordinate', 'Blockly.utils.dom'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/block_svg.js', ['Blockly.BlockSvg'], ['Blockly.ASTNode', 'Blockly.Block', 'Blockly.ConnectionType', 'Blockly.ContextMenu', 'Blockly.ContextMenuRegistry', 'Blockly.Events.BlockMove', 'Blockly.Events.Selected', 'Blockly.Events.utils', 'Blockly.FieldLabel', 'Blockly.IASTNodeLocationSvg', 'Blockly.IBoundedElement', 'Blockly.ICopyable', 'Blockly.IDraggable', 'Blockly.MarkerManager', 'Blockly.Msg', 'Blockly.RenderedConnection', 'Blockly.TabNavigateCursor', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.blockAnimations', 'Blockly.browserEvents', 'Blockly.common', 'Blockly.config', 'Blockly.constants', 'Blockly.internalConstants', 'Blockly.serialization.blocks', 'Blockly.utils.Coordinate', 'Blockly.utils.Rect', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.svgMath', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/block_svg.js', ['Blockly.BlockSvg'], ['Blockly.ASTNode', 'Blockly.Block', 'Blockly.ConnectionType', 'Blockly.ContextMenu', 'Blockly.ContextMenuRegistry', 'Blockly.Events.BlockMove', 'Blockly.Events.Selected', 'Blockly.Events.utils', 'Blockly.FieldLabel', 'Blockly.IASTNodeLocationSvg', 'Blockly.IBoundedElement', 'Blockly.ICopyable', 'Blockly.IDraggable', 'Blockly.MarkerManager', 'Blockly.Msg', 'Blockly.RenderedConnection', 'Blockly.TabNavigateCursor', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.blockAnimations', 'Blockly.browserEvents', 'Blockly.common', 'Blockly.config', 'Blockly.constants', 'Blockly.internalConstants', 'Blockly.serialization.blocks', 'Blockly.utils.Coordinate', 'Blockly.utils.Rect', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.svgMath', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/blockly.js', ['Blockly'], ['Blockly.ASTNode', 'Blockly.BasicCursor', 'Blockly.Block', 'Blockly.BlockDragSurfaceSvg', 'Blockly.BlockDragger', 'Blockly.BlockSvg', 'Blockly.BlocklyOptions', 'Blockly.Bubble', 'Blockly.BubbleDragger', 'Blockly.CollapsibleToolboxCategory', 'Blockly.Comment', 'Blockly.ComponentManager', 'Blockly.Connection', 'Blockly.ConnectionChecker', 'Blockly.ConnectionDB', 'Blockly.ConnectionType', 'Blockly.ContextMenu', 'Blockly.ContextMenuItems', 'Blockly.ContextMenuRegistry', 'Blockly.Css', 'Blockly.Cursor', 'Blockly.DeleteArea', 'Blockly.DragTarget', 'Blockly.DropDownDiv', 'Blockly.Events', 'Blockly.Events.BlockCreate', 'Blockly.Events.FinishedLoading', 'Blockly.Events.Ui', 'Blockly.Events.UiBase', 'Blockly.Events.VarCreate', 'Blockly.Extensions', 'Blockly.Field', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabel', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.Flyout', 'Blockly.FlyoutButton', 'Blockly.FlyoutMetricsManager', 'Blockly.Generator', 'Blockly.Gesture', 'Blockly.Grid', 'Blockly.HorizontalFlyout', 'Blockly.IASTNodeLocation', 'Blockly.IASTNodeLocationSvg', 'Blockly.IASTNodeLocationWithBlock', 'Blockly.IAutoHideable', 'Blockly.IBlockDragger', 'Blockly.IBoundedElement', 'Blockly.IBubble', 'Blockly.ICollapsibleToolboxItem', 'Blockly.IComponent', 'Blockly.IConnectionChecker', 'Blockly.IContextMenu', 'Blockly.ICopyable', 'Blockly.IDeletable', 'Blockly.IDeleteArea', 'Blockly.IDragTarget', 'Blockly.IDraggable', 'Blockly.IFlyout', 'Blockly.IKeyboardAccessible', 'Blockly.IMetricsManager', 'Blockly.IMovable', 'Blockly.IPositionable', 'Blockly.IRegistrable', 'Blockly.IRegistrableField', 'Blockly.ISelectable', 'Blockly.ISelectableToolboxItem', 'Blockly.IStyleable', 'Blockly.IToolbox', 'Blockly.IToolboxItem', 'Blockly.Icon', 'Blockly.Input', 'Blockly.InsertionMarkerManager', 'Blockly.Marker', 'Blockly.MarkerManager', 'Blockly.Menu', 'Blockly.MenuItem', 'Blockly.MetricsManager', 'Blockly.Msg', 'Blockly.Mutator', 'Blockly.Names', 'Blockly.Options', 'Blockly.Procedures', 'Blockly.RenderedConnection', 'Blockly.Scrollbar', 'Blockly.ScrollbarPair', 'Blockly.ShortcutItems', 'Blockly.ShortcutRegistry', 'Blockly.TabNavigateCursor', 'Blockly.Theme', 'Blockly.ThemeManager', 'Blockly.Themes', 'Blockly.Toolbox', 'Blockly.ToolboxCategory', 'Blockly.ToolboxItem', 'Blockly.ToolboxSeparator', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.TouchGesture', 'Blockly.Trashcan', 'Blockly.VariableMap', 'Blockly.VariableModel', 'Blockly.Variables', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.WidgetDiv', 'Blockly.Workspace', 'Blockly.WorkspaceAudio', 'Blockly.WorkspaceComment', 'Blockly.WorkspaceCommentSvg', 'Blockly.WorkspaceDragSurfaceSvg', 'Blockly.WorkspaceDragger', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.ZoomControls', 'Blockly.blockAnimations', 'Blockly.blockRendering', 'Blockly.blocks', 'Blockly.browserEvents', 'Blockly.bumpObjects', 'Blockly.clipboard', 'Blockly.common', 'Blockly.config', 'Blockly.constants', 'Blockly.dialog', 'Blockly.fieldRegistry', 'Blockly.geras', 'Blockly.inject', 'Blockly.inputTypes', 'Blockly.internalConstants', 'Blockly.minimalist', 'Blockly.registry', 'Blockly.serialization.ISerializer', 'Blockly.serialization.blocks', 'Blockly.serialization.exceptions', 'Blockly.serialization.priorities', 'Blockly.serialization.registry', 'Blockly.serialization.variables', 'Blockly.serialization.workspaces', 'Blockly.thrasos', 'Blockly.uiPosition', 'Blockly.utils', 'Blockly.utils.colour', 'Blockly.utils.deprecation', 'Blockly.utils.global', 'Blockly.utils.svgMath', 'Blockly.utils.toolbox', 'Blockly.zelos'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/blockly_options.js', ['Blockly.BlocklyOptions'], [], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/blocks.js', ['Blockly.blocks'], [], {'lang': 'es6', 'module': 'goog'}); diff --git a/tests/mocha/extensions_test.js b/tests/mocha/extensions_test.js index d4aebf38e9c..d7846bb810f 100644 --- a/tests/mocha/extensions_test.js +++ b/tests/mocha/extensions_test.js @@ -244,8 +244,8 @@ suite('Extensions', function() { // Make sure all of the functions were installed correctly. chai.assert.equal(block.domToMutation(), 'domToMutationFn'); chai.assert.equal(block.mutationToDom(), 'mutationToDomFn'); - chai.assert.isFalse(Object.prototype.hasOwnProperty.call(block, 'compose')); - chai.assert.isFalse(Object.prototype.hasOwnProperty.call(block, 'decompose')); + chai.assert.isUndefined(block['compose']); + chai.assert.isUndefined(block['decompose']); }); }); });