/** * @license * Visual Blocks Editor * * Copyright 2012 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 Object representing a workspace. * @author fraser@google.com (Neil Fraser) */ 'use strict'; goog.provide('Blockly.Workspace'); goog.require('goog.array'); goog.require('goog.math'); /** * Class for a workspace. This is a data structure that contains blocks. * There is no UI, and can be created headlessly. * @param {Blockly.Options} opt_options Dictionary of options. * @constructor */ Blockly.Workspace = function(opt_options) { /** @type {string} */ this.id = Blockly.genUid(); Blockly.Workspace.WorkspaceDB_[this.id] = this; /** @type {!Blockly.Options} */ this.options = opt_options || {}; /** @type {boolean} */ this.RTL = !!this.options.RTL; /** @type {boolean} */ this.horizontalLayout = !!this.options.horizontalLayout; /** @type {number} */ this.toolboxPosition = this.options.toolboxPosition; /** * @type {!Array.} * @private */ this.topBlocks_ = []; /** * @type {!Array.} * @private */ this.listeners_ = []; /** * @type {!Array.} * @private */ this.undoStack_ = []; /** * @type {!Array.} * @private */ this.redoStack_ = []; /** * @type {!Object} * @private */ this.blockDB_ = Object.create(null); /* * @type {!Array.} * A list of all of the named variables in the workspace, including variables * that are not currently in use. */ this.variableList = []; }; /** * Returns `true` if the workspace is visible and `false` if it's headless. * @type {boolean} */ Blockly.Workspace.prototype.rendered = false; /** * Maximum number of undo events in stack. `0` turns off undo, `Infinity` sets it to unlimited. * @type {number} */ Blockly.Workspace.prototype.MAX_UNDO = 1024; /** * Dispose of this workspace. * Unlink from all DOM elements to prevent memory leaks. */ Blockly.Workspace.prototype.dispose = function() { this.listeners_.length = 0; this.clear(); // Remove from workspace database. delete Blockly.Workspace.WorkspaceDB_[this.id]; }; /** * Angle away from the horizontal to sweep for blocks. Order of execution is * generally top to bottom, but a small angle changes the scan to give a bit of * a left to right bias (reversed in RTL). Units are in degrees. * See: http://tvtropes.org/pmwiki/pmwiki.php/Main/DiagonalBilling. */ Blockly.Workspace.SCAN_ANGLE = 3; /** * Add a block to the list of top blocks. * @param {!Blockly.Block} block Block to remove. */ Blockly.Workspace.prototype.addTopBlock = function(block) { this.topBlocks_.push(block); if (this.isFlyout) { // This is for the (unlikely) case where you have a variable in a block in // an always-open flyout. It needs to be possible to edit the block in the // flyout, so the contents of the dropdown need to be correct. var variables = Blockly.Variables.allUsedVariables(block); for (var i = 0; i < variables.length; i++) { if (this.variableList.indexOf(variables[i]) == -1) { this.variableList.push(variables[i]); } } } }; /** * Remove a block from the list of top blocks. * @param {!Blockly.Block} block Block to remove. */ Blockly.Workspace.prototype.removeTopBlock = function(block) { if (!goog.array.remove(this.topBlocks_, block)) { throw 'Block not present in workspace\'s list of top-most blocks.'; } }; /** * Finds the top-level blocks and returns them. Blocks are optionally sorted * by position; top to bottom (with slight LTR or RTL bias). * @param {boolean} ordered Sort the list if true. * @return {!Array.} The top-level block objects. */ Blockly.Workspace.prototype.getTopBlocks = function(ordered) { // Copy the topBlocks_ list. var blocks = [].concat(this.topBlocks_); if (ordered && blocks.length > 1) { var offset = Math.sin(goog.math.toRadians(Blockly.Workspace.SCAN_ANGLE)); if (this.RTL) { offset *= -1; } blocks.sort(function(a, b) { var aXY = a.getRelativeToSurfaceXY(); var bXY = b.getRelativeToSurfaceXY(); return (aXY.y + offset * aXY.x) - (bXY.y + offset * bXY.x); }); } return blocks; }; /** * Find all blocks in workspace. No particular order. * @return {!Array.} Array of blocks. */ Blockly.Workspace.prototype.getAllBlocks = function() { var blocks = this.getTopBlocks(false); for (var i = 0; i < blocks.length; i++) { blocks.push.apply(blocks, blocks[i].getChildren()); } return blocks; }; /** * Dispose of all blocks in workspace. */ Blockly.Workspace.prototype.clear = function() { var existingGroup = Blockly.Events.getGroup(); if (!existingGroup) { Blockly.Events.setGroup(true); } while (this.topBlocks_.length) { this.topBlocks_[0].dispose(); } if (!existingGroup) { Blockly.Events.setGroup(false); } this.variableList.length = 0; }; /** * Walk the workspace and update the list of variables to only contain ones in * use on the workspace. Use when loading new workspaces from disk. * @param {boolean} clearList True if the old variable list should be cleared. */ Blockly.Workspace.prototype.updateVariableList = function(clearList) { // TODO: Sort if (!this.isFlyout) { // Update the list in place so that the flyout's references stay correct. if (clearList) { this.variableList.length = 0; } var allVariables = Blockly.Variables.allUsedVariables(this); for (var i = 0; i < allVariables.length; i++) { this.createVariable(allVariables[i]); } } }; /** * Rename a variable by updating its name in the variable list. * TODO: #468 * @param {string} oldName Variable to rename. * @param {string} newName New variable name. */ Blockly.Workspace.prototype.renameVariable = function(oldName, newName) { // Find the old name in the list. var variableIndex = this.variableIndexOf(oldName); var newVariableIndex = this.variableIndexOf(newName); // We might be renaming to an existing name but with different case. If so, // we will also update all of the blocks using the new name to have the // correct case. if (newVariableIndex != -1 && this.variableList[newVariableIndex] != newName) { var oldCase = this.variableList[newVariableIndex]; } Blockly.Events.setGroup(true); var blocks = this.getAllBlocks(); // Iterate through every block. for (var i = 0; i < blocks.length; i++) { blocks[i].renameVar(oldName, newName); if (oldCase) { blocks[i].renameVar(oldCase, newName); } } Blockly.Events.setGroup(false); if (variableIndex == newVariableIndex || variableIndex != -1 && newVariableIndex == -1) { // Only changing case, or renaming to a completely novel name. this.variableList[variableIndex] = newName; } else if (variableIndex != -1 && newVariableIndex != -1) { // Renaming one existing variable to another existing variable. // The case might have changed, so we update the destination ID. this.variableList[newVariableIndex] = newName; this.variableList.splice(variableIndex, 1); } else { this.variableList.push(newName); console.log('Tried to rename an non-existent variable.'); } }; /** * Create a variable with the given name. * TODO: #468 * @param {string} name The new variable's name. */ Blockly.Workspace.prototype.createVariable = function(name) { var index = this.variableIndexOf(name); if (index == -1) { this.variableList.push(name); } }; /** * Find all the uses of a named variable. * @param {string} name Name of variable. * @return {!Array.} Array of block usages. */ Blockly.Workspace.prototype.getVariableUses = function(name) { var uses = []; var blocks = this.getAllBlocks(); // Iterate through every block and check the name. for (var i = 0; i < blocks.length; i++) { var blockVariables = blocks[i].getVars(); if (blockVariables) { for (var j = 0; j < blockVariables.length; j++) { var varName = blockVariables[j]; // Variable name may be null if the block is only half-built. if (varName && Blockly.Names.equals(varName, name)) { uses.push(blocks[i]); } } } } return uses; }; /** * Delete a variables and all of its uses from this workspace. * @param {string} name Name of variable to delete. */ Blockly.Workspace.prototype.deleteVariable = function(name) { var workspace = this; var variableIndex = this.variableIndexOf(name); if (variableIndex != -1) { // Check whether this variable is a function parameter before deleting. var uses = this.getVariableUses(name); for (var i = 0, block; block = uses[i]; i++) { if (block.type == 'procedures_defnoreturn' || block.type == 'procedures_defreturn') { var procedureName = block.getFieldValue('NAME'); Blockly.alert( Blockly.Msg.CANNOT_DELETE_VARIABLE_PROCEDURE. replace('%1', name). replace('%2', procedureName)); return; } } function doDeletion() { Blockly.Events.setGroup(true); for (var i = 0; i < uses.length; i++) { uses[i].dispose(true, false); } Blockly.Events.setGroup(false); workspace.variableList.splice(variableIndex, 1); } if (uses.length > 1) { // Confirm before deleting multiple blocks. Blockly.confirm( Blockly.Msg.DELETE_VARIABLE_CONFIRMATION.replace('%1', uses.length). replace('%2', name), function(ok) { if (ok) { doDeletion(); } }); } else { // No confirmation necessary for a single block. doDeletion(); } } }; /** * Check whether a variable exists with the given name. The check is * case-insensitive. * @param {string} name The name to check for. * @return {number} The index of the name in the variable list, or -1 if it is * not present. */ Blockly.Workspace.prototype.variableIndexOf = function(name) { for (var i = 0, varname; varname = this.variableList[i]; i++) { if (Blockly.Names.equals(varname, name)) { return i; } } return -1; }; /** * Returns the horizontal offset of the workspace. * Intended for LTR/RTL compatibility in XML. * Not relevant for a headless workspace. * @return {number} Width. */ Blockly.Workspace.prototype.getWidth = function() { return 0; }; /** * Obtain a newly created block. * @param {?string} prototypeName Name of the language object containing * type-specific functions for this block. * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise * create a new id. * @return {!Blockly.Block} The created block. */ Blockly.Workspace.prototype.newBlock = function(prototypeName, opt_id) { return new Blockly.Block(this, prototypeName, opt_id); }; /** * The number of blocks that may be added to the workspace before reaching * the maxBlocks. * @return {number} Number of blocks left. */ Blockly.Workspace.prototype.remainingCapacity = function() { if (isNaN(this.options.maxBlocks)) { return Infinity; } return this.options.maxBlocks - this.getAllBlocks().length; }; /** * Undo or redo the previous action. * @param {boolean} redo False if undo, true if redo. */ Blockly.Workspace.prototype.undo = function(redo) { var inputStack = redo ? this.redoStack_ : this.undoStack_; var outputStack = redo ? this.undoStack_ : this.redoStack_; var inputEvent = inputStack.pop(); if (!inputEvent) { return; } var events = [inputEvent]; // Do another undo/redo if the next one is of the same group. while (inputStack.length && inputEvent.group && inputEvent.group == inputStack[inputStack.length - 1].group) { events.push(inputStack.pop()); } // Push these popped events on the opposite stack. for (var i = 0, event; event = events[i]; i++) { outputStack.push(event); } events = Blockly.Events.filter(events, redo); Blockly.Events.recordUndo = false; for (var i = 0, event; event = events[i]; i++) { event.run(redo); } Blockly.Events.recordUndo = true; }; /** * Clear the undo/redo stacks. */ Blockly.Workspace.prototype.clearUndo = function() { this.undoStack_.length = 0; this.redoStack_.length = 0; // Stop any events already in the firing queue from being undoable. Blockly.Events.clearPendingUndo(); }; /** * When something in this workspace changes, call a function. * @param {!Function} func Function to call. * @return {!Function} Function that can be passed to * removeChangeListener. */ Blockly.Workspace.prototype.addChangeListener = function(func) { this.listeners_.push(func); return func; }; /** * Stop listening for this workspace's changes. * @param {Function} func Function to stop calling. */ Blockly.Workspace.prototype.removeChangeListener = function(func) { goog.array.remove(this.listeners_, func); }; /** * Fire a change event. * @param {!Blockly.Events.Abstract} event Event to fire. */ Blockly.Workspace.prototype.fireChangeListener = function(event) { if (event.recordUndo) { this.undoStack_.push(event); this.redoStack_.length = 0; if (this.undoStack_.length > this.MAX_UNDO) { this.undoStack_.unshift(); } } for (var i = 0, func; func = this.listeners_[i]; i++) { func(event); } }; /** * Find the block on this workspace with the specified ID. * @param {string} id ID of block to find. * @return {Blockly.Block} The sought after block or null if not found. */ Blockly.Workspace.prototype.getBlockById = function(id) { return this.blockDB_[id] || null; }; /** * Database of all workspaces. * @private */ Blockly.Workspace.WorkspaceDB_ = Object.create(null); /** * Find the workspace with the specified ID. * @param {string} id ID of workspace to find. * @return {Blockly.Workspace} The sought after workspace or null if not found. */ Blockly.Workspace.getById = function(id) { return Blockly.Workspace.WorkspaceDB_[id] || null; }; // Export symbols that would otherwise be renamed by Closure compiler. Blockly.Workspace.prototype['clear'] = Blockly.Workspace.prototype.clear; Blockly.Workspace.prototype['clearUndo'] = Blockly.Workspace.prototype.clearUndo; Blockly.Workspace.prototype['addChangeListener'] = Blockly.Workspace.prototype.addChangeListener; Blockly.Workspace.prototype['removeChangeListener'] = Blockly.Workspace.prototype.removeChangeListener;