From 4cf1a5c886e1a4a32a55534a103482184c0943e4 Mon Sep 17 00:00:00 2001 From: Rachel Fenichel Date: Fri, 4 Feb 2022 10:58:22 -0800 Subject: [PATCH] refactor: convert some files to es classes (#5913) * refactor: update workspace_comment and _svg to es classes * refactor: update classes that extend icon to es classes * refactor: update icon to es6 class * refactor: update connection classes to es6 classes and add casts as needed * refactor: update scrollbar to es6 class and add casts as needed * refactor: update workspace_svg to es6 class * refactor: update several files to es6 classes * refactor: update several files to es6 classes * refactor: update renderers/common/info.js to es6 class * refactor: update several files to es6 classes * chore: rebuild deps.js * chore: run format --- core/block_svg.js | 36 +- core/comment.js | 736 +-- core/connection.js | 1188 ++-- core/connection_checker.js | 508 +- core/connection_db.js | 505 +- core/field_label_serializable.js | 87 +- core/field_multilineinput.js | 739 +-- core/icon.js | 332 +- core/marker_manager.js | 318 +- core/mutator.js | 948 ++-- core/rendered_connection.js | 1008 ++-- core/renderers/common/constants.js | 2234 ++++---- core/renderers/common/debugger.js | 753 +-- core/renderers/common/drawer.js | 797 +-- core/renderers/common/info.js | 1319 ++--- core/renderers/common/marker_svg.js | 1217 ++-- core/renderers/geras/drawer.js | 5 + core/renderers/geras/info.js | 5 + .../geras/measurables/inline_input.js | 9 +- .../geras/measurables/statement_input.js | 9 +- core/renderers/zelos/drawer.js | 5 + core/renderers/zelos/info.js | 3 + core/renderers/zelos/marker_svg.js | 16 +- core/scrollbar.js | 1590 +++--- core/theme_manager.js | 310 +- core/toolbox/separator.js | 104 +- core/warning.js | 254 +- core/workspace_comment.js | 661 +-- core/workspace_comment_svg.js | 2013 +++---- core/workspace_svg.js | 5002 +++++++++-------- core/xml.js | 2 +- tests/deps.js | 16 +- 32 files changed, 11523 insertions(+), 11206 deletions(-) diff --git a/core/block_svg.js b/core/block_svg.js index 5fd46d8a7..832659136 100644 --- a/core/block_svg.js +++ b/core/block_svg.js @@ -1423,14 +1423,17 @@ BlockSvg.prototype.appendInput_ = function(type, name) { */ BlockSvg.prototype.setConnectionTracking = function(track) { if (this.previousConnection) { - this.previousConnection.setTracking(track); + /** @type {!RenderedConnection} */ (this.previousConnection) + .setTracking(track); } if (this.outputConnection) { - this.outputConnection.setTracking(track); + /** @type {!RenderedConnection} */ (this.outputConnection) + .setTracking(track); } if (this.nextConnection) { - this.nextConnection.setTracking(track); - const child = this.nextConnection.targetBlock(); + /** @type {!RenderedConnection} */ (this.nextConnection).setTracking(track); + const child = + /** @type {!RenderedConnection} */ (this.nextConnection).targetBlock(); if (child) { child.setConnectionTracking(track); } @@ -1444,7 +1447,8 @@ BlockSvg.prototype.setConnectionTracking = function(track) { } for (let i = 0; i < this.inputList.length; i++) { - const conn = this.inputList[i].connection; + const conn = + /** @type {!RenderedConnection} */ (this.inputList[i].connection); if (conn) { conn.setTracking(track); @@ -1547,23 +1551,26 @@ BlockSvg.prototype.bumpNeighbours = function() { // 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 (connection.isConnected() && connection.isSuperior()) { - connection.targetBlock().bumpNeighbours(); + if (renderedConn.isConnected() && renderedConn.isSuperior()) { + renderedConn.targetBlock().bumpNeighbours(); } - const neighbours = connection.neighbours(internalConstants.SNAP_RADIUS); + const neighbours = renderedConn.neighbours(internalConstants.SNAP_RADIUS); 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 (!connection.isConnected() || !otherConnection.isConnected()) { + if (!renderedConn.isConnected() || !renderedOther.isConnected()) { // Only bump blocks if they are from different tree structures. - if (otherConnection.getSourceBlock().getRootBlock() !== rootBlock) { + if (renderedOther.getSourceBlock().getRootBlock() !== rootBlock) { // Always bump the inferior block. - if (connection.isSuperior()) { - otherConnection.bumpAwayFrom(connection); + if (renderedConn.isSuperior()) { + renderedOther.bumpAwayFrom(renderedConn); } else { - connection.bumpAwayFrom(otherConnection); + renderedConn.bumpAwayFrom(renderedOther); } } } @@ -1706,7 +1713,8 @@ BlockSvg.prototype.updateConnectionLocations_ = function() { } for (let i = 0; i < this.inputList.length; i++) { - const conn = this.inputList[i].connection; + const conn = + /** @type {!RenderedConnection} */ (this.inputList[i].connection); if (conn) { conn.moveToOffset(blockTL); if (conn.isConnected()) { diff --git a/core/comment.js b/core/comment.js index d11e2b636..8f1efbe93 100644 --- a/core/comment.js +++ b/core/comment.js @@ -19,7 +19,6 @@ const Css = goog.require('Blockly.Css'); const browserEvents = goog.require('Blockly.browserEvents'); const dom = goog.require('Blockly.utils.dom'); const eventUtils = goog.require('Blockly.Events.utils'); -const object = goog.require('Blockly.utils.object'); const userAgent = goog.require('Blockly.utils.userAgent'); /* eslint-disable-next-line no-unused-vars */ const {BlockSvg} = goog.requireType('Blockly.BlockSvg'); @@ -44,391 +43,392 @@ goog.require('Blockly.Warning'); /** * Class for a comment. - * @param {!Block} block The block associated with this comment. * @extends {Icon} - * @constructor - * @alias Blockly.Comment */ -const Comment = function(block) { - Comment.superClass_.constructor.call(this, block); - +class Comment extends Icon { /** - * The model for this comment. - * @type {!Block.CommentModel} - * @private + * @param {!BlockSvg} block The block associated with this comment. + * @alias Blockly.Comment */ - this.model_ = block.commentModel; - // If someone creates the comment directly instead of calling - // block.setCommentText we want to make sure the text is non-null; - this.model_.text = this.model_.text || ''; + constructor(block) { + super(block); - /** - * The model's text value at the start of an edit. - * Used to tell if an event should be fired at the end of an edit. - * @type {?string} - * @private - */ - this.cachedText_ = ''; + /** + * The model for this comment. + * @type {!Block.CommentModel} + * @private + */ + this.model_ = block.commentModel; + // If someone creates the comment directly instead of calling + // block.setCommentText we want to make sure the text is non-null; + this.model_.text = this.model_.text || ''; - /** - * Mouse up event data. - * @type {?browserEvents.Data} - * @private - */ - this.onMouseUpWrapper_ = null; + /** + * The model's text value at the start of an edit. + * Used to tell if an event should be fired at the end of an edit. + * @type {?string} + * @private + */ + this.cachedText_ = ''; - /** - * Wheel event data. - * @type {?browserEvents.Data} - * @private - */ - this.onWheelWrapper_ = null; - - /** - * Change event data. - * @type {?browserEvents.Data} - * @private - */ - this.onChangeWrapper_ = null; - - /** - * Input event data. - * @type {?browserEvents.Data} - * @private - */ - this.onInputWrapper_ = null; - - /** - * The SVG element that contains the text edit area, or null if not created. - * @type {?SVGForeignObjectElement} - * @private - */ - this.foreignObject_ = null; - - /** - * The editable text area, or null if not created. - * @type {?Element} - * @private - */ - this.textarea_ = null; - - /** - * The top-level node of the comment text, or null if not created. - * @type {?SVGTextElement} - * @private - */ - this.paragraphElement_ = null; - - - this.createIcon(); -}; -object.inherits(Comment, Icon); - -/** - * Draw the comment icon. - * @param {!Element} group The icon group. - * @protected - */ -Comment.prototype.drawIcon_ = function(group) { - // Circle. - dom.createSvgElement( - Svg.CIRCLE, {'class': 'blocklyIconShape', 'r': '8', 'cx': '8', 'cy': '8'}, - group); - // Can't use a real '?' text character since different browsers and operating - // systems render it differently. - // Body of question mark. - dom.createSvgElement( - Svg.PATH, { - 'class': 'blocklyIconSymbol', - 'd': 'm6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.405' + - '0.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25' + - '-1.201,0.998 -1.201,1.528 -1.204,2.19z', - }, - group); - // Dot of question mark. - dom.createSvgElement( - Svg.RECT, { - 'class': 'blocklyIconSymbol', - 'x': '6.8', - 'y': '10.78', - 'height': '2', - 'width': '2', - }, - group); -}; - -/** - * Create the editor for the comment's bubble. - * @return {!SVGElement} The top-level node of the editor. - * @private - */ -Comment.prototype.createEditor_ = function() { - /* Create the editor. Here's the markup that will be generated in - * editable mode: - - - - - - * For non-editable mode see Warning.textToDom_. - */ - - this.foreignObject_ = dom.createSvgElement( - Svg.FOREIGNOBJECT, {'x': Bubble.BORDER_WIDTH, 'y': Bubble.BORDER_WIDTH}, - null); - - const body = document.createElementNS(dom.HTML_NS, 'body'); - body.setAttribute('xmlns', dom.HTML_NS); - body.className = 'blocklyMinimalBody'; - - this.textarea_ = document.createElementNS(dom.HTML_NS, 'textarea'); - const textarea = this.textarea_; - textarea.className = 'blocklyCommentTextarea'; - textarea.setAttribute('dir', this.block_.RTL ? 'RTL' : 'LTR'); - textarea.value = this.model_.text; - this.resizeTextarea_(); - - body.appendChild(textarea); - this.foreignObject_.appendChild(body); - - // Ideally this would be hooked to the focus event for the comment. - // However doing so in Firefox swallows the cursor for unknown reasons. - // So this is hooked to mouseup instead. No big deal. - this.onMouseUpWrapper_ = browserEvents.conditionalBind( - textarea, 'mouseup', this, this.startEdit_, true, true); - // Don't zoom with mousewheel. - this.onWheelWrapper_ = - browserEvents.conditionalBind(textarea, 'wheel', this, function(e) { - e.stopPropagation(); - }); - this.onChangeWrapper_ = browserEvents.conditionalBind( - textarea, 'change', this, - /** - * @this {Comment} - * @param {Event} _e Unused event parameter. - */ - function(_e) { - if (this.cachedText_ !== this.model_.text) { - eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( - this.block_, 'comment', null, this.cachedText_, - this.model_.text)); - } - }); - this.onInputWrapper_ = browserEvents.conditionalBind( - textarea, 'input', this, - /** - * @this {Comment} - * @param {Event} _e Unused event parameter. - */ - function(_e) { - this.model_.text = textarea.value; - }); - - setTimeout(textarea.focus.bind(textarea), 0); - - return this.foreignObject_; -}; - -/** - * Add or remove editability of the comment. - * @override - */ -Comment.prototype.updateEditable = function() { - Comment.superClass_.updateEditable.call(this); - if (this.isVisible()) { - // Recreate the bubble with the correct UI. - this.disposeBubble_(); - this.createBubble_(); - } -}; - -/** - * Callback function triggered when the bubble has resized. - * Resize the text area accordingly. - * @private - */ -Comment.prototype.onBubbleResize_ = function() { - if (!this.isVisible()) { - return; - } - this.model_.size = this.bubble_.getBubbleSize(); - this.resizeTextarea_(); -}; - -/** - * Resizes the text area to match the size defined on the model (which is - * the size of the bubble). - * @private - */ -Comment.prototype.resizeTextarea_ = function() { - const size = this.model_.size; - const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH; - const widthMinusBorder = size.width - doubleBorderWidth; - const heightMinusBorder = size.height - doubleBorderWidth; - this.foreignObject_.setAttribute('width', widthMinusBorder); - this.foreignObject_.setAttribute('height', heightMinusBorder); - this.textarea_.style.width = (widthMinusBorder - 4) + 'px'; - this.textarea_.style.height = (heightMinusBorder - 4) + 'px'; -}; - -/** - * Show or hide the comment bubble. - * @param {boolean} visible True if the bubble should be visible. - */ -Comment.prototype.setVisible = function(visible) { - if (visible === this.isVisible()) { - return; - } - eventUtils.fire(new (eventUtils.get(eventUtils.BUBBLE_OPEN))( - this.block_, visible, 'comment')); - this.model_.pinned = visible; - if (visible) { - this.createBubble_(); - } else { - this.disposeBubble_(); - } -}; - -/** - * Show the bubble. Handles deciding if it should be editable or not. - * @private - */ -Comment.prototype.createBubble_ = function() { - if (!this.block_.isEditable() || userAgent.IE) { - // MSIE does not support foreignobject; textareas are impossible. - // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-svg/56e6e04c-7c8c-44dd-8100-bd745ee42034 - // Always treat comments in IE as uneditable. - this.createNonEditableBubble_(); - } else { - this.createEditableBubble_(); - } -}; - -/** - * Show an editable bubble. - * @private - */ -Comment.prototype.createEditableBubble_ = function() { - this.bubble_ = new Bubble( - /** @type {!WorkspaceSvg} */ (this.block_.workspace), - this.createEditor_(), this.block_.pathObject.svgPath, - /** @type {!Coordinate} */ (this.iconXY_), this.model_.size.width, - this.model_.size.height); - // Expose this comment's block's ID on its top-level SVG group. - this.bubble_.setSvgId(this.block_.id); - this.bubble_.registerResizeEvent(this.onBubbleResize_.bind(this)); - this.applyColour(); -}; - -/** - * Show a non-editable bubble. - * @private - * @suppress {checkTypes} Suppress `this` type mismatch. - */ -Comment.prototype.createNonEditableBubble_ = function() { - // TODO (#2917): It would be great if the comment could support line breaks. - this.paragraphElement_ = Bubble.textToDom(this.block_.getCommentText()); - this.bubble_ = Bubble.createNonEditableBubble( - this.paragraphElement_, /** @type {!BlockSvg} */ (this.block_), - /** @type {!Coordinate} */ (this.iconXY_)); - this.applyColour(); -}; - -/** - * Dispose of the bubble. - * @private - * @suppress {checkTypes} Suppress `this` type mismatch. - */ -Comment.prototype.disposeBubble_ = function() { - if (this.onMouseUpWrapper_) { - browserEvents.unbind(this.onMouseUpWrapper_); + /** + * Mouse up event data. + * @type {?browserEvents.Data} + * @private + */ this.onMouseUpWrapper_ = null; - } - if (this.onWheelWrapper_) { - browserEvents.unbind(this.onWheelWrapper_); + + /** + * Wheel event data. + * @type {?browserEvents.Data} + * @private + */ this.onWheelWrapper_ = null; - } - if (this.onChangeWrapper_) { - browserEvents.unbind(this.onChangeWrapper_); + + /** + * Change event data. + * @type {?browserEvents.Data} + * @private + */ this.onChangeWrapper_ = null; - } - if (this.onInputWrapper_) { - browserEvents.unbind(this.onInputWrapper_); + + /** + * Input event data. + * @type {?browserEvents.Data} + * @private + */ this.onInputWrapper_ = null; - } - this.bubble_.dispose(); - this.bubble_ = null; - this.textarea_ = null; - this.foreignObject_ = null; - this.paragraphElement_ = null; -}; -/** - * Callback fired when an edit starts. - * - * Bring the comment to the top of the stack when clicked on. Also cache the - * current text so it can be used to fire a change event. - * @param {!Event} _e Mouse up event. - * @private - */ -Comment.prototype.startEdit_ = function(_e) { - if (this.bubble_.promote()) { - // Since the act of moving this node within the DOM causes a loss of focus, - // we need to reapply the focus. - this.textarea_.focus(); + /** + * The SVG element that contains the text edit area, or null if not created. + * @type {?SVGForeignObjectElement} + * @private + */ + this.foreignObject_ = null; + + /** + * The editable text area, or null if not created. + * @type {?Element} + * @private + */ + this.textarea_ = null; + + /** + * The top-level node of the comment text, or null if not created. + * @type {?SVGTextElement} + * @private + */ + this.paragraphElement_ = null; + + + this.createIcon(); } - this.cachedText_ = this.model_.text; -}; - -/** - * Get the dimensions of this comment's bubble. - * @return {Size} Object with width and height properties. - */ -Comment.prototype.getBubbleSize = function() { - return this.model_.size; -}; - -/** - * Size this comment's bubble. - * @param {number} width Width of the bubble. - * @param {number} height Height of the bubble. - */ -Comment.prototype.setBubbleSize = function(width, height) { - if (this.bubble_) { - this.bubble_.setBubbleSize(width, height); - } else { - this.model_.size.width = width; - this.model_.size.height = height; + /** + * Draw the comment icon. + * @param {!Element} group The icon group. + * @protected + */ + drawIcon_(group) { + // Circle. + dom.createSvgElement( + Svg.CIRCLE, + {'class': 'blocklyIconShape', 'r': '8', 'cx': '8', 'cy': '8'}, group); + // Can't use a real '?' text character since different browsers and + // operating systems render it differently. Body of question mark. + dom.createSvgElement( + Svg.PATH, { + 'class': 'blocklyIconSymbol', + 'd': 'm6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.405' + + '0.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25' + + '-1.201,0.998 -1.201,1.528 -1.204,2.19z', + }, + group); + // Dot of question mark. + dom.createSvgElement( + Svg.RECT, { + 'class': 'blocklyIconSymbol', + 'x': '6.8', + 'y': '10.78', + 'height': '2', + 'width': '2', + }, + group); } -}; -/** - * Update the comment's view to match the model. - * @package - */ -Comment.prototype.updateText = function() { - if (this.textarea_) { - this.textarea_.value = this.model_.text; - } else if (this.paragraphElement_) { - // Non-Editable mode. - // TODO (#2917): If 2917 gets added this will probably need to be updated. - this.paragraphElement_.firstChild.textContent = this.model_.text; + /** + * Create the editor for the comment's bubble. + * @return {!SVGElement} The top-level node of the editor. + * @private + */ + createEditor_() { + /* Create the editor. Here's the markup that will be generated in + * editable mode: + + + + + + * For non-editable mode see Warning.textToDom_. + */ + + this.foreignObject_ = dom.createSvgElement( + Svg.FOREIGNOBJECT, {'x': Bubble.BORDER_WIDTH, 'y': Bubble.BORDER_WIDTH}, + null); + + const body = document.createElementNS(dom.HTML_NS, 'body'); + body.setAttribute('xmlns', dom.HTML_NS); + body.className = 'blocklyMinimalBody'; + + this.textarea_ = document.createElementNS(dom.HTML_NS, 'textarea'); + const textarea = this.textarea_; + textarea.className = 'blocklyCommentTextarea'; + textarea.setAttribute('dir', this.block_.RTL ? 'RTL' : 'LTR'); + textarea.value = this.model_.text; + this.resizeTextarea_(); + + body.appendChild(textarea); + this.foreignObject_.appendChild(body); + + // Ideally this would be hooked to the focus event for the comment. + // However doing so in Firefox swallows the cursor for unknown reasons. + // So this is hooked to mouseup instead. No big deal. + this.onMouseUpWrapper_ = browserEvents.conditionalBind( + textarea, 'mouseup', this, this.startEdit_, true, true); + // Don't zoom with mousewheel. + this.onWheelWrapper_ = + browserEvents.conditionalBind(textarea, 'wheel', this, function(e) { + e.stopPropagation(); + }); + this.onChangeWrapper_ = browserEvents.conditionalBind( + textarea, 'change', this, + /** + * @this {Comment} + * @param {Event} _e Unused event parameter. + */ + function(_e) { + if (this.cachedText_ !== this.model_.text) { + eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( + this.block_, 'comment', null, this.cachedText_, + this.model_.text)); + } + }); + this.onInputWrapper_ = browserEvents.conditionalBind( + textarea, 'input', this, + /** + * @this {Comment} + * @param {Event} _e Unused event parameter. + */ + function(_e) { + this.model_.text = textarea.value; + }); + + setTimeout(textarea.focus.bind(textarea), 0); + + return this.foreignObject_; } -}; -/** - * Dispose of this comment. - * - * If you want to receive a comment "delete" event (newValue: null), then this - * should not be called directly. Instead call block.setCommentText(null); - */ -Comment.prototype.dispose = function() { - this.block_.comment = null; - Icon.prototype.dispose.call(this); -}; + /** + * Add or remove editability of the comment. + * @override + */ + updateEditable() { + super.updateEditable(); + if (this.isVisible()) { + // Recreate the bubble with the correct UI. + this.disposeBubble_(); + this.createBubble_(); + } + } + + /** + * Callback function triggered when the bubble has resized. + * Resize the text area accordingly. + * @private + */ + onBubbleResize_() { + if (!this.isVisible()) { + return; + } + this.model_.size = this.bubble_.getBubbleSize(); + this.resizeTextarea_(); + } + + /** + * Resizes the text area to match the size defined on the model (which is + * the size of the bubble). + * @private + */ + resizeTextarea_() { + const size = this.model_.size; + const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH; + const widthMinusBorder = size.width - doubleBorderWidth; + const heightMinusBorder = size.height - doubleBorderWidth; + this.foreignObject_.setAttribute('width', widthMinusBorder); + this.foreignObject_.setAttribute('height', heightMinusBorder); + this.textarea_.style.width = (widthMinusBorder - 4) + 'px'; + this.textarea_.style.height = (heightMinusBorder - 4) + 'px'; + } + + /** + * Show or hide the comment bubble. + * @param {boolean} visible True if the bubble should be visible. + */ + setVisible(visible) { + if (visible === this.isVisible()) { + return; + } + eventUtils.fire(new (eventUtils.get(eventUtils.BUBBLE_OPEN))( + this.block_, visible, 'comment')); + this.model_.pinned = visible; + if (visible) { + this.createBubble_(); + } else { + this.disposeBubble_(); + } + } + + /** + * Show the bubble. Handles deciding if it should be editable or not. + * @private + */ + createBubble_() { + if (!this.block_.isEditable() || userAgent.IE) { + // MSIE does not support foreignobject; textareas are impossible. + // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-svg/56e6e04c-7c8c-44dd-8100-bd745ee42034 + // Always treat comments in IE as uneditable. + this.createNonEditableBubble_(); + } else { + this.createEditableBubble_(); + } + } + + /** + * Show an editable bubble. + * @private + */ + createEditableBubble_() { + this.bubble_ = new Bubble( + /** @type {!WorkspaceSvg} */ (this.block_.workspace), + this.createEditor_(), this.block_.pathObject.svgPath, + /** @type {!Coordinate} */ (this.iconXY_), this.model_.size.width, + this.model_.size.height); + // Expose this comment's block's ID on its top-level SVG group. + this.bubble_.setSvgId(this.block_.id); + this.bubble_.registerResizeEvent(this.onBubbleResize_.bind(this)); + this.applyColour(); + } + + /** + * Show a non-editable bubble. + * @private + * @suppress {checkTypes} Suppress `this` type mismatch. + */ + createNonEditableBubble_() { + // TODO (#2917): It would be great if the comment could support line breaks. + this.paragraphElement_ = Bubble.textToDom(this.block_.getCommentText()); + this.bubble_ = Bubble.createNonEditableBubble( + this.paragraphElement_, /** @type {!BlockSvg} */ (this.block_), + /** @type {!Coordinate} */ (this.iconXY_)); + this.applyColour(); + } + + /** + * Dispose of the bubble. + * @private + * @suppress {checkTypes} Suppress `this` type mismatch. + */ + disposeBubble_() { + if (this.onMouseUpWrapper_) { + browserEvents.unbind(this.onMouseUpWrapper_); + this.onMouseUpWrapper_ = null; + } + if (this.onWheelWrapper_) { + browserEvents.unbind(this.onWheelWrapper_); + this.onWheelWrapper_ = null; + } + if (this.onChangeWrapper_) { + browserEvents.unbind(this.onChangeWrapper_); + this.onChangeWrapper_ = null; + } + if (this.onInputWrapper_) { + browserEvents.unbind(this.onInputWrapper_); + this.onInputWrapper_ = null; + } + this.bubble_.dispose(); + this.bubble_ = null; + this.textarea_ = null; + this.foreignObject_ = null; + this.paragraphElement_ = null; + } + + /** + * Callback fired when an edit starts. + * + * Bring the comment to the top of the stack when clicked on. Also cache the + * current text so it can be used to fire a change event. + * @param {!Event} _e Mouse up event. + * @private + */ + startEdit_(_e) { + if (this.bubble_.promote()) { + // Since the act of moving this node within the DOM causes a loss of + // focus, we need to reapply the focus. + this.textarea_.focus(); + } + + this.cachedText_ = this.model_.text; + } + + /** + * Get the dimensions of this comment's bubble. + * @return {Size} Object with width and height properties. + */ + getBubbleSize() { + return this.model_.size; + } + + /** + * Size this comment's bubble. + * @param {number} width Width of the bubble. + * @param {number} height Height of the bubble. + */ + setBubbleSize(width, height) { + if (this.bubble_) { + this.bubble_.setBubbleSize(width, height); + } else { + this.model_.size.width = width; + this.model_.size.height = height; + } + } + + /** + * Update the comment's view to match the model. + * @package + */ + updateText() { + if (this.textarea_) { + this.textarea_.value = this.model_.text; + } else if (this.paragraphElement_) { + // Non-Editable mode. + // TODO (#2917): If 2917 gets added this will probably need to be updated. + this.paragraphElement_.firstChild.textContent = this.model_.text; + } + } + + /** + * Dispose of this comment. + * + * If you want to receive a comment "delete" event (newValue: null), then this + * should not be called directly. Instead call block.setCommentText(null); + */ + dispose() { + this.block_.comment = null; + Icon.prototype.dispose.call(this); + } +} /** * CSS for block comment. See css.js for use. diff --git a/core/connection.js b/core/connection.js index 3273ab100..962b884ca 100644 --- a/core/connection.js +++ b/core/connection.js @@ -35,62 +35,627 @@ goog.require('Blockly.constants'); /** * Class for a connection between blocks. - * @param {!Block} source The block establishing this connection. - * @param {number} type The type of the connection. - * @constructor * @implements {IASTNodeLocationWithBlock} - * @alias Blockly.Connection */ -const Connection = function(source, type) { +class Connection { /** - * @type {!Block} + * @param {!Block} source The block establishing this connection. + * @param {number} type The type of the connection. + * @alias Blockly.Connection + */ + constructor(source, type) { + /** + * @type {!Block} + * @protected + */ + this.sourceBlock_ = source; + /** @type {number} */ + this.type = type; + + /** + * Connection this connection connects to. Null if not connected. + * @type {Connection} + */ + this.targetConnection = null; + + /** + * Has this connection been disposed of? + * @type {boolean} + * @package + */ + this.disposed = false; + + /** + * List of compatible value types. Null if all types are compatible. + * @type {Array} + * @private + */ + this.check_ = null; + + /** + * DOM representation of a shadow block, or null if none. + * @type {Element} + * @private + */ + this.shadowDom_ = null; + + /** + * Horizontal location of this connection. + * @type {number} + * @package + */ + this.x = 0; + + /** + * Vertical location of this connection. + * @type {number} + * @package + */ + this.y = 0; + + /** + * @type {?blocks.State} + * @private + */ + this.shadowState_ = null; + } + + /** + * Connect two connections together. This is the connection on the superior + * block. + * @param {!Connection} childConnection Connection on inferior block. * @protected */ - this.sourceBlock_ = source; - /** @type {number} */ - this.type = type; + connect_(childConnection) { + const INPUT = ConnectionType.INPUT_VALUE; + const parentConnection = this; + const parentBlock = parentConnection.getSourceBlock(); + const childBlock = childConnection.getSourceBlock(); + + // Make sure the childConnection is available. + if (childConnection.isConnected()) { + childConnection.disconnect(); + } + + // Make sure the parentConnection is available. + let orphan; + if (parentConnection.isConnected()) { + const shadowState = parentConnection.stashShadowState_(); + const target = parentConnection.targetBlock(); + if (target.isShadow()) { + target.dispose(false); + } else { + parentConnection.disconnect(); + orphan = target; + } + parentConnection.applyShadowState_(shadowState); + } + + // Connect the new connection to the parent. + let event; + if (eventUtils.isEnabled()) { + event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock); + } + connectReciprocally(parentConnection, childConnection); + childBlock.setParent(parentBlock); + if (event) { + event.recordNew(); + eventUtils.fire(event); + } + + // Deal with the orphan if it exists. + if (orphan) { + const orphanConnection = parentConnection.type === INPUT ? + orphan.outputConnection : + orphan.previousConnection; + const connection = Connection.getConnectionForOrphanedConnection( + childBlock, /** @type {!Connection} */ (orphanConnection)); + if (connection) { + orphanConnection.connect(connection); + } else { + orphanConnection.onFailedConnect(parentConnection); + } + } + } /** - * Connection this connection connects to. Null if not connected. - * @type {Connection} - */ - this.targetConnection = null; - - /** - * Has this connection been disposed of? - * @type {boolean} + * Dispose of this connection and deal with connected blocks. * @package */ - this.disposed = false; + dispose() { + // isConnected returns true for shadows and non-shadows. + if (this.isConnected()) { + // Destroy the attached shadow block & its children (if it exists). + this.setShadowStateInternal_(); + + const targetBlock = this.targetBlock(); + if (targetBlock) { + // Disconnect the attached normal block. + targetBlock.unplug(); + } + } + + this.disposed = true; + } /** - * List of compatible value types. Null if all types are compatible. - * @type {Array} + * Get the source block for this connection. + * @return {!Block} The source block. + */ + getSourceBlock() { + return this.sourceBlock_; + } + + /** + * Does the connection belong to a superior block (higher in the source + * stack)? + * @return {boolean} True if connection faces down or right. + */ + isSuperior() { + return this.type === ConnectionType.INPUT_VALUE || + this.type === ConnectionType.NEXT_STATEMENT; + } + + /** + * Is the connection connected? + * @return {boolean} True if connection is connected to another connection. + */ + isConnected() { + return !!this.targetConnection; + } + + /** + * Get the workspace's connection type checker object. + * @return {!IConnectionChecker} The connection type checker for the + * source block's workspace. + * @package + */ + getConnectionChecker() { + return this.sourceBlock_.workspace.connectionChecker; + } + + /** + * Called when an attempted connection fails. NOP by default (i.e. for + * headless workspaces). + * @param {!Connection} _otherConnection Connection that this connection + * failed to connect to. + * @package + */ + onFailedConnect(_otherConnection) { + // NOP + } + + /** + * Connect this connection to another connection. + * @param {!Connection} otherConnection Connection to connect to. + * @return {boolean} Whether the the blocks are now connected or not. + */ + connect(otherConnection) { + if (this.targetConnection === otherConnection) { + // Already connected together. NOP. + return true; + } + + const checker = this.getConnectionChecker(); + if (checker.canConnect(this, otherConnection, false)) { + const eventGroup = eventUtils.getGroup(); + if (!eventGroup) { + eventUtils.setGroup(true); + } + // Determine which block is superior (higher in the source stack). + if (this.isSuperior()) { + // Superior block. + this.connect_(otherConnection); + } else { + // Inferior block. + otherConnection.connect_(this); + } + if (!eventGroup) { + eventUtils.setGroup(false); + } + } + + return this.isConnected(); + } + + /** + * Disconnect this connection. + */ + disconnect() { + const otherConnection = this.targetConnection; + if (!otherConnection) { + throw Error('Source connection not connected.'); + } + if (otherConnection.targetConnection !== this) { + throw Error('Target connection not connected to source connection.'); + } + let parentBlock; + let childBlock; + let parentConnection; + if (this.isSuperior()) { + // Superior block. + parentBlock = this.sourceBlock_; + childBlock = otherConnection.getSourceBlock(); + parentConnection = this; + } else { + // Inferior block. + parentBlock = otherConnection.getSourceBlock(); + childBlock = this.sourceBlock_; + parentConnection = otherConnection; + } + + const eventGroup = eventUtils.getGroup(); + if (!eventGroup) { + eventUtils.setGroup(true); + } + this.disconnectInternal_(parentBlock, childBlock); + if (!childBlock.isShadow()) { + // If we were disconnecting a shadow, no need to spawn a new one. + parentConnection.respawnShadow_(); + } + if (!eventGroup) { + eventUtils.setGroup(false); + } + } + + /** + * Disconnect two blocks that are connected by this connection. + * @param {!Block} parentBlock The superior block. + * @param {!Block} childBlock The inferior block. + * @protected + */ + disconnectInternal_(parentBlock, childBlock) { + let event; + if (eventUtils.isEnabled()) { + event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock); + } + const otherConnection = this.targetConnection; + otherConnection.targetConnection = null; + this.targetConnection = null; + childBlock.setParent(null); + if (event) { + event.recordNew(); + eventUtils.fire(event); + } + } + + /** + * Respawn the shadow block if there was one connected to the this connection. + * @protected + */ + respawnShadow_() { + // Have to keep respawnShadow_ for backwards compatibility. + this.createShadowBlock_(true); + } + + /** + * Returns the block that this connection connects to. + * @return {?Block} The connected block or null if none is connected. + */ + targetBlock() { + if (this.isConnected()) { + return this.targetConnection.getSourceBlock(); + } + return null; + } + + /** + * Function to be called when this connection's compatible types have changed. + * @protected + */ + onCheckChanged_() { + // The new value type may not be compatible with the existing connection. + if (this.isConnected() && + (!this.targetConnection || + !this.getConnectionChecker().canConnect( + this, this.targetConnection, false))) { + const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_; + child.unplug(); + } + } + + /** + * Change a connection's compatibility. + * @param {?(string|!Array)} check Compatible value type or list of + * value types. Null if all types are compatible. + * @return {!Connection} The connection being modified + * (to allow chaining). + */ + setCheck(check) { + if (check) { + // Ensure that check is in an array. + if (!Array.isArray(check)) { + check = [check]; + } + this.check_ = check; + this.onCheckChanged_(); + } else { + this.check_ = null; + } + return this; + } + + /** + * Get a connection's compatibility. + * @return {?Array} List of compatible value types. + * Null if all types are compatible. + * @public + */ + getCheck() { + return this.check_; + } + + /** + * Changes the connection's shadow block. + * @param {?Element} shadowDom DOM representation of a block or null. + */ + setShadowDom(shadowDom) { + this.setShadowStateInternal_({shadowDom: shadowDom}); + } + + /** + * Returns the xml representation of the connection's shadow block. + * @param {boolean=} returnCurrent If true, and the shadow block is currently + * attached to this connection, this serializes the state of that block + * and returns it (so that field values are correct). Otherwise the saved + * shadowDom is just returned. + * @return {?Element} Shadow DOM representation of a block or null. + */ + getShadowDom(returnCurrent) { + return (returnCurrent && this.targetBlock().isShadow()) ? + /** @type {!Element} */ (Xml.blockToDom( + /** @type {!Block} */ (this.targetBlock()))) : + this.shadowDom_; + } + + /** + * Changes the connection's shadow block. + * @param {?blocks.State} shadowState An state represetation of the block or + * null. + */ + setShadowState(shadowState) { + this.setShadowStateInternal_({shadowState: shadowState}); + } + + /** + * Returns the serialized object representation of the connection's shadow + * block. + * @param {boolean=} returnCurrent If true, and the shadow block is currently + * attached to this connection, this serializes the state of that block + * and returns it (so that field values are correct). Otherwise the saved + * state is just returned. + * @return {?blocks.State} Serialized object representation of the block, or + * null. + */ + getShadowState(returnCurrent) { + if (returnCurrent && this.targetBlock() && this.targetBlock().isShadow()) { + return blocks.save(/** @type {!Block} */ (this.targetBlock())); + } + return this.shadowState_; + } + + /** + * Find all nearby compatible connections to this connection. + * Type checking does not apply, since this function is used for bumping. + * + * Headless configurations (the default) do not have neighboring connection, + * and always return an empty list (the default). + * {@link Blockly.RenderedConnection} overrides this behavior with a list + * computed from the rendered positioning. + * @param {number} _maxLimit The maximum radius to another connection. + * @return {!Array} List of connections. + * @package + */ + neighbours(_maxLimit) { + return []; + } + + /** + * Get the parent input of a connection. + * @return {?Input} The input that the connection belongs to or null if + * no parent exists. + * @package + */ + getParentInput() { + let parentInput = null; + const inputs = this.sourceBlock_.inputList; + for (let i = 0; i < inputs.length; i++) { + if (inputs[i].connection === this) { + parentInput = inputs[i]; + break; + } + } + return parentInput; + } + + /** + * This method returns a string describing this Connection in developer terms + * (English only). Intended to on be used in console logs and errors. + * @return {string} The description. + */ + toString() { + const block = this.sourceBlock_; + if (!block) { + return 'Orphan Connection'; + } + let msg; + if (block.outputConnection === this) { + msg = 'Output Connection of '; + } else if (block.previousConnection === this) { + msg = 'Previous Connection of '; + } else if (block.nextConnection === this) { + msg = 'Next Connection of '; + } else { + let parentInput = null; + for (let i = 0, input; (input = block.inputList[i]); i++) { + if (input.connection === this) { + parentInput = input; + break; + } + } + if (parentInput) { + msg = 'Input "' + parentInput.name + '" connection on '; + } else { + console.warn('Connection not actually connected to sourceBlock_'); + return 'Orphan Connection'; + } + } + return msg + block.toDevString(); + } + + /** + * Returns the state of the shadowDom_ and shadowState_ properties, then + * temporarily sets those properties to null so no shadow respawns. + * @return {{shadowDom: ?Element, shadowState: ?blocks.State}} The state of + * both the shadowDom_ and shadowState_ properties. * @private */ - this.check_ = null; + stashShadowState_() { + const shadowDom = this.getShadowDom(true); + const shadowState = this.getShadowState(true); + // Set to null so it doesn't respawn. + this.shadowDom_ = null; + this.shadowState_ = null; + return {shadowDom, shadowState}; + } /** - * DOM representation of a shadow block, or null if none. - * @type {Element} + * Reapplies the stashed state of the shadowDom_ and shadowState_ properties. + * @param {{shadowDom: ?Element, shadowState: ?blocks.State}} param0 The state + * to reapply to the shadowDom_ and shadowState_ properties. * @private */ - this.shadowDom_ = null; + applyShadowState_({shadowDom, shadowState}) { + this.shadowDom_ = shadowDom; + this.shadowState_ = shadowState; + } /** - * Horizontal location of this connection. - * @type {number} - * @package + * Sets the state of the shadow of this connection. + * @param {{shadowDom: (?Element|undefined), shadowState: + * (?blocks.State|undefined)}=} param0 The state to set the shadow of this + * connection to. + * @private */ - this.x = 0; + setShadowStateInternal_({shadowDom = null, shadowState = null} = {}) { + // One or both of these should always be null. + // If neither is null, the shadowState will get priority. + this.shadowDom_ = shadowDom; + this.shadowState_ = shadowState; + + const target = this.targetBlock(); + if (!target) { + this.respawnShadow_(); + if (this.targetBlock() && this.targetBlock().isShadow()) { + this.serializeShadow_(this.targetBlock()); + } + } else if (target.isShadow()) { + target.dispose(false); + this.respawnShadow_(); + if (this.targetBlock() && this.targetBlock().isShadow()) { + this.serializeShadow_(this.targetBlock()); + } + } else { + const shadow = this.createShadowBlock_(false); + this.serializeShadow_(shadow); + if (shadow) { + shadow.dispose(false); + } + } + } /** - * Vertical location of this connection. - * @type {number} - * @package + * Creates a shadow block based on the current shadowState_ or shadowDom_. + * shadowState_ gets priority. + * @param {boolean} attemptToConnect Whether to try to connect the shadow + * block to this connection or not. + * @return {?Block} The shadow block that was created, or null if both the + * shadowState_ and shadowDom_ are null. + * @private */ - this.y = 0; -}; + createShadowBlock_(attemptToConnect) { + const parentBlock = this.getSourceBlock(); + const shadowState = this.getShadowState(); + const shadowDom = this.getShadowDom(); + if (!parentBlock.workspace || (!shadowState && !shadowDom)) { + return null; + } + + let blockShadow; + if (shadowState) { + blockShadow = blocks.appendInternal(shadowState, parentBlock.workspace, { + parentConnection: attemptToConnect ? this : undefined, + isShadow: true, + recordUndo: false, + }); + return blockShadow; + } + + if (shadowDom) { + blockShadow = Xml.domToBlock(shadowDom, parentBlock.workspace); + if (attemptToConnect) { + if (this.type === ConnectionType.INPUT_VALUE) { + if (!blockShadow.outputConnection) { + throw new Error('Shadow block is missing an output connection'); + } + if (!this.connect(blockShadow.outputConnection)) { + throw new Error('Could not connect shadow block to connection'); + } + } else if (this.type === ConnectionType.NEXT_STATEMENT) { + if (!blockShadow.previousConnection) { + throw new Error('Shadow block is missing previous connection'); + } + if (!this.connect(blockShadow.previousConnection)) { + throw new Error('Could not connect shadow block to connection'); + } + } else { + throw new Error( + 'Cannot connect a shadow block to a previous/output connection'); + } + } + return blockShadow; + } + return null; + } + + /** + * Saves the given shadow block to both the shadowDom_ and shadowState_ + * properties, in their respective serialized forms. + * @param {?Block} shadow The shadow to serialize, or null. + * @private + */ + serializeShadow_(shadow) { + if (!shadow) { + return; + } + this.shadowDom_ = /** @type {!Element} */ (Xml.blockToDom(shadow)); + this.shadowState_ = blocks.save(shadow); + } + + /** + * Returns the connection (starting at the startBlock) which will accept + * the given connection. This includes compatible connection types and + * connection checks. + * @param {!Block} startBlock The block on which to start the search. + * @param {!Connection} orphanConnection The connection that is looking + * for a home. + * @return {?Connection} The suitable connection point on the chain of + * blocks, or null. + */ + static getConnectionForOrphanedConnection(startBlock, orphanConnection) { + if (orphanConnection.type === ConnectionType.OUTPUT_VALUE) { + return getConnectionForOrphanedOutput( + startBlock, orphanConnection.getSourceBlock()); + } + // Otherwise we're dealing with a stack. + const connection = startBlock.lastConnectionInStack(true); + const checker = orphanConnection.getConnectionChecker(); + if (connection && checker.canConnect(orphanConnection, connection, false)) { + return connection; + } + return null; + } +} /** * Constants for checking whether two connections are compatible. @@ -105,163 +670,6 @@ Connection.REASON_SHADOW_PARENT = 6; Connection.REASON_DRAG_CHECKS_FAILED = 7; Connection.REASON_PREVIOUS_AND_OUTPUT = 8; -/** - * Connect two connections together. This is the connection on the superior - * block. - * @param {!Connection} childConnection Connection on inferior block. - * @protected - */ -Connection.prototype.connect_ = function(childConnection) { - const INPUT = ConnectionType.INPUT_VALUE; - const parentConnection = this; - const parentBlock = parentConnection.getSourceBlock(); - const childBlock = childConnection.getSourceBlock(); - - // Make sure the childConnection is available. - if (childConnection.isConnected()) { - childConnection.disconnect(); - } - - // Make sure the parentConnection is available. - let orphan; - if (parentConnection.isConnected()) { - const shadowState = parentConnection.stashShadowState_(); - const target = parentConnection.targetBlock(); - if (target.isShadow()) { - target.dispose(false); - } else { - parentConnection.disconnect(); - orphan = target; - } - parentConnection.applyShadowState_(shadowState); - } - - // Connect the new connection to the parent. - let event; - if (eventUtils.isEnabled()) { - event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock); - } - connectReciprocally(parentConnection, childConnection); - childBlock.setParent(parentBlock); - if (event) { - event.recordNew(); - eventUtils.fire(event); - } - - // Deal with the orphan if it exists. - if (orphan) { - const orphanConnection = parentConnection.type === INPUT ? - orphan.outputConnection : - orphan.previousConnection; - const connection = Connection.getConnectionForOrphanedConnection( - childBlock, /** @type {!Connection} */ (orphanConnection)); - if (connection) { - orphanConnection.connect(connection); - } else { - orphanConnection.onFailedConnect(parentConnection); - } - } -}; - -/** - * Dispose of this connection and deal with connected blocks. - * @package - */ -Connection.prototype.dispose = function() { - // isConnected returns true for shadows and non-shadows. - if (this.isConnected()) { - // Destroy the attached shadow block & its children (if it exists). - this.setShadowStateInternal_(); - - const targetBlock = this.targetBlock(); - if (targetBlock) { - // Disconnect the attached normal block. - targetBlock.unplug(); - } - } - - this.disposed = true; -}; - -/** - * Get the source block for this connection. - * @return {!Block} The source block. - */ -Connection.prototype.getSourceBlock = function() { - return this.sourceBlock_; -}; - -/** - * Does the connection belong to a superior block (higher in the source stack)? - * @return {boolean} True if connection faces down or right. - */ -Connection.prototype.isSuperior = function() { - return this.type === ConnectionType.INPUT_VALUE || - this.type === ConnectionType.NEXT_STATEMENT; -}; - -/** - * Is the connection connected? - * @return {boolean} True if connection is connected to another connection. - */ -Connection.prototype.isConnected = function() { - return !!this.targetConnection; -}; - -/** - * Get the workspace's connection type checker object. - * @return {!IConnectionChecker} The connection type checker for the - * source block's workspace. - * @package - */ -Connection.prototype.getConnectionChecker = function() { - return this.sourceBlock_.workspace.connectionChecker; -}; - -/** - * Called when an attempted connection fails. NOP by default (i.e. for headless - * workspaces). - * @param {!Connection} _otherConnection Connection that this connection - * failed to connect to. - * @package - */ -Connection.prototype.onFailedConnect = function(_otherConnection) { - // NOP -}; - -/** - * Connect this connection to another connection. - * @param {!Connection} otherConnection Connection to connect to. - * @return {boolean} Whether the the blocks are now connected or not. - */ -Connection.prototype.connect = function(otherConnection) { - if (this.targetConnection === otherConnection) { - // Already connected together. NOP. - return true; - } - - const checker = this.getConnectionChecker(); - if (checker.canConnect(this, otherConnection, false)) { - const eventGroup = eventUtils.getGroup(); - if (!eventGroup) { - eventUtils.setGroup(true); - } - // Determine which block is superior (higher in the source stack). - if (this.isSuperior()) { - // Superior block. - this.connect_(otherConnection); - } else { - // Inferior block. - otherConnection.connect_(this); - } - if (!eventGroup) { - eventUtils.setGroup(false); - } - } - - return this.isConnected(); -}; - /** * Update two connections to target each other. * @param {Connection} first The first connection to update. @@ -327,404 +735,4 @@ const getConnectionForOrphanedOutput = function(startBlock, orphanBlock) { return null; }; -/** - * Returns the connection (starting at the startBlock) which will accept - * the given connection. This includes compatible connection types and - * connection checks. - * @param {!Block} startBlock The block on which to start the search. - * @param {!Connection} orphanConnection The connection that is looking - * for a home. - * @return {?Connection} The suitable connection point on the chain of - * blocks, or null. - */ -Connection.getConnectionForOrphanedConnection = function( - startBlock, orphanConnection) { - if (orphanConnection.type === ConnectionType.OUTPUT_VALUE) { - return getConnectionForOrphanedOutput( - startBlock, orphanConnection.getSourceBlock()); - } - // Otherwise we're dealing with a stack. - const connection = startBlock.lastConnectionInStack(true); - const checker = orphanConnection.getConnectionChecker(); - if (connection && checker.canConnect(orphanConnection, connection, false)) { - return connection; - } - return null; -}; - -/** - * Disconnect this connection. - */ -Connection.prototype.disconnect = function() { - const otherConnection = this.targetConnection; - if (!otherConnection) { - throw Error('Source connection not connected.'); - } - if (otherConnection.targetConnection !== this) { - throw Error('Target connection not connected to source connection.'); - } - let parentBlock; - let childBlock; - let parentConnection; - if (this.isSuperior()) { - // Superior block. - parentBlock = this.sourceBlock_; - childBlock = otherConnection.getSourceBlock(); - parentConnection = this; - } else { - // Inferior block. - parentBlock = otherConnection.getSourceBlock(); - childBlock = this.sourceBlock_; - parentConnection = otherConnection; - } - - const eventGroup = eventUtils.getGroup(); - if (!eventGroup) { - eventUtils.setGroup(true); - } - this.disconnectInternal_(parentBlock, childBlock); - if (!childBlock.isShadow()) { - // If we were disconnecting a shadow, no need to spawn a new one. - parentConnection.respawnShadow_(); - } - if (!eventGroup) { - eventUtils.setGroup(false); - } -}; - -/** - * Disconnect two blocks that are connected by this connection. - * @param {!Block} parentBlock The superior block. - * @param {!Block} childBlock The inferior block. - * @protected - */ -Connection.prototype.disconnectInternal_ = function(parentBlock, childBlock) { - let event; - if (eventUtils.isEnabled()) { - event = new (eventUtils.get(eventUtils.BLOCK_MOVE))(childBlock); - } - const otherConnection = this.targetConnection; - otherConnection.targetConnection = null; - this.targetConnection = null; - childBlock.setParent(null); - if (event) { - event.recordNew(); - eventUtils.fire(event); - } -}; - -/** - * Respawn the shadow block if there was one connected to the this connection. - * @protected - */ -Connection.prototype.respawnShadow_ = function() { - // Have to keep respawnShadow_ for backwards compatibility. - this.createShadowBlock_(true); -}; - -/** - * Returns the block that this connection connects to. - * @return {?Block} The connected block or null if none is connected. - */ -Connection.prototype.targetBlock = function() { - if (this.isConnected()) { - return this.targetConnection.getSourceBlock(); - } - return null; -}; - -/** - * Function to be called when this connection's compatible types have changed. - * @protected - */ -Connection.prototype.onCheckChanged_ = function() { - // The new value type may not be compatible with the existing connection. - if (this.isConnected() && - (!this.targetConnection || - !this.getConnectionChecker().canConnect( - this, this.targetConnection, false))) { - const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_; - child.unplug(); - } -}; - -/** - * Change a connection's compatibility. - * @param {?(string|!Array)} check Compatible value type or list of - * value types. Null if all types are compatible. - * @return {!Connection} The connection being modified - * (to allow chaining). - */ -Connection.prototype.setCheck = function(check) { - if (check) { - // Ensure that check is in an array. - if (!Array.isArray(check)) { - check = [check]; - } - this.check_ = check; - this.onCheckChanged_(); - } else { - this.check_ = null; - } - return this; -}; - -/** - * Get a connection's compatibility. - * @return {?Array} List of compatible value types. - * Null if all types are compatible. - * @public - */ -Connection.prototype.getCheck = function() { - return this.check_; -}; - -/** - * Changes the connection's shadow block. - * @param {?Element} shadowDom DOM representation of a block or null. - */ -Connection.prototype.setShadowDom = function(shadowDom) { - this.setShadowStateInternal_({shadowDom: shadowDom}); -}; - -/** - * Returns the xml representation of the connection's shadow block. - * @param {boolean=} returnCurrent If true, and the shadow block is currently - * attached to this connection, this serializes the state of that block - * and returns it (so that field values are correct). Otherwise the saved - * shadowDom is just returned. - * @return {?Element} Shadow DOM representation of a block or null. - */ -Connection.prototype.getShadowDom = function(returnCurrent) { - return (returnCurrent && this.targetBlock().isShadow()) ? - /** @type {!Element} */ (Xml.blockToDom( - /** @type {!Block} */ (this.targetBlock()))) : - this.shadowDom_; -}; - -/** - * Changes the connection's shadow block. - * @param {?blocks.State} shadowState An state represetation of the block or - * null. - */ -Connection.prototype.setShadowState = function(shadowState) { - this.setShadowStateInternal_({shadowState: shadowState}); -}; - -/** - * Returns the serialized object representation of the connection's shadow - * block. - * @param {boolean=} returnCurrent If true, and the shadow block is currently - * attached to this connection, this serializes the state of that block - * and returns it (so that field values are correct). Otherwise the saved - * state is just returned. - * @return {?blocks.State} Serialized object representation of the block, or - * null. - */ -Connection.prototype.getShadowState = function(returnCurrent) { - if (returnCurrent && this.targetBlock() && this.targetBlock().isShadow()) { - return blocks.save(/** @type {!Block} */ (this.targetBlock())); - } - return this.shadowState_; -}; - -/** - * Find all nearby compatible connections to this connection. - * Type checking does not apply, since this function is used for bumping. - * - * Headless configurations (the default) do not have neighboring connection, - * and always return an empty list (the default). - * {@link Blockly.RenderedConnection} overrides this behavior with a list - * computed from the rendered positioning. - * @param {number} _maxLimit The maximum radius to another connection. - * @return {!Array} List of connections. - * @package - */ -Connection.prototype.neighbours = function(_maxLimit) { - return []; -}; - -/** - * Get the parent input of a connection. - * @return {?Input} The input that the connection belongs to or null if - * no parent exists. - * @package - */ -Connection.prototype.getParentInput = function() { - let parentInput = null; - const inputs = this.sourceBlock_.inputList; - for (let i = 0; i < inputs.length; i++) { - if (inputs[i].connection === this) { - parentInput = inputs[i]; - break; - } - } - return parentInput; -}; - -/** - * This method returns a string describing this Connection in developer terms - * (English only). Intended to on be used in console logs and errors. - * @return {string} The description. - */ -Connection.prototype.toString = function() { - const block = this.sourceBlock_; - if (!block) { - return 'Orphan Connection'; - } - let msg; - if (block.outputConnection === this) { - msg = 'Output Connection of '; - } else if (block.previousConnection === this) { - msg = 'Previous Connection of '; - } else if (block.nextConnection === this) { - msg = 'Next Connection of '; - } else { - let parentInput = null; - for (let i = 0, input; (input = block.inputList[i]); i++) { - if (input.connection === this) { - parentInput = input; - break; - } - } - if (parentInput) { - msg = 'Input "' + parentInput.name + '" connection on '; - } else { - console.warn('Connection not actually connected to sourceBlock_'); - return 'Orphan Connection'; - } - } - return msg + block.toDevString(); -}; - -/** - * Returns the state of the shadowDom_ and shadowState_ properties, then - * temporarily sets those properties to null so no shadow respawns. - * @return {{shadowDom: ?Element, shadowState: ?blocks.State}} The state of both - * the shadowDom_ and shadowState_ properties. - * @private - */ -Connection.prototype.stashShadowState_ = function() { - const shadowDom = this.getShadowDom(true); - const shadowState = this.getShadowState(true); - // Set to null so it doesn't respawn. - this.shadowDom_ = null; - this.shadowState_ = null; - return {shadowDom, shadowState}; -}; - -/** - * Reapplies the stashed state of the shadowDom_ and shadowState_ properties. - * @param {{shadowDom: ?Element, shadowState: ?blocks.State}} param0 The state - * to reapply to the shadowDom_ and shadowState_ properties. - * @private - */ -Connection.prototype.applyShadowState_ = function({shadowDom, shadowState}) { - this.shadowDom_ = shadowDom; - this.shadowState_ = shadowState; -}; - -/** - * Sets the state of the shadow of this connection. - * @param {{shadowDom: (?Element|undefined), shadowState: - * (?blocks.State|undefined)}=} param0 The state to set the shadow of this - * connection to. - * @private - */ -Connection.prototype.setShadowStateInternal_ = function( - {shadowDom = null, shadowState = null} = {}) { - // One or both of these should always be null. - // If neither is null, the shadowState will get priority. - this.shadowDom_ = shadowDom; - this.shadowState_ = shadowState; - - const target = this.targetBlock(); - if (!target) { - this.respawnShadow_(); - if (this.targetBlock() && this.targetBlock().isShadow()) { - this.serializeShadow_(this.targetBlock()); - } - } else if (target.isShadow()) { - target.dispose(false); - this.respawnShadow_(); - if (this.targetBlock() && this.targetBlock().isShadow()) { - this.serializeShadow_(this.targetBlock()); - } - } else { - const shadow = this.createShadowBlock_(false); - this.serializeShadow_(shadow); - if (shadow) { - shadow.dispose(false); - } - } -}; - -/** - * Creates a shadow block based on the current shadowState_ or shadowDom_. - * shadowState_ gets priority. - * @param {boolean} attemptToConnect Whether to try to connect the shadow block - * to this connection or not. - * @return {?Block} The shadow block that was created, or null if both the - * shadowState_ and shadowDom_ are null. - * @private - */ -Connection.prototype.createShadowBlock_ = function(attemptToConnect) { - const parentBlock = this.getSourceBlock(); - const shadowState = this.getShadowState(); - const shadowDom = this.getShadowDom(); - if (!parentBlock.workspace || (!shadowState && !shadowDom)) { - return null; - } - - let blockShadow; - if (shadowState) { - blockShadow = blocks.appendInternal(shadowState, parentBlock.workspace, { - parentConnection: attemptToConnect ? this : undefined, - isShadow: true, - recordUndo: false, - }); - return blockShadow; - } - - if (shadowDom) { - blockShadow = Xml.domToBlock(shadowDom, parentBlock.workspace); - if (attemptToConnect) { - if (this.type === ConnectionType.INPUT_VALUE) { - if (!blockShadow.outputConnection) { - throw new Error('Shadow block is missing an output connection'); - } - if (!this.connect(blockShadow.outputConnection)) { - throw new Error('Could not connect shadow block to connection'); - } - } else if (this.type === ConnectionType.NEXT_STATEMENT) { - if (!blockShadow.previousConnection) { - throw new Error('Shadow block is missing previous connection'); - } - if (!this.connect(blockShadow.previousConnection)) { - throw new Error('Could not connect shadow block to connection'); - } - } else { - throw new Error( - 'Cannot connect a shadow block to a previous/output connection'); - } - } - return blockShadow; - } - return null; -}; - -/** - * Saves the given shadow block to both the shadowDom_ and shadowState_ - * properties, in their respective serialized forms. - * @param {?Block} shadow The shadow to serialize, or null. - * @private - */ -Connection.prototype.serializeShadow_ = function(shadow) { - if (!shadow) { - return; - } - this.shadowDom_ = /** @type {!Element} */ (Xml.blockToDom(shadow)); - this.shadowState_ = blocks.save(shadow); -}; - exports.Connection = Connection; diff --git a/core/connection_checker.js b/core/connection_checker.js index d512488ef..5ad5e36f5 100644 --- a/core/connection_checker.js +++ b/core/connection_checker.js @@ -31,282 +31,284 @@ const {RenderedConnection} = goog.requireType('Blockly.RenderedConnection'); /** * Class for connection type checking logic. * @implements {IConnectionChecker} - * @constructor - * @alias Blockly.ConnectionChecker */ -const ConnectionChecker = function() {}; +class ConnectionChecker { + /** + * @alias Blockly.ConnectionChecker + */ + constructor() {} -/** - * Check whether the current connection can connect with the target - * connection. - * @param {Connection} a Connection to check compatibility with. - * @param {Connection} b Connection to check compatibility with. - * @param {boolean} isDragging True if the connection is being made by dragging - * a block. - * @param {number=} opt_distance The max allowable distance between the - * connections for drag checks. - * @return {boolean} Whether the connection is legal. - * @public - */ -ConnectionChecker.prototype.canConnect = function( - a, b, isDragging, opt_distance) { - return this.canConnectWithReason(a, b, isDragging, opt_distance) === - Connection.CAN_CONNECT; -}; - -/** - * Checks whether the current connection can connect with the target - * connection, and return an error code if there are problems. - * @param {Connection} a Connection to check compatibility with. - * @param {Connection} b Connection to check compatibility with. - * @param {boolean} isDragging True if the connection is being made by dragging - * a block. - * @param {number=} opt_distance The max allowable distance between the - * connections for drag checks. - * @return {number} Connection.CAN_CONNECT if the connection is legal, - * an error code otherwise. - * @public - */ -ConnectionChecker.prototype.canConnectWithReason = function( - a, b, isDragging, opt_distance) { - const safety = this.doSafetyChecks(a, b); - if (safety !== Connection.CAN_CONNECT) { - return safety; + /** + * Check whether the current connection can connect with the target + * connection. + * @param {Connection} a Connection to check compatibility with. + * @param {Connection} b Connection to check compatibility with. + * @param {boolean} isDragging True if the connection is being made by + * dragging a block. + * @param {number=} opt_distance The max allowable distance between the + * connections for drag checks. + * @return {boolean} Whether the connection is legal. + * @public + */ + canConnect(a, b, isDragging, opt_distance) { + return this.canConnectWithReason(a, b, isDragging, opt_distance) === + Connection.CAN_CONNECT; } - // If the safety checks passed, both connections are non-null. - const connOne = /** @type {!Connection} **/ (a); - const connTwo = /** @type {!Connection} **/ (b); - if (!this.doTypeChecks(connOne, connTwo)) { - return Connection.REASON_CHECKS_FAILED; - } - - if (isDragging && - !this.doDragChecks( - /** @type {!RenderedConnection} **/ (a), - /** @type {!RenderedConnection} **/ (b), opt_distance || 0)) { - return Connection.REASON_DRAG_CHECKS_FAILED; - } - - return Connection.CAN_CONNECT; -}; - -/** - * Helper method that translates a connection error code into a string. - * @param {number} errorCode The error code. - * @param {Connection} a One of the two connections being checked. - * @param {Connection} b The second of the two connections being - * checked. - * @return {string} A developer-readable error string. - * @public - */ -ConnectionChecker.prototype.getErrorMessage = function(errorCode, a, b) { - switch (errorCode) { - case Connection.REASON_SELF_CONNECTION: - return 'Attempted to connect a block to itself.'; - case Connection.REASON_DIFFERENT_WORKSPACES: - // Usually this means one block has been deleted. - return 'Blocks not on same workspace.'; - case Connection.REASON_WRONG_TYPE: - return 'Attempt to connect incompatible types.'; - case Connection.REASON_TARGET_NULL: - return 'Target connection is null.'; - case Connection.REASON_CHECKS_FAILED: { - const connOne = /** @type {!Connection} **/ (a); - const connTwo = /** @type {!Connection} **/ (b); - let msg = 'Connection checks failed. '; - msg += connOne + ' expected ' + connOne.getCheck() + ', found ' + - connTwo.getCheck(); - return msg; + /** + * Checks whether the current connection can connect with the target + * connection, and return an error code if there are problems. + * @param {Connection} a Connection to check compatibility with. + * @param {Connection} b Connection to check compatibility with. + * @param {boolean} isDragging True if the connection is being made by + * dragging a block. + * @param {number=} opt_distance The max allowable distance between the + * connections for drag checks. + * @return {number} Connection.CAN_CONNECT if the connection is legal, + * an error code otherwise. + * @public + */ + canConnectWithReason(a, b, isDragging, opt_distance) { + const safety = this.doSafetyChecks(a, b); + if (safety !== Connection.CAN_CONNECT) { + return safety; } - case Connection.REASON_SHADOW_PARENT: - return 'Connecting non-shadow to shadow block.'; - case Connection.REASON_DRAG_CHECKS_FAILED: - return 'Drag checks failed.'; - case Connection.REASON_PREVIOUS_AND_OUTPUT: - return 'Block would have an output and a previous connection.'; - default: - return 'Unknown connection failure: this should never happen!'; - } -}; -/** - * Check that connecting the given connections is safe, meaning that it would - * not break any of Blockly's basic assumptions (e.g. no self connections). - * @param {Connection} a The first of the connections to check. - * @param {Connection} b The second of the connections to check. - * @return {number} An enum with the reason this connection is safe or unsafe. - * @public - */ -ConnectionChecker.prototype.doSafetyChecks = function(a, b) { - if (!a || !b) { - return Connection.REASON_TARGET_NULL; - } - let superiorBlock; - let inferiorBlock; - let superiorConnection; - let inferiorConnection; - if (a.isSuperior()) { - superiorBlock = a.getSourceBlock(); - inferiorBlock = b.getSourceBlock(); - superiorConnection = a; - inferiorConnection = b; - } else { - inferiorBlock = a.getSourceBlock(); - superiorBlock = b.getSourceBlock(); - inferiorConnection = a; - superiorConnection = b; - } - if (superiorBlock === inferiorBlock) { - return Connection.REASON_SELF_CONNECTION; - } else if ( - inferiorConnection.type !== - internalConstants.OPPOSITE_TYPE[superiorConnection.type]) { - return Connection.REASON_WRONG_TYPE; - } else if (superiorBlock.workspace !== inferiorBlock.workspace) { - return Connection.REASON_DIFFERENT_WORKSPACES; - } else if (superiorBlock.isShadow() && !inferiorBlock.isShadow()) { - return Connection.REASON_SHADOW_PARENT; - } else if ( - inferiorConnection.type === ConnectionType.OUTPUT_VALUE && - inferiorBlock.previousConnection && - inferiorBlock.previousConnection.isConnected()) { - return Connection.REASON_PREVIOUS_AND_OUTPUT; - } else if ( - inferiorConnection.type === ConnectionType.PREVIOUS_STATEMENT && - inferiorBlock.outputConnection && - inferiorBlock.outputConnection.isConnected()) { - return Connection.REASON_PREVIOUS_AND_OUTPUT; - } - return Connection.CAN_CONNECT; -}; + // If the safety checks passed, both connections are non-null. + const connOne = /** @type {!Connection} **/ (a); + const connTwo = /** @type {!Connection} **/ (b); + if (!this.doTypeChecks(connOne, connTwo)) { + return Connection.REASON_CHECKS_FAILED; + } -/** - * Check whether this connection is compatible with another connection with - * respect to the value type system. E.g. square_root("Hello") is not - * compatible. - * @param {!Connection} a Connection to compare. - * @param {!Connection} b Connection to compare against. - * @return {boolean} True if the connections share a type. - * @public - */ -ConnectionChecker.prototype.doTypeChecks = function(a, b) { - const checkArrayOne = a.getCheck(); - const checkArrayTwo = b.getCheck(); + if (isDragging && + !this.doDragChecks( + /** @type {!RenderedConnection} **/ (a), + /** @type {!RenderedConnection} **/ (b), opt_distance || 0)) { + return Connection.REASON_DRAG_CHECKS_FAILED; + } - if (!checkArrayOne || !checkArrayTwo) { - // One or both sides are promiscuous enough that anything will fit. - return true; + return Connection.CAN_CONNECT; } - // Find any intersection in the check lists. - for (let i = 0; i < checkArrayOne.length; i++) { - if (checkArrayTwo.indexOf(checkArrayOne[i]) !== -1) { + + /** + * Helper method that translates a connection error code into a string. + * @param {number} errorCode The error code. + * @param {Connection} a One of the two connections being checked. + * @param {Connection} b The second of the two connections being + * checked. + * @return {string} A developer-readable error string. + * @public + */ + getErrorMessage(errorCode, a, b) { + switch (errorCode) { + case Connection.REASON_SELF_CONNECTION: + return 'Attempted to connect a block to itself.'; + case Connection.REASON_DIFFERENT_WORKSPACES: + // Usually this means one block has been deleted. + return 'Blocks not on same workspace.'; + case Connection.REASON_WRONG_TYPE: + return 'Attempt to connect incompatible types.'; + case Connection.REASON_TARGET_NULL: + return 'Target connection is null.'; + case Connection.REASON_CHECKS_FAILED: { + const connOne = /** @type {!Connection} **/ (a); + const connTwo = /** @type {!Connection} **/ (b); + let msg = 'Connection checks failed. '; + msg += connOne + ' expected ' + connOne.getCheck() + ', found ' + + connTwo.getCheck(); + return msg; + } + case Connection.REASON_SHADOW_PARENT: + return 'Connecting non-shadow to shadow block.'; + case Connection.REASON_DRAG_CHECKS_FAILED: + return 'Drag checks failed.'; + case Connection.REASON_PREVIOUS_AND_OUTPUT: + return 'Block would have an output and a previous connection.'; + default: + return 'Unknown connection failure: this should never happen!'; + } + } + + /** + * Check that connecting the given connections is safe, meaning that it would + * not break any of Blockly's basic assumptions (e.g. no self connections). + * @param {Connection} a The first of the connections to check. + * @param {Connection} b The second of the connections to check. + * @return {number} An enum with the reason this connection is safe or unsafe. + * @public + */ + doSafetyChecks(a, b) { + if (!a || !b) { + return Connection.REASON_TARGET_NULL; + } + let superiorBlock; + let inferiorBlock; + let superiorConnection; + let inferiorConnection; + if (a.isSuperior()) { + superiorBlock = a.getSourceBlock(); + inferiorBlock = b.getSourceBlock(); + superiorConnection = a; + inferiorConnection = b; + } else { + inferiorBlock = a.getSourceBlock(); + superiorBlock = b.getSourceBlock(); + inferiorConnection = a; + superiorConnection = b; + } + if (superiorBlock === inferiorBlock) { + return Connection.REASON_SELF_CONNECTION; + } else if ( + inferiorConnection.type !== + internalConstants.OPPOSITE_TYPE[superiorConnection.type]) { + return Connection.REASON_WRONG_TYPE; + } else if (superiorBlock.workspace !== inferiorBlock.workspace) { + return Connection.REASON_DIFFERENT_WORKSPACES; + } else if (superiorBlock.isShadow() && !inferiorBlock.isShadow()) { + return Connection.REASON_SHADOW_PARENT; + } else if ( + inferiorConnection.type === ConnectionType.OUTPUT_VALUE && + inferiorBlock.previousConnection && + inferiorBlock.previousConnection.isConnected()) { + return Connection.REASON_PREVIOUS_AND_OUTPUT; + } else if ( + inferiorConnection.type === ConnectionType.PREVIOUS_STATEMENT && + inferiorBlock.outputConnection && + inferiorBlock.outputConnection.isConnected()) { + return Connection.REASON_PREVIOUS_AND_OUTPUT; + } + return Connection.CAN_CONNECT; + } + + /** + * Check whether this connection is compatible with another connection with + * respect to the value type system. E.g. square_root("Hello") is not + * compatible. + * @param {!Connection} a Connection to compare. + * @param {!Connection} b Connection to compare against. + * @return {boolean} True if the connections share a type. + * @public + */ + doTypeChecks(a, b) { + const checkArrayOne = a.getCheck(); + const checkArrayTwo = b.getCheck(); + + if (!checkArrayOne || !checkArrayTwo) { + // One or both sides are promiscuous enough that anything will fit. return true; } - } - // No intersection. - return false; -}; - -/** - * Check whether this connection can be made by dragging. - * @param {!RenderedConnection} a Connection to compare. - * @param {!RenderedConnection} b Connection to compare against. - * @param {number} distance The maximum allowable distance between connections. - * @return {boolean} True if the connection is allowed during a drag. - * @public - */ -ConnectionChecker.prototype.doDragChecks = function(a, b, distance) { - if (a.distanceFrom(b) > distance) { + // Find any intersection in the check lists. + for (let i = 0; i < checkArrayOne.length; i++) { + if (checkArrayTwo.indexOf(checkArrayOne[i]) !== -1) { + return true; + } + } + // No intersection. return false; } - // Don't consider insertion markers. - if (b.getSourceBlock().isInsertionMarker()) { - return false; - } - - switch (b.type) { - case ConnectionType.PREVIOUS_STATEMENT: - return this.canConnectToPrevious_(a, b); - case ConnectionType.OUTPUT_VALUE: { - // Don't offer to connect an already connected left (male) value plug to - // an available right (female) value plug. - if ((b.isConnected() && !b.targetBlock().isInsertionMarker()) || - a.isConnected()) { - return false; - } - break; - } - case ConnectionType.INPUT_VALUE: { - // Offering to connect the left (male) of a value block to an already - // connected value pair is ok, we'll splice it in. - // However, don't offer to splice into an immovable block. - if (b.isConnected() && !b.targetBlock().isMovable() && - !b.targetBlock().isShadow()) { - return false; - } - break; - } - case ConnectionType.NEXT_STATEMENT: { - // Don't let a block with no next connection bump other blocks out of the - // stack. But covering up a shadow block or stack of shadow blocks is - // fine. Similarly, replacing a terminal statement with another terminal - // statement is allowed. - if (b.isConnected() && !a.getSourceBlock().nextConnection && - !b.targetBlock().isShadow() && b.targetBlock().nextConnection) { - return false; - } - break; - } - default: - // Unexpected connection type. + /** + * Check whether this connection can be made by dragging. + * @param {!RenderedConnection} a Connection to compare. + * @param {!RenderedConnection} b Connection to compare against. + * @param {number} distance The maximum allowable distance between + * connections. + * @return {boolean} True if the connection is allowed during a drag. + * @public + */ + doDragChecks(a, b, distance) { + if (a.distanceFrom(b) > distance) { return false; - } + } - // Don't let blocks try to connect to themselves or ones they nest. - if (common.draggingConnections.indexOf(b) !== -1) { - return false; - } + // Don't consider insertion markers. + if (b.getSourceBlock().isInsertionMarker()) { + return false; + } - return true; -}; + switch (b.type) { + case ConnectionType.PREVIOUS_STATEMENT: + return this.canConnectToPrevious_(a, b); + case ConnectionType.OUTPUT_VALUE: { + // Don't offer to connect an already connected left (male) value plug to + // an available right (female) value plug. + if ((b.isConnected() && !b.targetBlock().isInsertionMarker()) || + a.isConnected()) { + return false; + } + break; + } + case ConnectionType.INPUT_VALUE: { + // Offering to connect the left (male) of a value block to an already + // connected value pair is ok, we'll splice it in. + // However, don't offer to splice into an immovable block. + if (b.isConnected() && !b.targetBlock().isMovable() && + !b.targetBlock().isShadow()) { + return false; + } + break; + } + case ConnectionType.NEXT_STATEMENT: { + // Don't let a block with no next connection bump other blocks out of + // the stack. But covering up a shadow block or stack of shadow blocks + // is fine. Similarly, replacing a terminal statement with another + // terminal statement is allowed. + if (b.isConnected() && !a.getSourceBlock().nextConnection && + !b.targetBlock().isShadow() && b.targetBlock().nextConnection) { + return false; + } + break; + } + default: + // Unexpected connection type. + return false; + } -/** - * Helper function for drag checking. - * @param {!Connection} a The connection to check, which must be a - * statement input or next connection. - * @param {!Connection} b A nearby connection to check, which - * must be a previous connection. - * @return {boolean} True if the connection is allowed, false otherwise. - * @protected - */ -ConnectionChecker.prototype.canConnectToPrevious_ = function(a, b) { - if (a.targetConnection) { - // This connection is already occupied. - // A next connection will never disconnect itself mid-drag. - return false; - } + // Don't let blocks try to connect to themselves or ones they nest. + if (common.draggingConnections.indexOf(b) !== -1) { + return false; + } - // Don't let blocks try to connect to themselves or ones they nest. - if (common.draggingConnections.indexOf(b) !== -1) { - return false; - } - - if (!b.targetConnection) { return true; } - const targetBlock = b.targetBlock(); - // If it is connected to a real block, game over. - if (!targetBlock.isInsertionMarker()) { - return false; + /** + * Helper function for drag checking. + * @param {!Connection} a The connection to check, which must be a + * statement input or next connection. + * @param {!Connection} b A nearby connection to check, which + * must be a previous connection. + * @return {boolean} True if the connection is allowed, false otherwise. + * @protected + */ + canConnectToPrevious_(a, b) { + if (a.targetConnection) { + // This connection is already occupied. + // A next connection will never disconnect itself mid-drag. + return false; + } + + // Don't let blocks try to connect to themselves or ones they nest. + if (common.draggingConnections.indexOf(b) !== -1) { + return false; + } + + if (!b.targetConnection) { + return true; + } + + const targetBlock = b.targetBlock(); + // If it is connected to a real block, game over. + if (!targetBlock.isInsertionMarker()) { + return false; + } + // If it's connected to an insertion marker but that insertion marker + // is the first block in a stack, it's still fine. If that insertion + // marker is in the middle of a stack, it won't work. + return !targetBlock.getPreviousBlock(); } - // If it's connected to an insertion marker but that insertion marker - // is the first block in a stack, it's still fine. If that insertion - // marker is in the middle of a stack, it won't work. - return !targetBlock.getPreviousBlock(); -}; +} registry.register( registry.Type.CONNECTION_CHECKER, registry.DEFAULT, ConnectionChecker); diff --git a/core/connection_db.js b/core/connection_db.js index 35c1363a1..1979b68f7 100644 --- a/core/connection_db.js +++ b/core/connection_db.js @@ -34,276 +34,281 @@ goog.require('Blockly.constants'); * Database of connections. * Connections are stored in order of their vertical component. This way * connections in an area may be looked up quickly using a binary search. - * @param {!IConnectionChecker} checker The workspace's - * connection type checker, used to decide if connections are valid during a - * drag. - * @constructor - * @alias Blockly.ConnectionDB */ -const ConnectionDB = function(checker) { +class ConnectionDB { /** - * Array of connections sorted by y position in workspace units. - * @type {!Array} + * @param {!IConnectionChecker} checker The workspace's + * connection type checker, used to decide if connections are valid during + * a drag. + * @alias Blockly.ConnectionDB + */ + constructor(checker) { + /** + * Array of connections sorted by y position in workspace units. + * @type {!Array} + * @private + */ + this.connections_ = []; + /** + * The workspace's connection type checker, used to decide if connections + * are valid during a drag. + * @type {!IConnectionChecker} + * @private + */ + this.connectionChecker_ = checker; + } + + /** + * Add a connection to the database. Should not already exist in the database. + * @param {!RenderedConnection} connection The connection to be added. + * @param {number} yPos The y position used to decide where to insert the + * connection. + * @package + */ + addConnection(connection, yPos) { + const index = this.calculateIndexForYPos_(yPos); + this.connections_.splice(index, 0, connection); + } + + /** + * Finds the index of the given connection. + * + * Starts by doing a binary search to find the approximate location, then + * linearly searches nearby for the exact connection. + * @param {!RenderedConnection} conn The connection to find. + * @param {number} yPos The y position used to find the index of the + * connection. + * @return {number} The index of the connection, or -1 if the connection was + * not found. * @private */ - this.connections_ = []; - /** - * The workspace's connection type checker, used to decide if connections are - * valid during a drag. - * @type {!IConnectionChecker} - * @private - */ - this.connectionChecker_ = checker; -}; + findIndexOfConnection_(conn, yPos) { + if (!this.connections_.length) { + return -1; + } -/** - * Add a connection to the database. Should not already exist in the database. - * @param {!RenderedConnection} connection The connection to be added. - * @param {number} yPos The y position used to decide where to insert the - * connection. - * @package - */ -ConnectionDB.prototype.addConnection = function(connection, yPos) { - const index = this.calculateIndexForYPos_(yPos); - this.connections_.splice(index, 0, connection); -}; + const bestGuess = this.calculateIndexForYPos_(yPos); + if (bestGuess >= this.connections_.length) { + // Not in list + return -1; + } -/** - * Finds the index of the given connection. - * - * Starts by doing a binary search to find the approximate location, then - * linearly searches nearby for the exact connection. - * @param {!RenderedConnection} conn The connection to find. - * @param {number} yPos The y position used to find the index of the connection. - * @return {number} The index of the connection, or -1 if the connection was - * not found. - * @private - */ -ConnectionDB.prototype.findIndexOfConnection_ = function(conn, yPos) { - if (!this.connections_.length) { + yPos = conn.y; + // Walk forward and back on the y axis looking for the connection. + let pointer = bestGuess; + while (pointer >= 0 && this.connections_[pointer].y === yPos) { + if (this.connections_[pointer] === conn) { + return pointer; + } + pointer--; + } + + pointer = bestGuess; + while (pointer < this.connections_.length && + this.connections_[pointer].y === yPos) { + if (this.connections_[pointer] === conn) { + return pointer; + } + pointer++; + } return -1; } - const bestGuess = this.calculateIndexForYPos_(yPos); - if (bestGuess >= this.connections_.length) { - // Not in list - return -1; - } - - yPos = conn.y; - // Walk forward and back on the y axis looking for the connection. - let pointer = bestGuess; - while (pointer >= 0 && this.connections_[pointer].y === yPos) { - if (this.connections_[pointer] === conn) { - return pointer; - } - pointer--; - } - - pointer = bestGuess; - while (pointer < this.connections_.length && - this.connections_[pointer].y === yPos) { - if (this.connections_[pointer] === conn) { - return pointer; - } - pointer++; - } - return -1; -}; - -/** - * Finds the correct index for the given y position. - * @param {number} yPos The y position used to decide where to - * insert the connection. - * @return {number} The candidate index. - * @private - */ -ConnectionDB.prototype.calculateIndexForYPos_ = function(yPos) { - if (!this.connections_.length) { - return 0; - } - let pointerMin = 0; - let pointerMax = this.connections_.length; - while (pointerMin < pointerMax) { - const pointerMid = Math.floor((pointerMin + pointerMax) / 2); - if (this.connections_[pointerMid].y < yPos) { - pointerMin = pointerMid + 1; - } else if (this.connections_[pointerMid].y > yPos) { - pointerMax = pointerMid; - } else { - pointerMin = pointerMid; - break; - } - } - return pointerMin; -}; - -/** - * Remove a connection from the database. Must already exist in DB. - * @param {!RenderedConnection} connection The connection to be removed. - * @param {number} yPos The y position used to find the index of the connection. - * @throws {Error} If the connection cannot be found in the database. - */ -ConnectionDB.prototype.removeConnection = function(connection, yPos) { - const index = this.findIndexOfConnection_(connection, yPos); - if (index === -1) { - throw Error('Unable to find connection in connectionDB.'); - } - this.connections_.splice(index, 1); -}; - -/** - * Find all nearby connections to the given connection. - * Type checking does not apply, since this function is used for bumping. - * @param {!RenderedConnection} connection The connection whose - * neighbours should be returned. - * @param {number} maxRadius The maximum radius to another connection. - * @return {!Array} List of connections. - */ -ConnectionDB.prototype.getNeighbours = function(connection, maxRadius) { - const db = this.connections_; - const currentX = connection.x; - const currentY = connection.y; - - // Binary search to find the closest y location. - let pointerMin = 0; - let pointerMax = db.length - 2; - let pointerMid = pointerMax; - while (pointerMin < pointerMid) { - if (db[pointerMid].y < currentY) { - pointerMin = pointerMid; - } else { - pointerMax = pointerMid; - } - pointerMid = Math.floor((pointerMin + pointerMax) / 2); - } - - const neighbours = []; /** - * Computes if the current connection is within the allowed radius of another - * connection. - * This function is a closure and has access to outside variables. - * @param {number} yIndex The other connection's index in the database. - * @return {boolean} True if the current connection's vertical distance from - * the other connection is less than the allowed radius. + * Finds the correct index for the given y position. + * @param {number} yPos The y position used to decide where to + * insert the connection. + * @return {number} The candidate index. + * @private */ - function checkConnection_(yIndex) { - const dx = currentX - db[yIndex].x; - const dy = currentY - db[yIndex].y; - const r = Math.sqrt(dx * dx + dy * dy); - if (r <= maxRadius) { - neighbours.push(db[yIndex]); + calculateIndexForYPos_(yPos) { + if (!this.connections_.length) { + return 0; } - return dy < maxRadius; + let pointerMin = 0; + let pointerMax = this.connections_.length; + while (pointerMin < pointerMax) { + const pointerMid = Math.floor((pointerMin + pointerMax) / 2); + if (this.connections_[pointerMid].y < yPos) { + pointerMin = pointerMid + 1; + } else if (this.connections_[pointerMid].y > yPos) { + pointerMax = pointerMid; + } else { + pointerMin = pointerMid; + break; + } + } + return pointerMin; } - // Walk forward and back on the y axis looking for the closest x,y point. - pointerMin = pointerMid; - pointerMax = pointerMid; - if (db.length) { - while (pointerMin >= 0 && checkConnection_(pointerMin)) { + /** + * Remove a connection from the database. Must already exist in DB. + * @param {!RenderedConnection} connection The connection to be removed. + * @param {number} yPos The y position used to find the index of the + * connection. + * @throws {Error} If the connection cannot be found in the database. + */ + removeConnection(connection, yPos) { + const index = this.findIndexOfConnection_(connection, yPos); + if (index === -1) { + throw Error('Unable to find connection in connectionDB.'); + } + this.connections_.splice(index, 1); + } + + /** + * Find all nearby connections to the given connection. + * Type checking does not apply, since this function is used for bumping. + * @param {!RenderedConnection} connection The connection whose + * neighbours should be returned. + * @param {number} maxRadius The maximum radius to another connection. + * @return {!Array} List of connections. + */ + getNeighbours(connection, maxRadius) { + const db = this.connections_; + const currentX = connection.x; + const currentY = connection.y; + + // Binary search to find the closest y location. + let pointerMin = 0; + let pointerMax = db.length - 2; + let pointerMid = pointerMax; + while (pointerMin < pointerMid) { + if (db[pointerMid].y < currentY) { + pointerMin = pointerMid; + } else { + pointerMax = pointerMid; + } + pointerMid = Math.floor((pointerMin + pointerMax) / 2); + } + + const neighbours = []; + /** + * Computes if the current connection is within the allowed radius of + * another connection. This function is a closure and has access to outside + * variables. + * @param {number} yIndex The other connection's index in the database. + * @return {boolean} True if the current connection's vertical distance from + * the other connection is less than the allowed radius. + */ + function checkConnection_(yIndex) { + const dx = currentX - db[yIndex].x; + const dy = currentY - db[yIndex].y; + const r = Math.sqrt(dx * dx + dy * dy); + if (r <= maxRadius) { + neighbours.push(db[yIndex]); + } + return dy < maxRadius; + } + + // Walk forward and back on the y axis looking for the closest x,y point. + pointerMin = pointerMid; + pointerMax = pointerMid; + if (db.length) { + while (pointerMin >= 0 && checkConnection_(pointerMin)) { + pointerMin--; + } + do { + pointerMax++; + } while (pointerMax < db.length && checkConnection_(pointerMax)); + } + + return neighbours; + } + + /** + * Is the candidate connection close to the reference connection. + * Extremely fast; only looks at Y distance. + * @param {number} index Index in database of candidate connection. + * @param {number} baseY Reference connection's Y value. + * @param {number} maxRadius The maximum radius to another connection. + * @return {boolean} True if connection is in range. + * @private + */ + isInYRange_(index, baseY, maxRadius) { + return (Math.abs(this.connections_[index].y - baseY) <= maxRadius); + } + + /** + * Find the closest compatible connection to this connection. + * @param {!RenderedConnection} conn The connection searching for a compatible + * mate. + * @param {number} maxRadius The maximum radius to another connection. + * @param {!Coordinate} dxy Offset between this connection's + * location in the database and the current location (as a result of + * dragging). + * @return {!{connection: RenderedConnection, radius: number}} + * Contains two properties: 'connection' which is either another + * connection or null, and 'radius' which is the distance. + */ + searchForClosest(conn, maxRadius, dxy) { + if (!this.connections_.length) { + // Don't bother. + return {connection: null, radius: maxRadius}; + } + + // Stash the values of x and y from before the drag. + const baseY = conn.y; + const baseX = conn.x; + + conn.x = baseX + dxy.x; + conn.y = baseY + dxy.y; + + // calculateIndexForYPos_ finds an index for insertion, which is always + // after any block with the same y index. We want to search both forward + // and back, so search on both sides of the index. + const closestIndex = this.calculateIndexForYPos_(conn.y); + + let bestConnection = null; + let bestRadius = maxRadius; + let temp; + + // Walk forward and back on the y axis looking for the closest x,y point. + let pointerMin = closestIndex - 1; + while (pointerMin >= 0 && this.isInYRange_(pointerMin, conn.y, maxRadius)) { + temp = this.connections_[pointerMin]; + if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) { + bestConnection = temp; + bestRadius = temp.distanceFrom(conn); + } pointerMin--; } - do { + + let pointerMax = closestIndex; + while (pointerMax < this.connections_.length && + this.isInYRange_(pointerMax, conn.y, maxRadius)) { + temp = this.connections_[pointerMax]; + if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) { + bestConnection = temp; + bestRadius = temp.distanceFrom(conn); + } pointerMax++; - } while (pointerMax < db.length && checkConnection_(pointerMax)); - } - - return neighbours; -}; - -/** - * Is the candidate connection close to the reference connection. - * Extremely fast; only looks at Y distance. - * @param {number} index Index in database of candidate connection. - * @param {number} baseY Reference connection's Y value. - * @param {number} maxRadius The maximum radius to another connection. - * @return {boolean} True if connection is in range. - * @private - */ -ConnectionDB.prototype.isInYRange_ = function(index, baseY, maxRadius) { - return (Math.abs(this.connections_[index].y - baseY) <= maxRadius); -}; - -/** - * Find the closest compatible connection to this connection. - * @param {!RenderedConnection} conn The connection searching for a compatible - * mate. - * @param {number} maxRadius The maximum radius to another connection. - * @param {!Coordinate} dxy Offset between this connection's - * location in the database and the current location (as a result of - * dragging). - * @return {!{connection: RenderedConnection, radius: number}} - * Contains two properties: 'connection' which is either another - * connection or null, and 'radius' which is the distance. - */ -ConnectionDB.prototype.searchForClosest = function(conn, maxRadius, dxy) { - if (!this.connections_.length) { - // Don't bother. - return {connection: null, radius: maxRadius}; - } - - // Stash the values of x and y from before the drag. - const baseY = conn.y; - const baseX = conn.x; - - conn.x = baseX + dxy.x; - conn.y = baseY + dxy.y; - - // calculateIndexForYPos_ finds an index for insertion, which is always - // after any block with the same y index. We want to search both forward - // and back, so search on both sides of the index. - const closestIndex = this.calculateIndexForYPos_(conn.y); - - let bestConnection = null; - let bestRadius = maxRadius; - let temp; - - // Walk forward and back on the y axis looking for the closest x,y point. - let pointerMin = closestIndex - 1; - while (pointerMin >= 0 && this.isInYRange_(pointerMin, conn.y, maxRadius)) { - temp = this.connections_[pointerMin]; - if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) { - bestConnection = temp; - bestRadius = temp.distanceFrom(conn); } - pointerMin--; + + // Reset the values of x and y. + conn.x = baseX; + conn.y = baseY; + + // If there were no valid connections, bestConnection will be null. + return {connection: bestConnection, radius: bestRadius}; } - let pointerMax = closestIndex; - while (pointerMax < this.connections_.length && - this.isInYRange_(pointerMax, conn.y, maxRadius)) { - temp = this.connections_[pointerMax]; - if (this.connectionChecker_.canConnect(conn, temp, true, bestRadius)) { - bestConnection = temp; - bestRadius = temp.distanceFrom(conn); - } - pointerMax++; + /** + * Initialize a set of connection DBs for a workspace. + * @param {!IConnectionChecker} checker The workspace's + * connection checker, used to decide if connections are valid during a + * drag. + * @return {!Array} Array of databases. + */ + static init(checker) { + // Create four databases, one for each connection type. + const dbList = []; + dbList[ConnectionType.INPUT_VALUE] = new ConnectionDB(checker); + dbList[ConnectionType.OUTPUT_VALUE] = new ConnectionDB(checker); + dbList[ConnectionType.NEXT_STATEMENT] = new ConnectionDB(checker); + dbList[ConnectionType.PREVIOUS_STATEMENT] = new ConnectionDB(checker); + return dbList; } - - // Reset the values of x and y. - conn.x = baseX; - conn.y = baseY; - - // If there were no valid connections, bestConnection will be null. - return {connection: bestConnection, radius: bestRadius}; -}; - -/** - * Initialize a set of connection DBs for a workspace. - * @param {!IConnectionChecker} checker The workspace's - * connection checker, used to decide if connections are valid during a - * drag. - * @return {!Array} Array of databases. - */ -ConnectionDB.init = function(checker) { - // Create four databases, one for each connection type. - const dbList = []; - dbList[ConnectionType.INPUT_VALUE] = new ConnectionDB(checker); - dbList[ConnectionType.OUTPUT_VALUE] = new ConnectionDB(checker); - dbList[ConnectionType.NEXT_STATEMENT] = new ConnectionDB(checker); - dbList[ConnectionType.PREVIOUS_STATEMENT] = new ConnectionDB(checker); - return dbList; -}; +} exports.ConnectionDB = ConnectionDB; diff --git a/core/field_label_serializable.js b/core/field_label_serializable.js index 4c4990f03..b8d3e027a 100644 --- a/core/field_label_serializable.js +++ b/core/field_label_serializable.js @@ -20,59 +20,62 @@ goog.module('Blockly.FieldLabelSerializable'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); const parsing = goog.require('Blockly.utils.parsing'); const {FieldLabel} = goog.require('Blockly.FieldLabel'); /** * Class for a non-editable, serializable text field. - * @param {*} opt_value The initial value of the field. Should cast to a - * string. Defaults to an empty string if null or undefined. - * @param {string=} opt_class Optional CSS class for the field's text. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label-serializable#creation} - * for a list of properties this parameter supports. * @extends {FieldLabel} - * @constructor - * - * @alias Blockly.FieldLabelSerializable */ -const FieldLabelSerializable = function(opt_value, opt_class, opt_config) { - FieldLabelSerializable.superClass_.constructor.call( - this, opt_value, opt_class, opt_config); -}; -object.inherits(FieldLabelSerializable, FieldLabel); +class FieldLabelSerializable extends FieldLabel { + /** + * @param {*} opt_value The initial value of the field. Should cast to a + * string. Defaults to an empty string if null or undefined. + * @param {string=} opt_class Optional CSS class for the field's text. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label-serializable#creation} + * for a list of properties this parameter supports. + * + * @alias Blockly.FieldLabelSerializable + */ + constructor(opt_value, opt_class, opt_config) { + const stringValue = opt_value == undefined ? '' : String(opt_value); + super(stringValue, opt_class, opt_config); -/** - * Construct a FieldLabelSerializable from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (text, and class). - * @return {!FieldLabelSerializable} The new field instance. - * @package - * @nocollapse - */ -FieldLabelSerializable.fromJson = function(options) { - const text = parsing.replaceMessageReferences(options['text']); - // `this` might be a subclass of FieldLabelSerializable if that class doesn't - // override the static fromJson method. - return new this(text, undefined, options); -}; + /** + * Editable fields usually show some sort of UI indicating they are + * editable. This field should not. + * @type {boolean} + */ + this.EDITABLE = false; -/** - * Editable fields usually show some sort of UI indicating they are - * editable. This field should not. - * @type {boolean} - */ -FieldLabelSerializable.prototype.EDITABLE = false; + /** + * Serializable fields are saved by the XML renderer, non-serializable + * fields are not. This field should be serialized, but only edited + * programmatically. + * @type {boolean} + */ + this.SERIALIZABLE = true; + } -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. This field should be serialized, but only edited programmatically. - * @type {boolean} - */ -FieldLabelSerializable.prototype.SERIALIZABLE = true; + /** + * Construct a FieldLabelSerializable from a JSON arg object, + * dereferencing any string table references. + * @param {!Object} options A JSON object with options (text, and class). + * @return {!FieldLabelSerializable} The new field instance. + * @package + * @nocollapse + * @override + */ + static fromJson(options) { + const text = parsing.replaceMessageReferences(options['text']); + // `this` might be a subclass of FieldLabelSerializable if that class + // doesn't override the static fromJson method. + return new this(text, undefined, options); + } +} fieldRegistry.register('field_label_serializable', FieldLabelSerializable); diff --git a/core/field_multilineinput.js b/core/field_multilineinput.js index 9f7899d6c..b03dc2d2b 100644 --- a/core/field_multilineinput.js +++ b/core/field_multilineinput.js @@ -20,7 +20,6 @@ const WidgetDiv = goog.require('Blockly.WidgetDiv'); const aria = goog.require('Blockly.utils.aria'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); const parsing = goog.require('Blockly.utils.parsing'); const userAgent = goog.require('Blockly.utils.userAgent'); const {FieldTextInput} = goog.require('Blockly.FieldTextInput'); @@ -31,401 +30,419 @@ const {Svg} = goog.require('Blockly.utils.Svg'); /** * Class for an editable text area field. - * @param {string=} opt_value The initial content of the field. Should cast to a - * string. Defaults to an empty string if null or undefined. - * @param {Function=} opt_validator An optional function that is called - * to validate any constraints on what the user entered. Takes the new - * text as an argument and returns either the accepted text, a replacement - * text, or null to abort the change. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation} - * for a list of properties this parameter supports. * @extends {FieldTextInput} - * @constructor - * @alias Blockly.FieldMultilineInput */ -const FieldMultilineInput = function(opt_value, opt_validator, opt_config) { - FieldMultilineInput.superClass_.constructor.call( - this, opt_value, opt_validator, opt_config); - +class FieldMultilineInput extends FieldTextInput { /** - * The SVG group element that will contain a text element for each text row - * when initialized. - * @type {SVGGElement} + * @param {string=} opt_value The initial content of the field. Should cast to + * a + * string. Defaults to an empty string if null or undefined. + * @param {Function=} opt_validator An optional function that is called + * to validate any constraints on what the user entered. Takes the new + * text as an argument and returns either the accepted text, a replacement + * text, or null to abort the change. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldMultilineInput */ - this.textGroup_ = null; + constructor(opt_value, opt_validator, opt_config) { + const stringValue = opt_value == undefined ? '' : String(opt_value); + super(stringValue, opt_validator, opt_config); + + /** + * The SVG group element that will contain a text element for each text row + * when initialized. + * @type {SVGGElement} + */ + this.textGroup_ = null; + + /** + * Defines the maximum number of lines of field. + * If exceeded, scrolling functionality is enabled. + * @type {number} + * @protected + */ + this.maxLines_ = Infinity; + + /** + * Whether Y overflow is currently occurring. + * @type {boolean} + * @protected + */ + this.isOverflowedY_ = false; + + /** + * @type {boolean} + * @private + */ + this.isBeingEdited_ = false; + + /** + * @type {boolean} + * @private + */ + this.isTextValid_ = false; + } /** - * Defines the maximum number of lines of field. - * If exceeded, scrolling functionality is enabled. - * @type {number} + * @override + */ + configure_(config) { + super.configure_(config); + config.maxLines && this.setMaxLines(config.maxLines); + } + + /** + * Serializes this field's value to XML. Should only be called by Blockly.Xml. + * @param {!Element} fieldElement The element to populate with info about the + * field's state. + * @return {!Element} The element containing info about the field's state. + * @package + */ + toXml(fieldElement) { + // Replace '\n' characters with HTML-escaped equivalent ' '. This is + // needed so the plain-text representation of the XML produced by + // `Blockly.Xml.domToText` will appear on a single line (this is a + // limitation of the plain-text format). + fieldElement.textContent = this.getValue().replace(/\n/g, ' '); + return fieldElement; + } + + /** + * Sets the field's value based on the given XML element. Should only be + * called by Blockly.Xml. + * @param {!Element} fieldElement The element containing info about the + * field's state. + * @package + */ + fromXml(fieldElement) { + this.setValue(fieldElement.textContent.replace(/ /g, '\n')); + } + + /** + * Saves this field's value. + * @return {*} The state of this field. + * @package + */ + saveState() { + const legacyState = this.saveLegacyState(FieldMultilineInput); + if (legacyState !== null) { + return legacyState; + } + return this.getValue(); + } + + /** + * Sets the field's value based on the given state. + * @param {*} state The state of the variable to assign to this variable + * field. + * @override + * @package + */ + loadState(state) { + if (this.loadLegacyState(Field, state)) { + return; + } + this.setValue(state); + } + + /** + * Create the block UI for this field. + * @package + */ + initView() { + this.createBorderRect_(); + this.textGroup_ = dom.createSvgElement( + Svg.G, { + 'class': 'blocklyEditableText', + }, + this.fieldGroup_); + } + + /** + * Get the text from this field as displayed on screen. May differ from + * getText due to ellipsis, and other formatting. + * @return {string} Currently displayed text. + * @protected + * @override + */ + getDisplayText_() { + let textLines = this.getText(); + if (!textLines) { + // Prevent the field from disappearing if empty. + return Field.NBSP; + } + const lines = textLines.split('\n'); + textLines = ''; + const displayLinesNumber = + this.isOverflowedY_ ? this.maxLines_ : lines.length; + for (let i = 0; i < displayLinesNumber; i++) { + let text = lines[i]; + if (text.length > this.maxDisplayLength) { + // Truncate displayed string and add an ellipsis ('...'). + text = text.substring(0, this.maxDisplayLength - 4) + '...'; + } else if (this.isOverflowedY_ && i === displayLinesNumber - 1) { + text = text.substring(0, text.length - 3) + '...'; + } + // Replace whitespace with non-breaking spaces so the text doesn't + // collapse. + text = text.replace(/\s/g, Field.NBSP); + + textLines += text; + if (i !== displayLinesNumber - 1) { + textLines += '\n'; + } + } + if (this.sourceBlock_.RTL) { + // The SVG is LTR, force value to be RTL. + textLines += '\u200F'; + } + return textLines; + } + + /** + * Called by setValue if the text input is valid. Updates the value of the + * field, and updates the text of the field if it is not currently being + * edited (i.e. handled by the htmlInput_). Is being redefined here to update + * overflow state of the field. + * @param {*} newValue The value to be saved. The default validator guarantees + * that this is a string. * @protected */ - this.maxLines_ = Infinity; + doValueUpdate_(newValue) { + super.doValueUpdate_(newValue); + this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_; + } /** - * Whether Y overflow is currently occurring. - * @type {boolean} + * Updates the text of the textElement. * @protected */ - this.isOverflowedY_ = false; -}; -object.inherits(FieldMultilineInput, FieldTextInput); - -/** - * @override - */ -FieldMultilineInput.prototype.configure_ = function(config) { - FieldMultilineInput.superClass_.configure_.call(this, config); - config.maxLines && this.setMaxLines(config.maxLines); -}; - -/** - * Construct a FieldMultilineInput from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (text, and spellcheck). - * @return {!FieldMultilineInput} The new field instance. - * @package - * @nocollapse - */ -FieldMultilineInput.fromJson = function(options) { - const text = parsing.replaceMessageReferences(options['text']); - // `this` might be a subclass of FieldMultilineInput if that class doesn't - // override the static fromJson method. - return new this(text, undefined, options); -}; - -/** - * Serializes this field's value to XML. Should only be called by Blockly.Xml. - * @param {!Element} fieldElement The element to populate with info about the - * field's state. - * @return {!Element} The element containing info about the field's state. - * @package - */ -FieldMultilineInput.prototype.toXml = function(fieldElement) { - // Replace '\n' characters with HTML-escaped equivalent ' '. This is - // needed so the plain-text representation of the XML produced by - // `Blockly.Xml.domToText` will appear on a single line (this is a limitation - // of the plain-text format). - fieldElement.textContent = this.getValue().replace(/\n/g, ' '); - return fieldElement; -}; - -/** - * Sets the field's value based on the given XML element. Should only be - * called by Blockly.Xml. - * @param {!Element} fieldElement The element containing info about the - * field's state. - * @package - */ -FieldMultilineInput.prototype.fromXml = function(fieldElement) { - this.setValue(fieldElement.textContent.replace(/ /g, '\n')); -}; - -/** - * Saves this field's value. - * @return {*} The state of this field. - * @package - */ -FieldMultilineInput.prototype.saveState = function() { - const legacyState = this.saveLegacyState(FieldMultilineInput); - if (legacyState !== null) { - return legacyState; - } - return this.getValue(); -}; - -/** - * Sets the field's value based on the given state. - * @param {*} state The state of the variable to assign to this variable field. - * @override - * @package - */ -FieldMultilineInput.prototype.loadState = function(state) { - if (this.loadLegacyState(Field, state)) { - return; - } - this.setValue(state); -}; - -/** - * Create the block UI for this field. - * @package - */ -FieldMultilineInput.prototype.initView = function() { - this.createBorderRect_(); - this.textGroup_ = dom.createSvgElement( - Svg.G, { - 'class': 'blocklyEditableText', - }, - this.fieldGroup_); -}; - -/** - * Get the text from this field as displayed on screen. May differ from getText - * due to ellipsis, and other formatting. - * @return {string} Currently displayed text. - * @protected - * @override - */ -FieldMultilineInput.prototype.getDisplayText_ = function() { - let textLines = this.getText(); - if (!textLines) { - // Prevent the field from disappearing if empty. - return Field.NBSP; - } - const lines = textLines.split('\n'); - textLines = ''; - const displayLinesNumber = - this.isOverflowedY_ ? this.maxLines_ : lines.length; - for (let i = 0; i < displayLinesNumber; i++) { - let text = lines[i]; - if (text.length > this.maxDisplayLength) { - // Truncate displayed string and add an ellipsis ('...'). - text = text.substring(0, this.maxDisplayLength - 4) + '...'; - } else if (this.isOverflowedY_ && i === displayLinesNumber - 1) { - text = text.substring(0, text.length - 3) + '...'; + render_() { + // Remove all text group children. + let currentChild; + while ((currentChild = this.textGroup_.firstChild)) { + this.textGroup_.removeChild(currentChild); } - // Replace whitespace with non-breaking spaces so the text doesn't collapse. - text = text.replace(/\s/g, Field.NBSP); - textLines += text; - if (i !== displayLinesNumber - 1) { - textLines += '\n'; + // Add in text elements into the group. + const lines = this.getDisplayText_().split('\n'); + let y = 0; + for (let i = 0; i < lines.length; i++) { + const lineHeight = this.getConstants().FIELD_TEXT_HEIGHT + + this.getConstants().FIELD_BORDER_RECT_Y_PADDING; + const span = dom.createSvgElement( + Svg.TEXT, { + 'class': 'blocklyText blocklyMultilineText', + 'x': this.getConstants().FIELD_BORDER_RECT_X_PADDING, + 'y': y + this.getConstants().FIELD_BORDER_RECT_Y_PADDING, + 'dy': this.getConstants().FIELD_TEXT_BASELINE, + }, + this.textGroup_); + span.appendChild(document.createTextNode(lines[i])); + y += lineHeight; + } + + if (this.isBeingEdited_) { + const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_); + if (this.isOverflowedY_) { + dom.addClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY'); + } else { + dom.removeClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY'); + } + } + + this.updateSize_(); + + if (this.isBeingEdited_) { + if (this.sourceBlock_.RTL) { + // in RTL, we need to let the browser reflow before resizing + // in order to get the correct bounding box of the borderRect + // avoiding issue #2777. + setTimeout(this.resizeEditor_.bind(this), 0); + } else { + this.resizeEditor_(); + } + const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_); + if (!this.isTextValid_) { + dom.addClass(htmlInput, 'blocklyInvalidInput'); + aria.setState(htmlInput, aria.State.INVALID, true); + } else { + dom.removeClass(htmlInput, 'blocklyInvalidInput'); + aria.setState(htmlInput, aria.State.INVALID, false); + } } } - if (this.sourceBlock_.RTL) { - // The SVG is LTR, force value to be RTL. - textLines += '\u200F'; - } - return textLines; -}; -/** - * Called by setValue if the text input is valid. Updates the value of the - * field, and updates the text of the field if it is not currently being - * edited (i.e. handled by the htmlInput_). Is being redefined here to update - * overflow state of the field. - * @param {*} newValue The value to be saved. The default validator guarantees - * that this is a string. - * @protected - */ -FieldMultilineInput.prototype.doValueUpdate_ = function(newValue) { - FieldMultilineInput.superClass_.doValueUpdate_.call(this, newValue); - this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_; -}; + /** + * Updates the size of the field based on the text. + * @protected + */ + updateSize_() { + const nodes = this.textGroup_.childNodes; + let totalWidth = 0; + let totalHeight = 0; + for (let i = 0; i < nodes.length; i++) { + const tspan = /** @type {!Element} */ (nodes[i]); + const textWidth = dom.getTextWidth(tspan); + if (textWidth > totalWidth) { + totalWidth = textWidth; + } + totalHeight += this.getConstants().FIELD_TEXT_HEIGHT + + (i > 0 ? this.getConstants().FIELD_BORDER_RECT_Y_PADDING : 0); + } + if (this.isBeingEdited_) { + // The default width is based on the longest line in the display text, + // but when it's being edited, width should be calculated based on the + // absolute longest line, even if it would be truncated after editing. + // Otherwise we would get wrong editor width when there are more + // lines than this.maxLines_. + const actualEditorLines = this.value_.split('\n'); + const dummyTextElement = dom.createSvgElement( + Svg.TEXT, {'class': 'blocklyText blocklyMultilineText'}); + const fontSize = this.getConstants().FIELD_TEXT_FONTSIZE; + const fontWeight = this.getConstants().FIELD_TEXT_FONTWEIGHT; + const fontFamily = this.getConstants().FIELD_TEXT_FONTFAMILY; -/** - * Updates the text of the textElement. - * @protected - */ -FieldMultilineInput.prototype.render_ = function() { - // Remove all text group children. - let currentChild; - while ((currentChild = this.textGroup_.firstChild)) { - this.textGroup_.removeChild(currentChild); + for (let i = 0; i < actualEditorLines.length; i++) { + if (actualEditorLines[i].length > this.maxDisplayLength) { + actualEditorLines[i] = + actualEditorLines[i].substring(0, this.maxDisplayLength); + } + dummyTextElement.textContent = actualEditorLines[i]; + const lineWidth = dom.getFastTextWidth( + dummyTextElement, fontSize, fontWeight, fontFamily); + if (lineWidth > totalWidth) { + totalWidth = lineWidth; + } + } + + const scrollbarWidth = + this.htmlInput_.offsetWidth - this.htmlInput_.clientWidth; + totalWidth += scrollbarWidth; + } + if (this.borderRect_) { + totalHeight += this.getConstants().FIELD_BORDER_RECT_Y_PADDING * 2; + totalWidth += this.getConstants().FIELD_BORDER_RECT_X_PADDING * 2; + this.borderRect_.setAttribute('width', totalWidth); + this.borderRect_.setAttribute('height', totalHeight); + } + this.size_.width = totalWidth; + this.size_.height = totalHeight; + + this.positionBorderRect_(); } - // Add in text elements into the group. - const lines = this.getDisplayText_().split('\n'); - let y = 0; - for (let i = 0; i < lines.length; i++) { + /** + * Show the inline free-text editor on top of the text. + * Overrides the default behaviour to force rerender in order to + * correct block size, based on editor text. + * @param {Event=} _opt_e Optional mouse event that triggered the field to + * open, or undefined if triggered programmatically. + * @param {boolean=} opt_quietInput True if editor should be created without + * focus. Defaults to false. + * @override + */ + showEditor_(_opt_e, opt_quietInput) { + super.showEditor_(_opt_e, opt_quietInput); + this.forceRerender(); + } + + /** + * Create the text input editor widget. + * @return {!HTMLTextAreaElement} The newly created text input editor. + * @protected + */ + widgetCreate_() { + const div = WidgetDiv.getDiv(); + const scale = this.workspace_.getScale(); + + const htmlInput = + /** @type {HTMLTextAreaElement} */ (document.createElement('textarea')); + htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput'; + htmlInput.setAttribute('spellcheck', this.spellcheck_); + const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt'; + div.style.fontSize = fontSize; + htmlInput.style.fontSize = fontSize; + const borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px'; + htmlInput.style.borderRadius = borderRadius; + const paddingX = this.getConstants().FIELD_BORDER_RECT_X_PADDING * scale; + const paddingY = + this.getConstants().FIELD_BORDER_RECT_Y_PADDING * scale / 2; + htmlInput.style.padding = paddingY + 'px ' + paddingX + 'px ' + paddingY + + 'px ' + paddingX + 'px'; const lineHeight = this.getConstants().FIELD_TEXT_HEIGHT + this.getConstants().FIELD_BORDER_RECT_Y_PADDING; - const span = dom.createSvgElement( - Svg.TEXT, { - 'class': 'blocklyText blocklyMultilineText', - 'x': this.getConstants().FIELD_BORDER_RECT_X_PADDING, - 'y': y + this.getConstants().FIELD_BORDER_RECT_Y_PADDING, - 'dy': this.getConstants().FIELD_TEXT_BASELINE, - }, - this.textGroup_); - span.appendChild(document.createTextNode(lines[i])); - y += lineHeight; - } + htmlInput.style.lineHeight = (lineHeight * scale) + 'px'; - if (this.isBeingEdited_) { - const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_); - if (this.isOverflowedY_) { - dom.addClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY'); - } else { - dom.removeClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY'); - } - } + div.appendChild(htmlInput); - this.updateSize_(); - - if (this.isBeingEdited_) { - if (this.sourceBlock_.RTL) { - // in RTL, we need to let the browser reflow before resizing - // in order to get the correct bounding box of the borderRect - // avoiding issue #2777. + htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); + htmlInput.untypedDefaultValue_ = this.value_; + htmlInput.oldValue_ = null; + if (userAgent.GECKO) { + // In FF, ensure the browser reflows before resizing to avoid issue #2777. setTimeout(this.resizeEditor_.bind(this), 0); } else { this.resizeEditor_(); } - const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_); - if (!this.isTextValid_) { - dom.addClass(htmlInput, 'blocklyInvalidInput'); - aria.setState(htmlInput, aria.State.INVALID, true); - } else { - dom.removeClass(htmlInput, 'blocklyInvalidInput'); - aria.setState(htmlInput, aria.State.INVALID, false); + + this.bindInputEvents_(htmlInput); + + return htmlInput; + } + + /** + * Sets the maxLines config for this field. + * @param {number} maxLines Defines the maximum number of lines allowed, + * before scrolling functionality is enabled. + */ + setMaxLines(maxLines) { + if (typeof maxLines === 'number' && maxLines > 0 && + maxLines !== this.maxLines_) { + this.maxLines_ = maxLines; + this.forceRerender(); } } -}; -/** - * Updates the size of the field based on the text. - * @protected - */ -FieldMultilineInput.prototype.updateSize_ = function() { - const nodes = this.textGroup_.childNodes; - let totalWidth = 0; - let totalHeight = 0; - for (let i = 0; i < nodes.length; i++) { - const tspan = /** @type {!Element} */ (nodes[i]); - const textWidth = dom.getTextWidth(tspan); - if (textWidth > totalWidth) { - totalWidth = textWidth; + /** + * Returns the maxLines config of this field. + * @return {number} The maxLines config value. + */ + getMaxLines() { + return this.maxLines_; + } + + /** + * Handle key down to the editor. Override the text input definition of this + * so as to not close the editor when enter is typed in. + * @param {!Event} e Keyboard event. + * @protected + */ + onHtmlInputKeyDown_(e) { + if (e.keyCode !== KeyCodes.ENTER) { + super.onHtmlInputKeyDown_(e); } - totalHeight += this.getConstants().FIELD_TEXT_HEIGHT + - (i > 0 ? this.getConstants().FIELD_BORDER_RECT_Y_PADDING : 0); - } - if (this.isBeingEdited_) { - // The default width is based on the longest line in the display text, - // but when it's being edited, width should be calculated based on the - // absolute longest line, even if it would be truncated after editing. - // Otherwise we would get wrong editor width when there are more - // lines than this.maxLines_. - const actualEditorLines = this.value_.split('\n'); - const dummyTextElement = dom.createSvgElement( - Svg.TEXT, {'class': 'blocklyText blocklyMultilineText'}); - const fontSize = this.getConstants().FIELD_TEXT_FONTSIZE; - const fontWeight = this.getConstants().FIELD_TEXT_FONTWEIGHT; - const fontFamily = this.getConstants().FIELD_TEXT_FONTFAMILY; - - for (let i = 0; i < actualEditorLines.length; i++) { - if (actualEditorLines[i].length > this.maxDisplayLength) { - actualEditorLines[i] = - actualEditorLines[i].substring(0, this.maxDisplayLength); - } - dummyTextElement.textContent = actualEditorLines[i]; - const lineWidth = dom.getFastTextWidth( - dummyTextElement, fontSize, fontWeight, fontFamily); - if (lineWidth > totalWidth) { - totalWidth = lineWidth; - } - } - - const scrollbarWidth = - this.htmlInput_.offsetWidth - this.htmlInput_.clientWidth; - totalWidth += scrollbarWidth; - } - if (this.borderRect_) { - totalHeight += this.getConstants().FIELD_BORDER_RECT_Y_PADDING * 2; - totalWidth += this.getConstants().FIELD_BORDER_RECT_X_PADDING * 2; - this.borderRect_.setAttribute('width', totalWidth); - this.borderRect_.setAttribute('height', totalHeight); - } - this.size_.width = totalWidth; - this.size_.height = totalHeight; - - this.positionBorderRect_(); -}; - -/** - * Show the inline free-text editor on top of the text. - * Overrides the default behaviour to force rerender in order to - * correct block size, based on editor text. - * @param {Event=} _opt_e Optional mouse event that triggered the field to open, - * or undefined if triggered programmatically. - * @param {boolean=} opt_quietInput True if editor should be created without - * focus. Defaults to false. - * @override - */ -FieldMultilineInput.prototype.showEditor_ = function(_opt_e, opt_quietInput) { - FieldMultilineInput.superClass_.showEditor_.call( - this, _opt_e, opt_quietInput); - this.forceRerender(); -}; - -/** - * Create the text input editor widget. - * @return {!HTMLTextAreaElement} The newly created text input editor. - * @protected - */ -FieldMultilineInput.prototype.widgetCreate_ = function() { - const div = WidgetDiv.getDiv(); - const scale = this.workspace_.getScale(); - - const htmlInput = - /** @type {HTMLTextAreaElement} */ (document.createElement('textarea')); - htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput'; - htmlInput.setAttribute('spellcheck', this.spellcheck_); - const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt'; - div.style.fontSize = fontSize; - htmlInput.style.fontSize = fontSize; - const borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px'; - htmlInput.style.borderRadius = borderRadius; - const paddingX = this.getConstants().FIELD_BORDER_RECT_X_PADDING * scale; - const paddingY = this.getConstants().FIELD_BORDER_RECT_Y_PADDING * scale / 2; - htmlInput.style.padding = - paddingY + 'px ' + paddingX + 'px ' + paddingY + 'px ' + paddingX + 'px'; - const lineHeight = this.getConstants().FIELD_TEXT_HEIGHT + - this.getConstants().FIELD_BORDER_RECT_Y_PADDING; - htmlInput.style.lineHeight = (lineHeight * scale) + 'px'; - - div.appendChild(htmlInput); - - htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); - htmlInput.untypedDefaultValue_ = this.value_; - htmlInput.oldValue_ = null; - if (userAgent.GECKO) { - // In FF, ensure the browser reflows before resizing to avoid issue #2777. - setTimeout(this.resizeEditor_.bind(this), 0); - } else { - this.resizeEditor_(); } - this.bindInputEvents_(htmlInput); - - return htmlInput; -}; - -/** - * Sets the maxLines config for this field. - * @param {number} maxLines Defines the maximum number of lines allowed, - * before scrolling functionality is enabled. - */ -FieldMultilineInput.prototype.setMaxLines = function(maxLines) { - if (typeof maxLines === 'number' && maxLines > 0 && - maxLines !== this.maxLines_) { - this.maxLines_ = maxLines; - this.forceRerender(); + /** + * Construct a FieldMultilineInput from a JSON arg object, + * dereferencing any string table references. + * @param {!Object} options A JSON object with options (text, and spellcheck). + * @return {!FieldMultilineInput} The new field instance. + * @package + * @nocollapse + * @override + */ + static fromJson(options) { + const text = parsing.replaceMessageReferences(options['text']); + // `this` might be a subclass of FieldMultilineInput if that class doesn't + // override the static fromJson method. + return new this(text, undefined, options); } -}; - -/** - * Returns the maxLines config of this field. - * @return {number} The maxLines config value. - */ -FieldMultilineInput.prototype.getMaxLines = function() { - return this.maxLines_; -}; - -/** - * Handle key down to the editor. Override the text input definition of this - * so as to not close the editor when enter is typed in. - * @param {!Event} e Keyboard event. - * @protected - */ -FieldMultilineInput.prototype.onHtmlInputKeyDown_ = function(e) { - if (e.keyCode !== KeyCodes.ENTER) { - FieldMultilineInput.superClass_.onHtmlInputKeyDown_.call(this, e); - } -}; +} /** * CSS for multiline field. See css.js for use. diff --git a/core/icon.js b/core/icon.js index 3f81e9795..66a4a7299 100644 --- a/core/icon.js +++ b/core/icon.js @@ -29,189 +29,197 @@ const {Svg} = goog.require('Blockly.utils.Svg'); /** * Class for an icon. - * @param {BlockSvg} block The block associated with this icon. - * @constructor * @abstract - * @alias Blockly.Icon */ -const Icon = function(block) { +class Icon { /** - * The block this icon is attached to. - * @type {BlockSvg} + * @param {BlockSvg} block The block associated with this icon. + * @alias Blockly.Icon + */ + constructor(block) { + /** + * The block this icon is attached to. + * @type {BlockSvg} + * @protected + */ + this.block_ = block; + + /** + * The icon SVG group. + * @type {?SVGGElement} + */ + this.iconGroup_ = null; + + /** + * Whether this icon gets hidden when the block is collapsed. + * @type {boolean} + */ + this.collapseHidden = true; + + /** + * Height and width of icons. + * @const + */ + this.SIZE = 17; + + /** + * Bubble UI (if visible). + * @type {?Bubble} + * @protected + */ + this.bubble_ = null; + + /** + * Absolute coordinate of icon's center. + * @type {?Coordinate} + * @protected + */ + this.iconXY_ = null; + } + + /** + * Create the icon on the block. + */ + createIcon() { + if (this.iconGroup_) { + // Icon already exists. + return; + } + /* Here's the markup that will be generated: + + ... + + */ + this.iconGroup_ = + dom.createSvgElement(Svg.G, {'class': 'blocklyIconGroup'}, null); + if (this.block_.isInFlyout) { + dom.addClass( + /** @type {!Element} */ (this.iconGroup_), + 'blocklyIconGroupReadonly'); + } + this.drawIcon_(this.iconGroup_); + + this.block_.getSvgRoot().appendChild(this.iconGroup_); + browserEvents.conditionalBind( + this.iconGroup_, 'mouseup', this, this.iconClick_); + this.updateEditable(); + } + + /** + * Dispose of this icon. + */ + dispose() { + // Dispose of and unlink the icon. + dom.removeNode(this.iconGroup_); + this.iconGroup_ = null; + // Dispose of and unlink the bubble. + this.setVisible(false); + this.block_ = null; + } + + /** + * Add or remove the UI indicating if this icon may be clicked or not. + */ + updateEditable() { + // No-op on the base class. + } + + /** + * Is the associated bubble visible? + * @return {boolean} True if the bubble is visible. + */ + isVisible() { + return !!this.bubble_; + } + + /** + * Clicking on the icon toggles if the bubble is visible. + * @param {!Event} e Mouse click event. * @protected */ - this.block_ = block; + iconClick_(e) { + if (this.block_.workspace.isDragging()) { + // Drag operation is concluding. Don't open the editor. + return; + } + if (!this.block_.isInFlyout && !browserEvents.isRightButton(e)) { + this.setVisible(!this.isVisible()); + } + } /** - * The icon SVG group. - * @type {?SVGGElement} + * Change the colour of the associated bubble to match its block. */ - this.iconGroup_ = null; + applyColour() { + if (this.isVisible()) { + this.bubble_.setColour(this.block_.style.colourPrimary); + } + } /** - * Whether this icon gets hidden when the block is collapsed. - * @type {boolean} + * Notification that the icon has moved. Update the arrow accordingly. + * @param {!Coordinate} xy Absolute location in workspace coordinates. */ - this.collapseHidden = true; + setIconLocation(xy) { + this.iconXY_ = xy; + if (this.isVisible()) { + this.bubble_.setAnchorLocation(xy); + } + } /** - * Height and width of icons. - * @const + * Notification that the icon has moved, but we don't really know where. + * Recompute the icon's location from scratch. */ - this.SIZE = 17; + computeIconLocation() { + // Find coordinates for the centre of the icon and update the arrow. + const blockXY = this.block_.getRelativeToSurfaceXY(); + const iconXY = svgMath.getRelativeXY( + /** @type {!SVGElement} */ (this.iconGroup_)); + const newXY = new Coordinate( + blockXY.x + iconXY.x + this.SIZE / 2, + blockXY.y + iconXY.y + this.SIZE / 2); + if (!Coordinate.equals(this.getIconLocation(), newXY)) { + this.setIconLocation(newXY); + } + } /** - * Bubble UI (if visible). - * @type {?Bubble} + * Returns the center of the block's icon relative to the surface. + * @return {?Coordinate} Object with x and y properties in + * workspace coordinates. + */ + getIconLocation() { + return this.iconXY_; + } + + /** + * Get the size of the icon as used for rendering. + * This differs from the actual size of the icon, because it bulges slightly + * out of its row rather than increasing the height of its row. + * @return {!Size} Height and width. + */ + getCorrectedSize() { + // TODO (#2562): Remove getCorrectedSize. + return new Size(this.SIZE, this.SIZE - 2); + } + + /** + * Draw the icon. + * @param {!Element} _group The icon group. * @protected */ - this.bubble_ = null; + drawIcon_(_group) { + // No-op on base class. + } /** - * Absolute coordinate of icon's center. - * @type {?Coordinate} - * @protected + * Show or hide the icon. + * @param {boolean} _visible True if the icon should be visible. */ - this.iconXY_ = null; -}; - -/** - * Create the icon on the block. - */ -Icon.prototype.createIcon = function() { - if (this.iconGroup_) { - // Icon already exists. - return; + setVisible(_visible) { + // No-op on base class } - /* Here's the markup that will be generated: - - ... - - */ - this.iconGroup_ = - dom.createSvgElement(Svg.G, {'class': 'blocklyIconGroup'}, null); - if (this.block_.isInFlyout) { - dom.addClass( - /** @type {!Element} */ (this.iconGroup_), 'blocklyIconGroupReadonly'); - } - this.drawIcon_(this.iconGroup_); - - this.block_.getSvgRoot().appendChild(this.iconGroup_); - browserEvents.conditionalBind( - this.iconGroup_, 'mouseup', this, this.iconClick_); - this.updateEditable(); -}; - -/** - * Dispose of this icon. - */ -Icon.prototype.dispose = function() { - // Dispose of and unlink the icon. - dom.removeNode(this.iconGroup_); - this.iconGroup_ = null; - // Dispose of and unlink the bubble. - this.setVisible(false); - this.block_ = null; -}; - -/** - * Add or remove the UI indicating if this icon may be clicked or not. - */ -Icon.prototype.updateEditable = function() { - // No-op on the base class. -}; - -/** - * Is the associated bubble visible? - * @return {boolean} True if the bubble is visible. - */ -Icon.prototype.isVisible = function() { - return !!this.bubble_; -}; - -/** - * Clicking on the icon toggles if the bubble is visible. - * @param {!Event} e Mouse click event. - * @protected - */ -Icon.prototype.iconClick_ = function(e) { - if (this.block_.workspace.isDragging()) { - // Drag operation is concluding. Don't open the editor. - return; - } - if (!this.block_.isInFlyout && !browserEvents.isRightButton(e)) { - this.setVisible(!this.isVisible()); - } -}; - -/** - * Change the colour of the associated bubble to match its block. - */ -Icon.prototype.applyColour = function() { - if (this.isVisible()) { - this.bubble_.setColour(this.block_.style.colourPrimary); - } -}; - -/** - * Notification that the icon has moved. Update the arrow accordingly. - * @param {!Coordinate} xy Absolute location in workspace coordinates. - */ -Icon.prototype.setIconLocation = function(xy) { - this.iconXY_ = xy; - if (this.isVisible()) { - this.bubble_.setAnchorLocation(xy); - } -}; - -/** - * Notification that the icon has moved, but we don't really know where. - * Recompute the icon's location from scratch. - */ -Icon.prototype.computeIconLocation = function() { - // Find coordinates for the centre of the icon and update the arrow. - const blockXY = this.block_.getRelativeToSurfaceXY(); - const iconXY = svgMath.getRelativeXY( - /** @type {!SVGElement} */ (this.iconGroup_)); - const newXY = new Coordinate( - blockXY.x + iconXY.x + this.SIZE / 2, - blockXY.y + iconXY.y + this.SIZE / 2); - if (!Coordinate.equals(this.getIconLocation(), newXY)) { - this.setIconLocation(newXY); - } -}; - -/** - * Returns the center of the block's icon relative to the surface. - * @return {?Coordinate} Object with x and y properties in - * workspace coordinates. - */ -Icon.prototype.getIconLocation = function() { - return this.iconXY_; -}; - -/** - * Get the size of the icon as used for rendering. - * This differs from the actual size of the icon, because it bulges slightly - * out of its row rather than increasing the height of its row. - * @return {!Size} Height and width. - */ -// TODO (#2562): Remove getCorrectedSize. -Icon.prototype.getCorrectedSize = function() { - return new Size(this.SIZE, this.SIZE - 2); -}; - -/** - * Draw the icon. - * @param {!Element} group The icon group. - * @protected - */ -Icon.prototype.drawIcon_; - -/** - * Show or hide the icon. - * @param {boolean} visible True if the icon should be visible. - */ -Icon.prototype.setVisible; +} exports.Icon = Icon; diff --git a/core/marker_manager.js b/core/marker_manager.js index 035f6a4c4..79bc384ea 100644 --- a/core/marker_manager.js +++ b/core/marker_manager.js @@ -25,40 +25,183 @@ const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg'); /** * Class to manage the multiple markers and the cursor on a workspace. - * @param {!WorkspaceSvg} workspace The workspace for the marker manager. - * @constructor - * @alias Blockly.MarkerManager - * @package */ -const MarkerManager = function(workspace) { +class MarkerManager { /** - * The cursor. - * @type {?Cursor} - * @private + * @param {!WorkspaceSvg} workspace The workspace for the marker manager. + * @alias Blockly.MarkerManager + * @package */ - this.cursor_ = null; + constructor(workspace) { + /** + * The cursor. + * @type {?Cursor} + * @private + */ + this.cursor_ = null; + + /** + * The cursor's SVG element. + * @type {?SVGElement} + * @private + */ + this.cursorSvg_ = null; + + /** + * The map of markers for the workspace. + * @type {!Object} + * @private + */ + this.markers_ = Object.create(null); + + /** + * The workspace this marker manager is associated with. + * @type {!WorkspaceSvg} + * @private + */ + this.workspace_ = workspace; + + /** + * The marker's SVG element. + * @type {?SVGElement} + * @private + */ + this.markerSvg_ = null; + } /** - * The cursor's SVG element. - * @type {?SVGElement} - * @private + * Register the marker by adding it to the map of markers. + * @param {string} id A unique identifier for the marker. + * @param {!Marker} marker The marker to register. */ - this.cursorSvg_ = null; + registerMarker(id, marker) { + if (this.markers_[id]) { + this.unregisterMarker(id); + } + marker.setDrawer(this.workspace_.getRenderer().makeMarkerDrawer( + this.workspace_, marker)); + this.setMarkerSvg(marker.getDrawer().createDom()); + this.markers_[id] = marker; + } /** - * The map of markers for the workspace. - * @type {!Object} - * @private + * Unregister the marker by removing it from the map of markers. + * @param {string} id The ID of the marker to unregister. */ - this.markers_ = Object.create(null); + unregisterMarker(id) { + const marker = this.markers_[id]; + if (marker) { + marker.dispose(); + delete this.markers_[id]; + } else { + throw Error( + 'Marker with ID ' + id + ' does not exist. ' + + 'Can only unregister markers that exist.'); + } + } /** - * The workspace this marker manager is associated with. - * @type {!WorkspaceSvg} - * @private + * Get the cursor for the workspace. + * @return {?Cursor} The cursor for this workspace. */ - this.workspace_ = workspace; -}; + getCursor() { + return this.cursor_; + } + + /** + * Get a single marker that corresponds to the given ID. + * @param {string} id A unique identifier for the marker. + * @return {?Marker} The marker that corresponds to the given ID, + * or null if none exists. + */ + getMarker(id) { + return this.markers_[id] || null; + } + + /** + * Sets the cursor and initializes the drawer for use with keyboard + * navigation. + * @param {Cursor} cursor The cursor used to move around this workspace. + */ + setCursor(cursor) { + if (this.cursor_ && this.cursor_.getDrawer()) { + this.cursor_.getDrawer().dispose(); + } + this.cursor_ = cursor; + if (this.cursor_) { + const drawer = this.workspace_.getRenderer().makeMarkerDrawer( + this.workspace_, this.cursor_); + this.cursor_.setDrawer(drawer); + this.setCursorSvg(this.cursor_.getDrawer().createDom()); + } + } + + /** + * Add the cursor SVG to this workspace SVG group. + * @param {?SVGElement} cursorSvg The SVG root of the cursor to be added to + * the workspace SVG group. + * @package + */ + setCursorSvg(cursorSvg) { + if (!cursorSvg) { + this.cursorSvg_ = null; + return; + } + + this.workspace_.getBlockCanvas().appendChild(cursorSvg); + this.cursorSvg_ = cursorSvg; + } + + /** + * Add the marker SVG to this workspaces SVG group. + * @param {?SVGElement} markerSvg The SVG root of the marker to be added to + * the workspace SVG group. + * @package + */ + setMarkerSvg(markerSvg) { + if (!markerSvg) { + this.markerSvg_ = null; + return; + } + + if (this.workspace_.getBlockCanvas()) { + if (this.cursorSvg_) { + this.workspace_.getBlockCanvas().insertBefore( + markerSvg, this.cursorSvg_); + } else { + this.workspace_.getBlockCanvas().appendChild(markerSvg); + } + } + } + + /** + * Redraw the attached cursor SVG if needed. + * @package + */ + updateMarkers() { + if (this.workspace_.keyboardAccessibilityMode && this.cursorSvg_) { + this.workspace_.getCursor().draw(); + } + } + + /** + * Dispose of the marker manager. + * Go through and delete all markers associated with this marker manager. + * @suppress {checkTypes} + * @package + */ + dispose() { + const markerIds = Object.keys(this.markers_); + for (let i = 0, markerId; (markerId = markerIds[i]); i++) { + this.unregisterMarker(markerId); + } + this.markers_ = null; + if (this.cursor_) { + this.cursor_.dispose(); + this.cursor_ = null; + } + } +} /** * The name of the local marker. @@ -67,135 +210,4 @@ const MarkerManager = function(workspace) { */ MarkerManager.LOCAL_MARKER = 'local_marker_1'; -/** - * Register the marker by adding it to the map of markers. - * @param {string} id A unique identifier for the marker. - * @param {!Marker} marker The marker to register. - */ -MarkerManager.prototype.registerMarker = function(id, marker) { - if (this.markers_[id]) { - this.unregisterMarker(id); - } - marker.setDrawer( - this.workspace_.getRenderer().makeMarkerDrawer(this.workspace_, marker)); - this.setMarkerSvg(marker.getDrawer().createDom()); - this.markers_[id] = marker; -}; - -/** - * Unregister the marker by removing it from the map of markers. - * @param {string} id The ID of the marker to unregister. - */ -MarkerManager.prototype.unregisterMarker = function(id) { - const marker = this.markers_[id]; - if (marker) { - marker.dispose(); - delete this.markers_[id]; - } else { - throw Error( - 'Marker with ID ' + id + ' does not exist. ' + - 'Can only unregister markers that exist.'); - } -}; - -/** - * Get the cursor for the workspace. - * @return {?Cursor} The cursor for this workspace. - */ -MarkerManager.prototype.getCursor = function() { - return this.cursor_; -}; - -/** - * Get a single marker that corresponds to the given ID. - * @param {string} id A unique identifier for the marker. - * @return {?Marker} The marker that corresponds to the given ID, - * or null if none exists. - */ -MarkerManager.prototype.getMarker = function(id) { - return this.markers_[id] || null; -}; - -/** - * Sets the cursor and initializes the drawer for use with keyboard navigation. - * @param {Cursor} cursor The cursor used to move around this workspace. - */ -MarkerManager.prototype.setCursor = function(cursor) { - if (this.cursor_ && this.cursor_.getDrawer()) { - this.cursor_.getDrawer().dispose(); - } - this.cursor_ = cursor; - if (this.cursor_) { - const drawer = this.workspace_.getRenderer().makeMarkerDrawer( - this.workspace_, this.cursor_); - this.cursor_.setDrawer(drawer); - this.setCursorSvg(this.cursor_.getDrawer().createDom()); - } -}; - -/** - * Add the cursor SVG to this workspace SVG group. - * @param {?SVGElement} cursorSvg The SVG root of the cursor to be added to the - * workspace SVG group. - * @package - */ -MarkerManager.prototype.setCursorSvg = function(cursorSvg) { - if (!cursorSvg) { - this.cursorSvg_ = null; - return; - } - - this.workspace_.getBlockCanvas().appendChild(cursorSvg); - this.cursorSvg_ = cursorSvg; -}; - -/** - * Add the marker SVG to this workspaces SVG group. - * @param {?SVGElement} markerSvg The SVG root of the marker to be added to the - * workspace SVG group. - * @package - */ -MarkerManager.prototype.setMarkerSvg = function(markerSvg) { - if (!markerSvg) { - this.markerSvg_ = null; - return; - } - - if (this.workspace_.getBlockCanvas()) { - if (this.cursorSvg_) { - this.workspace_.getBlockCanvas().insertBefore(markerSvg, this.cursorSvg_); - } else { - this.workspace_.getBlockCanvas().appendChild(markerSvg); - } - } -}; - -/** - * Redraw the attached cursor SVG if needed. - * @package - */ -MarkerManager.prototype.updateMarkers = function() { - if (this.workspace_.keyboardAccessibilityMode && this.cursorSvg_) { - this.workspace_.getCursor().draw(); - } -}; - -/** - * Dispose of the marker manager. - * Go through and delete all markers associated with this marker manager. - * @suppress {checkTypes} - * @package - */ -MarkerManager.prototype.dispose = function() { - const markerIds = Object.keys(this.markers_); - for (let i = 0, markerId; (markerId = markerIds[i]); i++) { - this.unregisterMarker(markerId); - } - this.markers_ = null; - if (this.cursor_) { - this.cursor_.dispose(); - this.cursor_ = null; - } -}; - exports.MarkerManager = MarkerManager; diff --git a/core/mutator.js b/core/mutator.js index d1c2ba07d..97adf1b43 100644 --- a/core/mutator.js +++ b/core/mutator.js @@ -22,7 +22,6 @@ const Abstract = goog.requireType('Blockly.Events.Abstract'); 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 toolbox = goog.require('Blockly.utils.toolbox'); const xml = goog.require('Blockly.utils.xml'); const {BlockChange} = goog.require('Blockly.Events.BlockChange'); @@ -49,526 +48,529 @@ goog.require('Blockly.Events.BubbleOpen'); /** * Class for a mutator dialog. - * @param {!Array} quarkNames List of names of sub-blocks for flyout. * @extends {Icon} - * @constructor - * @alias Blockly.Mutator */ -const Mutator = function(quarkNames) { - Mutator.superClass_.constructor.call(this, null); - this.quarkNames_ = quarkNames; - +class Mutator extends Icon { /** - * Workspace in the mutator's bubble. - * @type {?WorkspaceSvg} - * @private + * @param {!Array} quarkNames List of names of sub-blocks for flyout. + * @alias Blockly.Mutator */ - this.workspace_ = null; + constructor(quarkNames) { + super(null); + this.quarkNames_ = quarkNames; - /** - * Width of workspace. - * @type {number} - * @private - */ - this.workspaceWidth_ = 0; + /** + * Workspace in the mutator's bubble. + * @type {?WorkspaceSvg} + * @private + */ + this.workspace_ = null; - /** - * Height of workspace. - * @type {number} - * @private - */ - this.workspaceHeight_ = 0; + /** + * Width of workspace. + * @type {number} + * @private + */ + this.workspaceWidth_ = 0; - /** - * The SVG element that is the parent of the mutator workspace, or null if - * not created. - * @type {?SVGSVGElement} - * @private - */ - this.svgDialog_ = null; + /** + * Height of workspace. + * @type {number} + * @private + */ + this.workspaceHeight_ = 0; - /** - * The root block of the mutator workspace, created by decomposing the source - * block. - * @type {?BlockSvg} - * @private - */ - this.rootBlock_ = null; + /** + * The SVG element that is the parent of the mutator workspace, or null if + * not created. + * @type {?SVGSVGElement} + * @private + */ + this.svgDialog_ = null; - /** - * Function registered on the main workspace to update the mutator contents - * when the main workspace changes. - * @type {?Function} - * @private - */ - this.sourceListener_ = null; -}; -object.inherits(Mutator, Icon); + /** + * The root block of the mutator workspace, created by decomposing the + * source block. + * @type {?BlockSvg} + * @private + */ + this.rootBlock_ = null; -/** - * Set the block this mutator is associated with. - * @param {!BlockSvg} block The block associated with this mutator. - * @package - */ -Mutator.prototype.setBlock = function(block) { - this.block_ = block; -}; - -/** - * Returns the workspace inside this mutator icon's bubble. - * @return {?WorkspaceSvg} The workspace inside this mutator icon's - * bubble or null if the mutator isn't open. - * @package - */ -Mutator.prototype.getWorkspace = function() { - return this.workspace_; -}; - -/** - * Draw the mutator icon. - * @param {!Element} group The icon group. - * @protected - */ -Mutator.prototype.drawIcon_ = function(group) { - // Square with rounded corners. - dom.createSvgElement( - Svg.RECT, { - 'class': 'blocklyIconShape', - 'rx': '4', - 'ry': '4', - 'height': '16', - 'width': '16', - }, - group); - // Gear teeth. - dom.createSvgElement( - Svg.PATH, { - 'class': 'blocklyIconSymbol', - 'd': 'm4.203,7.296 0,1.368 -0.92,0.677 -0.11,0.41 0.9,1.559 0.41,' + - '0.11 1.043,-0.457 1.187,0.683 0.127,1.134 0.3,0.3 1.8,0 0.3,' + - '-0.299 0.127,-1.138 1.185,-0.682 1.046,0.458 0.409,-0.11 0.9,' + - '-1.559 -0.11,-0.41 -0.92,-0.677 0,-1.366 0.92,-0.677 0.11,' + - '-0.41 -0.9,-1.559 -0.409,-0.109 -1.046,0.458 -1.185,-0.682 ' + - '-0.127,-1.138 -0.3,-0.299 -1.8,0 -0.3,0.3 -0.126,1.135 -1.187,' + - '0.682 -1.043,-0.457 -0.41,0.11 -0.899,1.559 0.108,0.409z', - }, - group); - // Axle hole. - dom.createSvgElement( - Svg.CIRCLE, - {'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'}, group); -}; - -/** - * Clicking on the icon toggles if the mutator bubble is visible. - * Disable if block is uneditable. - * @param {!Event} e Mouse click event. - * @protected - * @override - */ -Mutator.prototype.iconClick_ = function(e) { - if (this.block_.isEditable()) { - Icon.prototype.iconClick_.call(this, e); + /** + * Function registered on the main workspace to update the mutator contents + * when the main workspace changes. + * @type {?Function} + * @private + */ + this.sourceListener_ = null; } -}; -/** - * Create the editor for the mutator's bubble. - * @return {!SVGElement} The top-level node of the editor. - * @private - */ -Mutator.prototype.createEditor_ = function() { - /* Create the editor. Here's the markup that will be generated: - - [Workspace] - - */ - this.svgDialog_ = dom.createSvgElement( - Svg.SVG, {'x': Bubble.BORDER_WIDTH, 'y': Bubble.BORDER_WIDTH}, null); - // Convert the list of names into a list of XML objects for the flyout. - let quarkXml; - if (this.quarkNames_.length) { - quarkXml = xml.createElement('xml'); - for (let i = 0, quarkName; (quarkName = this.quarkNames_[i]); i++) { - const element = xml.createElement('block'); - element.setAttribute('type', quarkName); - quarkXml.appendChild(element); - } - } else { - quarkXml = null; + /** + * Set the block this mutator is associated with. + * @param {!BlockSvg} block The block associated with this mutator. + * @package + */ + setBlock(block) { + this.block_ = block; } - const workspaceOptions = new Options( - /** @type {!BlocklyOptions} */ - ({ - // If you want to enable disabling, also remove the - // event filter from workspaceChanged_ . - 'disable': false, - 'parentWorkspace': this.block_.workspace, - 'media': this.block_.workspace.options.pathToMedia, - 'rtl': this.block_.RTL, - 'horizontalLayout': false, - 'renderer': this.block_.workspace.options.renderer, - 'rendererOverrides': this.block_.workspace.options.rendererOverrides, - })); - workspaceOptions.toolboxPosition = - this.block_.RTL ? toolbox.Position.RIGHT : toolbox.Position.LEFT; - const hasFlyout = !!quarkXml; - if (hasFlyout) { - workspaceOptions.languageTree = toolbox.convertToolboxDefToJson(quarkXml); + + /** + * Returns the workspace inside this mutator icon's bubble. + * @return {?WorkspaceSvg} The workspace inside this mutator icon's + * bubble or null if the mutator isn't open. + * @package + */ + getWorkspace() { + return this.workspace_; } - this.workspace_ = new WorkspaceSvg(workspaceOptions); - this.workspace_.isMutator = true; - this.workspace_.addChangeListener(eventUtils.disableOrphans); - // Mutator flyouts go inside the mutator workspace's rather than in - // a top level SVG. Instead of handling scale themselves, mutators - // inherit scale from the parent workspace. - // To fix this, scale needs to be applied at a different level in the DOM. - const flyoutSvg = hasFlyout ? this.workspace_.addFlyout(Svg.G) : null; - const background = this.workspace_.createDom('blocklyMutatorBackground'); - - if (flyoutSvg) { - // Insert the flyout after the but before the block canvas so that - // the flyout is underneath in z-order. This makes blocks layering during - // dragging work properly. - background.insertBefore(flyoutSvg, this.workspace_.svgBlockCanvas_); + /** + * Draw the mutator icon. + * @param {!Element} group The icon group. + * @protected + */ + drawIcon_(group) { + // Square with rounded corners. + dom.createSvgElement( + Svg.RECT, { + 'class': 'blocklyIconShape', + 'rx': '4', + 'ry': '4', + 'height': '16', + 'width': '16', + }, + group); + // Gear teeth. + dom.createSvgElement( + Svg.PATH, { + 'class': 'blocklyIconSymbol', + 'd': 'm4.203,7.296 0,1.368 -0.92,0.677 -0.11,0.41 0.9,1.559 0.41,' + + '0.11 1.043,-0.457 1.187,0.683 0.127,1.134 0.3,0.3 1.8,0 0.3,' + + '-0.299 0.127,-1.138 1.185,-0.682 1.046,0.458 0.409,-0.11 0.9,' + + '-1.559 -0.11,-0.41 -0.92,-0.677 0,-1.366 0.92,-0.677 0.11,' + + '-0.41 -0.9,-1.559 -0.409,-0.109 -1.046,0.458 -1.185,-0.682 ' + + '-0.127,-1.138 -0.3,-0.299 -1.8,0 -0.3,0.3 -0.126,1.135 -1.187,' + + '0.682 -1.043,-0.457 -0.41,0.11 -0.899,1.559 0.108,0.409z', + }, + group); + // Axle hole. + dom.createSvgElement( + Svg.CIRCLE, + {'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'}, group); } - this.svgDialog_.appendChild(background); - return this.svgDialog_; -}; - -/** - * Add or remove the UI indicating if this icon may be clicked or not. - */ -Mutator.prototype.updateEditable = function() { - Mutator.superClass_.updateEditable.call(this); - if (!this.block_.isInFlyout) { + /** + * Clicking on the icon toggles if the mutator bubble is visible. + * Disable if block is uneditable. + * @param {!Event} e Mouse click event. + * @protected + * @override + */ + iconClick_(e) { if (this.block_.isEditable()) { - if (this.iconGroup_) { - dom.removeClass( - /** @type {!Element} */ (this.iconGroup_), - 'blocklyIconGroupReadonly'); + Icon.prototype.iconClick_.call(this, e); + } + } + + /** + * Create the editor for the mutator's bubble. + * @return {!SVGElement} The top-level node of the editor. + * @private + */ + createEditor_() { + /* Create the editor. Here's the markup that will be generated: + + [Workspace] + + */ + this.svgDialog_ = dom.createSvgElement( + Svg.SVG, {'x': Bubble.BORDER_WIDTH, 'y': Bubble.BORDER_WIDTH}, null); + // Convert the list of names into a list of XML objects for the flyout. + let quarkXml; + if (this.quarkNames_.length) { + quarkXml = xml.createElement('xml'); + for (let i = 0, quarkName; (quarkName = this.quarkNames_[i]); i++) { + const element = xml.createElement('block'); + element.setAttribute('type', quarkName); + quarkXml.appendChild(element); } } else { - // Close any mutator bubble. Icon is not clickable. - this.setVisible(false); - if (this.iconGroup_) { - dom.addClass( - /** @type {!Element} */ (this.iconGroup_), - 'blocklyIconGroupReadonly'); + quarkXml = null; + } + const workspaceOptions = new Options( + /** @type {!BlocklyOptions} */ + ({ + // If you want to enable disabling, also remove the + // event filter from workspaceChanged_ . + 'disable': false, + 'parentWorkspace': this.block_.workspace, + 'media': this.block_.workspace.options.pathToMedia, + 'rtl': this.block_.RTL, + 'horizontalLayout': false, + 'renderer': this.block_.workspace.options.renderer, + 'rendererOverrides': this.block_.workspace.options.rendererOverrides, + })); + workspaceOptions.toolboxPosition = + this.block_.RTL ? toolbox.Position.RIGHT : toolbox.Position.LEFT; + const hasFlyout = !!quarkXml; + if (hasFlyout) { + workspaceOptions.languageTree = toolbox.convertToolboxDefToJson(quarkXml); + } + this.workspace_ = new WorkspaceSvg(workspaceOptions); + this.workspace_.isMutator = true; + this.workspace_.addChangeListener(eventUtils.disableOrphans); + + // Mutator flyouts go inside the mutator workspace's rather than in + // a top level SVG. Instead of handling scale themselves, mutators + // inherit scale from the parent workspace. + // To fix this, scale needs to be applied at a different level in the DOM. + const flyoutSvg = hasFlyout ? this.workspace_.addFlyout(Svg.G) : null; + const background = this.workspace_.createDom('blocklyMutatorBackground'); + + if (flyoutSvg) { + // Insert the flyout after the but before the block canvas so that + // the flyout is underneath in z-order. This makes blocks layering during + // dragging work properly. + background.insertBefore(flyoutSvg, this.workspace_.svgBlockCanvas_); + } + this.svgDialog_.appendChild(background); + + return this.svgDialog_; + } + + /** + * Add or remove the UI indicating if this icon may be clicked or not. + */ + updateEditable() { + super.updateEditable(); + if (!this.block_.isInFlyout) { + if (this.block_.isEditable()) { + if (this.iconGroup_) { + dom.removeClass( + /** @type {!Element} */ (this.iconGroup_), + 'blocklyIconGroupReadonly'); + } + } else { + // Close any mutator bubble. Icon is not clickable. + this.setVisible(false); + if (this.iconGroup_) { + dom.addClass( + /** @type {!Element} */ (this.iconGroup_), + 'blocklyIconGroupReadonly'); + } } } } -}; -/** - * Resize the bubble to match the size of the workspace. - * @private - */ -Mutator.prototype.resizeBubble_ = function() { - const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH; - const workspaceSize = this.workspace_.getCanvas().getBBox(); - let width = workspaceSize.width + workspaceSize.x; - let height = workspaceSize.height + doubleBorderWidth * 3; - const flyout = this.workspace_.getFlyout(); - if (flyout) { - const flyoutScrollMetrics = - flyout.getWorkspace().getMetricsManager().getScrollMetrics(); - height = Math.max(height, flyoutScrollMetrics.height + 20); - width += flyout.getWidth(); - } - if (this.block_.RTL) { - width = -workspaceSize.x; - } - width += doubleBorderWidth * 3; - // Only resize if the size difference is significant. Eliminates shuddering. - if (Math.abs(this.workspaceWidth_ - width) > doubleBorderWidth || - Math.abs(this.workspaceHeight_ - height) > doubleBorderWidth) { - // Record some layout information for workspace metrics. - this.workspaceWidth_ = width; - this.workspaceHeight_ = height; - // Resize the bubble. - this.bubble_.setBubbleSize( - width + doubleBorderWidth, height + doubleBorderWidth); - this.svgDialog_.setAttribute('width', this.workspaceWidth_); - this.svgDialog_.setAttribute('height', this.workspaceHeight_); - this.workspace_.setCachedParentSvgSize( - this.workspaceWidth_, this.workspaceHeight_); - } - - if (this.block_.RTL) { - // Scroll the workspace to always left-align. - const translation = 'translate(' + this.workspaceWidth_ + ',0)'; - this.workspace_.getCanvas().setAttribute('transform', translation); - } - this.workspace_.resize(); -}; - -/** - * A method handler for when the bubble is moved. - * @private - */ -Mutator.prototype.onBubbleMove_ = function() { - if (this.workspace_) { - this.workspace_.recordDragTargets(); - } -}; - -/** - * Show or hide the mutator bubble. - * @param {boolean} visible True if the bubble should be visible. - */ -Mutator.prototype.setVisible = function(visible) { - if (visible === this.isVisible()) { - // No change. - return; - } - eventUtils.fire(new (eventUtils.get(eventUtils.BUBBLE_OPEN))( - this.block_, visible, 'mutator')); - if (visible) { - // Create the bubble. - this.bubble_ = new Bubble( - /** @type {!WorkspaceSvg} */ (this.block_.workspace), - this.createEditor_(), this.block_.pathObject.svgPath, - /** @type {!Coordinate} */ (this.iconXY_), null, null); - // Expose this mutator's block's ID on its top-level SVG group. - this.bubble_.setSvgId(this.block_.id); - this.bubble_.registerMoveEvent(this.onBubbleMove_.bind(this)); - const tree = this.workspace_.options.languageTree; + /** + * Resize the bubble to match the size of the workspace. + * @private + */ + resizeBubble_() { + const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH; + const workspaceSize = this.workspace_.getCanvas().getBBox(); + let width = workspaceSize.width + workspaceSize.x; + let height = workspaceSize.height + doubleBorderWidth * 3; const flyout = this.workspace_.getFlyout(); - if (tree) { - flyout.init(this.workspace_); - flyout.show(tree); - } - - this.rootBlock_ = this.block_.decompose(this.workspace_); - const blocks = this.rootBlock_.getDescendants(false); - for (let i = 0, child; (child = blocks[i]); i++) { - child.render(); - } - // The root block should not be draggable or deletable. - this.rootBlock_.setMovable(false); - this.rootBlock_.setDeletable(false); - let margin; - let x; if (flyout) { - margin = flyout.CORNER_RADIUS * 2; - x = this.rootBlock_.RTL ? flyout.getWidth() + margin : margin; - } else { - margin = 16; - x = margin; + const flyoutScrollMetrics = + flyout.getWorkspace().getMetricsManager().getScrollMetrics(); + height = Math.max(height, flyoutScrollMetrics.height + 20); + width += flyout.getWidth(); } if (this.block_.RTL) { - x = -x; + width = -workspaceSize.x; } - this.rootBlock_.moveBy(x, margin); - // 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.sourceListener_ = function() { - mutatorBlock.saveConnections(thisRootBlock); - }; - this.block_.workspace.addChangeListener(this.sourceListener_); + width += doubleBorderWidth * 3; + // Only resize if the size difference is significant. Eliminates + // shuddering. + if (Math.abs(this.workspaceWidth_ - width) > doubleBorderWidth || + Math.abs(this.workspaceHeight_ - height) > doubleBorderWidth) { + // Record some layout information for workspace metrics. + this.workspaceWidth_ = width; + this.workspaceHeight_ = height; + // Resize the bubble. + this.bubble_.setBubbleSize( + width + doubleBorderWidth, height + doubleBorderWidth); + this.svgDialog_.setAttribute('width', this.workspaceWidth_); + this.svgDialog_.setAttribute('height', this.workspaceHeight_); + this.workspace_.setCachedParentSvgSize( + this.workspaceWidth_, this.workspaceHeight_); } - this.resizeBubble_(); - // When the mutator's workspace changes, update the source block. - this.workspace_.addChangeListener(this.workspaceChanged_.bind(this)); - // Update the source block immediately after the bubble becomes visible. - this.updateWorkspace_(); - this.applyColour(); - } else { - // Dispose of the bubble. - this.svgDialog_ = null; - this.workspace_.dispose(); - this.workspace_ = null; - this.rootBlock_ = null; - this.bubble_.dispose(); - this.bubble_ = null; - this.workspaceWidth_ = 0; - this.workspaceHeight_ = 0; - if (this.sourceListener_) { - this.block_.workspace.removeChangeListener(this.sourceListener_); - this.sourceListener_ = null; + + if (this.block_.RTL) { + // Scroll the workspace to always left-align. + const translation = 'translate(' + this.workspaceWidth_ + ',0)'; + this.workspace_.getCanvas().setAttribute('transform', translation); + } + this.workspace_.resize(); + } + + /** + * A method handler for when the bubble is moved. + * @private + */ + onBubbleMove_() { + if (this.workspace_) { + this.workspace_.recordDragTargets(); } } -}; -/** - * Fired whenever a change is made to the mutator's workspace. - * @param {!Abstract} e Custom data for event. - * @private - */ -Mutator.prototype.workspaceChanged_ = function(e) { - if (!(e.isUiEvent || - (e.type === eventUtils.CHANGE && e.element === 'disabled') || - e.type === eventUtils.CREATE)) { - this.updateWorkspace_(); - } -}; - -/** - * Updates the source block when the mutator's blocks are changed. - * Bump down any block that's too high. - * @private - */ -Mutator.prototype.updateWorkspace_ = function() { - if (!this.workspace_.isDragging()) { - const blocks = this.workspace_.getTopBlocks(false); - const MARGIN = 20; - - for (let b = 0, block; (block = blocks[b]); b++) { - const blockXY = block.getRelativeToSurfaceXY(); - - // Bump any block that's above the top back inside. - if (blockXY.y < MARGIN) { - block.moveBy(0, MARGIN - blockXY.y); + /** + * Show or hide the mutator bubble. + * @param {boolean} visible True if the bubble should be visible. + */ + setVisible(visible) { + if (visible === this.isVisible()) { + // No change. + return; + } + eventUtils.fire(new (eventUtils.get(eventUtils.BUBBLE_OPEN))( + this.block_, visible, 'mutator')); + if (visible) { + // Create the bubble. + this.bubble_ = new Bubble( + /** @type {!WorkspaceSvg} */ (this.block_.workspace), + this.createEditor_(), this.block_.pathObject.svgPath, + /** @type {!Coordinate} */ (this.iconXY_), null, null); + // Expose this mutator's block's ID on its top-level SVG group. + this.bubble_.setSvgId(this.block_.id); + this.bubble_.registerMoveEvent(this.onBubbleMove_.bind(this)); + const tree = this.workspace_.options.languageTree; + const flyout = this.workspace_.getFlyout(); + if (tree) { + flyout.init(this.workspace_); + flyout.show(tree); } - // Bump any block overlapping the flyout back inside. - if (block.RTL) { - let right = -MARGIN; - const flyout = this.workspace_.getFlyout(); - if (flyout) { - right -= flyout.getWidth(); - } - if (blockXY.x > right) { - block.moveBy(right - blockXY.x, 0); - } - } else if (blockXY.x < MARGIN) { - block.moveBy(MARGIN - blockXY.x, 0); + + this.rootBlock_ = this.block_.decompose(this.workspace_); + const blocks = this.rootBlock_.getDescendants(false); + for (let i = 0, child; (child = blocks[i]); i++) { + child.render(); + } + // The root block should not be draggable or deletable. + this.rootBlock_.setMovable(false); + this.rootBlock_.setDeletable(false); + let margin; + let x; + if (flyout) { + margin = flyout.CORNER_RADIUS * 2; + x = this.rootBlock_.RTL ? flyout.getWidth() + margin : margin; + } else { + margin = 16; + x = margin; + } + if (this.block_.RTL) { + x = -x; + } + this.rootBlock_.moveBy(x, margin); + // 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.sourceListener_ = function() { + mutatorBlock.saveConnections(thisRootBlock); + }; + this.block_.workspace.addChangeListener(this.sourceListener_); } - } - } - - // When the mutator's workspace changes, update the source block. - if (this.rootBlock_.workspace === this.workspace_) { - const existingGroup = eventUtils.getGroup(); - if (!existingGroup) { - eventUtils.setGroup(true); - } - const block = /** @type {!BlockSvg} */ (this.block_); - const oldExtraState = BlockChange.getExtraBlockState_(block); - - // Switch off rendering while the source block is rebuilt. - const savedRendered = block.rendered; - // TODO(#4288): We should not be setting the rendered property to false. - block.rendered = false; - - // Allow the source block to rebuild itself. - block.compose(this.rootBlock_); - // Restore rendering and show the changes. - block.rendered = savedRendered; - // Mutation may have added some elements that need initializing. - block.initSvg(); - - if (block.rendered) { - block.render(); - } - - const newExtraState = BlockChange.getExtraBlockState_(block); - if (oldExtraState !== newExtraState) { - eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( - block, 'mutation', null, oldExtraState, newExtraState)); - // Ensure that any bump is part of this mutation's event group. - const mutationGroup = eventUtils.getGroup(); - setTimeout(function() { - const oldGroup = eventUtils.getGroup(); - eventUtils.setGroup(mutationGroup); - block.bumpNeighbours(); - eventUtils.setGroup(oldGroup); - }, internalConstants.BUMP_DELAY); - } - - // Don't update the bubble until the drag has ended, to avoid moving blocks - // under the cursor. - if (!this.workspace_.isDragging()) { this.resizeBubble_(); + // When the mutator's workspace changes, update the source block. + this.workspace_.addChangeListener(this.workspaceChanged_.bind(this)); + // Update the source block immediately after the bubble becomes visible. + this.updateWorkspace_(); + this.applyColour(); + } else { + // Dispose of the bubble. + this.svgDialog_ = null; + this.workspace_.dispose(); + this.workspace_ = null; + this.rootBlock_ = null; + this.bubble_.dispose(); + this.bubble_ = null; + this.workspaceWidth_ = 0; + this.workspaceHeight_ = 0; + if (this.sourceListener_) { + this.block_.workspace.removeChangeListener(this.sourceListener_); + this.sourceListener_ = null; + } } - eventUtils.setGroup(existingGroup); } -}; -/** - * Dispose of this mutator. - */ -Mutator.prototype.dispose = function() { - this.block_.mutator = null; - Icon.prototype.dispose.call(this); -}; + /** + * Fired whenever a change is made to the mutator's workspace. + * @param {!Abstract} e Custom data for event. + * @private + */ + workspaceChanged_(e) { + if (!(e.isUiEvent || + (e.type === eventUtils.CHANGE && e.element === 'disabled') || + e.type === eventUtils.CREATE)) { + this.updateWorkspace_(); + } + } -/** - * Update the styles on all blocks in the mutator. - * @public - */ -Mutator.prototype.updateBlockStyle = function() { - const ws = this.workspace_; + /** + * Updates the source block when the mutator's blocks are changed. + * Bump down any block that's too high. + * @private + */ + updateWorkspace_() { + if (!this.workspace_.isDragging()) { + const blocks = this.workspace_.getTopBlocks(false); + const MARGIN = 20; - if (ws && ws.getAllBlocks(false)) { - const workspaceBlocks = ws.getAllBlocks(false); - for (let i = 0, block; (block = workspaceBlocks[i]); i++) { - block.setStyle(block.getStyleName()); + for (let b = 0, block; (block = blocks[b]); b++) { + const blockXY = block.getRelativeToSurfaceXY(); + + // Bump any block that's above the top back inside. + if (blockXY.y < MARGIN) { + block.moveBy(0, MARGIN - blockXY.y); + } + // Bump any block overlapping the flyout back inside. + if (block.RTL) { + let right = -MARGIN; + const flyout = this.workspace_.getFlyout(); + if (flyout) { + right -= flyout.getWidth(); + } + if (blockXY.x > right) { + block.moveBy(right - blockXY.x, 0); + } + } else if (blockXY.x < MARGIN) { + block.moveBy(MARGIN - blockXY.x, 0); + } + } } - const flyout = ws.getFlyout(); - if (flyout) { - const flyoutBlocks = flyout.workspace_.getAllBlocks(false); - for (let i = 0, block; (block = flyoutBlocks[i]); i++) { + // When the mutator's workspace changes, update the source block. + if (this.rootBlock_.workspace === this.workspace_) { + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); + } + const block = /** @type {!BlockSvg} */ (this.block_); + const oldExtraState = BlockChange.getExtraBlockState_(block); + + // Switch off rendering while the source block is rebuilt. + const savedRendered = block.rendered; + // TODO(#4288): We should not be setting the rendered property to false. + block.rendered = false; + + // Allow the source block to rebuild itself. + block.compose(this.rootBlock_); + // Restore rendering and show the changes. + block.rendered = savedRendered; + // Mutation may have added some elements that need initializing. + block.initSvg(); + + if (block.rendered) { + block.render(); + } + + const newExtraState = BlockChange.getExtraBlockState_(block); + if (oldExtraState !== newExtraState) { + eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( + block, 'mutation', null, oldExtraState, newExtraState)); + // Ensure that any bump is part of this mutation's event group. + const mutationGroup = eventUtils.getGroup(); + setTimeout(function() { + const oldGroup = eventUtils.getGroup(); + eventUtils.setGroup(mutationGroup); + block.bumpNeighbours(); + eventUtils.setGroup(oldGroup); + }, internalConstants.BUMP_DELAY); + } + + // Don't update the bubble until the drag has ended, to avoid moving + // blocks under the cursor. + if (!this.workspace_.isDragging()) { + this.resizeBubble_(); + } + eventUtils.setGroup(existingGroup); + } + } + + /** + * Dispose of this mutator. + */ + dispose() { + this.block_.mutator = null; + Icon.prototype.dispose.call(this); + } + + /** + * Update the styles on all blocks in the mutator. + * @public + */ + updateBlockStyle() { + const ws = this.workspace_; + + if (ws && ws.getAllBlocks(false)) { + const workspaceBlocks = ws.getAllBlocks(false); + for (let i = 0, block; (block = workspaceBlocks[i]); i++) { block.setStyle(block.getStyleName()); } - } - } -}; -/** - * Reconnect an block to a mutated input. - * @param {Connection} connectionChild Connection on child block. - * @param {!Block} block Parent block. - * @param {string} inputName Name of input on parent block. - * @return {boolean} True iff a reconnection was made, false otherwise. - */ -Mutator.reconnect = function(connectionChild, block, inputName) { - if (!connectionChild || !connectionChild.getSourceBlock().workspace) { - return false; // No connection or block has been deleted. - } - const connectionParent = block.getInput(inputName).connection; - const currentParent = connectionChild.targetBlock(); - if ((!currentParent || currentParent === block) && - connectionParent.targetConnection !== connectionChild) { - if (connectionParent.isConnected()) { - // There's already something connected here. Get rid of it. - connectionParent.disconnect(); - } - connectionParent.connect(connectionChild); - return true; - } - return false; -}; - -/** - * Get the parent workspace of a workspace that is inside a mutator, taking into - * account whether it is a flyout. - * @param {Workspace} workspace The workspace that is inside a mutator. - * @return {?Workspace} The mutator's parent workspace or null. - * @public - */ -Mutator.findParentWs = function(workspace) { - let outerWs = null; - if (workspace && workspace.options) { - const parent = workspace.options.parentWorkspace; - // If we were in a flyout in a mutator, need to go up two levels to find - // the actual parent. - if (workspace.isFlyout) { - if (parent && parent.options) { - outerWs = parent.options.parentWorkspace; + const flyout = ws.getFlyout(); + if (flyout) { + const flyoutBlocks = flyout.workspace_.getAllBlocks(false); + for (let i = 0, block; (block = flyoutBlocks[i]); i++) { + block.setStyle(block.getStyleName()); + } } - } else if (parent) { - outerWs = parent; } } - return outerWs; -}; + + /** + * Reconnect an block to a mutated input. + * @param {Connection} connectionChild Connection on child block. + * @param {!Block} block Parent block. + * @param {string} inputName Name of input on parent block. + * @return {boolean} True iff a reconnection was made, false otherwise. + */ + static reconnect(connectionChild, block, inputName) { + if (!connectionChild || !connectionChild.getSourceBlock().workspace) { + return false; // No connection or block has been deleted. + } + const connectionParent = block.getInput(inputName).connection; + const currentParent = connectionChild.targetBlock(); + if ((!currentParent || currentParent === block) && + connectionParent.targetConnection !== connectionChild) { + if (connectionParent.isConnected()) { + // There's already something connected here. Get rid of it. + connectionParent.disconnect(); + } + connectionParent.connect(connectionChild); + return true; + } + return false; + } + + /** + * Get the parent workspace of a workspace that is inside a mutator, taking + * into account whether it is a flyout. + * @param {Workspace} workspace The workspace that is inside a mutator. + * @return {?Workspace} The mutator's parent workspace or null. + * @public + */ + static findParentWs(workspace) { + let outerWs = null; + if (workspace && workspace.options) { + const parent = workspace.options.parentWorkspace; + // If we were in a flyout in a mutator, need to go up two levels to find + // the actual parent. + if (workspace.isFlyout) { + if (parent && parent.options) { + outerWs = parent.options.parentWorkspace; + } + } else if (parent) { + outerWs = parent; + } + } + return outerWs; + } +} exports.Mutator = Mutator; diff --git a/core/rendered_connection.js b/core/rendered_connection.js index 08d6529ec..a85ac3c95 100644 --- a/core/rendered_connection.js +++ b/core/rendered_connection.js @@ -19,7 +19,6 @@ const common = goog.require('Blockly.common'); 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 svgPaths = goog.require('Blockly.utils.svgPaths'); const svgMath = goog.require('Blockly.utils.svgMath'); /* eslint-disable-next-line no-unused-vars */ @@ -35,53 +34,527 @@ const {Svg} = goog.require('Blockly.utils.Svg'); /** - * Class for a connection between blocks that may be rendered on screen. - * @param {!BlockSvg} source The block establishing this connection. - * @param {number} type The type of the connection. - * @extends {Connection} - * @constructor - * @alias Blockly.RenderedConnection + * Maximum randomness in workspace units for bumping a block. + * @const */ -const RenderedConnection = function(source, type) { - RenderedConnection.superClass_.constructor.call(this, source, type); +const BUMP_RANDOMNESS = 10; + +/** + * Class for a connection between blocks that may be rendered on screen. + * @extends {Connection} + */ +class RenderedConnection extends Connection { + /** + * @param {!BlockSvg} source The block establishing this connection. + * @param {number} type The type of the connection. + * @alias Blockly.RenderedConnection + */ + constructor(source, type) { + super(source, type); + + /** + * Connection database for connections of this type on the current + * workspace. + * @const {!ConnectionDB} + * @private + */ + this.db_ = source.workspace.connectionDBList[type]; + + /** + * Connection database for connections compatible with this type on the + * current workspace. + * @const {!ConnectionDB} + * @private + */ + this.dbOpposite_ = + source.workspace + .connectionDBList[internalConstants.OPPOSITE_TYPE[type]]; + + /** + * Workspace units, (0, 0) is top left of block. + * @type {!Coordinate} + * @private + */ + this.offsetInBlock_ = new Coordinate(0, 0); + + /** + * Describes the state of this connection's tracked-ness. + * @type {RenderedConnection.TrackedState} + * @private + */ + this.trackedState_ = RenderedConnection.TrackedState.WILL_TRACK; + + /** + * Connection this connection connects to. Null if not connected. + * @type {RenderedConnection} + */ + this.targetConnection = null; + } /** - * Connection database for connections of this type on the current workspace. - * @const {!ConnectionDB} - * @private + * Dispose of this connection. Remove it from the database (if it is + * tracked) and call the super-function to deal with connected blocks. + * @override + * @package */ - this.db_ = source.workspace.connectionDBList[type]; + dispose() { + super.dispose(); + if (this.trackedState_ === RenderedConnection.TrackedState.TRACKED) { + this.db_.removeConnection(this, this.y); + } + } /** - * Connection database for connections compatible with this type on the - * current workspace. - * @const {!ConnectionDB} - * @private + * Get the source block for this connection. + * @return {!BlockSvg} The source block. + * @override */ - this.dbOpposite_ = - source.workspace.connectionDBList[internalConstants.OPPOSITE_TYPE[type]]; + getSourceBlock() { + return /** @type {!BlockSvg} */ (super.getSourceBlock()); + } /** - * Workspace units, (0, 0) is top left of block. - * @type {!Coordinate} - * @private + * Returns the block that this connection connects to. + * @return {?BlockSvg} The connected block or null if none is connected. + * @override */ - this.offsetInBlock_ = new Coordinate(0, 0); + targetBlock() { + return /** @type {BlockSvg} */ (super.targetBlock()); + } /** - * Describes the state of this connection's tracked-ness. - * @type {RenderedConnection.TrackedState} - * @private + * Returns the distance between this connection and another connection in + * workspace units. + * @param {!Connection} otherConnection The other connection to measure + * the distance to. + * @return {number} The distance between connections, in workspace units. */ - this.trackedState_ = RenderedConnection.TrackedState.WILL_TRACK; + distanceFrom(otherConnection) { + const xDiff = this.x - otherConnection.x; + const yDiff = this.y - otherConnection.y; + return Math.sqrt(xDiff * xDiff + yDiff * yDiff); + } /** - * Connection this connection connects to. Null if not connected. - * @type {RenderedConnection} + * 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 + * from. + * @package */ - this.targetConnection = null; -}; -object.inherits(RenderedConnection, Connection); + bumpAwayFrom(staticConnection) { + if (this.sourceBlock_.workspace.isDragging()) { + // Don't move blocks around while the user is doing the same. + return; + } + // Move the root block. + let rootBlock = this.sourceBlock_.getRootBlock(); + if (rootBlock.isInFlyout) { + // Don't move blocks around in a flyout. + return; + } + let reverse = false; + if (!rootBlock.isMovable()) { + // Can't bump an uneditable block away. + // Check to see if the other block is movable. + rootBlock = staticConnection.getSourceBlock().getRootBlock(); + if (!rootBlock.isMovable()) { + return; + } + // Swap the connections and move the 'static' connection instead. + staticConnection = this; + reverse = true; + } + // Raise it to the top for extra visibility. + const selected = common.getSelected() == rootBlock; + selected || rootBlock.addSelect(); + let dx = (staticConnection.x + internalConstants.SNAP_RADIUS + + Math.floor(Math.random() * BUMP_RANDOMNESS)) - + this.x; + let dy = (staticConnection.y + internalConstants.SNAP_RADIUS + + Math.floor(Math.random() * BUMP_RANDOMNESS)) - + this.y; + if (reverse) { + // When reversing a bump due to an uneditable block, bump up. + dy = -dy; + } + if (rootBlock.RTL) { + dx = (staticConnection.x - internalConstants.SNAP_RADIUS - + Math.floor(Math.random() * BUMP_RANDOMNESS)) - + this.x; + } + rootBlock.moveBy(dx, dy); + selected || rootBlock.removeSelect(); + } + + /** + * Change the connection's coordinates. + * @param {number} x New absolute x coordinate, in workspace coordinates. + * @param {number} y New absolute y coordinate, in workspace coordinates. + */ + moveTo(x, y) { + if (this.trackedState_ === RenderedConnection.TrackedState.WILL_TRACK) { + this.db_.addConnection(this, y); + this.trackedState_ = RenderedConnection.TrackedState.TRACKED; + } else if (this.trackedState_ === RenderedConnection.TrackedState.TRACKED) { + this.db_.removeConnection(this, this.y); + this.db_.addConnection(this, y); + } + this.x = x; + this.y = y; + } + + /** + * Change the connection's coordinates. + * @param {number} dx Change to x coordinate, in workspace units. + * @param {number} dy Change to y coordinate, in workspace units. + */ + moveBy(dx, dy) { + this.moveTo(this.x + dx, this.y + dy); + } + + /** + * Move this connection to the location given by its offset within the block + * and the location of the block's top left corner. + * @param {!Coordinate} blockTL The location of the top left + * corner of the block, in workspace coordinates. + */ + moveToOffset(blockTL) { + this.moveTo( + blockTL.x + this.offsetInBlock_.x, blockTL.y + this.offsetInBlock_.y); + } + + /** + * Set the offset of this connection relative to the top left of its block. + * @param {number} x The new relative x, in workspace units. + * @param {number} y The new relative y, in workspace units. + */ + setOffsetInBlock(x, y) { + this.offsetInBlock_.x = x; + this.offsetInBlock_.y = y; + } + + /** + * Get the offset of this connection relative to the top left of its block. + * @return {!Coordinate} The offset of the connection. + * @package + */ + getOffsetInBlock() { + return this.offsetInBlock_; + } + + /** + * Move the blocks on either side of this connection right next to each other. + * @package + */ + tighten() { + const dx = this.targetConnection.x - this.x; + const dy = this.targetConnection.y - this.y; + if (dx !== 0 || dy !== 0) { + const block = this.targetBlock(); + const svgRoot = block.getSvgRoot(); + if (!svgRoot) { + throw Error('block is not rendered.'); + } + // Workspace coordinates. + const xy = svgMath.getRelativeXY(svgRoot); + block.getSvgRoot().setAttribute( + 'transform', 'translate(' + (xy.x - dx) + ',' + (xy.y - dy) + ')'); + block.moveConnections(-dx, -dy); + } + } + + /** + * Find the closest compatible connection to this connection. + * All parameters are in workspace units. + * @param {number} maxLimit The maximum radius to another connection. + * @param {!Coordinate} dxy Offset between this connection's location + * in the database and the current location (as a result of dragging). + * @return {!{connection: ?Connection, radius: number}} Contains two + * properties: 'connection' which is either another connection or null, + * and 'radius' which is the distance. + */ + closest(maxLimit, dxy) { + return this.dbOpposite_.searchForClosest(this, maxLimit, dxy); + } + + /** + * Add highlighting around this connection. + */ + highlight() { + let steps; + const sourceBlockSvg = /** @type {!BlockSvg} */ (this.sourceBlock_); + const renderConstants = + sourceBlockSvg.workspace.getRenderer().getConstants(); + const shape = renderConstants.shapeFor(this); + if (this.type === ConnectionType.INPUT_VALUE || + this.type === ConnectionType.OUTPUT_VALUE) { + // Vertical line, puzzle tab, vertical line. + const yLen = renderConstants.TAB_OFFSET_FROM_TOP; + steps = svgPaths.moveBy(0, -yLen) + svgPaths.lineOnAxis('v', yLen) + + shape.pathDown + svgPaths.lineOnAxis('v', yLen); + } else { + const xLen = + renderConstants.NOTCH_OFFSET_LEFT - renderConstants.CORNER_RADIUS; + // Horizontal line, notch, horizontal line. + steps = svgPaths.moveBy(-xLen, 0) + svgPaths.lineOnAxis('h', xLen) + + shape.pathLeft + svgPaths.lineOnAxis('h', xLen); + } + const xy = this.sourceBlock_.getRelativeToSurfaceXY(); + const x = this.x - xy.x; + const y = this.y - xy.y; + Connection.highlightedPath_ = dom.createSvgElement( + Svg.PATH, { + 'class': 'blocklyHighlightedConnectionPath', + 'd': steps, + 'transform': 'translate(' + x + ',' + y + ')' + + (this.sourceBlock_.RTL ? ' scale(-1 1)' : ''), + }, + this.sourceBlock_.getSvgRoot()); + } + + /** + * Remove the highlighting around this connection. + */ + unhighlight() { + dom.removeNode(Connection.highlightedPath_); + delete Connection.highlightedPath_; + } + + /** + * Set whether this connections is tracked in the database or not. + * @param {boolean} doTracking If true, start tracking. If false, stop + * tracking. + * @package + */ + setTracking(doTracking) { + if ((doTracking && + this.trackedState_ === RenderedConnection.TrackedState.TRACKED) || + (!doTracking && + this.trackedState_ === RenderedConnection.TrackedState.UNTRACKED)) { + return; + } + if (this.sourceBlock_.isInFlyout) { + // Don't bother maintaining a database of connections in a flyout. + return; + } + if (doTracking) { + this.db_.addConnection(this, this.y); + this.trackedState_ = RenderedConnection.TrackedState.TRACKED; + return; + } + if (this.trackedState_ === RenderedConnection.TrackedState.TRACKED) { + this.db_.removeConnection(this, this.y); + } + this.trackedState_ = RenderedConnection.TrackedState.UNTRACKED; + } + + /** + * Stop tracking this connection, as well as all down-stream connections on + * any block attached to this connection. This happens when a block is + * collapsed. + * + * Also closes down-stream icons/bubbles. + * @package + */ + stopTrackingAll() { + this.setTracking(false); + if (this.targetConnection) { + const blocks = this.targetBlock().getDescendants(false); + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + // Stop tracking connections of all children. + const connections = block.getConnections_(true); + for (let j = 0; j < connections.length; j++) { + /** @type {!RenderedConnection} */ (connections[j]) + .setTracking(false); + } + // Close all bubbles of all children. + const icons = block.getIcons(); + for (let j = 0; j < icons.length; j++) { + icons[j].setVisible(false); + } + } + } + } + + /** + * Start tracking this connection, as well as all down-stream connections on + * any block attached to this connection. This happens when a block is + * expanded. + * @return {!Array} List of blocks to render. + */ + startTrackingAll() { + this.setTracking(true); + // All blocks that are not tracked must start tracking before any + // rendering takes place, since rendering requires knowing the dimensions + // of lower blocks. Also, since rendering a block renders all its parents, + // we only need to render the leaf nodes. + let renderList = []; + if (this.type !== ConnectionType.INPUT_VALUE && + this.type !== ConnectionType.NEXT_STATEMENT) { + // Only spider down. + return renderList; + } + const block = this.targetBlock(); + if (block) { + let connections; + if (block.isCollapsed()) { + // This block should only be partially revealed since it is collapsed. + connections = []; + block.outputConnection && connections.push(block.outputConnection); + block.nextConnection && connections.push(block.nextConnection); + block.previousConnection && connections.push(block.previousConnection); + } else { + // Show all connections of this block. + connections = block.getConnections_(true); + } + for (let i = 0; i < connections.length; i++) { + renderList.push.apply(renderList, connections[i].startTrackingAll()); + } + if (!renderList.length) { + // Leaf block. + renderList = [block]; + } + } + return renderList; + } + + /** + * Behavior after a connection attempt fails. + * Bumps this connection away from the other connection. Called when an + * attempted connection fails. + * @param {!Connection} otherConnection Connection that this connection + * failed to connect to. + * @package + */ + onFailedConnect(otherConnection) { + const block = this.getSourceBlock(); + if (eventUtils.getRecordUndo()) { + const group = eventUtils.getGroup(); + setTimeout(function() { + if (!block.isDisposed() && !block.getParent()) { + eventUtils.setGroup(group); + this.bumpAwayFrom(otherConnection); + eventUtils.setGroup(false); + } + }.bind(this), internalConstants.BUMP_DELAY); + } + } + + /** + * Disconnect two blocks that are connected by this connection. + * @param {!Block} parentBlock The superior block. + * @param {!Block} childBlock The inferior block. + * @protected + * @override + */ + disconnectInternal_(parentBlock, childBlock) { + super.disconnectInternal_(parentBlock, childBlock); + // Rerender the parent so that it may reflow. + if (parentBlock.rendered) { + parentBlock.render(); + } + if (childBlock.rendered) { + childBlock.updateDisabled(); + childBlock.render(); + // Reset visibility, since the child is now a top block. + childBlock.getSvgRoot().style.display = 'block'; + } + } + + /** + * Respawn the shadow block if there was one connected to the this connection. + * Render/rerender blocks as needed. + * @protected + * @override + */ + respawnShadow_() { + super.respawnShadow_(); + const blockShadow = this.targetBlock(); + if (!blockShadow) { + return; + } + blockShadow.initSvg(); + blockShadow.render(false); + + const parentBlock = this.getSourceBlock(); + if (parentBlock.rendered) { + parentBlock.render(); + } + } + + /** + * Find all nearby compatible connections to this connection. + * Type checking does not apply, since this function is used for bumping. + * @param {number} maxLimit The maximum radius to another connection, in + * workspace units. + * @return {!Array} List of connections. + * @package + */ + neighbours(maxLimit) { + return this.dbOpposite_.getNeighbours(this, maxLimit); + } + + /** + * Connect two connections together. This is the connection on the superior + * block. Rerender blocks as needed. + * @param {!Connection} childConnection Connection on inferior block. + * @protected + */ + connect_(childConnection) { + super.connect_(childConnection); + + const parentConnection = this; + const parentBlock = parentConnection.getSourceBlock(); + const childBlock = childConnection.getSourceBlock(); + const parentRendered = parentBlock.rendered; + const childRendered = childBlock.rendered; + + if (parentRendered) { + parentBlock.updateDisabled(); + } + if (childRendered) { + childBlock.updateDisabled(); + } + if (parentRendered && childRendered) { + if (parentConnection.type === ConnectionType.NEXT_STATEMENT || + parentConnection.type === ConnectionType.PREVIOUS_STATEMENT) { + // Child block may need to square off its corners if it is in a stack. + // Rendering a child will render its parent. + childBlock.render(); + } else { + // Child block does not change shape. Rendering the parent node will + // move its connected children into position. + parentBlock.render(); + } + } + + // The input the child block is connected to (if any). + const parentInput = parentBlock.getInputWithBlock(childBlock); + if (parentInput) { + const visible = parentInput.isVisible(); + childBlock.getSvgRoot().style.display = visible ? 'block' : 'none'; + } + } + + /** + * Function to be called when this connection's compatible types have changed. + * @protected + */ + onCheckChanged_() { + // The new value type may not be compatible with the existing connection. + if (this.isConnected() && + (!this.targetConnection || + !this.getConnectionChecker().canConnect( + this, this.targetConnection, false))) { + const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_; + child.unplug(); + // Bump away. + this.sourceBlock_.bumpNeighbours(); + } + } +} /** * Enum for different kinds of tracked states. @@ -101,475 +574,4 @@ RenderedConnection.TrackedState = { TRACKED: 1, }; -/** - * Maximum randomness in workspace units for bumping a block. - * @const - */ -const BUMP_RANDOMNESS = 10; - -/** - * Dispose of this connection. Remove it from the database (if it is - * tracked) and call the super-function to deal with connected blocks. - * @override - * @package - */ -RenderedConnection.prototype.dispose = function() { - RenderedConnection.superClass_.dispose.call(this); - if (this.trackedState_ === RenderedConnection.TrackedState.TRACKED) { - this.db_.removeConnection(this, this.y); - } -}; - -/** - * Get the source block for this connection. - * @return {!BlockSvg} The source block. - * @override - */ -RenderedConnection.prototype.getSourceBlock = function() { - return /** @type {!BlockSvg} */ ( - RenderedConnection.superClass_.getSourceBlock.call(this)); -}; - -/** - * Returns the block that this connection connects to. - * @return {?BlockSvg} The connected block or null if none is connected. - * @override - */ -RenderedConnection.prototype.targetBlock = function() { - return /** @type {BlockSvg} */ ( - RenderedConnection.superClass_.targetBlock.call(this)); -}; - -/** - * Returns the distance between this connection and another connection in - * workspace units. - * @param {!Connection} otherConnection The other connection to measure - * the distance to. - * @return {number} The distance between connections, in workspace units. - */ -RenderedConnection.prototype.distanceFrom = function(otherConnection) { - const xDiff = this.x - otherConnection.x; - const yDiff = this.y - otherConnection.y; - return Math.sqrt(xDiff * xDiff + yDiff * yDiff); -}; - -/** - * 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 - * from. - * @package - */ -RenderedConnection.prototype.bumpAwayFrom = function(staticConnection) { - if (this.sourceBlock_.workspace.isDragging()) { - // Don't move blocks around while the user is doing the same. - return; - } - // Move the root block. - let rootBlock = this.sourceBlock_.getRootBlock(); - if (rootBlock.isInFlyout) { - // Don't move blocks around in a flyout. - return; - } - let reverse = false; - if (!rootBlock.isMovable()) { - // Can't bump an uneditable block away. - // Check to see if the other block is movable. - rootBlock = staticConnection.getSourceBlock().getRootBlock(); - if (!rootBlock.isMovable()) { - return; - } - // Swap the connections and move the 'static' connection instead. - staticConnection = this; - reverse = true; - } - // Raise it to the top for extra visibility. - const selected = common.getSelected() == rootBlock; - selected || rootBlock.addSelect(); - let dx = (staticConnection.x + internalConstants.SNAP_RADIUS + - Math.floor(Math.random() * BUMP_RANDOMNESS)) - - this.x; - let dy = (staticConnection.y + internalConstants.SNAP_RADIUS + - Math.floor(Math.random() * BUMP_RANDOMNESS)) - - this.y; - if (reverse) { - // When reversing a bump due to an uneditable block, bump up. - dy = -dy; - } - if (rootBlock.RTL) { - dx = (staticConnection.x - internalConstants.SNAP_RADIUS - - Math.floor(Math.random() * BUMP_RANDOMNESS)) - - this.x; - } - rootBlock.moveBy(dx, dy); - selected || rootBlock.removeSelect(); -}; - -/** - * Change the connection's coordinates. - * @param {number} x New absolute x coordinate, in workspace coordinates. - * @param {number} y New absolute y coordinate, in workspace coordinates. - */ -RenderedConnection.prototype.moveTo = function(x, y) { - if (this.trackedState_ === RenderedConnection.TrackedState.WILL_TRACK) { - this.db_.addConnection(this, y); - this.trackedState_ = RenderedConnection.TrackedState.TRACKED; - } else if (this.trackedState_ === RenderedConnection.TrackedState.TRACKED) { - this.db_.removeConnection(this, this.y); - this.db_.addConnection(this, y); - } - this.x = x; - this.y = y; -}; - -/** - * Change the connection's coordinates. - * @param {number} dx Change to x coordinate, in workspace units. - * @param {number} dy Change to y coordinate, in workspace units. - */ -RenderedConnection.prototype.moveBy = function(dx, dy) { - this.moveTo(this.x + dx, this.y + dy); -}; - -/** - * Move this connection to the location given by its offset within the block and - * the location of the block's top left corner. - * @param {!Coordinate} blockTL The location of the top left - * corner of the block, in workspace coordinates. - */ -RenderedConnection.prototype.moveToOffset = function(blockTL) { - this.moveTo( - blockTL.x + this.offsetInBlock_.x, blockTL.y + this.offsetInBlock_.y); -}; - -/** - * Set the offset of this connection relative to the top left of its block. - * @param {number} x The new relative x, in workspace units. - * @param {number} y The new relative y, in workspace units. - */ -RenderedConnection.prototype.setOffsetInBlock = function(x, y) { - this.offsetInBlock_.x = x; - this.offsetInBlock_.y = y; -}; - -/** - * Get the offset of this connection relative to the top left of its block. - * @return {!Coordinate} The offset of the connection. - * @package - */ -RenderedConnection.prototype.getOffsetInBlock = function() { - return this.offsetInBlock_; -}; - -/** - * Move the blocks on either side of this connection right next to each other. - * @package - */ -RenderedConnection.prototype.tighten = function() { - const dx = this.targetConnection.x - this.x; - const dy = this.targetConnection.y - this.y; - if (dx !== 0 || dy !== 0) { - const block = this.targetBlock(); - const svgRoot = block.getSvgRoot(); - if (!svgRoot) { - throw Error('block is not rendered.'); - } - // Workspace coordinates. - const xy = svgMath.getRelativeXY(svgRoot); - block.getSvgRoot().setAttribute( - 'transform', 'translate(' + (xy.x - dx) + ',' + (xy.y - dy) + ')'); - block.moveConnections(-dx, -dy); - } -}; - -/** - * Find the closest compatible connection to this connection. - * All parameters are in workspace units. - * @param {number} maxLimit The maximum radius to another connection. - * @param {!Coordinate} dxy Offset between this connection's location - * in the database and the current location (as a result of dragging). - * @return {!{connection: ?Connection, radius: number}} Contains two - * properties: 'connection' which is either another connection or null, - * and 'radius' which is the distance. - */ -RenderedConnection.prototype.closest = function(maxLimit, dxy) { - return this.dbOpposite_.searchForClosest(this, maxLimit, dxy); -}; - -/** - * Add highlighting around this connection. - */ -RenderedConnection.prototype.highlight = function() { - let steps; - const sourceBlockSvg = /** @type {!BlockSvg} */ (this.sourceBlock_); - const renderConstants = sourceBlockSvg.workspace.getRenderer().getConstants(); - const shape = renderConstants.shapeFor(this); - if (this.type === ConnectionType.INPUT_VALUE || - this.type === ConnectionType.OUTPUT_VALUE) { - // Vertical line, puzzle tab, vertical line. - const yLen = renderConstants.TAB_OFFSET_FROM_TOP; - steps = svgPaths.moveBy(0, -yLen) + svgPaths.lineOnAxis('v', yLen) + - shape.pathDown + svgPaths.lineOnAxis('v', yLen); - } else { - const xLen = - renderConstants.NOTCH_OFFSET_LEFT - renderConstants.CORNER_RADIUS; - // Horizontal line, notch, horizontal line. - steps = svgPaths.moveBy(-xLen, 0) + svgPaths.lineOnAxis('h', xLen) + - shape.pathLeft + svgPaths.lineOnAxis('h', xLen); - } - const xy = this.sourceBlock_.getRelativeToSurfaceXY(); - const x = this.x - xy.x; - const y = this.y - xy.y; - Connection.highlightedPath_ = dom.createSvgElement( - Svg.PATH, { - 'class': 'blocklyHighlightedConnectionPath', - 'd': steps, - 'transform': 'translate(' + x + ',' + y + ')' + - (this.sourceBlock_.RTL ? ' scale(-1 1)' : ''), - }, - this.sourceBlock_.getSvgRoot()); -}; - -/** - * Remove the highlighting around this connection. - */ -RenderedConnection.prototype.unhighlight = function() { - dom.removeNode(Connection.highlightedPath_); - delete Connection.highlightedPath_; -}; - -/** - * Set whether this connections is tracked in the database or not. - * @param {boolean} doTracking If true, start tracking. If false, stop tracking. - * @package - */ -RenderedConnection.prototype.setTracking = function(doTracking) { - if ((doTracking && - this.trackedState_ === RenderedConnection.TrackedState.TRACKED) || - (!doTracking && - this.trackedState_ === RenderedConnection.TrackedState.UNTRACKED)) { - return; - } - if (this.sourceBlock_.isInFlyout) { - // Don't bother maintaining a database of connections in a flyout. - return; - } - if (doTracking) { - this.db_.addConnection(this, this.y); - this.trackedState_ = RenderedConnection.TrackedState.TRACKED; - return; - } - if (this.trackedState_ === RenderedConnection.TrackedState.TRACKED) { - this.db_.removeConnection(this, this.y); - } - this.trackedState_ = RenderedConnection.TrackedState.UNTRACKED; -}; - -/** - * Stop tracking this connection, as well as all down-stream connections on - * any block attached to this connection. This happens when a block is - * collapsed. - * - * Also closes down-stream icons/bubbles. - * @package - */ -RenderedConnection.prototype.stopTrackingAll = function() { - this.setTracking(false); - if (this.targetConnection) { - const blocks = this.targetBlock().getDescendants(false); - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i]; - // Stop tracking connections of all children. - const connections = block.getConnections_(true); - for (let j = 0; j < connections.length; j++) { - connections[j].setTracking(false); - } - // Close all bubbles of all children. - const icons = block.getIcons(); - for (let j = 0; j < icons.length; j++) { - icons[j].setVisible(false); - } - } - } -}; - -/** - * Start tracking this connection, as well as all down-stream connections on - * any block attached to this connection. This happens when a block is expanded. - * @return {!Array} List of blocks to render. - */ -RenderedConnection.prototype.startTrackingAll = function() { - this.setTracking(true); - // All blocks that are not tracked must start tracking before any - // rendering takes place, since rendering requires knowing the dimensions - // of lower blocks. Also, since rendering a block renders all its parents, - // we only need to render the leaf nodes. - let renderList = []; - if (this.type !== ConnectionType.INPUT_VALUE && - this.type !== ConnectionType.NEXT_STATEMENT) { - // Only spider down. - return renderList; - } - const block = this.targetBlock(); - if (block) { - let connections; - if (block.isCollapsed()) { - // This block should only be partially revealed since it is collapsed. - connections = []; - block.outputConnection && connections.push(block.outputConnection); - block.nextConnection && connections.push(block.nextConnection); - block.previousConnection && connections.push(block.previousConnection); - } else { - // Show all connections of this block. - connections = block.getConnections_(true); - } - for (let i = 0; i < connections.length; i++) { - renderList.push.apply(renderList, connections[i].startTrackingAll()); - } - if (!renderList.length) { - // Leaf block. - renderList = [block]; - } - } - return renderList; -}; - -/** - * Behavior after a connection attempt fails. - * Bumps this connection away from the other connection. Called when an - * attempted connection fails. - * @param {!Connection} otherConnection Connection that this connection - * failed to connect to. - * @package - */ -RenderedConnection.prototype.onFailedConnect = function(otherConnection) { - const block = this.getSourceBlock(); - if (eventUtils.getRecordUndo()) { - const group = eventUtils.getGroup(); - setTimeout(function() { - if (!block.isDisposed() && !block.getParent()) { - eventUtils.setGroup(group); - this.bumpAwayFrom(otherConnection); - eventUtils.setGroup(false); - } - }.bind(this), internalConstants.BUMP_DELAY); - } -}; - - -/** - * Disconnect two blocks that are connected by this connection. - * @param {!Block} parentBlock The superior block. - * @param {!Block} childBlock The inferior block. - * @protected - * @override - */ -RenderedConnection.prototype.disconnectInternal_ = function( - parentBlock, childBlock) { - RenderedConnection.superClass_.disconnectInternal_.call( - this, parentBlock, childBlock); - // Rerender the parent so that it may reflow. - if (parentBlock.rendered) { - parentBlock.render(); - } - if (childBlock.rendered) { - childBlock.updateDisabled(); - childBlock.render(); - // Reset visibility, since the child is now a top block. - childBlock.getSvgRoot().style.display = 'block'; - } -}; - -/** - * Respawn the shadow block if there was one connected to the this connection. - * Render/rerender blocks as needed. - * @protected - * @override - */ -RenderedConnection.prototype.respawnShadow_ = function() { - RenderedConnection.superClass_.respawnShadow_.call(this); - const blockShadow = this.targetBlock(); - if (!blockShadow) { - return; - } - blockShadow.initSvg(); - blockShadow.render(false); - - const parentBlock = this.getSourceBlock(); - if (parentBlock.rendered) { - parentBlock.render(); - } -}; - -/** - * Find all nearby compatible connections to this connection. - * Type checking does not apply, since this function is used for bumping. - * @param {number} maxLimit The maximum radius to another connection, in - * workspace units. - * @return {!Array} List of connections. - * @package - */ -RenderedConnection.prototype.neighbours = function(maxLimit) { - return this.dbOpposite_.getNeighbours(this, maxLimit); -}; - -/** - * Connect two connections together. This is the connection on the superior - * block. Rerender blocks as needed. - * @param {!Connection} childConnection Connection on inferior block. - * @protected - */ -RenderedConnection.prototype.connect_ = function(childConnection) { - RenderedConnection.superClass_.connect_.call(this, childConnection); - - const parentConnection = this; - const parentBlock = parentConnection.getSourceBlock(); - const childBlock = childConnection.getSourceBlock(); - const parentRendered = parentBlock.rendered; - const childRendered = childBlock.rendered; - - if (parentRendered) { - parentBlock.updateDisabled(); - } - if (childRendered) { - childBlock.updateDisabled(); - } - if (parentRendered && childRendered) { - if (parentConnection.type === ConnectionType.NEXT_STATEMENT || - parentConnection.type === ConnectionType.PREVIOUS_STATEMENT) { - // Child block may need to square off its corners if it is in a stack. - // Rendering a child will render its parent. - childBlock.render(); - } else { - // Child block does not change shape. Rendering the parent node will - // move its connected children into position. - parentBlock.render(); - } - } - - // The input the child block is connected to (if any). - const parentInput = parentBlock.getInputWithBlock(childBlock); - if (parentInput) { - const visible = parentInput.isVisible(); - childBlock.getSvgRoot().style.display = visible ? 'block' : 'none'; - } -}; - -/** - * Function to be called when this connection's compatible types have changed. - * @protected - */ -RenderedConnection.prototype.onCheckChanged_ = function() { - // The new value type may not be compatible with the existing connection. - if (this.isConnected() && - (!this.targetConnection || - !this.getConnectionChecker().canConnect( - this, this.targetConnection, false))) { - const child = this.isSuperior() ? this.targetBlock() : this.sourceBlock_; - child.unplug(); - // Bump away. - this.sourceBlock_.bumpNeighbours(); - } -}; - exports.RenderedConnection = RenderedConnection; diff --git a/core/renderers/common/constants.js b/core/renderers/common/constants.js index 295ccf368..1c21b7195 100644 --- a/core/renderers/common/constants.js +++ b/core/renderers/common/constants.js @@ -31,1212 +31,1220 @@ const {Theme} = goog.requireType('Blockly.Theme'); /** * An object that provides constants for rendering blocks. - * @constructor - * @package - * @alias Blockly.blockRendering.ConstantProvider */ -const ConstantProvider = function() { +class ConstantProvider { /** - * The size of an empty spacer. - * @type {number} - */ - this.NO_PADDING = 0; - - /** - * The size of small padding. - * @type {number} - */ - this.SMALL_PADDING = 3; - - /** - * The size of medium padding. - * @type {number} - */ - this.MEDIUM_PADDING = 5; - - /** - * The size of medium-large padding. - * @type {number} - */ - this.MEDIUM_LARGE_PADDING = 8; - - /** - * The size of large padding. - * @type {number} - */ - this.LARGE_PADDING = 10; - - /** - * Offset from the top of the row for placing fields on inline input rows - * and statement input rows. - * Matches existing rendering (in 2019). - * @type {number} - */ - this.TALL_INPUT_FIELD_OFFSET_Y = this.MEDIUM_PADDING; - - /** - * The height of the puzzle tab used for input and output connections. - * @type {number} - */ - this.TAB_HEIGHT = 15; - - /** - * The offset from the top of the block at which a puzzle tab is positioned. - * @type {number} - */ - this.TAB_OFFSET_FROM_TOP = 5; - - /** - * Vertical overlap of the puzzle tab, used to make it look more like a puzzle - * piece. - * @type {number} - */ - this.TAB_VERTICAL_OVERLAP = 2.5; - - /** - * The width of the puzzle tab used for input and output connections. - * @type {number} - */ - this.TAB_WIDTH = 8; - - /** - * The width of the notch used for previous and next connections. - * @type {number} - */ - this.NOTCH_WIDTH = 15; - - /** - * The height of the notch used for previous and next connections. - * @type {number} - */ - this.NOTCH_HEIGHT = 4; - - /** - * The minimum width of the block. - * @type {number} - */ - this.MIN_BLOCK_WIDTH = 12; - - this.EMPTY_BLOCK_SPACER_HEIGHT = 16; - - /** - * The minimum height of a dummy input row. - * @type {number} - */ - this.DUMMY_INPUT_MIN_HEIGHT = this.TAB_HEIGHT; - - /** - * The minimum height of a dummy input row in a shadow block. - * @type {number} - */ - this.DUMMY_INPUT_SHADOW_MIN_HEIGHT = this.TAB_HEIGHT; - - /** - * Rounded corner radius. - * @type {number} - */ - this.CORNER_RADIUS = 8; - - /** - * Offset from the left side of a block or the inside of a statement input to - * the left side of the notch. - * @type {number} - */ - this.NOTCH_OFFSET_LEFT = 15; - - /** - * Additional offset added to the statement input's width to account for the - * notch. - * @type {number} - */ - this.STATEMENT_INPUT_NOTCH_OFFSET = this.NOTCH_OFFSET_LEFT; - - this.STATEMENT_BOTTOM_SPACER = 0; - this.STATEMENT_INPUT_PADDING_LEFT = 20; - - /** - * Vertical padding between consecutive statement inputs. - * @type {number} - */ - this.BETWEEN_STATEMENT_PADDING_Y = 4; - - /** - * The top row's minimum height. - * @type {number} - */ - this.TOP_ROW_MIN_HEIGHT = this.MEDIUM_PADDING; - - /** - * The top row's minimum height if it precedes a statement. - * @type {number} - */ - this.TOP_ROW_PRECEDES_STATEMENT_MIN_HEIGHT = this.LARGE_PADDING; - - /** - * The bottom row's minimum height. - * @type {number} - */ - this.BOTTOM_ROW_MIN_HEIGHT = this.MEDIUM_PADDING; - - /** - * The bottom row's minimum height if it follows a statement input. - * @type {number} - */ - this.BOTTOM_ROW_AFTER_STATEMENT_MIN_HEIGHT = this.LARGE_PADDING; - - /** - * Whether to add a 'hat' on top of all blocks with no previous or output - * connections. Can be overridden by 'hat' property on Theme.BlockStyle. - * @type {boolean} - */ - this.ADD_START_HATS = false; - - /** - * Height of the top hat. - * @type {number} - */ - this.START_HAT_HEIGHT = 15; - - /** - * Width of the top hat. - * @type {number} - */ - this.START_HAT_WIDTH = 100; - - this.SPACER_DEFAULT_HEIGHT = 15; - - this.MIN_BLOCK_HEIGHT = 24; - - this.EMPTY_INLINE_INPUT_PADDING = 14.5; - - /** - * The height of an empty inline input. - * @type {number} - */ - this.EMPTY_INLINE_INPUT_HEIGHT = this.TAB_HEIGHT + 11; - - this.EXTERNAL_VALUE_INPUT_PADDING = 2; - - /** - * The height of an empty statement input. Note that in the old rendering - * this varies slightly depending on whether the block has external or inline - * inputs. In the new rendering this is consistent. It seems unlikely that - * the old behaviour was intentional. - * @type {number} - */ - this.EMPTY_STATEMENT_INPUT_HEIGHT = this.MIN_BLOCK_HEIGHT; - - this.START_POINT = svgPaths.moveBy(0, 0); - - /** - * Height of SVG path for jagged teeth at the end of collapsed blocks. - * @type {number} - */ - this.JAGGED_TEETH_HEIGHT = 12; - - /** - * Width of SVG path for jagged teeth at the end of collapsed blocks. - * @type {number} - */ - this.JAGGED_TEETH_WIDTH = 6; - - /** - * Point size of text. - * @type {number} - */ - this.FIELD_TEXT_FONTSIZE = 11; - - /** - * Text font weight. - * @type {string} - */ - this.FIELD_TEXT_FONTWEIGHT = 'normal'; - - /** - * Text font family. - * @type {string} - */ - this.FIELD_TEXT_FONTFAMILY = 'sans-serif'; - - /** - * Height of text. This constant is dynamically set in ``setFontConstants_`` - * to be the height of the text based on the font used. - * @type {number} - */ - this.FIELD_TEXT_HEIGHT = -1; // Dynamically set. - - /** - * Text baseline. This constant is dynamically set in ``setFontConstants_`` - * to be the baseline of the text based on the font used. - * @type {number} - */ - this.FIELD_TEXT_BASELINE = -1; // Dynamically set. - - /** - * A field's border rect corner radius. - * @type {number} - */ - this.FIELD_BORDER_RECT_RADIUS = 4; - - /** - * A field's border rect default height. - * @type {number} - */ - this.FIELD_BORDER_RECT_HEIGHT = 16; - - /** - * A field's border rect X padding. - * @type {number} - */ - this.FIELD_BORDER_RECT_X_PADDING = 5; - - /** - * A field's border rect Y padding. - * @type {number} - */ - this.FIELD_BORDER_RECT_Y_PADDING = 3; - - /** - * The backing colour of a field's border rect. - * @type {string} * @package + * @alias Blockly.blockRendering.ConstantProvider */ - this.FIELD_BORDER_RECT_COLOUR = '#fff'; + constructor() { + /** + * The size of an empty spacer. + * @type {number} + */ + this.NO_PADDING = 0; - /** - * A field's text element's dominant baseline. - * @type {boolean} - */ - this.FIELD_TEXT_BASELINE_CENTER = !userAgent.IE && !userAgent.EDGE; + /** + * The size of small padding. + * @type {number} + */ + this.SMALL_PADDING = 3; - /** - * A dropdown field's border rect height. - * @type {number} - */ - this.FIELD_DROPDOWN_BORDER_RECT_HEIGHT = this.FIELD_BORDER_RECT_HEIGHT; + /** + * The size of medium padding. + * @type {number} + */ + this.MEDIUM_PADDING = 5; - /** - * Whether or not a dropdown field should add a border rect when in a shadow - * block. - * @type {boolean} - */ - this.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW = false; + /** + * The size of medium-large padding. + * @type {number} + */ + this.MEDIUM_LARGE_PADDING = 8; - /** - * Whether or not a dropdown field's div should be coloured to match the - * block colours. - * @type {boolean} - */ - this.FIELD_DROPDOWN_COLOURED_DIV = false; + /** + * The size of large padding. + * @type {number} + */ + this.LARGE_PADDING = 10; - /** - * Whether or not a dropdown field uses a text or SVG arrow. - * @type {boolean} - */ - this.FIELD_DROPDOWN_SVG_ARROW = false; + /** + * Offset from the top of the row for placing fields on inline input rows + * and statement input rows. + * Matches existing rendering (in 2019). + * @type {number} + */ + this.TALL_INPUT_FIELD_OFFSET_Y = this.MEDIUM_PADDING; - /** - * A dropdown field's SVG arrow padding. - * @type {number} - */ - this.FIELD_DROPDOWN_SVG_ARROW_PADDING = this.FIELD_BORDER_RECT_X_PADDING; + /** + * The height of the puzzle tab used for input and output connections. + * @type {number} + */ + this.TAB_HEIGHT = 15; - /** - * A dropdown field's SVG arrow size. - * @type {number} - */ - this.FIELD_DROPDOWN_SVG_ARROW_SIZE = 12; + /** + * The offset from the top of the block at which a puzzle tab is positioned. + * @type {number} + */ + this.TAB_OFFSET_FROM_TOP = 5; - /** - * A dropdown field's SVG arrow datauri. - * @type {string} - */ - this.FIELD_DROPDOWN_SVG_ARROW_DATAURI = - 'data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllci' + - 'AxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMi43MSIgaG' + - 'VpZ2h0PSI4Ljc5IiB2aWV3Qm94PSIwIDAgMTIuNzEgOC43OSI+PHRpdGxlPmRyb3Bkb3duLW' + - 'Fycm93PC90aXRsZT48ZyBvcGFjaXR5PSIwLjEiPjxwYXRoIGQ9Ik0xMi43MSwyLjQ0QTIuND' + - 'EsMi40MSwwLDAsMSwxMiw0LjE2TDguMDgsOC4wOGEyLjQ1LDIuNDUsMCwwLDEtMy40NSwwTD' + - 'AuNzIsNC4xNkEyLjQyLDIuNDIsMCwwLDEsMCwyLjQ0LDIuNDgsMi40OCwwLDAsMSwuNzEuNz' + - 'FDMSwwLjQ3LDEuNDMsMCw2LjM2LDBTMTEuNzUsMC40NiwxMiwuNzFBMi40NCwyLjQ0LDAsMC' + - 'wxLDEyLjcxLDIuNDRaIiBmaWxsPSIjMjMxZjIwIi8+PC9nPjxwYXRoIGQ9Ik02LjM2LDcuNz' + - 'lhMS40MywxLjQzLDAsMCwxLTEtLjQyTDEuNDIsMy40NWExLjQ0LDEuNDQsMCwwLDEsMC0yYz' + - 'AuNTYtLjU2LDkuMzEtMC41Niw5Ljg3LDBhMS40NCwxLjQ0LDAsMCwxLDAsMkw3LjM3LDcuMz' + - 'dBMS40MywxLjQzLDAsMCwxLDYuMzYsNy43OVoiIGZpbGw9IiNmZmYiLz48L3N2Zz4='; + /** + * Vertical overlap of the puzzle tab, used to make it look more like a + * puzzle piece. + * @type {number} + */ + this.TAB_VERTICAL_OVERLAP = 2.5; - /** - * Whether or not to show a box shadow around the widget div. This is only a - * feature of full block fields. - * @type {boolean} - */ - this.FIELD_TEXTINPUT_BOX_SHADOW = false; + /** + * The width of the puzzle tab used for input and output connections. + * @type {number} + */ + this.TAB_WIDTH = 8; - /** - * Whether or not the colour field should display its colour value on the - * entire block. - * @type {boolean} - */ - this.FIELD_COLOUR_FULL_BLOCK = false; + /** + * The width of the notch used for previous and next connections. + * @type {number} + */ + this.NOTCH_WIDTH = 15; - /** - * A colour field's default width. - * @type {number} - */ - this.FIELD_COLOUR_DEFAULT_WIDTH = 26; + /** + * The height of the notch used for previous and next connections. + * @type {number} + */ + this.NOTCH_HEIGHT = 4; - /** - * A colour field's default height. - * @type {number} - */ - this.FIELD_COLOUR_DEFAULT_HEIGHT = this.FIELD_BORDER_RECT_HEIGHT; + /** + * The minimum width of the block. + * @type {number} + */ + this.MIN_BLOCK_WIDTH = 12; - /** - * A checkbox field's X offset. - * @type {number} - */ - this.FIELD_CHECKBOX_X_OFFSET = this.FIELD_BORDER_RECT_X_PADDING - 3; + this.EMPTY_BLOCK_SPACER_HEIGHT = 16; - /** - * A random identifier used to ensure a unique ID is used for each - * filter/pattern for the case of multiple Blockly instances on a page. - * @type {string} - * @package - */ - this.randomIdentifier = String(Math.random()).substring(2); + /** + * The minimum height of a dummy input row. + * @type {number} + */ + this.DUMMY_INPUT_MIN_HEIGHT = this.TAB_HEIGHT; - /** - * The defs tag that contains all filters and patterns for this Blockly - * instance. - * @type {?SVGElement} - * @private - */ - this.defs_ = null; + /** + * The minimum height of a dummy input row in a shadow block. + * @type {number} + */ + this.DUMMY_INPUT_SHADOW_MIN_HEIGHT = this.TAB_HEIGHT; - /** - * The ID of the emboss filter, or the empty string if no filter is set. - * @type {string} - * @package - */ - this.embossFilterId = ''; + /** + * Rounded corner radius. + * @type {number} + */ + this.CORNER_RADIUS = 8; - /** - * The element to use for highlighting, or null if not set. - * @type {SVGElement} - * @private - */ - this.embossFilter_ = null; + /** + * Offset from the left side of a block or the inside of a statement input + * to the left side of the notch. + * @type {number} + */ + this.NOTCH_OFFSET_LEFT = 15; - /** - * The ID of the disabled pattern, or the empty string if no pattern is set. - * @type {string} - * @package - */ - this.disabledPatternId = ''; + /** + * Additional offset added to the statement input's width to account for the + * notch. + * @type {number} + */ + this.STATEMENT_INPUT_NOTCH_OFFSET = this.NOTCH_OFFSET_LEFT; - /** - * The element to use for disabled blocks, or null if not set. - * @type {SVGElement} - * @private - */ - this.disabledPattern_ = null; + this.STATEMENT_BOTTOM_SPACER = 0; + this.STATEMENT_INPUT_PADDING_LEFT = 20; - /** - * The ID of the debug filter, or the empty string if no pattern is set. - * @type {string} - * @package - */ - this.debugFilterId = ''; + /** + * Vertical padding between consecutive statement inputs. + * @type {number} + */ + this.BETWEEN_STATEMENT_PADDING_Y = 4; - /** - * The element to use for a debug highlight, or null if not set. - * @type {SVGElement} - * @private - */ - this.debugFilter_ = null; + /** + * The top row's minimum height. + * @type {number} + */ + this.TOP_ROW_MIN_HEIGHT = this.MEDIUM_PADDING; - /** - * The