diff --git a/blockly_uncompressed.js b/blockly_uncompressed.js index 8c7996a6e..ccd5115ac 100644 --- a/blockly_uncompressed.js +++ b/blockly_uncompressed.js @@ -37,7 +37,7 @@ this.BLOCKLY_BOOT = function(root) { goog.addDependency("../../../" + dir + "/core/block.js", ['Blockly.Block'], ['Blockly.Blocks', 'Blockly.Comment', 'Blockly.Connection', 'Blockly.Events.BlockChange', 'Blockly.Events.BlockCreate', 'Blockly.Events.BlockDelete', 'Blockly.Events.BlockMove', 'Blockly.Extensions', 'Blockly.Input', 'Blockly.Mutator', 'Blockly.utils', 'Blockly.Warning', 'Blockly.Workspace', 'goog.math.Coordinate']); goog.addDependency("../../../" + dir + "/core/block_animations.js", ['Blockly.BlockAnimations'], ['Blockly.utils']); goog.addDependency("../../../" + dir + "/core/block_drag_surface.js", ['Blockly.BlockDragSurfaceSvg'], ['Blockly.utils', 'goog.math.Coordinate']); -goog.addDependency("../../../" + dir + "/core/block_dragger.js", ['Blockly.BlockDragger'], ['Blockly.BlockAnimations', 'Blockly.DraggedConnectionManager', 'Blockly.Events.BlockMove', 'goog.math.Coordinate']); +goog.addDependency("../../../" + dir + "/core/block_dragger.js", ['Blockly.BlockDragger'], ['Blockly.BlockAnimations', 'Blockly.InsertionMarkerManager', 'Blockly.Events.BlockMove', 'goog.math.Coordinate']); goog.addDependency("../../../" + dir + "/core/block_events.js", ['Blockly.Events.BlockBase', 'Blockly.Events.BlockChange', 'Blockly.Events.BlockCreate', 'Blockly.Events.BlockDelete', 'Blockly.Events.BlockMove', 'Blockly.Events.Change', 'Blockly.Events.Create', 'Blockly.Events.Delete', 'Blockly.Events.Move'], ['Blockly.Events', 'Blockly.Events.Abstract', 'Blockly.Xml.utils', 'goog.math.Coordinate']); goog.addDependency("../../../" + dir + "/core/block_render_svg.js", ['Blockly.BlockSvg.render'], ['Blockly.BlockSvg']); goog.addDependency("../../../" + dir + "/core/block_svg.js", ['Blockly.BlockSvg'], ['Blockly.Block', 'Blockly.BlockAnimations', 'Blockly.ContextMenu', 'Blockly.Events.Ui', 'Blockly.Events.BlockMove', 'Blockly.Grid', 'Blockly.RenderedConnection', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.utils', 'goog.color', 'goog.math.Coordinate']); @@ -77,6 +77,7 @@ goog.addDependency("../../../" + dir + "/core/grid.js", ['Blockly.Grid'], ['Bloc goog.addDependency("../../../" + dir + "/core/icon.js", ['Blockly.Icon'], ['Blockly.utils', 'goog.math.Coordinate']); goog.addDependency("../../../" + dir + "/core/inject.js", ['Blockly.inject'], ['Blockly.BlockDragSurfaceSvg', 'Blockly.Css', 'Blockly.Grid', 'Blockly.Options', 'Blockly.utils', 'Blockly.WorkspaceSvg', 'Blockly.WorkspaceDragSurfaceSvg', 'goog.ui.Component', 'goog.userAgent']); goog.addDependency("../../../" + dir + "/core/input.js", ['Blockly.Input'], ['Blockly.Connection', 'Blockly.FieldLabel']); +goog.addDependency("../../../" + dir + "/core/insertion_marker_manager.js", ['Blockly.InsertionMarkerManager'], ['Blockly.BlockAnimations', 'Blockly.Events.BlockMove', 'Blockly.RenderedConnection', 'goog.math.Coordinate']); goog.addDependency("../../../" + dir + "/core/msg.js", ['Blockly.Msg'], []); goog.addDependency("../../../" + dir + "/core/mutator.js", ['Blockly.Mutator'], ['Blockly.Bubble', 'Blockly.Events.BlockChange', 'Blockly.Events.Ui', 'Blockly.Icon', 'Blockly.utils', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.Xml.utils']); goog.addDependency("../../../" + dir + "/core/names.js", ['Blockly.Names'], []); @@ -1786,6 +1787,7 @@ goog.require('Blockly.Grid'); goog.require('Blockly.HorizontalFlyout'); goog.require('Blockly.Icon'); goog.require('Blockly.Input'); +goog.require('Blockly.InsertionMarkerManager'); goog.require('Blockly.Msg'); goog.require('Blockly.Mutator'); goog.require('Blockly.Names'); diff --git a/core/block_dragger.js b/core/block_dragger.js index 7de40fbf5..47624a335 100644 --- a/core/block_dragger.js +++ b/core/block_dragger.js @@ -27,7 +27,7 @@ goog.provide('Blockly.BlockDragger'); goog.require('Blockly.BlockAnimations'); -goog.require('Blockly.DraggedConnectionManager'); +goog.require('Blockly.InsertionMarkerManager'); goog.require('Blockly.Events.BlockMove'); goog.require('goog.math.Coordinate'); @@ -57,10 +57,10 @@ Blockly.BlockDragger = function(block, workspace) { /** * Object that keeps track of connections on dragged blocks. - * @type {!Blockly.DraggedConnectionManager} + * @type {!Blockly.InsertionMarkerManager} * @private */ - this.draggedConnectionManager_ = new Blockly.DraggedConnectionManager( + this.draggedConnectionManager_ = new Blockly.InsertionMarkerManager( this.draggingBlock_); /** @@ -162,6 +162,9 @@ Blockly.BlockDragger.prototype.startBlockDrag = function(currentDragDeltaXY, this.draggingBlock_.bringToFront(); } + // During a drag there may be a lot of rerenders, but not field changes. + // Turn the cache on so we don't do spurious remeasures during the drag. + Blockly.Field.startCache(); this.workspace_.setResizesEnabled(false); Blockly.BlockAnimations.disconnectUiStop(); @@ -222,6 +225,8 @@ Blockly.BlockDragger.prototype.endBlockDrag = function(e, currentDragDeltaXY) { this.dragBlock(e, currentDragDeltaXY); this.dragIconData_ = []; + Blockly.Field.stopCache(); + Blockly.BlockAnimations.disconnectUiStop(); var delta = this.pixelsToWorkspaceUnits_(currentDragDeltaXY); diff --git a/core/block_render_svg.js b/core/block_render_svg.js index f9a36d66f..a1d4af37e 100644 --- a/core/block_render_svg.js +++ b/core/block_render_svg.js @@ -385,6 +385,11 @@ Blockly.BlockSvg.prototype.renderFields_ = function(fieldList, Blockly.BlockSvg.SEP_SPACE_X; } } + // Fields are invisible on insertion marker. They still have to be rendered + // so that the block can be sized correctly. + if (this.isInsertionMarker()) { + root.setAttribute('display', 'none'); + } } return this.RTL ? -cursorX : cursorX; }; @@ -1144,3 +1149,43 @@ Blockly.BlockSvg.prototype.renderStatementInput_ = function(pathObject, row, cursor.y += Blockly.BlockSvg.SEP_SPACE_Y; } }; + +/** + * Position an new block correctly, so that it doesn't move the existing block + * when connected to it. + * @param {!Blockly.Block} newBlock The block to position - either the first + * block in a dragged stack or an insertion marker. + * @param {!Blockly.Connection} newConnection The connection on the new block's + * stack - either a connection on newBlock, or the last NEXT_STATEMENT + * connection on the stack if the stack's being dropped before another + * block. + * @param {!Blockly.Connection} existingConnection The connection on the + * existing block, which newBlock should line up with. + */ +Blockly.BlockSvg.prototype.positionNewBlock = function(newBlock, newConnection, + existingConnection) { + // We only need to position the new block if it's before the existing one, + // otherwise its position is set by the previous block. + if (newConnection.type == Blockly.NEXT_STATEMENT || + newConnection.type == Blockly.INPUT_VALUE) { + var dx = existingConnection.x_ - newConnection.x_; + var dy = existingConnection.y_ - newConnection.y_; + + newBlock.moveBy(dx, dy); + } +}; + +/** + * Visual effect to show that if the dragging block is dropped, this block will + * be replaced. If a shadow block, it will disappear. Otherwise it will bump. + * @param {boolean} add True if highlighting should be added. + */ +Blockly.BlockSvg.prototype.highlightForReplacement = function(add) { + if (add) { + Blockly.utils.addClass(/** @type {!Element} */ (this.svgGroup_), + 'blocklyReplaceable'); + } else { + Blockly.utils.removeClass(/** @type {!Element} */ (this.svgGroup_), + 'blocklyReplaceable'); + } +}; diff --git a/core/connection.js b/core/connection.js index b94d07fec..11d26cbd4 100644 --- a/core/connection.js +++ b/core/connection.js @@ -334,12 +334,67 @@ Blockly.Connection.prototype.checkConnection_ = function(target) { } }; +/** + * Check if the two connections can be dragged to connect to each other. + * This is used by the connection database when searching for the closest + * connection. + * @param {!Blockly.Connection} candidate A nearby connection to check, which + * must be a previous connection. + * @return {boolean} True if the connection is allowed, false otherwise. + * @private + */ +Blockly.Connection.prototype.canConnectToPrevious_ = function(candidate) { + if (this.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 (Blockly.draggingConnections_.indexOf(candidate) != -1) { + return false; + } + + var firstStatementConnection = + this.sourceBlock_.getFirstStatementConnection(); + var isFirstStatementConnection = this == firstStatementConnection; + var isNextConnection = this == this.sourceBlock_.nextConnection; + + // Complex blocks with no previous connection will not be allowed to connect + // mid-stack. + var sourceHasPreviousConn = this.sourceBlock_.previousConnection != null; + + if (isNextConnection || + (isFirstStatementConnection && !sourceHasPreviousConn)) { + // If the candidate is the first connection in a stack, we can connect. + if (!candidate.targetConnection) { + return true; + } + + var targetBlock = candidate.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(); + } + console.warn('Returning false by default from canConnectToPrevious_.'); + return false; +}; + /** * Check if the two connections can be dragged to connect to each other. * @param {!Blockly.Connection} candidate A nearby connection to check. * @return {boolean} True if the connection is allowed, false otherwise. */ Blockly.Connection.prototype.isConnectionAllowed = function(candidate) { + // Don't consider insertion markers. + if (candidate.sourceBlock_.isInsertionMarker()) { + return false; + } // Type checking. var canConnect = this.canConnectWithReason_(candidate); if (canConnect != Blockly.Connection.CAN_CONNECT) { @@ -348,30 +403,27 @@ Blockly.Connection.prototype.isConnectionAllowed = function(candidate) { switch (candidate.type) { case Blockly.PREVIOUS_STATEMENT: - // Don't offer to connect the bottom of a statement block to one that's - // already connected. - if (candidate.isConnected() || this.isConnected()) { - return false; - } - break; - case Blockly.OUTPUT_VALUE: + return this.canConnectToPrevious_(candidate); + case Blockly.OUTPUT_VALUE: { // Don't offer to connect an already connected left (male) value plug to // an available right (female) value plug. if (candidate.isConnected() || this.isConnected()) { return false; } break; - case Blockly.INPUT_VALUE: + } + case Blockly.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 unmovable block. - if (candidate.targetConnection && + // However, don't offer to splice into an immovable block. + if (candidate.isConnected() && !candidate.targetBlock().isMovable() && !candidate.targetBlock().isShadow()) { return false; } break; - case Blockly.NEXT_STATEMENT: + } + case Blockly.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 @@ -383,6 +435,7 @@ Blockly.Connection.prototype.isConnectionAllowed = function(candidate) { return false; } break; + } default: throw Error('Unknown connection type in isConnectionAllowed'); } diff --git a/core/constants.js b/core/constants.js index 54ed1fb49..7668989f7 100644 --- a/core/constants.js +++ b/core/constants.js @@ -42,7 +42,26 @@ Blockly.FLYOUT_DRAG_RADIUS = 10; /** * Maximum misalignment between connections for them to snap together. */ -Blockly.SNAP_RADIUS = 20; +Blockly.SNAP_RADIUS = 36; + +/** + * Maximum misalignment between connections for them to snap together, + * when a connection is already highlighted. + */ +Blockly.CONNECTING_SNAP_RADIUS = 48; + +/** + * How much to prefer staying connected to the current connection over moving to + * a new connection. The current previewed connection is considered to be this + * much closer to the matching connection on the block than it actually is. + */ +Blockly.CURRENT_CONNECTION_PREFERENCE = 0; + +/** + * The main colour of insertion markers, in hex. The block is rendered a + * transparent grey by changing the fill opacity in CSS. + */ +Blockly.INSERTION_MARKER_COLOUR = '#000000'; /** * Delay in ms between trigger and bumping unconnected block out of alignment. diff --git a/core/css.js b/core/css.js index e19e2dfe0..0043be9f3 100644 --- a/core/css.js +++ b/core/css.js @@ -284,6 +284,22 @@ Blockly.Css.CONTENT = [ 'display: none;', '}', + '.blocklyInsertionMarker>.blocklyPath,', + '.blocklyInsertionMarker>.blocklyPathLight,', + '.blocklyInsertionMarker>.blocklyPathDark {', + 'fill-opacity: .2;', + 'stroke: none', + '}', + + '.blocklyReplaceable .blocklyPath {', + 'fill-opacity: 0.5;', + '}', + + '.blocklyReplaceable .blocklyPathLight,', + '.blocklyReplaceable .blocklyPathDark {', + 'display: none;', + '}', + '.blocklyText {', 'cursor: default;', 'fill: #fff;', diff --git a/core/dragged_connection_manager.js b/core/dragged_connection_manager.js index 1b2642280..211ec8bf4 100644 --- a/core/dragged_connection_manager.js +++ b/core/dragged_connection_manager.js @@ -36,6 +36,7 @@ goog.require('goog.math.Coordinate'); * Class that controls updates to connections during drags. It is primarily * responsible for finding the closest eligible connection and highlighting or * unhiglighting it as needed during a drag. + * @deprecated July 2018. Use InsertionMarkerManager. * @param {!Blockly.BlockSvg} block The top block in the stack being dragged. * @constructor */ diff --git a/core/icon.js b/core/icon.js index 6c259245d..0d3f2ddd3 100644 --- a/core/icon.js +++ b/core/icon.js @@ -147,7 +147,8 @@ Blockly.Icon.prototype.updateColour = function() { * @return {number} Horizontal offset for next item to draw. */ Blockly.Icon.prototype.renderIcon = function(cursorX) { - if (this.collapseHidden && this.block_.isCollapsed()) { + if ((this.collapseHidden && this.block_.isCollapsed()) || + this.block_.isInsertionMarker()) { this.iconGroup_.setAttribute('display', 'none'); return cursorX; } diff --git a/core/insertion_marker_manager.js b/core/insertion_marker_manager.js new file mode 100644 index 000000000..fb2fbd81b --- /dev/null +++ b/core/insertion_marker_manager.js @@ -0,0 +1,697 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2017 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Class that controls updates to connections during drags. + * @author fenichel@google.com (Rachel Fenichel) + */ +'use strict'; + +goog.provide('Blockly.InsertionMarkerManager'); + +goog.require('Blockly.BlockAnimations'); +goog.require('Blockly.Events.BlockMove'); +goog.require('Blockly.RenderedConnection'); + +goog.require('goog.math.Coordinate'); + + +/** + * Class that controls updates to connections during drags. It is primarily + * responsible for finding the closest eligible connection and highlighting or + * unhiglighting it as needed during a drag. + * @param {!Blockly.BlockSvg} block The top block in the stack being dragged. + * @constructor + */ +Blockly.InsertionMarkerManager = function(block) { + Blockly.selected = block; + + /** + * The top block in the stack being dragged. + * Does not change during a drag. + * @type {!Blockly.Block} + * @private + */ + this.topBlock_ = block; + + /** + * The workspace on which these connections are being dragged. + * Does not change during a drag. + * @type {!Blockly.WorkspaceSvg} + * @private + */ + this.workspace_ = block.workspace; + + /** + * The last connection on the stack, if it's not the last connection on the + * first block. + * Set in initAvailableConnections, if at all. + * @type {Blockly.RenderedConnection} + * @private + */ + this.lastOnStack_ = null; + + /** + * The insertion marker corresponding to the last block in the stack, if + * that's not the same as the first block in the stack. + * Set in initAvailableConnections, if at all + * @type {Blockly.BlockSvg} + * @private + */ + this.lastMarker_ = null; + + /** + * The insertion marker that shows up between blocks to show where a block + * would go if dropped immediately. + * @type {Blockly.BlockSvg} + * @private + */ + this.firstMarker_ = this.createMarkerBlock_(this.topBlock_); + + /** + * The connection that this block would connect to if released immediately. + * Updated on every mouse move. + * This is not on any of the blocks that are being dragged. + * @type {Blockly.RenderedConnection} + * @private + */ + this.closestConnection_ = null; + + /** + * The connection that would connect to this.closestConnection_ if this block + * were released immediately. + * Updated on every mouse move. + * This is on the top block that is being dragged or the last block in the + * dragging stack. + * @type {Blockly.RenderedConnection} + * @private + */ + this.localConnection_ = null; + + /** + * Whether the block would be deleted if it were dropped immediately. + * Updated on every mouse move. + * @type {boolean} + * @private + */ + this.wouldDeleteBlock_ = false; + + /** + * Connection on the insertion marker block that corresponds to + * this.localConnection_ on the currently dragged block. + * @type {Blockly.RenderedConnection} + * @private + */ + this.markerConnection_ = null; + + /** + * Whether we are currently highlighting the block (shadow or real) that would + * be replaced if the drag were released immediately. + * @type {boolean} + * @private + */ + this.highlightingBlock_ = false; + + /** + * The block that is being highlighted for replacement, or null. + * @type {Blockly.BlockSvg} + * @private + */ + this.highlightedBlock_ = null; + + /** + * The connections on the dragging blocks that are available to connect to + * other blocks. This includes all open connections on the top block, as well + * as the last connection on the block stack. + * Does not change during a drag. + * @type {!Array.} + * @private + */ + this.availableConnections_ = this.initAvailableConnections_(); +}; + +/** + * Sever all links from this object. + * @package + */ +Blockly.InsertionMarkerManager.prototype.dispose = function() { + this.topBlock_ = null; + this.workspace_ = null; + this.availableConnections_.length = 0; + this.closestConnection_ = null; + this.localConnection_ = null; + + Blockly.Events.disable(); + try { + if (this.firstMarker_) { + this.firstMarker_.dispose(); + this.firstMarker_ = null; + } + if (this.lastMarker_) { + this.lastMarker_.dispose(); + this.lastMarker_ = null; + } + } finally { + Blockly.Events.enable(); + } + + this.highlightedBlock_ = null; +}; + +/** + * Return whether the block would be deleted if dropped immediately, based on + * information from the most recent move event. + * @return {boolean} true if the block would be deleted if dropped immediately. + * @package + */ +Blockly.InsertionMarkerManager.prototype.wouldDeleteBlock = function() { + return this.wouldDeleteBlock_; +}; + +/** + * Return whether the block would be connected if dropped immediately, based on + * information from the most recent move event. + * @return {boolean} true if the block would be connected if dropped immediately. + * @package + */ +Blockly.InsertionMarkerManager.prototype.wouldConnectBlock = function() { + return !!this.closestConnection_; +}; + +/** + * Connect to the closest connection and render the results. + * This should be called at the end of a drag. + * @package + */ +Blockly.InsertionMarkerManager.prototype.applyConnections = function() { + if (this.closestConnection_) { + // Don't fire events for insertion markers. + Blockly.Events.disable(); + this.hidePreview_(); + Blockly.Events.enable(); + // Connect two blocks together. + this.localConnection_.connect(this.closestConnection_); + if (this.topBlock_.rendered) { + // Trigger a connection animation. + // Determine which connection is inferior (lower in the source stack). + var inferiorConnection = this.localConnection_.isSuperior() ? + this.closestConnection_ : this.localConnection_; + Blockly.BlockAnimations.connectionUiEffect( + inferiorConnection.getSourceBlock()); + // Bring the just-edited stack to the front. + var rootBlock = this.topBlock_.getRootBlock(); + rootBlock.bringToFront(); + } + } +}; + +/** + * Update highlighted connections based on the most recent move location. + * @param {!goog.math.Coordinate} dxy Position relative to drag start, + * in workspace units. + * @param {?number} deleteArea One of {@link Blockly.DELETE_AREA_TRASH}, + * {@link Blockly.DELETE_AREA_TOOLBOX}, or {@link Blockly.DELETE_AREA_NONE}. + * @package + */ +Blockly.InsertionMarkerManager.prototype.update = function(dxy, deleteArea) { + var candidate = this.getCandidate_(dxy); + + this.wouldDeleteBlock_ = this.shouldDelete_(candidate, deleteArea); + var shouldUpdate = this.wouldDeleteBlock_ || + this.shouldUpdatePreviews_(candidate, dxy); + + if (shouldUpdate) { + // Don't fire events for insertion marker creation or movement. + Blockly.Events.disable(); + this.maybeHidePreview_(candidate); + this.maybeShowPreview_(candidate); + Blockly.Events.enable(); + } +}; + +/**** Begin initialization functions ****/ + +/** + * Create an insertion marker that represents the given block. + * @param {!Blockly.BlockSvg} sourceBlock The block that the insertion marker + * will represent. + * @return {!Blockly.BlockSvg} The insertion marker that represents the given + * block. + * @private + */ +Blockly.InsertionMarkerManager.prototype.createMarkerBlock_ = function(sourceBlock) { + var imType = sourceBlock.type; + + Blockly.Events.disable(); + try { + var result = this.workspace_.newBlock(imType); + result.setInsertionMarker(true, sourceBlock.width); + if (sourceBlock.mutationToDom) { + var oldMutationDom = sourceBlock.mutationToDom(); + if (oldMutationDom) { + result.domToMutation(oldMutationDom); + } + } + // Copy field values from the other block. These values may impact the + // rendered size of the insertion marker. Note that we do not care about + // child blocks here. + for (var i = 0; i < sourceBlock.inputList.length; i++) { + var input = sourceBlock.inputList[i]; + for (var j = 0; j < input.fieldRow.length; j++) { + var field = input.fieldRow[j]; + result.setFieldValue(field.getValue(), field.name); + } + } + + result.initSvg(); + result.getSvgRoot().setAttribute('visibility', 'hidden'); + } finally { + Blockly.Events.enable(); + } + + return result; +}; + +/** + * Populate the list of available connections on this block stack. This should + * only be called once, at the beginning of a drag. + * If the stack has more than one block, this function will populate + * lastOnStack_ and create the corresponding insertion marker. + * @return {!Array.} a list of available + * connections. + * @private + */ +Blockly.InsertionMarkerManager.prototype.initAvailableConnections_ = function() { + var available = this.topBlock_.getConnections_(false); + // Also check the last connection on this stack + var lastOnStack = this.topBlock_.lastConnectionInStack(); + if (lastOnStack && lastOnStack != this.topBlock_.nextConnection) { + available.push(lastOnStack); + this.lastOnStack_ = lastOnStack; + this.lastMarker_ = this.createMarkerBlock_(lastOnStack.sourceBlock_); + } + return available; +}; + +/**** End initialization functions ****/ + + +/** + * Whether the previews (insertion marker and replacement marker) should be + * updated based on the closest candidate and the current drag distance. + * @param {!Object} candidate An object containing a local connection, a closest + * connection, and a radius. Returned by getCandidate_. + * @param {!goog.math.Coordinate} dxy Position relative to drag start, + * in workspace units. + * @return {boolean} whether the preview should be updated. + * @private + */ +Blockly.InsertionMarkerManager.prototype.shouldUpdatePreviews_ = function( + candidate, dxy) { + var candidateLocal = candidate.local; + var candidateClosest = candidate.closest; + var radius = candidate.radius; + + // Found a connection! + if (candidateLocal && candidateClosest) { + // We're already showing an insertion marker. + // Decide whether the new connection has higher priority. + if (this.localConnection_ && this.closestConnection_) { + // The connection was the same as the current connection. + if (this.closestConnection_ == candidateClosest) { + return false; + } + var xDiff = this.localConnection_.x_ + dxy.x - this.closestConnection_.x_; + var yDiff = this.localConnection_.y_ + dxy.y - this.closestConnection_.y_; + var curDistance = Math.sqrt(xDiff * xDiff + yDiff * yDiff); + // Slightly prefer the existing preview over a new preview. + return !(candidateClosest && radius > curDistance - + Blockly.CURRENT_CONNECTION_PREFERENCE); + } else if (!this.localConnection_ && !this.closestConnection_) { + // We weren't showing a preview before, but we should now. + return true; + } else { + console.error('Only one of localConnection_ and closestConnection_ was set.'); + } + } else { // No connection found. + // Only need to update if we were showing a preview before. + return !!(this.localConnection_ && this.closestConnection_); + } + + console.error('Returning true from shouldUpdatePreviews, but it\'s not clear why.'); + return true; +}; + +/** + * Find the nearest valid connection, which may be the same as the current + * closest connection. + * @param {!goog.math.Coordinate} dxy Position relative to drag start, + * in workspace units. + * @return {!Object} candidate An object containing a local connection, a closest + * connection, and a radius. + */ +Blockly.InsertionMarkerManager.prototype.getCandidate_ = function(dxy) { + var radius = this.getStartRadius_(); + var candidateClosest = null; + var candidateLocal = null; + + for (var i = 0; i < this.availableConnections_.length; i++) { + var myConnection = this.availableConnections_[i]; + var neighbour = myConnection.closest(radius, dxy); + if (neighbour.connection) { + candidateClosest = neighbour.connection; + candidateLocal = myConnection; + radius = neighbour.radius; + } + } + return { + closest: candidateClosest, + local: candidateLocal, + radius: radius + }; +}; + +/** + * Decide the radius at which to start searching for the closest connection. + * @return {number} The radius at which to start the search for the closest + * connection. + * @private + */ +Blockly.InsertionMarkerManager.prototype.getStartRadius_ = function() { + // If there is already a connection highlighted, + // increase the radius we check for making new connections. + // Why? When a connection is highlighted, blocks move around when the insertion + // marker is created, which could cause the connection became out of range. + // By increasing radiusConnection when a connection already exists, + // we never "lose" the connection from the offset. + if (this.closestConnection_ && this.localConnection_) { + return Blockly.CONNECTING_SNAP_RADIUS; + } + return Blockly.SNAP_RADIUS; +}; + +/** + * Whether ending the drag would replace a block or insert a block. + * @return {boolean} True if dropping the block immediately would replace + * another block. False if dropping the block immediately would result in + * the block being inserted in a block stack. + * @private + */ +Blockly.InsertionMarkerManager.prototype.shouldReplace_ = function() { + var closest = this.closestConnection_; + var local = this.localConnection_; + + // Dragging a block over an existing block in an input. + if (local.type == Blockly.OUTPUT_VALUE) { + // Insert the dragged block into the stack if possible. + if (!closest.isConnected() || + Blockly.Connection.lastConnectionInRow_(this.topBlock_, + closest.targetConnection.getSourceBlock())) { + return false; // Insert. + } + // Otherwise replace the existing block and bump it out. + return true; // Replace. + } + + // Connecting to a statement input of c-block is an insertion, even if that + // c-block is terminal (e.g. forever). + if (local == local.sourceBlock_.getFirstStatementConnection()) { + return false; // Insert. + } + + // Dragging a terminal block over another (connected) terminal block will + // replace, not insert. + var isTerminalBlock = !this.topBlock_.nextConnection; + var isConnectedTerminal = isTerminalBlock && + local.type == Blockly.PREVIOUS_STATEMENT && closest.isConnected(); + if (isConnectedTerminal) { + return true; // Replace. + } + + // Otherwise it's an insertion. + return false; +}; + +/** + * Whether ending the drag would delete the block. + * @param {!Object} candidate An object containing a local connection, a closest + * connection, and a radius. + * @param {?number} deleteArea One of {@link Blockly.DELETE_AREA_TRASH}, + * {@link Blockly.DELETE_AREA_TOOLBOX}, or {@link Blockly.DELETE_AREA_NONE}. + * @return {boolean} True if dropping the block immediately would replace + * delete the block. False otherwise. + * @private + */ +Blockly.InsertionMarkerManager.prototype.shouldDelete_ = function(candidate, + deleteArea) { + // Prefer connecting over dropping into the trash can, but prefer dragging to + // the toolbox over connecting to other blocks. + var wouldConnect = candidate && !!candidate.closest && + deleteArea != Blockly.DELETE_AREA_TOOLBOX; + var wouldDelete = !!deleteArea && !this.topBlock_.getParent() && + this.topBlock_.isDeletable(); + + return wouldDelete && !wouldConnect; +}; + +/**** Begin preview visibility functions ****/ + +/** + * Show an insertion marker or replacement highlighting during a drag, if + * needed. + * At the beginning of this function, this.localConnection_ and + * this.closestConnection_ should both be null. + * @param {!Object} candidate An object containing a local connection, a closest + * connection, and a radius. + * @private + */ +Blockly.InsertionMarkerManager.prototype.maybeShowPreview_ = function(candidate) { + // Nope, don't add a marker. + if (this.wouldDeleteBlock_) { + return; + } + var closest = candidate.closest; + var local = candidate.local; + + // Nothing to connect to. + if (!closest) { + return; + } + + // Something went wrong and we're trying to connect to an invalid connection. + if (closest == this.closestConnection_ || + closest.sourceBlock_.isInsertionMarker()) { + console.log("trying to connect to an insertion marker"); + return; + } + // Add an insertion marker or replacement marker. + this.closestConnection_ = closest; + this.localConnection_ = local; + this.showPreview_(); +}; + +/** + * A preview should be shown. This function figures out if it should be a block + * highlight or an insertion marker, and shows the appropriate one. + * @private + */ +Blockly.InsertionMarkerManager.prototype.showPreview_ = function() { + if (this.shouldReplace_()) { + this.highlightBlock_(); + } else { // Should insert + this.connectMarker_(); + } + // Also highlight the actual connection, as a nod to previous behaviour. + if (this.closestConnection_) { + this.closestConnection_.highlight(); + } +}; + +/** + * Show an insertion marker or replacement highlighting during a drag, if + * needed. + * At the end of this function, this.localConnection_ and + * this.closestConnection_ should both be null. + * @param {!Object} candidate An object containing a local connection, a closest + * connection, and a radius. + * @private + */ +Blockly.InsertionMarkerManager.prototype.maybeHidePreview_ = function(candidate) { + // If there's no new preview, remove the old one but don't bother deleting it. + // We might need it later, and this saves disposing of it and recreating it. + if (!candidate.closest) { + this.hidePreview_(); + } else { + // If there's a new preview and there was an preview before, and either + // connection has changed, remove the old preview. + var hadPreview = this.closestConnection_ && this.localConnection_; + var closestChanged = this.closestConnection_ != candidate.closest; + var localChanged = this.localConnection_ != candidate.local; + + // Also hide if we had a preview before but now we're going to delete instead. + if (hadPreview && (closestChanged || localChanged || this.wouldDeleteBlock_)) { + this.hidePreview_(); + } + } + + // Either way, clear out old state. + this.markerConnection_ = null; + this.closestConnection_ = null; + this.localConnection_ = null; +}; + +/** + * A preview should be hidden. This function figures out if it is a block + * highlight or an insertion marker, and hides the appropriate one. + * @private + */ +Blockly.InsertionMarkerManager.prototype.hidePreview_ = function() { + if (this.closestConnection_) { + this.closestConnection_.unhighlight(); + } + if (this.highlightingBlock_) { + this.unhighlightBlock_(); + } else if (this.markerConnection_) { + this.disconnectMarker_(); + } +}; + +/**** End preview visibility functions ****/ + +/**** Begin block highlighting functions ****/ + +/** + * Add highlighting showing which block will be replaced. + */ +Blockly.InsertionMarkerManager.prototype.highlightBlock_ = function() { + var closest = this.closestConnection_; + var local = this.localConnection_; + if (closest.targetBlock()) { + this.highlightedBlock_ = closest.targetBlock(); + closest.targetBlock().highlightForReplacement(true); + } else if (local.type == Blockly.OUTPUT_VALUE) { + this.highlightedBlock_ = closest.sourceBlock_; + // TODO: remove? + closest.sourceBlock_.highlightShapeForInput(closest, true); + } + this.highlightingBlock_ = true; +}; + +/** + * Get rid of the highlighting marking the block that will be replaced. + */ +Blockly.InsertionMarkerManager.prototype.unhighlightBlock_ = function() { + var closest = this.closestConnection_; + // If there's no block in place, but we're still connecting to a value input, + // then we must have been highlighting an input shape. + if (closest.type == Blockly.INPUT_VALUE && !closest.isConnected()) { + this.highlightedBlock_.highlightShapeForInput(closest, false); + } else { + this.highlightedBlock_.highlightForReplacement(false); + } + this.highlightedBlock_ = null; + this.highlightingBlock_ = false; +}; + +/**** End block highlighting functions ****/ + +/**** Begin insertion marker display functions ****/ + +/** + * Disconnect the insertion marker block in a manner that returns the stack to + * original state. + * @private + */ +Blockly.InsertionMarkerManager.prototype.disconnectMarker_ = function() { + if (!this.markerConnection_) { + console.log('No insertion marker connection to disconnect'); + return; + } + + var imConn = this.markerConnection_; + var imBlock = imConn.sourceBlock_; + var markerNext = imBlock.nextConnection; + var markerPrev = imBlock.previousConnection; + var markerOutput = imBlock.outputConnection; + + var isFirstInStatementStack = + (imConn == markerNext && !(markerPrev && markerPrev.targetConnection)); + + var isFirstInOutputStack = imConn.type == Blockly.INPUT_VALUE && + !(markerOutput && markerOutput.targetConnection); + // The insertion marker is the first block in a stack. Unplug won't do + // anything in that case. Instead, unplug the following block. + if (isFirstInStatementStack || isFirstInOutputStack) { + imConn.targetBlock().unplug(false); + } + // Inside of a C-block, first statement connection. + else if (imConn.type == Blockly.NEXT_STATEMENT && imConn != markerNext) { + var innerConnection = imConn.targetConnection; + innerConnection.sourceBlock_.unplug(false); + + var previousBlockNextConnection = + markerPrev ? markerPrev.targetConnection : null; + + imBlock.unplug(true); + if (previousBlockNextConnection) { + previousBlockNextConnection.connect(innerConnection); + } + } else { + imBlock.unplug(true /* healStack */); + } + + if (imConn.targetConnection) { + throw 'markerConnection_ still connected at the end of disconnectInsertionMarker'; + } + + this.markerConnection_ = null; + imBlock.getSvgRoot().setAttribute('visibility', 'hidden'); +}; + +/** + * Add an insertion marker connected to the appropriate blocks. + * @private + */ +Blockly.InsertionMarkerManager.prototype.connectMarker_ = function() { + var local = this.localConnection_; + var closest = this.closestConnection_; + + var isLastInStack = this.lastOnStack_ && local == this.lastOnStack_; + var imBlock = isLastInStack ? this.lastMarker_ : this.firstMarker_; + var imConn = imBlock.getMatchingConnection(local.sourceBlock_, local); + + goog.asserts.assert(imConn != this.markerConnection_, + 'Made it to connectMarker_ even though the marker isn\'t changing'); + + // Render disconnected from everything else so that we have a valid + // connection location. + imBlock.render(); + imBlock.rendered = true; + imBlock.getSvgRoot().setAttribute('visibility', 'visible'); + + // Position based on the calculated connection locations. + imBlock.positionNewBlock(imBlock, imConn, closest); + + // Connect() also renders the insertion marker. + imConn.connect(closest); + this.markerConnection_ = imConn; +}; + +/**** End insertion marker display functions ****/ diff --git a/tests/jsunit/connection_db_test.js b/tests/jsunit/connection_db_test.js index c9eb6add1..f488d5a96 100644 --- a/tests/jsunit/connection_db_test.js +++ b/tests/jsunit/connection_db_test.js @@ -279,6 +279,7 @@ function helper_searchDB(db, x, y, radius, shared_workspace) { var tempConn = helper_createConnection(x, y, Blockly.NEXT_STATEMENT, shared_workspace, true); tempConn.sourceBlock_ = helper_makeSourceBlock(shared_workspace); + tempConn.sourceBlock_.nextConnection = tempConn; var closest = db.searchForClosest(tempConn, radius, {x: 0, y: 0}); return closest.connection; } @@ -289,7 +290,9 @@ function helper_makeSourceBlock(sharedWorkspace) { getParent: function() { return null; }, movable_: true, isMovable: function() { return true; }, - isShadow: function() { return false; } + isShadow: function() { return false; }, + isInsertionMarker: function() { return false; }, + getFirstStatementConnection: function() { return null; } }; } diff --git a/tests/jsunit/connection_test.js b/tests/jsunit/connection_test.js index 0d10e63ea..1f7e5cdab 100644 --- a/tests/jsunit/connection_test.js +++ b/tests/jsunit/connection_test.js @@ -313,6 +313,112 @@ function test_isConnectionAllowed_NoNext() { assertTrue(two.isConnectionAllowed(one)); } +function test_canConnectToPrevious_alreadyConnected() { + var sharedWorkspace = {}; + var one = helper_createConnection(0, 0, Blockly.NEXT_STATEMENT); + one.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + one.sourceBlock_.nextConnection = one; + + var two = helper_createConnection(0, 0, Blockly.PREVIOUS_STATEMENT); + two.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + two.sourceBlock_.previousConnection = two; + + Blockly.Connection.connectReciprocally_(one, two); + + var three = helper_createConnection(0, 0, Blockly.PREVIOUS_STATEMENT); + three.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + three.sourceBlock_.previousConnection = three; + + // The next connection is already occupied and cannot disconnect itself + // mid-drag. + assertFalse(one.canConnectToPrevious_(three)); +} + +function test_canConnect_dragging() { + var sharedWorkspace = {}; + var one = helper_createConnection(0, 0, Blockly.NEXT_STATEMENT); + one.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + one.sourceBlock_.nextConnection = one; + + var two = helper_createConnection(0, 0, Blockly.PREVIOUS_STATEMENT); + two.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + two.sourceBlock_.previousConnection = two; + + Blockly.Connection.connectReciprocally_(one, two); + + Blockly.draggingConnections_.push(one); + Blockly.draggingConnections_.push(two); + + assertFalse(two.isConnectionAllowed(one)); + assertFalse(one.isConnectionAllowed(two)); +} + +function test_canConnect_stackStart() { + var sharedWorkspace = {}; + var block1Next = helper_createConnection(0, 0, Blockly.NEXT_STATEMENT); + var block1 = helper_makeSourceBlock(sharedWorkspace); + block1Next.sourceBlock_ = block1 + block1.nextConnection = block1Next; + + var block1Prev = helper_createConnection(0, 0, Blockly.PREVIOUS_STATEMENT); + block1Prev.sourceBlock_ = block1; + block1.previousConnection = block1Prev + + var block2Prev = helper_createConnection(0, 0, Blockly.PREVIOUS_STATEMENT); + block2Prev.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + block2Prev.sourceBlock_.previousConnection = block2Prev; + + Blockly.Connection.connectReciprocally_(block1Next, block2Prev); + + var three = helper_createConnection(0, 0, Blockly.NEXT_STATEMENT); + three.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + three.sourceBlock_.nextConnection = three; + + // Can connect at the beginning of the stack. + assertTrue(three.canConnectToPrevious_(block1Prev)); + // But not in the middle of the stack. + assertFalse(three.canConnectToPrevious_(block2Prev)); +} + +function test_canConnect_stackStart_insertionMarker() { + var sharedWorkspace = {}; + var block1Next = helper_createConnection(0, 0, Blockly.NEXT_STATEMENT); + var block1 = helper_makeSourceBlock(sharedWorkspace); + block1.isInsertionMarker = function() { + return true; + } + block1.getPreviousBlock = function() { + return false; + } + block1Next.sourceBlock_ = block1 + block1.nextConnection = block1Next; + + var block1Prev = helper_createConnection(0, 0, Blockly.PREVIOUS_STATEMENT); + block1Prev.sourceBlock_ = block1; + block1.previousConnection = block1Prev + + var block2Prev = helper_createConnection(0, 0, Blockly.PREVIOUS_STATEMENT); + var block2 = helper_makeSourceBlock(sharedWorkspace); + block2Prev.sourceBlock_ = block2; + block2.previousConnection = block2Prev; + block2.getPreviousBlock = function() { + return block1; + } + + + Blockly.Connection.connectReciprocally_(block1Next, block2Prev); + + var three = helper_createConnection(0, 0, Blockly.NEXT_STATEMENT); + three.sourceBlock_ = helper_makeSourceBlock(sharedWorkspace); + three.sourceBlock_.nextConnection = three; + + // Can't connect to the previous connection of an insertion marker. + assertFalse(three.isConnectionAllowed(block1Prev)); + // But can connect to a previous connection that is already connected to an + // insertion marker. + assertTrue(three.isConnectionAllowed(block2Prev)); +} + function testCheckConnection_Okay() { connectionTest_setUp(); previous.checkConnection_(next);