2013-10-30 14:46:03 -07:00
|
|
|
/**
|
2014-01-28 03:00:09 -08:00
|
|
|
* @license
|
2013-10-30 14:46:03 -07:00
|
|
|
* Visual Blocks Editor
|
|
|
|
*
|
|
|
|
* Copyright 2012 Google Inc.
|
2014-10-07 13:09:55 -07:00
|
|
|
* https://developers.google.com/blockly/
|
2013-10-30 14:46:03 -07:00
|
|
|
*
|
|
|
|
* 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');
|
2014-11-24 15:08:19 -08:00
|
|
|
goog.require('goog.math');
|
2014-11-28 21:43:39 -08:00
|
|
|
goog.require('goog.math.Coordinate');
|
2013-10-30 14:46:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.<!Blockly.Block>}
|
|
|
|
* @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() {
|
|
|
|
/*
|
|
|
|
<g>
|
|
|
|
[Trashcan may go here]
|
|
|
|
<g></g>
|
|
|
|
<g></g>
|
|
|
|
</g>
|
|
|
|
*/
|
|
|
|
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;
|
2014-11-28 21:43:39 -08:00
|
|
|
if (this.flyout_) {
|
|
|
|
this.flyout_.dispose();
|
|
|
|
this.flyout_ = null;
|
|
|
|
}
|
2013-10-30 14:46:03 -07:00
|
|
|
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);
|
2014-02-01 03:00:04 -08:00
|
|
|
if (Blockly.Realtime.isEnabled() && this == Blockly.mainWorkspace) {
|
2014-01-22 03:00:07 -08:00
|
|
|
Blockly.Realtime.addTopBlock(block);
|
|
|
|
}
|
2013-10-30 14:46:03 -07:00
|
|
|
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.';
|
|
|
|
}
|
2014-02-01 03:00:04 -08:00
|
|
|
if (Blockly.Realtime.isEnabled() && this == Blockly.mainWorkspace) {
|
2014-01-22 03:00:07 -08:00
|
|
|
Blockly.Realtime.removeTopBlock(block);
|
|
|
|
}
|
2013-10-30 14:46:03 -07:00
|
|
|
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.<!Blockly.Block>} 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) {
|
2014-11-24 15:08:19 -08:00
|
|
|
var offset = Math.sin(goog.math.toRadians(Blockly.Workspace.SCAN_ANGLE));
|
2013-10-30 14:46:03 -07:00
|
|
|
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.<!Blockly.Block>} Array of blocks.
|
|
|
|
*/
|
|
|
|
Blockly.Workspace.prototype.getAllBlocks = function() {
|
|
|
|
var blocks = this.getTopBlocks(false);
|
|
|
|
for (var x = 0; x < blocks.length; x++) {
|
2014-09-08 14:26:52 -07:00
|
|
|
blocks.push.apply(blocks, blocks[x].getChildren());
|
2013-10-30 14:46:03 -07:00
|
|
|
}
|
|
|
|
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) {
|
2014-09-08 14:26:52 -07:00
|
|
|
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);
|
|
|
|
}
|
2013-10-30 14:46:03 -07:00
|
|
|
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();
|
|
|
|
}
|
2014-09-08 14:26:52 -07:00
|
|
|
// Restore the monitor for user activity after the selection event has fired.
|
|
|
|
var thisWorkspace = this;
|
|
|
|
setTimeout(function() {thisWorkspace.traceOn(true);}, 1);
|
2013-10-30 14:46:03 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
2014-01-22 03:00:07 -08:00
|
|
|
var block = Blockly.Xml.domToBlock(this, xmlBlock);
|
2013-10-30 14:46:03 -07:00
|
|
|
// 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;
|
|
|
|
};
|
|
|
|
|
2014-11-28 21:43:39 -08:00
|
|
|
/**
|
|
|
|
* 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();
|
2014-12-02 15:00:10 -08:00
|
|
|
} else if (this.toolbox_) {
|
|
|
|
this.deleteAreaToolbox_ = this.toolbox_.getRect();
|
2014-11-28 21:43:39 -08:00
|
|
|
} 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;
|
|
|
|
};
|
|
|
|
|
2013-10-30 14:46:03 -07:00
|
|
|
// Export symbols that would otherwise be renamed by Closure compiler.
|
|
|
|
Blockly.Workspace.prototype['clear'] = Blockly.Workspace.prototype.clear;
|