/** * @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'); // TODO(scr): Fix circular dependencies // goog.require('Blockly.Block'); goog.require('Blockly.ScrollbarPair'); goog.require('Blockly.Trashcan'); goog.require('Blockly.Xml'); goog.require('goog.math'); goog.require('goog.math.Coordinate'); /** * Class for a workspace. * @param {Function} getMetrics A function that returns size/scrolling metrics. * @param {Function} setMetrics A function that sets size/scrolling metrics. * @constructor */ Blockly.Workspace = function(getMetrics, setMetrics) { this.getMetrics = getMetrics; this.setMetrics = setMetrics; /** @type {boolean} */ this.isFlyout = false; /** * @type {!Array.} * @private */ this.topBlocks_ = []; /** @type {number} */ this.maxBlocks = Infinity; Blockly.ConnectionDB.init(this); }; /** * 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; /** * Can this workspace be dragged around (true) or is it fixed (false)? * @type {boolean} */ Blockly.Workspace.prototype.dragMode = false; /** * Current horizontal scrolling offset. * @type {number} */ Blockly.Workspace.prototype.scrollX = 0; /** * Current vertical scrolling offset. * @type {number} */ Blockly.Workspace.prototype.scrollY = 0; /** * The workspace's trashcan (if any). * @type {Blockly.Trashcan} */ Blockly.Workspace.prototype.trashcan = null; /** * PID of upcoming firing of a change event. Used to fire only one event * after multiple changes. * @type {?number} * @private */ Blockly.Workspace.prototype.fireChangeEventPid_ = null; /** * This workspace's scrollbars, if they exist. * @type {Blockly.ScrollbarPair} */ Blockly.Workspace.prototype.scrollbar = null; /** * Create the trash can elements. * @return {!Element} The workspace's SVG group. */ Blockly.Workspace.prototype.createDom = function() { /* [Trashcan may go here] */ this.svgGroup_ = Blockly.createSvgElement('g', {}, null); this.svgBlockCanvas_ = Blockly.createSvgElement('g', {}, this.svgGroup_); this.svgBubbleCanvas_ = Blockly.createSvgElement('g', {}, this.svgGroup_); this.fireChangeEvent(); return this.svgGroup_; }; /** * Dispose of this workspace. * Unlink from all DOM elements to prevent memory leaks. */ Blockly.Workspace.prototype.dispose = function() { if (this.svgGroup_) { goog.dom.removeNode(this.svgGroup_); this.svgGroup_ = null; } this.svgBlockCanvas_ = null; this.svgBubbleCanvas_ = null; if (this.flyout_) { this.flyout_.dispose(); this.flyout_ = null; } if (this.trashcan) { this.trashcan.dispose(); this.trashcan = null; } }; /** * Add a trashcan. */ Blockly.Workspace.prototype.addTrashcan = function() { if (Blockly.hasTrashcan && !Blockly.readOnly) { this.trashcan = new Blockly.Trashcan(this); var svgTrashcan = this.trashcan.createDom(); this.svgGroup_.insertBefore(svgTrashcan, this.svgBlockCanvas_); this.trashcan.init(); } }; /** * Get the SVG element that forms the drawing surface. * @return {!Element} SVG element. */ Blockly.Workspace.prototype.getCanvas = function() { return this.svgBlockCanvas_; }; /** * Get the SVG element that forms the bubble surface. * @return {!SVGGElement} SVG element. */ Blockly.Workspace.prototype.getBubbleCanvas = function() { return this.svgBubbleCanvas_; }; /** * 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 (Blockly.Realtime.isEnabled() && this == Blockly.mainWorkspace) { Blockly.Realtime.addTopBlock(block); } this.fireChangeEvent(); }; /** * Remove a block from the list of top blocks. * @param {!Blockly.Block} block Block to remove. */ Blockly.Workspace.prototype.removeTopBlock = function(block) { var found = false; for (var child, x = 0; child = this.topBlocks_[x]; x++) { if (child == block) { this.topBlocks_.splice(x, 1); found = true; break; } } if (!found) { throw 'Block not present in workspace\'s list of top-most blocks.'; } if (Blockly.Realtime.isEnabled() && this == Blockly.mainWorkspace) { Blockly.Realtime.removeTopBlock(block); } this.fireChangeEvent(); }; /** * 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 (Blockly.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 x = 0; x < blocks.length; x++) { blocks.push.apply(blocks, blocks[x].getChildren()); } return blocks; }; /** * Dispose of all blocks in workspace. */ Blockly.Workspace.prototype.clear = function() { Blockly.hideChaff(); while (this.topBlocks_.length) { this.topBlocks_[0].dispose(); } }; /** * Render all blocks in workspace. */ Blockly.Workspace.prototype.render = function() { var renderList = this.getAllBlocks(); for (var x = 0, block; block = renderList[x]; x++) { if (!block.getChildren().length) { block.render(); } } }; /** * Finds the block with the specified ID in this workspace. * @param {string} id ID of block to find. * @return {Blockly.Block} The matching block, or null if not found. */ Blockly.Workspace.prototype.getBlockById = function(id) { // If this O(n) function fails to scale well, maintain a hash table of IDs. var blocks = this.getAllBlocks(); for (var x = 0, block; block = blocks[x]; x++) { if (block.id == id) { return block; } } return null; }; /** * Turn the visual trace functionality on or off. * @param {boolean} armed True if the trace should be on. */ Blockly.Workspace.prototype.traceOn = function(armed) { this.traceOn_ = armed; if (this.traceWrapper_) { Blockly.unbindEvent_(this.traceWrapper_); this.traceWrapper_ = null; } if (armed) { this.traceWrapper_ = Blockly.bindEvent_(this.svgBlockCanvas_, 'blocklySelectChange', this, function() {this.traceOn_ = false}); } }; /** * Highlight a block in the workspace. * @param {?string} id ID of block to find. */ Blockly.Workspace.prototype.highlightBlock = function(id) { if (this.traceOn_ && Blockly.Block.dragMode_ != 0) { // The blocklySelectChange event normally prevents this, but sometimes // there is a race condition on fast-executing apps. this.traceOn(false); } if (!this.traceOn_) { return; } var block = null; if (id) { block = this.getBlockById(id); if (!block) { return; } } // Temporary turn off the listener for selection changes, so that we don't // trip the monitor for detecting user activity. this.traceOn(false); // Select the current block. if (block) { block.select(); } else if (Blockly.selected) { Blockly.selected.unselect(); } // Restore the monitor for user activity after the selection event has fired. var thisWorkspace = this; setTimeout(function() {thisWorkspace.traceOn(true);}, 1); }; /** * Fire a change event for this workspace. Changes include new block, dropdown * edits, mutations, connections, etc. Groups of simultaneous changes (e.g. * a tree of blocks being deleted) are merged into one event. * Applications may hook workspace changes by listening for * 'blocklyWorkspaceChange' on Blockly.mainWorkspace.getCanvas(). */ Blockly.Workspace.prototype.fireChangeEvent = function() { if (this.fireChangeEventPid_) { window.clearTimeout(this.fireChangeEventPid_); } var canvas = this.svgBlockCanvas_; if (canvas) { this.fireChangeEventPid_ = window.setTimeout(function() { Blockly.fireUiEvent(canvas, 'blocklyWorkspaceChange'); }, 0); } }; /** * Paste the provided block onto the workspace. * @param {!Element} xmlBlock XML block element. */ Blockly.Workspace.prototype.paste = function(xmlBlock) { if (xmlBlock.getElementsByTagName('block').length >= this.remainingCapacity()) { return; } var block = Blockly.Xml.domToBlock(this, xmlBlock); // Move the duplicate to original position. var blockX = parseInt(xmlBlock.getAttribute('x'), 10); var blockY = parseInt(xmlBlock.getAttribute('y'), 10); if (!isNaN(blockX) && !isNaN(blockY)) { if (Blockly.RTL) { blockX = -blockX; } // Offset block until not clobbering another block. do { var collide = false; var allBlocks = this.getAllBlocks(); for (var x = 0, otherBlock; otherBlock = allBlocks[x]; x++) { var otherXY = otherBlock.getRelativeToSurfaceXY(); if (Math.abs(blockX - otherXY.x) <= 1 && Math.abs(blockY - otherXY.y) <= 1) { if (Blockly.RTL) { blockX -= Blockly.SNAP_RADIUS; } else { blockX += Blockly.SNAP_RADIUS; } blockY += Blockly.SNAP_RADIUS * 2; collide = true; } } } while (collide); block.moveBy(blockX, blockY); } block.select(); }; /** * 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 (this.maxBlocks == Infinity) { return Infinity; } return this.maxBlocks - this.getAllBlocks().length; }; /** * Make a list of all the delete areas for this workspace. */ Blockly.Workspace.prototype.recordDeleteAreas = function() { if (this.trashcan) { this.deleteAreaTrash_ = this.trashcan.getRect(); } else { this.deleteAreaTrash_ = null; } if (this.flyout_) { this.deleteAreaToolbox_ = this.flyout_.getRect(); } else if (this.toolbox_) { this.deleteAreaToolbox_ = this.toolbox_.getRect(); } else { this.deleteAreaToolbox_ = null; } }; /** * Is the mouse event over a delete area (toolbar or non-closing flyout)? * Opens or closes the trashcan and sets the cursor as a side effect. * @param {!Event} e Mouse move event. * @return {boolean} True if event is in a delete area. */ Blockly.Workspace.prototype.isDeleteArea = function(e) { var isDelete = false; var mouseXY = Blockly.mouseToSvg(e); var xy = new goog.math.Coordinate(mouseXY.x, mouseXY.y); if (this.deleteAreaTrash_) { if (this.deleteAreaTrash_.contains(xy)) { this.trashcan.setOpen_(true); Blockly.Css.setCursor(Blockly.Css.Cursor.DELETE); return true; } this.trashcan.setOpen_(false); } if (this.deleteAreaToolbox_) { if (this.deleteAreaToolbox_.contains(xy)) { Blockly.Css.setCursor(Blockly.Css.Cursor.DELETE); return true; } } Blockly.Css.setCursor(Blockly.Css.Cursor.CLOSED); return false; }; // Export symbols that would otherwise be renamed by Closure compiler. Blockly.Workspace.prototype['clear'] = Blockly.Workspace.prototype.clear;