diff --git a/core/keyboard_nav/ast_node.js b/core/keyboard_nav/ast_node.js new file mode 100644 index 000000000..53d77c053 --- /dev/null +++ b/core/keyboard_nav/ast_node.js @@ -0,0 +1,705 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 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 The class representing an ast node. + * Used to traverse the blockly ast. + */ +'use strict'; + +goog.provide('Blockly.ASTNode'); + +/** + * Class for an ast node. + * It is recommended that you use one of the createNode methods instead of + * creating a node directly. + * @constructor + * @param {!string} type The type of the location. + * Must be in Bockly.ASTNode.types. + * @param {Blockly.Block|Blockly.Connection|Blockly.Field|Blockly.Workspace} + * location The position in the ast. + * @param {Object=} params Optional dictionary of options. + */ +Blockly.ASTNode = function(type, location, params) { + if (!location) { + throw Error('Cannot create a node without a location.'); + } + + /** + * The type of the location. + * One of Blockly.ASTNode.types + * @type {string} + * @private + */ + this.type_ = type; + + /** + * Whether the location points to a connection. + * @type {boolean} + * @private + */ + this.isConnection_ = Blockly.ASTNode.isConnectionType(type); + + /** + * The location of the ast node. + * @type {!(Blockly.Block|Blockly.Connection|Blockly.Field|Blockly.Workspace)} + * @private + */ + this.location_ = location; + + this.processParams_(params || null); + +}; + +/** + * Object holding different types for an ast node. + */ +Blockly.ASTNode.types = { + FIELD: 'field', + BLOCK: 'block', + INPUT: 'input', + OUTPUT: 'output', + NEXT: 'next', + PREVIOUS: 'previous', + STACK: 'stack', + WORKSPACE: 'workspace' +}; + +/** + * The amount to move the workspace coordinate to the left or right. + * This occurs when we get the next or previous node from a workspace node. + * @type {number} + * @private + */ +Blockly.ASTNode.wsMove_ = 10; + +/** + * The default y offset to use when moving the cursor from a stack to the + * workspace. + * @type {number} + * @private + */ +Blockly.ASTNode.DEFAULT_OFFSET_Y = -20; + +/** + * Whether an ast node of the given type points to a connection. + * @param {string} type The type to check. One of Blockly.ASTNode.types. + * @return {boolean} True if a node of the given type points to a connection. + * @package + */ +Blockly.ASTNode.isConnectionType = function(type) { + switch (type) { + case Blockly.ASTNode.types.PREVIOUS: + case Blockly.ASTNode.types.NEXT: + case Blockly.ASTNode.types.INPUT: + case Blockly.ASTNode.types.OUTPUT: + return true; + } + return false; +}; + +/** + * Create an ast node pointing to a field. + * @param {Blockly.Field} field The location of the ast node. + * @return {Blockly.ASTNode} An ast node pointing to a field. + */ +Blockly.ASTNode.createFieldNode = function(field) { + return new Blockly.ASTNode(Blockly.ASTNode.types.FIELD, field); +}; + +/** + * Creates an ast node pointing to a connection. If the connection has a parent + * input then create an ast node of type input that will hold the connection. + * @param {Blockly.Connection} connection This is the connection the node will + * point to. + * @return {Blockly.ASTNode} An ast node pointing to a connection. + */ +Blockly.ASTNode.createConnectionNode = function(connection) { + if (!connection){ + return null; + } + if (connection.type === Blockly.INPUT_VALUE) { + return Blockly.ASTNode.createInputNode(connection.getParentInput()); + } else if (connection.type === Blockly.NEXT_STATEMENT + && connection.getParentInput()) { + return Blockly.ASTNode.createInputNode(connection.getParentInput()); + } else if (connection.type === Blockly.NEXT_STATEMENT) { + return new Blockly.ASTNode(Blockly.ASTNode.types.NEXT, connection); + } else if (connection.type === Blockly.OUTPUT_VALUE) { + return new Blockly.ASTNode(Blockly.ASTNode.types.OUTPUT, connection); + } else if (connection.type === Blockly.PREVIOUS_STATEMENT) { + return new Blockly.ASTNode(Blockly.ASTNode.types.PREVIOUS, connection); + } + return null; +}; + +/** + * Creates an ast node pointing to an input. Stores the input connection as the + * location. + * @param {Blockly.Input} input The input used to create an ast node. + * @return {Blockly.ASTNode} An ast node pointing to a input. + */ +Blockly.ASTNode.createInputNode = function(input) { + if (!input) { + return null; + } + var params = { + "input": input + }; + return new Blockly.ASTNode(Blockly.ASTNode.types.INPUT, input.connection, params); +}; + +/** + * Creates an ast node pointing to a block. + * @param {Blockly.Block} block The block used to create an ast node. + * @return {Blockly.ASTNode} An ast node pointing to a block. + */ +Blockly.ASTNode.createBlockNode = function(block) { + return new Blockly.ASTNode(Blockly.ASTNode.types.BLOCK, block); +}; + +/** + * Create an ast node of type stack. A stack, represented by its top block, is + * the set of all blocks connected to a top block, including the top block. + * @param {Blockly.Block} topBlock A top block has no parent and can be found + * in the list returned by workspace.getTopBlocks(). + * @return {Blockly.ASTNode} An ast node of type stack that points to the top + * block on the stack. + */ +Blockly.ASTNode.createStackNode = function(topBlock) { + return new Blockly.ASTNode(Blockly.ASTNode.types.STACK, topBlock); +}; + +/** + * Creates an ast node pointing to a workpsace. + * @param {Blockly.Workspace} workspace The workspace that we are on. + * @param {Blockly.utils.Coordinate} wsCoordinate The position on the workspace for + * this node. + * @return {Blockly.ASTNode} An ast node pointing to a workspace and a position + * on the workspace. + */ +Blockly.ASTNode.createWorkspaceNode = function(workspace, wsCoordinate) { + var params = { + "wsCoordinate": wsCoordinate + }; + return new Blockly.ASTNode( + Blockly.ASTNode.types.WORKSPACE, workspace, params); +}; + +/** + * Parse the optional parameters. + * @param {Object} params The user specified parameters. + * @private + */ +Blockly.ASTNode.prototype.processParams_ = function(params){ + if (!params) { + return; + } + if (params['wsCoordinate']) { + this.wsCoordinate_ = params['wsCoordinate']; + } else if (params['input']) { + this.parentInput_ = params['input']; + } +}; + +/** + * Gets the value pointed to by this node. + * It is the callers responsibility to check the node type to figure out what + * type of object they get back from this. + * @return {!(Blockly.Field|Blockly.Connection|Blockly.Block|Blockly.Workspace)} + * The current field, connection, workspace, or block the cursor is on. + */ +Blockly.ASTNode.prototype.getLocation = function() { + return this.location_; +}; + +/** + * The type of the current location. + * One of Blockly.ASTNode.types + * @return {string} The type of the location. + */ +Blockly.ASTNode.prototype.getType = function() { + return this.type_; +}; + +/** + * The coordinate on the workspace. + * @return {Blockly.utils.Coordinate} The workspace coordinate or null if the + * location is not a workspace. + */ +Blockly.ASTNode.prototype.getWsCoordinate = function() { + return this.wsCoordinate_; +}; + +/** + * Get the parent input of the location. + * @return {Blockly.Input} The parent input of the location or null if the node + * is not input type. + * @package + */ +Blockly.ASTNode.prototype.getParentInput = function() { + return this.parentInput_; +}; + +/** + * Whether the node points to a connection. + * @return {boolean} [description] + * @package + */ +Blockly.ASTNode.prototype.isConnection = function() { + return this.isConnection_; +}; + +/** + * Get either the previous editable field, or get the first editable field for + * the given input. + * @param {!(Blockly.Field|Blockly.Connection)} location The current location of + * the cursor, which must be a field or connection. + * @param {!Blockly.Input} parentInput The parentInput of the field. + * @param {boolean=} opt_last If true find the last editable field otherwise get + * the previous field. + * @return {Blockly.ASTNode} The ast node holding the previous or last field or + * null if no previous field exists. + * @private + */ +Blockly.ASTNode.prototype.findPreviousEditableField_ = function(location, + parentInput, opt_last) { + var fieldRow = parentInput.fieldRow; + var fieldIdx = fieldRow.indexOf(location); + var previousField = null; + var startIdx = opt_last ? fieldRow.length - 1 : fieldIdx - 1; + for (var i = startIdx, field; field = fieldRow[i]; i--) { + if (field.isCurrentlyEditable()) { + previousField = field; + return Blockly.ASTNode.createFieldNode(previousField); + } + } + return null; +}; + +/** + * Given an input find the next editable field or an input with a non null + * connection in the same block. The current location must be an input + * connection. + * @return {Blockly.ASTNode} The ast node holding the next field or connection + * or null if there is no editable field or input connection after the given + * input. + * @private + */ +Blockly.ASTNode.prototype.findNextForInput_ = function() { + var parentInput = this.location_.getParentInput(); + var block = parentInput.getSourceBlock(); + var curIdx = block.inputList.indexOf(parentInput); + for (var i = curIdx + 1, input; input = block.inputList[i]; i++) { + var fieldRow = input.fieldRow; + for (var j = 0, field; field = fieldRow[j]; j++) { + if (field.isCurrentlyEditable()) { + return Blockly.ASTNode.createFieldNode(field); + } + } + if (input.connection) { + return Blockly.ASTNode.createInputNode(input); + } + } + return null; +}; + +/** + * Given a field find the next editable field or an input with a non null + * connection in the same block. The current location must be a field. + * @return {Blockly.ASTNode} The ast node pointing to the next field or + * connection or null if there is no editable field or input connection + * after the given input. + * @private + */ +Blockly.ASTNode.prototype.findNextForField_ = function() { + var location = this.location_; + var input = location.getParentInput(); + var block = location.getSourceBlock(); + var curIdx = block.inputList.indexOf(input); + var fieldIdx = input.fieldRow.indexOf(location) + 1; + for (var i = curIdx, input; input = block.inputList[i]; i++) { + var fieldRow = input.fieldRow; + while (fieldIdx < fieldRow.length) { + if (fieldRow[fieldIdx].isCurrentlyEditable()) { + return Blockly.ASTNode.createFieldNode(fieldRow[fieldIdx]); + } + fieldIdx++; + } + fieldIdx = 0; + if (input.connection) { + return Blockly.ASTNode.createInputNode(input); + } + } + return null; +}; + +/** + * Given an input find the previous editable field or an input with a non null + * connection in the same block. The current location must be an input + * connection. + * @return {Blockly.ASTNode} The ast node holding the previous field or + * connection. + * @private + */ +Blockly.ASTNode.prototype.findPrevForInput_ = function(){ + var location = this.location_.getParentInput(); + var block = location.getSourceBlock(); + var curIdx = block.inputList.indexOf(location); + for (var i = curIdx, input; input = block.inputList[i]; i--) { + if (input.connection && input !== location) { + return Blockly.ASTNode.createInputNode(input); + } + var fieldRow = input.fieldRow; + for (var j = fieldRow.length - 1, field; field = fieldRow[j]; j--) { + if (field.isCurrentlyEditable()) { + return Blockly.ASTNode.createFieldNode(field); + } + } + } + return null; +}; + +/** + * Given a field find the previous editable field or an input with a non null + * connection in the same block. The current location must be a field. + * @return {Blockly.ASTNode} The ast node holding the previous input or field. + * @private + */ +Blockly.ASTNode.prototype.findPrevForField_ = function() { + var location = this.location_; + var parentInput = location.getParentInput(); + var block = location.getSourceBlock(); + var curIdx = block.inputList.indexOf(parentInput); + var fieldIdx = parentInput.fieldRow.indexOf(location) - 1; + for (var i = curIdx, input; input = block.inputList[i]; i--) { + if (input.connection && input !== parentInput) { + return Blockly.ASTNode.createInputNode(input); + } + var fieldRow = input.fieldRow; + while (fieldIdx > -1) { + if (fieldRow[fieldIdx].isCurrentlyEditable()) { + return Blockly.ASTNode.createFieldNode(fieldRow[fieldIdx]); + } + fieldIdx--; + } + //Reset the fieldIdx to the length of the field row of the previous input + if (i - 1 >= 0) { + fieldIdx = block.inputList[i - 1].fieldRow.length - 1; + } + } + return null; +}; + +/** + * Navigate between stacks of blocks on the workspace. + * @param {boolean} forward True to go forward. False to go backwards. + * @return {Blockly.ASTNode} The first block of the next stack or null if there + * are no blocks on the workspace. + * @private + */ +Blockly.ASTNode.prototype.navigateBetweenStacks_ = function(forward) { + var curLocation = this.getLocation(); + if (!(curLocation instanceof Blockly.Block)) { + curLocation = curLocation.getSourceBlock(); + } + if (!curLocation) { + return null; + } + var curRoot = curLocation.getRootBlock(); + var topBlocks = curRoot.workspace.getTopBlocks(true); + for (var i = 0, topBlock; topBlock = topBlocks[i]; i++) { + if (curRoot.id == topBlock.id) { + var offset = forward ? 1 : -1; + var resultIndex = i + offset; + if (resultIndex == -1 || resultIndex == topBlocks.length) { + return null; + } + return Blockly.ASTNode.createStackNode(topBlocks[resultIndex]); + } + } + throw Error('Couldn\'t find ' + (forward ? 'next' : 'previous') + + ' stack?!?!?!'); +}; + +/** + * Finds the top most ast node for a given block. + * This is either the previous connection, output connection or block depending + * on what kind of connections the block has. + * @param {Blockly.Block} block The block that we want to find the top + * connection on. + * @return {!Blockly.ASTNode} The ast node containing the top connection. + * @private + */ +Blockly.ASTNode.prototype.findTopASTNodeForBlock_ = function(block) { + var topConnection = block.previousConnection || block.outputConnection; + if (topConnection) { + return Blockly.ASTNode.createConnectionNode(topConnection); + } else { + return Blockly.ASTNode.createBlockNode(block); + } +}; + +/** + * Get the ast node pointing to the input that the block is nested under or if + * the block is not nested then get the stack ast node. + * @param {Blockly.Block} block The source block of the current location. + * @return {Blockly.ASTNode} The ast node pointing to the input connection or + * the top block of the stack this block is in. + * @private + */ +Blockly.ASTNode.prototype.getOutAstNodeForBlock_ = function(block) { + var topBlock = null; + //If the block doesn't have a previous connection then it is the top of the + //substack + if (!block.previousConnection) { + topBlock = block; + } else { + topBlock = this.findTopOfSubStack_(block); + } + var topConnection = topBlock.previousConnection || topBlock.outputConnection; + //If the top connection has a parentInput, create an ast node pointing to that input + if (topConnection && topConnection.targetConnection && + topConnection.targetConnection.getParentInput()) { + return Blockly.ASTNode.createInputNode( + topConnection.targetConnection.getParentInput()); + } else { + //Go to stack level if you are not underneath an input + return Blockly.ASTNode.createStackNode(topBlock); + } +}; + +/** + * Find the first editable field or input with a connection on a given block. + * @param {!Blockly.BlockSvg} block The source block of the current location. + * @return {Blockly.ASTNode} An ast node pointing to the first field or input. + * Null if there are no editable fields or inputs with connections on the block. + * @private + */ +Blockly.ASTNode.prototype.findFirstFieldOrInput_ = function(block) { + var inputs = block.inputList; + for (var i = 0, input; input = inputs[i]; i++) { + var fieldRow = input.fieldRow; + for (var j = 0, field; field = fieldRow[j]; j++) { + if (field.isCurrentlyEditable()) { + return Blockly.ASTNode.createFieldNode(field); + } + } + if (input.connection) { + return Blockly.ASTNode.createInputNode(input); + } + } + return null; +}; + +/** + * Walk backwards from the given block up through the stack of blocks to find + * the top block of the sub stack. If we are nested in a statement input only + * find the top most nested block. Do not go all the way to the top of the + * stack. + * @param {!Blockly.Block} sourceBlock A block in the stack. + * @return {!Blockly.Block} The top block in a stack. + * @private + */ +Blockly.ASTNode.prototype.findTopOfSubStack_ = function(sourceBlock) { + var topBlock = sourceBlock; + while (topBlock && topBlock.previousConnection + && topBlock.previousConnection.targetConnection + && topBlock.previousConnection.targetBlock().nextConnection + == topBlock.previousConnection.targetConnection) { + topBlock = topBlock.previousConnection.targetBlock(); + } + return topBlock; +}; + +/** + * Find the element to the right of the current element in the ast. + * @return {Blockly.ASTNode} An ast node that wraps the next field, connection, + * block, or workspace. Or null if there is no node to the right. + */ +Blockly.ASTNode.prototype.next = function() { + switch (this.type_) { + case Blockly.ASTNode.types.WORKSPACE: + //TODO: Need to limit this. The view is bounded to half a screen beyond + //the furthest block. + var newX = this.wsCoordinate_.x + Blockly.ASTNode.wsMove_; + var newWsCoordinate = new Blockly.utils.Coordinate(newX, this.wsCoordinate_.y); + var workspace = /** @type {Blockly.Workspace} */ (this.location_); + return Blockly.ASTNode.createWorkspaceNode(workspace, + newWsCoordinate); + + case Blockly.ASTNode.types.STACK: + return this.navigateBetweenStacks_(true); + + case Blockly.ASTNode.types.OUTPUT: + return Blockly.ASTNode.createBlockNode(this.location_.getSourceBlock()); + + case Blockly.ASTNode.types.FIELD: + return this.findNextForField_(); + + case Blockly.ASTNode.types.INPUT: + return this.findNextForInput_(); + + case Blockly.ASTNode.types.BLOCK: + var nextConnection = this.location_.nextConnection; + if (nextConnection) { + return Blockly.ASTNode.createConnectionNode(nextConnection); + } + break; + + case Blockly.ASTNode.types.PREVIOUS: + return Blockly.ASTNode.createBlockNode(this.location_.getSourceBlock()); + + case Blockly.ASTNode.types.NEXT: + var targetConnection = this.location_.targetConnection; + if (targetConnection) { + return Blockly.ASTNode.createConnectionNode(targetConnection); + } + break; + } + + return null; +}; + +/** + * Find the element one level below and all the way to the left of the current + * location. + * @return {Blockly.ASTNode} An ast node that wraps the next field, connection, + * workspace, or block. Or null if there is nothing below this node. + */ +Blockly.ASTNode.prototype.in = function() { + switch (this.type_) { + case Blockly.ASTNode.types.WORKSPACE: + var topBlocks = this.location_.getTopBlocks(true); + if (topBlocks.length > 0) { + return Blockly.ASTNode.createStackNode(topBlocks[0]); + } + break; + + case Blockly.ASTNode.types.STACK: + var block = /** @type {!Blockly.Block} */ (this.location_); + return this.findTopASTNodeForBlock_(block); + + case Blockly.ASTNode.types.BLOCK: + return this.findFirstFieldOrInput_(this.location_); + + case Blockly.ASTNode.types.INPUT: + var targetConnection = this.location_.targetConnection; + if (targetConnection) { + return Blockly.ASTNode.createConnectionNode(targetConnection); + } + break; + + } + return null; +}; + +/** + * Find the element to the left of the current element in the ast. + * @return {Blockly.ASTNode} An ast node that wraps the previous field, + * connection, workspace or block. Or null if no node exists to the left. + * null. + */ +Blockly.ASTNode.prototype.prev = function() { + switch (this.type_) { + case Blockly.ASTNode.types.WORKSPACE: + var newX = this.wsCoordinate_.x - Blockly.ASTNode.wsMove_; + var newCoord = new Blockly.utils.Coordinate(newX, this.wsCoordinate_.y); + var ws = /** @type {Blockly.Workspace} */ (this.location_); + return Blockly.ASTNode.createWorkspaceNode(ws, newCoord); + + case Blockly.ASTNode.types.STACK: + return this.navigateBetweenStacks_(false); + + case Blockly.ASTNode.types.OUTPUT: + return null; + + case Blockly.ASTNode.types.FIELD: + return this.findPrevForField_(); + + case Blockly.ASTNode.types.INPUT: + return this.findPrevForInput_(); + + case Blockly.ASTNode.types.BLOCK: + var prevConnection = this.location_.previousConnection; + var outputConnection = this.location_.outputConnection; + var topConnection = prevConnection || outputConnection; + if (topConnection) { + return Blockly.ASTNode.createConnectionNode(topConnection); + } + break; + + case Blockly.ASTNode.types.PREVIOUS: + var targetConnection = this.location_.targetConnection; + if (targetConnection) { + return Blockly.ASTNode.createConnectionNode(targetConnection); + } + break; + + case Blockly.ASTNode.types.NEXT: + return Blockly.ASTNode.createBlockNode(this.location_.getSourceBlock()); + } + + return null; +}; + +/** + * Find the next element that is one position above and all the way to the left + * of the current location. + * @return {Blockly.ASTNode} An ast node that wraps the next field, connection, + * workspace or block. Or null if we are at the workspace level. + */ +Blockly.ASTNode.prototype.out = function() { + switch (this.type_) { + case Blockly.ASTNode.types.STACK: + var blockPos = this.location_.getRelativeToSurfaceXY(); + //TODO: Make sure this is in the bounds of the workspace + var wsCoordinate = new Blockly.utils.Coordinate( + blockPos.x, blockPos.y + Blockly.ASTNode.DEFAULT_OFFSET_Y); + return Blockly.ASTNode.createWorkspaceNode( + this.location_.workspace, wsCoordinate); + + case Blockly.ASTNode.types.OUTPUT: + var target = this.location_.targetConnection; + if (target) { + return Blockly.ASTNode.createConnectionNode(target); + } else { + return Blockly.ASTNode.createStackNode(this.location_.getSourceBlock()); + } + + case Blockly.ASTNode.types.FIELD: + return Blockly.ASTNode.createBlockNode(this.location_.getSourceBlock()); + + case Blockly.ASTNode.types.INPUT: + return Blockly.ASTNode.createBlockNode(this.location_.getSourceBlock()); + + case Blockly.ASTNode.types.BLOCK: + var block = /** @type {!Blockly.Block} */ (this.location_); + return this.getOutAstNodeForBlock_(block); + + case Blockly.ASTNode.types.PREVIOUS: + return this.getOutAstNodeForBlock_(this.location_.getSourceBlock()); + + case Blockly.ASTNode.types.NEXT: + return this.getOutAstNodeForBlock_(this.location_.getSourceBlock()); + } + + return null; +}; diff --git a/core/keyboard_nav/cursor.js b/core/keyboard_nav/cursor.js new file mode 100644 index 000000000..df494fb09 --- /dev/null +++ b/core/keyboard_nav/cursor.js @@ -0,0 +1,163 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 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 The class representing a cursor. + * Used primarily for keyboard navigation. + */ +'use strict'; + +goog.provide('Blockly.Cursor'); + +/** + * Class for a cursor. + * @constructor + */ +Blockly.Cursor = function() { + /* + * The current location of the cursor. + * @type {Blockly.Field|Blockly.Connection|Blockly.Block} + * @private + */ + this.curNode_ = null; +}; + +/** + * Object holding different types for a cursor. + */ +Blockly.Cursor.prototype.types = { + FIELD: 'field', + BLOCK: 'block', + INPUT: 'input', + OUTPUT: 'output', + NEXT: 'next', + PREVIOUS: 'previous', + STACK: 'stack', + WORKSPACE: 'workspace' +}; + +/** + * Gets the current location of the cursor. + * @return {Blockly.ASTNode} The current field, connection, or block the cursor + * is on. + */ +Blockly.Cursor.prototype.getCurNode = function() { + return this.curNode_; +}; + +/** + * Set the location of the cursor and call the update method. + * Setting isStack to true will only work if the newLocation is the top most + * output or previous connection on a stack. + * @param {Blockly.ASTNode} newNode The new location of the cursor. + */ +Blockly.Cursor.prototype.setLocation = function(newNode) { + this.curNode_ = newNode; + this.update_(); +}; + +/** + * Update method to be overwritten in cursor_svg. + * @protected + */ +Blockly.Cursor.prototype.update_ = function() {}; + +/** + * Find the next connection, field, or block. + * @return {Blockly.ASTNode} The next element, or null if the current node is + * not set or there is no next value. + */ +Blockly.Cursor.prototype.next = function() { + var curNode = this.getCurNode(); + if (!curNode) { + return null; + } + var newNode = curNode.next(); + + if (newNode && newNode.getType() === Blockly.ASTNode.types.NEXT) { + newNode = newNode.next() || newNode; + } + + if (newNode) { + this.setLocation(newNode); + } + return newNode; +}; + +/** + * Find the in connection or field. + * @return {Blockly.ASTNode} The in element, or null if the current node is + * not set or there is no in value. + */ +Blockly.Cursor.prototype.in = function() { + var curNode = this.getCurNode(); + if (!curNode) { + return null; + } + var newNode = curNode.in(); + + if (newNode && newNode.getType() === Blockly.ASTNode.types.OUTPUT) { + newNode = newNode.next() || newNode; + } + + if (newNode) { + this.setLocation(newNode); + } + return newNode; +}; + +/** + * Find the previous connection, field, or block. + * @return {Blockly.ASTNode} The previous element, or null if the current node + * is not set or there is no previous value. + */ +Blockly.Cursor.prototype.prev = function() { + var curNode = this.getCurNode(); + if (!curNode) { + return null; + } + var newNode = curNode.prev(); + + if (newNode && newNode.getType() === Blockly.ASTNode.types.NEXT) { + newNode = newNode.prev() || newNode; + } + + if (newNode) { + this.setLocation(newNode); + } + return newNode; +}; + +/** + * Find the out connection, field, or block. + * @return {Blockly.ASTNode} The out element, or null if the current node is + * not set or there is no out value. + */ +Blockly.Cursor.prototype.out = function() { + var curNode = this.getCurNode(); + if (!curNode) { + return null; + } + var newNode = curNode.out(); + if (newNode) { + this.setLocation(newNode); + } + return newNode; +}; diff --git a/core/keyboard_nav/cursor_svg.js b/core/keyboard_nav/cursor_svg.js new file mode 100644 index 000000000..d7101fb18 --- /dev/null +++ b/core/keyboard_nav/cursor_svg.js @@ -0,0 +1,499 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 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 Methods for graphically rendering a cursor as SVG. + * @author samelh@microsoft.com (Sam El-Husseini) + */ +'use strict'; + +goog.provide('Blockly.CursorSvg'); +goog.require('Blockly.Cursor'); +/** + * Class for a cursor. + * @param {!Blockly.Workspace} workspace The workspace to sit in. + * @param {?boolean} opt_isImmovable True if the cursor cannot be moved with + * calls to prev/next/in/out. This is called a marker. + * @extends {Blockly.Cursor} + * @constructor + */ +Blockly.CursorSvg = function(workspace, opt_isImmovable) { + Blockly.CursorSvg.superClass_.constructor.call(this); + this.workspace_ = workspace; + this.isMarker_ = opt_isImmovable || false; +}; +goog.inherits(Blockly.CursorSvg, Blockly.Cursor); + +/** + * Height of the horizontal cursor. + * @type {number} + * @const + */ +Blockly.CursorSvg.CURSOR_HEIGHT = 5; + +/** + * Width of the horizontal cursor. + * @type {number} + * @const + */ +Blockly.CursorSvg.CURSOR_WIDTH = 100; + +/** + * The start length of the notch. + * @type {number} + * @const + */ +Blockly.CursorSvg.NOTCH_START_LENGTH = 24; + +/** + * Padding around the input. + * @type {number} + * @const + */ +Blockly.CursorSvg.VERTICAL_PADDING = 5; + +/** + * Padding around a stack. + * @type {number} + * @const + */ +Blockly.CursorSvg.STACK_PADDING = 10; +/** + * Cursor color. + * @type {string} + * @const + */ +Blockly.CursorSvg.CURSOR_COLOR = '#cc0a0a'; + +/** + * Immovable marker color. + * @type {string} + * @const + */ +Blockly.CursorSvg.MARKER_COLOR = '#4286f4'; + +/** + * Parent svg element. + * This is generally a block's svg root, unless the cursor is on the workspace. + * @type {Element} + */ +Blockly.CursorSvg.prototype.parent_ = null; + +/** + * The current svg element for the cursor. + * @type {Element} + */ +Blockly.CursorSvg.prototype.currentCursorSvg = null; + +/** + * Return the root node of the SVG or null if none exists. + * @return {Element} The root SVG node. + */ +Blockly.CursorSvg.prototype.getSvgRoot = function() { + return this.svgGroup_; +}; + +/** + * Create the dom element for the cursor. + * @return {!Element} The cursor controls SVG group. + */ +Blockly.CursorSvg.prototype.createDom = function() { + this.svgGroup_ = + Blockly.utils.dom.createSvgElement('g', { + 'class': 'blocklyCursor' + }, null); + + this.createCursorSvg_(); + return this.svgGroup_; +}; + +/** + * Set parent of the cursor. This is so that the cursor will be on the correct + * svg group. + * @param {Element} newParent New parent of the cursor. + * @private + */ +Blockly.CursorSvg.prototype.setParent_ = function(newParent) { + var oldParent = this.parent_; + if (newParent == oldParent) { + return; + } + + var svgRoot = this.getSvgRoot(); + + if (newParent) { + newParent.appendChild(svgRoot); + } + // If we are losing a parent, we want to move our DOM element to the + // root of the workspace. + else if (oldParent) { + this.workspace_.getCanvas().appendChild(svgRoot); + } + this.parent_ = newParent; +}; + +/**************************/ +/**** Display ****/ +/**************************/ + +/** + * Show the cursor using coordinates. + * @private + */ +Blockly.CursorSvg.prototype.showWithCoordinates_ = function() { + var workspaceNode = this.getCurNode(); + var wsCoordinate = workspaceNode.getWsCoordinate(); + this.currentCursorSvg = this.cursorSvgLine_; + this.setParent_(this.workspace_.svgBlockCanvas_); + this.positionLine_(wsCoordinate.x, wsCoordinate.y, Blockly.CursorSvg.CURSOR_WIDTH); + this.showCurrent_(); +}; + +/** + * Show the cursor using a block + * @private + */ +Blockly.CursorSvg.prototype.showWithBlock_ = function() { + //TODO: Change this from getLocation to something else + var block = this.getCurNode().getLocation(); + + this.currentCursorSvg = this.cursorSvgRect_; + this.setParent_(block.getSvgRoot()); + this.positionRect_(0, 0, block.width , block.height); + this.showCurrent_(); +}; + +/** + * Show the cursor using a connection with input or output type + * @private + */ +Blockly.CursorSvg.prototype.showWithInputOutput_ = function() { + var connection = /** @type {Blockly.Connection} */ + (this.getCurNode().getLocation()); + this.currentCursorSvg = this.cursorInputOutput_; + this.setParent_(connection.getSourceBlock().getSvgRoot()); + this.positionInputOutput_(connection); + this.showCurrent_(); +}; + +/** + * Show the cursor using a next connection + * @private + */ +Blockly.CursorSvg.prototype.showWithNext_ = function() { + var connection = this.getCurNode().getLocation(); + var targetBlock = connection.getSourceBlock(); + var x = 0; + var y = connection.getOffsetInBlock().y; + var width = targetBlock.getHeightWidth().width; + + this.currentCursorSvg = this.cursorSvgLine_; + this.setParent_(connection.getSourceBlock().getSvgRoot()); + this.positionLine_(x, y, width); + this.showCurrent_(); +}; + +/** + * Show the cursor using a previous connection. + * @private + */ +Blockly.CursorSvg.prototype.showWithPrev_ = function() { + var connection = this.getCurNode().getLocation(); + var targetBlock = connection.getSourceBlock(); + var width = targetBlock.getHeightWidth().width; + + this.currentCursorSvg = this.cursorSvgLine_; + this.setParent_(connection.getSourceBlock().getSvgRoot()); + this.positionLine_(0, 0, width); + this.showCurrent_(); +}; + +/** + * Show the cursor using a field. + * @private + */ +Blockly.CursorSvg.prototype.showWithField_ = function() { + var field = this.getCurNode().getLocation(); + var width = field.borderRect_.width.baseVal.value; + var height = field.borderRect_.height.baseVal.value; + + this.currentCursorSvg = this.cursorSvgRect_; + this.setParent_(field.getSvgRoot()); + this.positionRect_(0, 0, width, height); + this.showCurrent_(); +}; + +/** + * Show the cursor using a stack. + * @private + */ +Blockly.CursorSvg.prototype.showWithStack_ = function() { + var block = this.getCurNode().getLocation(); + + // Gets the height and width of entire stack. + var heightWidth = block.getHeightWidth(); + + // Add padding so that being on a stack looks different than being on a block. + var width = heightWidth.width + Blockly.CursorSvg.STACK_PADDING; + var height = heightWidth.height + Blockly.CursorSvg.STACK_PADDING; + + // Shift the rectangle slightly to upper left so padding is equal on all sides. + var x = -1 * Blockly.CursorSvg.STACK_PADDING / 2; + var y = -1 * Blockly.CursorSvg.STACK_PADDING / 2; + + // If the block has an output connection it needs more padding. + if (block.outputConnection) { + x -= Blockly.BlockSvg.TAB_WIDTH; + } + + this.currentCursorSvg = this.cursorSvgRect_; + this.setParent_(block.getSvgRoot()); + + this.positionRect_(x, y, width, height); + this.showCurrent_(); +}; + + +/**************************/ +/**** Position ****/ +/**************************/ + +/** + * Move and show the cursor at the specified coordinate in workspace units. + * @param {number} x The new x, in workspace units. + * @param {number} y The new y, in workspace units. + * @param {number} width The new width, in workspace units. + * @private + */ +Blockly.CursorSvg.prototype.positionLine_ = function(x, y, width) { + this.cursorSvgLine_.setAttribute('x', x); + this.cursorSvgLine_.setAttribute('y', y); + this.cursorSvgLine_.setAttribute('width', width); +}; + +/** + * Move and show the cursor at the specified coordinate in workspace units. + * @param {number} x The new x, in workspace units. + * @param {number} y The new y, in workspace units. + * @param {number} width The new width, in workspace units. + * @param {number} height The new height, in workspace units. + * @private + */ +Blockly.CursorSvg.prototype.positionRect_ = function(x, y, width, height) { + this.cursorSvgRect_.setAttribute('x', x); + this.cursorSvgRect_.setAttribute('y', y); + this.cursorSvgRect_.setAttribute('width', width); + this.cursorSvgRect_.setAttribute('height', height); +}; + +/** + * Position the cursor for an output connection. + * @param {Blockly.Connection} connection The connection to position cursor around. + * @private + */ +Blockly.CursorSvg.prototype.positionInputOutput_ = function(connection) { + var x = connection.getOffsetInBlock().x; + var y = connection.getOffsetInBlock().y; + + this.cursorInputOutput_.setAttribute('transform', 'translate(' + x + ',' + y + ')' + + (connection.getSourceBlock().RTL ? ' scale(-1 1)' : '')); +}; + +/** + * Show the current cursor. + * @private + */ +Blockly.CursorSvg.prototype.showCurrent_ = function() { + this.hide(); + this.currentCursorSvg.style.display = ''; +}; + +/** + * Hide the cursor. + */ +Blockly.CursorSvg.prototype.hide = function() { + this.cursorSvgLine_.style.display = 'none'; + this.cursorSvgRect_.style.display = 'none'; + this.cursorInputOutput_.style.display = 'none'; +}; + +/** + * Update the cursor. + * @package + */ +Blockly.CursorSvg.prototype.update_ = function() { + if (!this.getCurNode()) { + return; + } + var curNode = this.getCurNode(); + if (curNode.getType() === Blockly.ASTNode.types.BLOCK) { + this.showWithBlock_(); + //This needs to be the location type because next connections can be input + //type but they need to draw like they are a next statement + } else if (curNode.getLocation().type === Blockly.INPUT_VALUE + || curNode.getType() === Blockly.ASTNode.types.OUTPUT) { + this.showWithInputOutput_(); + } else if (curNode.getLocation().type === Blockly.NEXT_STATEMENT) { + this.showWithNext_(); + } else if (curNode.getType() === Blockly.ASTNode.types.PREVIOUS) { + this.showWithPrev_(); + } else if (curNode.getType() === Blockly.ASTNode.types.FIELD) { + this.showWithField_(); + } else if (curNode.getType() === Blockly.ASTNode.types.WORKSPACE) { + this.showWithCoordinates_(); + } else if (curNode.getType() === Blockly.ASTNode.types.STACK) { + this.showWithStack_(); + } +}; + +/** + * Create the cursor svg. + * @return {Element} The SVG node created. + * @private + */ +Blockly.CursorSvg.prototype.createCursorSvg_ = function() { + /* This markup will be generated and added to the .svgGroup_: + + + + + + */ + + var colour = this.isMarker_ ? Blockly.CursorSvg.MARKER_COLOR : + Blockly.CursorSvg.CURSOR_COLOR; + this.cursorSvg_ = Blockly.utils.dom.createSvgElement('g', + { + 'width': Blockly.CursorSvg.CURSOR_WIDTH, + 'height': Blockly.CursorSvg.CURSOR_HEIGHT + }, this.svgGroup_); + + this.cursorSvgLine_ = Blockly.utils.dom.createSvgElement('rect', + { + 'x': '0', + 'y': '0', + 'fill': colour, + 'width': Blockly.CursorSvg.CURSOR_WIDTH, + 'height': Blockly.CursorSvg.CURSOR_HEIGHT, + 'style': 'display: none;' + }, + this.cursorSvg_); + + this.cursorSvgRect_ = Blockly.utils.dom.createSvgElement('rect', + { + 'class': 'blocklyVerticalCursor', + 'x': '0', + 'y': '0', + 'rx': '10', 'ry': '10', + 'style': 'display: none;', + 'stroke': colour + }, + this.cursorSvg_); + + this.cursorInputOutput_ = Blockly.utils.dom.createSvgElement( + 'path', + { + 'width': Blockly.CursorSvg.CURSOR_WIDTH, + 'height': Blockly.CursorSvg.CURSOR_HEIGHT, + 'd': 'm 0,0 ' + Blockly.BlockSvg.TAB_PATH_DOWN + ' v 5', + 'transform':'', + 'style':'display: none;', + 'fill': colour + }, + this.cursorSvg_); + + // Markers don't blink. + if (!this.isMarker_) { + Blockly.utils.dom.createSvgElement('animate', + { + 'attributeType': 'XML', + 'attributeName': 'fill', + 'dur': '1s', + 'values': Blockly.CursorSvg.CURSOR_COLOR + ';transparent;transparent;', + 'repeatCount': 'indefinite' + }, + this.cursorSvgLine_); + + Blockly.utils.dom.createSvgElement('animate', + { + 'attributeType': 'XML', + 'attributeName': 'fill', + 'dur': '1s', + 'values': Blockly.CursorSvg.CURSOR_COLOR + ';transparent;transparent;', + 'repeatCount': 'indefinite' + }, + this.cursorInputOutput_); + } + + return this.cursorSvg_; +}; + +/** + * Find the next connection, field, or block. + * Does nothing if this cursor is an immovable marker. + * @return {Blockly.ASTNode} The next element, or null if the current node is + * not set or there is no next value. + */ +Blockly.CursorSvg.prototype.next = function() { + if (this.isMarker_) { + return null; + } + return Blockly.CursorSvg.superClass_.next.call(this); +}; + +/** + * Find the in connection or field. + * Does nothing if this cursor is an immovable marker. + * @return {Blockly.ASTNode} The in element, or null if the current node is + * not set or there is no in value. + */ +Blockly.CursorSvg.prototype.in = function() { + if (this.isMarker_) { + return null; + } + return Blockly.CursorSvg.superClass_.in.call(this); +}; + +/** + * Find the previous connection, field, or block. + * Does nothing if this cursor is an immovable marker. + * @return {Blockly.ASTNode} The previous element, or null if the current node + * is not set or there is no previous value. + */ +Blockly.CursorSvg.prototype.prev = function() { + if (this.isMarker_) { + return null; + } + return Blockly.CursorSvg.superClass_.prev.call(this); +}; + +/** + * Find the out connection, field, or block. + * Does nothing if this cursor is an immovable marker. + * @return {Blockly.ASTNode} The out element, or null if the current node is + * not set or there is no out value. + */ +Blockly.CursorSvg.prototype.out = function() { + if (this.isMarker_) { + return null; + } + return Blockly.CursorSvg.superClass_.out.call(this); +}; diff --git a/core/keyboard_nav/navigation.js b/core/keyboard_nav/navigation.js new file mode 100644 index 000000000..8cf1bc631 --- /dev/null +++ b/core/keyboard_nav/navigation.js @@ -0,0 +1,832 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 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. + */ + +goog.provide('Blockly.Navigation'); + +goog.require('Blockly.ASTNode'); + +/** + * The cursor for keyboard navigation. + * @type {Blockly.Cursor} + * @private + */ +Blockly.Navigation.cursor_ = null; + +/** + * The marker that shows where a user has marked while navigating blocks. + * @type {!Blockly.CursorSvg} + */ +Blockly.Navigation.marker_ = null; + +/** + * The current selected category if the toolbox is open or + * last selected category if focus is on a different element. + * @type {goog.ui.tree.BaseNode} + * @private + */ +Blockly.Navigation.currentCategory_ = null; + +/** + * The current selected block in the flyout. + * @type {Blockly.BlockSvg} + * @private + */ +Blockly.Navigation.flyoutBlock_ = null; + +/** + * A function to call to give feedback to the user about logs, warnings, and + * errors. You can override this to customize feedback (e.g. warning sounds, + * reading out the warning text, etc). + * Null by default. + * The first argument is one of 'log', 'warn', and 'error'. + * The second argument is the message. + * @type {function(string, string)} + * @public + */ +Blockly.Navigation.loggingCallback = null; + +/** + * State indicating focus is currently on the flyout. + * @type {number} + */ +Blockly.Navigation.STATE_FLYOUT = 1; + +/** + * State indicating focus is currently on the workspace. + * @type {number} + */ +Blockly.Navigation.STATE_WS = 2; + +/** + * State indicating focus is currently on the toolbox. + * @type {number} + */ +Blockly.Navigation.STATE_TOOLBOX = 3; + +/** + * The current state the user is in. + * Initialized to workspace state since a user enters navigation mode by shift + * clicking on a block or workspace. + * @type {number} + * @private + */ +Blockly.Navigation.currentState_ = Blockly.Navigation.STATE_WS; + +/** + * Set the navigation cursor. + * @param {Blockly.Cursor} cursor The cursor to navigate through blocks on a + * workspace. + * @package + */ +Blockly.Navigation.setCursor = function(cursor) { + Blockly.Navigation.cursor_ = cursor; +}; + +/** + * Set the navigation marker. + * @param {Blockly.CursorSvg} marker The marker that shows where a user has + * marked while navigating blocks. + * @package + */ +Blockly.Navigation.setMarker = function(marker) { + Blockly.Navigation.marker_ = marker; +}; + +/** + * Move the marker to the cursor's current location. + * @package + */ +Blockly.Navigation.markAtCursor = function() { + // TODO: bring the cursor (blinking) in front of the marker (solid) + Blockly.Navigation.marker_.setLocation( + Blockly.Navigation.cursor_.getCurNode()); +}; + +/** + * Remove the marker from its current location and hide it. + * @package + */ +Blockly.Navigation.removeMark = function() { + Blockly.Navigation.marker_.setLocation(null); + Blockly.Navigation.marker_.hide(); +}; + +/************************/ +/** Toolbox Navigation **/ +/************************/ + +/** + * Set the state to the toolbox state and the current category as the first + * category. + */ +Blockly.Navigation.focusToolbox = function() { + Blockly.Navigation.resetFlyout(false /* shouldHide */); + Blockly.Navigation.currentState_ = Blockly.Navigation.STATE_TOOLBOX; + var workspace = Blockly.getMainWorkspace(); + var toolbox = workspace.getToolbox(); + + if (!Blockly.Navigation.marker_.getCurNode()) { + Blockly.Navigation.markAtCursor(); + } + if (workspace && !Blockly.Navigation.currentCategory_) { + Blockly.Navigation.currentCategory_ = toolbox.tree_.firstChild_; + } + toolbox.tree_.setSelectedItem(Blockly.Navigation.currentCategory_); +}; + +/** + * Select the next category. + * Taken from closure/goog/ui/tree/basenode.js + */ +Blockly.Navigation.nextCategory = function() { + if (!Blockly.Navigation.currentCategory_) { + return; + } + var curCategory = Blockly.Navigation.currentCategory_; + var nextNode = curCategory.getNextShownNode(); + + if (nextNode) { + nextNode.select(); + Blockly.Navigation.currentCategory_ = nextNode; + } +}; + +/** + * Select the previous category. + * Taken from closure/goog/ui/tree/basenode.js + */ +Blockly.Navigation.previousCategory = function() { + if (!Blockly.Navigation.currentCategory_) { + return; + } + var curCategory = Blockly.Navigation.currentCategory_; + var previousNode = curCategory.getPreviousShownNode(); + + if (previousNode) { + previousNode.select(); + Blockly.Navigation.currentCategory_ = previousNode; + } +}; + +/** + * Go to child category if there is a nested category. + * Taken from closure/goog/ui/tree/basenode.js + */ +Blockly.Navigation.inCategory = function() { + if (!Blockly.Navigation.currentCategory_) { + return; + } + var curCategory = Blockly.Navigation.currentCategory_; + + if (curCategory.hasChildren()) { + if (!curCategory.getExpanded()) { + curCategory.setExpanded(true); + } else { + curCategory.getFirstChild().select(); + Blockly.Navigation.currentCategory_ = curCategory.getFirstChild(); + } + } else { + Blockly.Navigation.focusFlyout(); + } +}; + +/** + * Go to parent category if we are in a child category. + * Taken from closure/goog/ui/tree/basenode.js + */ +Blockly.Navigation.outCategory = function() { + if (!Blockly.Navigation.currentCategory_) { + return; + } + var curCategory = Blockly.Navigation.currentCategory_; + + if (curCategory.hasChildren() && curCategory.getExpanded() && curCategory.isUserCollapsible()) { + curCategory.setExpanded(false); + } else { + var parent = curCategory.getParent(); + var tree = curCategory.getTree(); + if (parent && (tree.getShowRootNode() || parent != tree)) { + parent.select(); + + Blockly.Navigation.currentCategory_ = /** @type {goog.ui.tree.BaseNode} */ + (parent); + } + } +}; + +/***********************/ +/** Flyout Navigation **/ +/***********************/ + +/** + * Change focus to the flyout. + */ +Blockly.Navigation.focusFlyout = function() { + var topBlock = null; + Blockly.Navigation.currentState_ = Blockly.Navigation.STATE_FLYOUT; + var workspace = Blockly.getMainWorkspace(); + var toolbox = workspace.getToolbox(); + var cursor = Blockly.Navigation.cursor_; + var flyout = toolbox ? toolbox.flyout_ : workspace.getFlyout(); + + if (!Blockly.Navigation.marker_.getCurNode()) { + Blockly.Navigation.markAtCursor(); + } + + if (flyout && flyout.getWorkspace()) { + var topBlocks = flyout.getWorkspace().getTopBlocks(); + if (topBlocks.length > 0) { + topBlock = topBlocks[0]; + Blockly.Navigation.flyoutBlock_ = topBlock; + var astNode = Blockly.ASTNode.createBlockNode(Blockly.Navigation.flyoutBlock_); + cursor.setLocation(astNode); + } + } +}; + +/** + * Select the next block in the flyout. + */ +Blockly.Navigation.selectNextBlockInFlyout = function() { + if (!Blockly.Navigation.flyoutBlock_) { + return; + } + var blocks = Blockly.Navigation.getFlyoutBlocks_(); + var curBlock = Blockly.Navigation.flyoutBlock_; + var curIdx = blocks.indexOf(curBlock); + var cursor = Blockly.Navigation.cursor_; + var nextBlock; + + if (curIdx > -1 && blocks[++curIdx]) { + nextBlock = blocks[curIdx]; + } + + if (nextBlock) { + Blockly.Navigation.flyoutBlock_ = nextBlock; + var astNode = Blockly.ASTNode.createBlockNode(nextBlock); + cursor.setLocation(astNode); + } +}; + +/** + * Select the previous block in the flyout. + */ +Blockly.Navigation.selectPreviousBlockInFlyout = function() { + if (!Blockly.Navigation.flyoutBlock_) { + return; + } + var blocks = Blockly.Navigation.getFlyoutBlocks_(); + var curBlock = Blockly.Navigation.flyoutBlock_; + var curIdx = blocks.indexOf(curBlock); + var cursor = Blockly.Navigation.cursor_; + var prevBlock; + + if (curIdx > -1 && blocks[--curIdx]) { + prevBlock = blocks[curIdx]; + } + + if (prevBlock) { + Blockly.Navigation.flyoutBlock_ = prevBlock; + var astNode = Blockly.ASTNode.createBlockNode(prevBlock); + cursor.setLocation(astNode); + } +}; + +/** + * Get a list of all blocks in the flyout. + * @return {!Array} List of blocks in the flyout. + */ +Blockly.Navigation.getFlyoutBlocks_ = function() { + var workspace = Blockly.getMainWorkspace(); + var toolbox = workspace.getToolbox(); + var topBlocks = []; + var flyout = toolbox ? toolbox.flyout_ : workspace.getFlyout(); + if (flyout && flyout.getWorkspace()) { + topBlocks = flyout.getWorkspace().getTopBlocks(); + } + return topBlocks; +}; + +/** + * If there is a marked connection try connecting the block from the flyout to + * that connection. If no connection has been marked then inserting will place + * it on the workspace. + */ +Blockly.Navigation.insertFromFlyout = function() { + + var flyout = Blockly.getMainWorkspace().getFlyout(); + if (!flyout || !flyout.isVisible()) { + Blockly.Navigation.warn('Trying to insert from the flyout when the flyout does not ' + + ' exist or is not visible'); + return; + } + + var newBlock = flyout.createBlock(Blockly.Navigation.flyoutBlock_); + // Render to get the sizing right. + newBlock.render(); + // Connections are hidden when the block is first created. Normally there's + // enough time for them to become unhidden in the user's mouse movements, + // but not here. + newBlock.setConnectionsHidden(false); + Blockly.Navigation.cursor_.setLocation( + Blockly.ASTNode.createBlockNode(newBlock)); + if (!Blockly.Navigation.modify()) { + Blockly.Navigation.warn('Something went wrong while inserting a block from the flyout.'); + } + + // Move the cursor to the right place on the inserted block. + Blockly.Navigation.focusWorkspace(); + var prevConnection = newBlock.previousConnection; + var outConnection = newBlock.outputConnection; + var topConnection = prevConnection ? prevConnection : outConnection; + //TODO: This will have to be fixed when we add in a block that does not have + //a previous or output connection + var astNode = Blockly.ASTNode.createConnectionNode(topConnection); + Blockly.Navigation.cursor_.setLocation(astNode); + Blockly.Navigation.removeMark(); +}; + +/** + * Reset flyout information, and optionally close the flyout. + * @param {boolean} shouldHide True if the flyout should be hidden. + */ +Blockly.Navigation.resetFlyout = function(shouldHide) { + var cursor = Blockly.Navigation.cursor_; + Blockly.Navigation.flyoutBlock_ = null; + cursor.hide(); + if (shouldHide) { + cursor.workspace_.getFlyout().hide(); + } +}; + +/************/ +/** Modify **/ +/************/ + +/** + * Handle the modifier key (currently I for Insert). + * @return {boolean} True if the key was handled; false if something went wrong. + * @package + */ +Blockly.Navigation.modify = function() { + var markerNode = Blockly.Navigation.marker_.getCurNode(); + var cursorNode = Blockly.Navigation.cursor_.getCurNode(); + + if (!markerNode) { + Blockly.Navigation.warn('Cannot insert with no marked node.'); + return false; + } + + if (!cursorNode) { + Blockly.Navigation.warn('Cannot insert with no cursor node.'); + return false; + } + var markerType = markerNode.getType(); + var cursorType = cursorNode.getType(); + + if (markerType == Blockly.ASTNode.types.FIELD) { + Blockly.Navigation.warn('Should not have been able to mark a field.'); + return false; + } + if (markerType == Blockly.ASTNode.types.BLOCK) { + Blockly.Navigation.warn('Should not have been able to mark a block.'); + return false; + } + if (markerType == Blockly.ASTNode.types.STACK) { + Blockly.Navigation.warn('Should not have been able to mark a stack.'); + return false; + } + + if (cursorType == Blockly.ASTNode.types.FIELD) { + Blockly.Navigation.warn('Cannot attach a field to anything else.'); + return false; + } + + if (cursorType == Blockly.ASTNode.types.WORKSPACE) { + Blockly.Navigation.warn('Cannot attach a workspace to anything else.'); + return false; + } + + var cursorLoc = cursorNode.getLocation(); + var markerLoc = markerNode.getLocation(); + + if (markerNode.isConnection()) { + // TODO: Handle the case when one or both are already connected. + if (cursorNode.isConnection()) { + return Blockly.Navigation.connect(cursorLoc, markerLoc); + } else if (cursorType == Blockly.ASTNode.types.BLOCK || + cursorType == Blockly.ASTNode.types.STACK) { + return Blockly.Navigation.insertBlock(cursorLoc, markerLoc); + } + } else if (markerType == Blockly.ASTNode.types.WORKSPACE) { + if (cursorNode.isConnection()) { + if (cursorType == Blockly.ASTNode.types.INPUT || + cursorType == Blockly.ASTNode.types.NEXT) { + Blockly.Navigation.warn( + 'Cannot move a next or input connection to the workspace.'); + return false; + } + var block = cursorLoc.getSourceBlock(); + } else if (cursorType == Blockly.ASTNode.types.BLOCK || + cursorType == Blockly.ASTNode.types.STACK) { + var block = cursorLoc; + } else { + return false; + } + if (block.isShadow()) { + Blockly.Navigation.warn('Cannot move a shadow block to the workspace.'); + return false; + } + if (block.getParent()) { + block.unplug(false); + } + block.moveTo(markerNode.getWsCoordinate()); + return true; + } + Blockly.Navigation.warn('Unexpected state in Blockly.Navigation.modify.'); + return false; + // TODO: Make sure the cursor and marker end up in the right places. +}; + +/** + * Connect the moving connection to the targetConnection. Disconnect the moving + * connection if necessary, and and position the blocks so that the target + * connection does not move. + * @param {Blockly.RenderedConnection} movingConnection The connection to move. + * @param {Blockly.RenderedConnection} targetConnection The connection that + * stays stationary as the movingConnection attaches to it. + * @return {boolean} Whether the connection was successful. + * @package + */ +Blockly.Navigation.connect = function(movingConnection, targetConnection) { + if (movingConnection) { + var movingBlock = movingConnection.getSourceBlock(); + if (targetConnection.type == Blockly.PREVIOUS_STATEMENT + || targetConnection.type == Blockly.OUTPUT_VALUE) { + movingBlock.positionNearConnection(movingConnection, targetConnection); + } + try { + targetConnection.connect(movingConnection); + return true; + } + catch (e) { + // TODO: Is there anything else useful to do at this catch? + // Perhaps position the block near the target connection? + Blockly.Navigation.warn('Connection failed with error: ' + e); + return false; + } + } + return false; +}; + +/** + * Finds our best guess of what connection point on the given block the user is + * trying to connect to given a target connection. + * @param {Blockly.Block} block The block to be connected. + * @param {Blockly.Connection} connection The connection to connect to. + * @return {Blockly.Connection} blockConnection The best connection we can + * determine for the block, or null if the block doesn't have a matching + * connection for the given target connection. + */ +Blockly.Navigation.findBestConnection = function(block, connection) { + if (!block || !connection) { + return null; + } + + // TODO: Possibly check types and return null if the types don't match. + if (connection.type === Blockly.PREVIOUS_STATEMENT) { + return block.nextConnection; + } else if (connection.type === Blockly.NEXT_STATEMENT) { + return block.previousConnection; + } else if (connection.type === Blockly.INPUT_VALUE) { + return block.outputConnection; + } else if (connection.type === Blockly.OUTPUT_VALUE) { + // Select the first input that has a connection. + for (var i = 0; i < block.inputList.length; i++) { + var inputConnection = block.inputList[i].connection; + if (inputConnection.type === Blockly.INPUT_VALUE) { + return inputConnection; + } + } + } + return null; +}; + +/** + * Tries to connect the given block to the target connection, making an + * intelligent guess about which connection to use to on the moving block. + * @param {!Blockly.Block} block The block to move. + * @param {Blockly.Connection} targetConnection The connection to connect to. + * @return {boolean} Whether the connection was successful. + */ +Blockly.Navigation.insertBlock = function(block, targetConnection) { + var bestConnection = + Blockly.Navigation.findBestConnection(block, targetConnection); + if (bestConnection && bestConnection.isConnected() && + !bestConnection.targetBlock().isShadow()) { + bestConnection.disconnect(); + } + return Blockly.Navigation.connect(bestConnection, targetConnection); +}; + +/** + * Disconnect the connection that the cursor is pointing to, and bump blocks. + * This is a no-op if the connection cannot be broken or if the cursor is not + * pointing to a connection. + * @package + */ +Blockly.Navigation.disconnectBlocks = function() { + var curNode = Blockly.Navigation.cursor_.getCurNode(); + if (!curNode.isConnection()) { + Blockly.Navigation.log('Cannot disconnect blocks when the cursor is not on a connection'); + return; + } + var curConnection = curNode.getLocation(); + if (!curConnection.isConnected()) { + Blockly.Navigation.log('Cannot disconnect unconnected connection'); + return; + } + var superiorConnection = + curConnection.isSuperior() ? curConnection : curConnection.targetConnection; + + var inferiorConnection = + curConnection.isSuperior() ? curConnection.targetConnection : curConnection; + + if (inferiorConnection.getSourceBlock().isShadow()) { + Blockly.Navigation.log('Cannot disconnect a shadow block'); + return; + } + superiorConnection.disconnect(); + inferiorConnection.bumpAwayFrom_(superiorConnection); + + var rootBlock = superiorConnection.getSourceBlock().getRootBlock(); + rootBlock.bringToFront(); + + var connectionNode = Blockly.ASTNode.createConnectionNode(superiorConnection); + Blockly.Navigation.cursor_.setLocation(connectionNode); +}; + +/*************************/ +/** Keyboard Navigation **/ +/*************************/ + +/** + * Sets the cursor to the previous or output connection of the selected block + * on the workspace. + * If no block is selected, places the cursor at a fixed point on the workspace. + */ +Blockly.Navigation.focusWorkspace = function() { + var cursor = Blockly.Navigation.cursor_; + var reset = Blockly.getMainWorkspace().getToolbox() ? true : false; + + Blockly.Navigation.resetFlyout(reset); + Blockly.Navigation.currentState_ = Blockly.Navigation.STATE_WS; + Blockly.Navigation.enableKeyboardAccessibility(); + if (Blockly.selected) { + var previousConnection = Blockly.selected.previousConnection; + var outputConnection = Blockly.selected.outputConnection; + //TODO: This still needs to work with blocks that have neither previous + //or output connection. + var connection = previousConnection ? previousConnection : outputConnection; + var newAstNode = Blockly.ASTNode.createConnectionNode(connection); + cursor.setLocation(newAstNode); + Blockly.selected.unselect(); + } else { + var ws = cursor.workspace_; + // TODO: Find the center of the visible workspace. + var wsCoord = new Blockly.utils.Coordinate(100, 100); + var wsNode = Blockly.ASTNode.createWorkspaceNode(ws, wsCoord); + cursor.setLocation(wsNode); + } +}; + +/** + * Handles hitting the enter key on the workspace. + */ +Blockly.Navigation.handleEnterForWS = function() { + var cursor = Blockly.Navigation.cursor_; + var curNode = cursor.getCurNode(); + var nodeType = curNode.getType(); + if (nodeType === Blockly.ASTNode.types.FIELD) { + var location = curNode.getLocation(); + location.showEditor_(); + } else if (curNode.isConnection() || + nodeType == Blockly.ASTNode.types.WORKSPACE) { + Blockly.Navigation.markAtCursor(); + } else if (nodeType == Blockly.ASTNode.types.BLOCK) { + Blockly.Navigation.warn('Cannot mark a block.'); + } else if (nodeType == Blockly.ASTNode.types.STACK) { + Blockly.Navigation.warn('Cannot mark a stack.'); + } +}; + +/**********************/ +/** Helper Functions **/ +/**********************/ + + +/** + * TODO: Revisit keycodes before releasing + * Handler for all the keyboard navigation events. + * @param {Event} e The keyboard event. + * @return {!boolean} True if the key was handled false otherwise. + */ +Blockly.Navigation.navigate = function(e) { + var curState = Blockly.Navigation.currentState_; + if (e.keyCode === goog.events.KeyCodes.T) { + var workspace = Blockly.getMainWorkspace(); + if (!workspace.getToolbox()) { + Blockly.Navigation.focusFlyout(); + Blockly.Navigation.log('T: Focus Flyout'); + } else { + Blockly.Navigation.focusToolbox(); + Blockly.Navigation.log('T: Focus Toolbox'); + } + return true; + } else if (curState === Blockly.Navigation.STATE_FLYOUT) { + return Blockly.Navigation.flyoutKeyHandler(e); + } else if (curState === Blockly.Navigation.STATE_WS) { + return Blockly.Navigation.workspaceKeyHandler(e); + } else if (curState === Blockly.Navigation.STATE_TOOLBOX) { + return Blockly.Navigation.toolboxKeyHandler(e); + } else { + Blockly.Navigation.log('Not a valid key '); + } + return false; +}; + +/** + * Handles all keyboard events when the user is focused on the flyout. + * @param {Event} e The keyboard event. + * @return {!boolean} True if the key was handled false otherwise. + */ +Blockly.Navigation.flyoutKeyHandler = function(e) { + if (e.keyCode === goog.events.KeyCodes.W) { + Blockly.Navigation.selectPreviousBlockInFlyout(); + Blockly.Navigation.log('W: Flyout : Previous'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.A) { + Blockly.Navigation.focusToolbox(); + Blockly.Navigation.log('A: Flyout : Go To Toolbox'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.S) { + Blockly.Navigation.selectNextBlockInFlyout(); + Blockly.Navigation.log('S: Flyout : Next'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.ENTER) { + Blockly.Navigation.insertFromFlyout(); + Blockly.Navigation.log('Enter: Flyout : Select'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.E || + e.keyCode === goog.events.KeyCodes.ESC) { + Blockly.Navigation.focusWorkspace(); + Blockly.Navigation.log('E or ESC: Flyout: Exit'); + return true; + } + return false; +}; + +/** + * Handles all keyboard events when the user is focused on the toolbox. + * @param {Event} e The keyboard event. + * @return {!boolean} True if the key was handled false otherwise. + */ +Blockly.Navigation.toolboxKeyHandler = function(e) { + if (e.keyCode === goog.events.KeyCodes.W) { + Blockly.Navigation.previousCategory(); + Blockly.Navigation.log('W: Toolbox : Previous'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.A) { + Blockly.Navigation.outCategory(); + Blockly.Navigation.log('A: Toolbox : Out'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.S) { + Blockly.Navigation.nextCategory(); + Blockly.Navigation.log('S: Toolbox : Next'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.D) { + Blockly.Navigation.inCategory(); + Blockly.Navigation.log('D: Toolbox : Go to flyout'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.ENTER) { + //TODO: focus on flyout OR open if the category is nested + return true; + } else if (e.keyCode === goog.events.KeyCodes.E || + e.keyCode === goog.events.KeyCodes.ESC) { + Blockly.Navigation.log('E or ESC: Toolbox: Exit'); + Blockly.Navigation.focusWorkspace(); + return true; + } + return false; +}; + +/** + * Handles all keyboard events when the user is focused on the workspace. + * @param {Event} e The keyboard event. + * @return {!boolean} True if the key was handled false otherwise. + */ +Blockly.Navigation.workspaceKeyHandler = function(e) { + if (e.keyCode === goog.events.KeyCodes.W) { + Blockly.Navigation.cursor_.prev(); + Blockly.Navigation.log('W: Workspace : Out'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.A) { + Blockly.Navigation.cursor_.out(); + Blockly.Navigation.log('S: Workspace : Previous'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.S) { + Blockly.Navigation.cursor_.next(); + Blockly.Navigation.log('S: Workspace : In'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.D) { + Blockly.Navigation.cursor_.in(); + Blockly.Navigation.log('S: Workspace : Next'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.I) { + Blockly.Navigation.modify(); + Blockly.Navigation.log('I: Workspace : Insert/Connect Blocks'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.ENTER) { + Blockly.Navigation.handleEnterForWS(); + Blockly.Navigation.log('Enter: Workspace : Mark'); + return true; + } else if (e.keyCode === goog.events.KeyCodes.X) { + Blockly.Navigation.log('X: Workspace: Disconnect Blocks'); + Blockly.Navigation.disconnectBlocks(); + return true; + } + return false; +}; + +/** + * Enable accessibility mode. + */ +Blockly.Navigation.enableKeyboardAccessibility = function() { + Blockly.keyboardAccessibilityMode = true; +}; + +/** + * Disable accessibility mode. + */ +Blockly.Navigation.disableKeyboardAccessibility = function() { + Blockly.keyboardAccessibilityMode = false; +}; + +/** + * Navigation log handler. If loggingCallback is defined, use it. + * Otherwise just log to the console. + * @param {string} msg The message to log. + * @package + */ +Blockly.Navigation.log = function(msg) { + if (Blockly.Navigation.loggingCallback) { + Blockly.Navigation.loggingCallback('log', msg); + } else { + console.log(msg); + } +}; + +/** + * Navigation warning handler. If loggingCallback is defined, use it. + * Otherwise call Blockly.Navigation.warn. + * @param {string} msg The warning message. + * @package + */ +Blockly.Navigation.warn = function(msg) { + if (Blockly.Navigation.loggingCallback) { + Blockly.Navigation.loggingCallback('warn', msg); + } else { + console.warn(msg); + } +}; + +/** + * Navigation error handler. If loggingCallback is defined, use it. + * Otherwise call console.error. + * @param {string} msg The error message. + * @package + */ +Blockly.Navigation.error = function(msg) { + if (Blockly.Navigation.loggingCallback) { + Blockly.Navigation.loggingCallback('error', msg); + } else { + console.error(msg); + } +};