diff --git a/.gitignore b/.gitignore
index 3277b7174..c546855b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,4 @@ npm-debug.log
tests/compile/main_compressed.js
tests/compile/*compiler*.jar
local_build/*compiler*.jar
-local_build/local_blockly_compressed.js
+local_build/local_blockly_compressed.js
\ No newline at end of file
diff --git a/core/block.js b/core/block.js
index eef55c47a..c5cea853b 100644
--- a/core/block.js
+++ b/core/block.js
@@ -164,6 +164,7 @@ Blockly.Block = function(workspace, prototypeName, opt_id) {
}
workspace.addTopBlock(this);
+ workspace.addTypedBlock(this);
// Call an initialization function, if it exists.
if (typeof this.init == 'function') {
@@ -255,6 +256,7 @@ Blockly.Block.prototype.dispose = function(healStack) {
// Remove this block from the workspace's list of top-most blocks.
if (this.workspace) {
this.workspace.removeTopBlock(this);
+ this.workspace.removeTypedBlock(this);
// Remove from block database.
delete this.workspace.blockDB_[this.id];
this.workspace = null;
@@ -668,6 +670,21 @@ Blockly.Block.prototype.setMovable = function(movable) {
this.movable_ = movable;
};
+/**
+ * Get whether is block is duplicatable or not. If duplicating this block and
+ * descendants will put this block over the workspace's capacity this block is
+ * not duplicatable. If duplicating this block and descendants will put any
+ * type over their maxInstances this block is not duplicatable.
+ * @return {boolean} True if duplicatable.
+ */
+Blockly.Block.prototype.isDuplicatable = function() {
+ if (!this.workspace.hasBlockLimits()) {
+ return true;
+ }
+ return this.workspace.isCapacityAvailable(
+ Blockly.utils.getBlockTypeCounts(this, true));
+};
+
/**
* Get whether this block is a shadow block or not.
* @return {boolean} True if a shadow.
diff --git a/core/blockly.js b/core/blockly.js
index bfd58cdff..366e55fdf 100644
--- a/core/blockly.js
+++ b/core/blockly.js
@@ -98,6 +98,13 @@ Blockly.clipboardXml_ = null;
*/
Blockly.clipboardSource_ = null;
+/**
+ * Map of types to type counts for the clipboard object and descendants.
+ * @type {Object}
+ * @private
+ */
+Blockly.clipboardTypeCounts_ = null;
+
/**
* Cached value for whether 3D is supported.
* @type {!boolean}
@@ -227,15 +234,18 @@ Blockly.onKeyDown_ = function(e) {
if (e.keyCode == 86) {
// 'v' for paste.
if (Blockly.clipboardXml_) {
- Blockly.Events.setGroup(true);
// Pasting always pastes to the main workspace, even if the copy
// started in a flyout workspace.
var workspace = Blockly.clipboardSource_;
if (workspace.isFlyout) {
workspace = workspace.targetWorkspace;
}
- workspace.paste(Blockly.clipboardXml_);
- Blockly.Events.setGroup(false);
+ if (Blockly.clipboardTypeCounts_ &&
+ workspace.isCapacityAvailable(Blockly.clipboardTypeCounts_)) {
+ Blockly.Events.setGroup(true);
+ workspace.paste(Blockly.clipboardXml_);
+ Blockly.Events.setGroup(false);
+ }
}
} else if (e.keyCode == 90) {
// 'z' for undo 'Z' is for redo.
@@ -273,6 +283,8 @@ Blockly.copy_ = function(toCopy) {
}
Blockly.clipboardXml_ = xml;
Blockly.clipboardSource_ = toCopy.workspace;
+ Blockly.clipboardTypeCounts_ = toCopy.isComment ? null :
+ Blockly.utils.getBlockTypeCounts(toCopy, true);
};
/**
diff --git a/core/contextmenu.js b/core/contextmenu.js
index b23e8e11e..bd8b38f8e 100644
--- a/core/contextmenu.js
+++ b/core/contextmenu.js
@@ -260,11 +260,7 @@ Blockly.ContextMenu.blockHelpOption = function(block) {
* @package
*/
Blockly.ContextMenu.blockDuplicateOption = function(block) {
- var enabled = true;
- if (block.getDescendants(false).length >
- block.workspace.remainingCapacity()) {
- enabled = false;
- }
+ var enabled = block.isDuplicatable();
var duplicateOption = {
text: Blockly.Msg['DUPLICATE_BLOCK'],
enabled: enabled,
diff --git a/core/flyout_base.js b/core/flyout_base.js
index 235a03bb9..aa315118c 100644
--- a/core/flyout_base.js
+++ b/core/flyout_base.js
@@ -228,6 +228,7 @@ Blockly.Flyout.prototype.createDom = function(tagName) {
Blockly.Flyout.prototype.init = function(targetWorkspace) {
this.targetWorkspace_ = targetWorkspace;
this.workspace_.targetWorkspace = targetWorkspace;
+
// Add scrollbar.
this.scrollbar_ = new Blockly.Scrollbar(this.workspace_,
this.horizontalLayout_, false, 'blocklyFlyoutScrollbar');
@@ -729,7 +730,8 @@ Blockly.Flyout.prototype.filterForCapacity_ = function() {
for (var i = 0, block; block = blocks[i]; i++) {
if (this.permanentlyDisabled_.indexOf(block) == -1) {
var allBlocks = block.getDescendants(false);
- block.setDisabled(allBlocks.length > remainingCapacity);
+ block.setDisabled(allBlocks.length > remainingCapacity ||
+ this.targetWorkspace_.remainingCapacityOfType(block.type) <= 0);
}
}
};
diff --git a/core/options.js b/core/options.js
index 51a47a1ce..0b91d9d64 100644
--- a/core/options.js
+++ b/core/options.js
@@ -119,6 +119,7 @@ Blockly.Options = function(options) {
this.disable = hasDisable;
this.readOnly = readOnly;
this.maxBlocks = options['maxBlocks'] || Infinity;
+ this.maxInstances = options['maxInstances'];
this.pathToMedia = pathToMedia;
this.hasCategories = hasCategories;
this.hasScrollbars = hasScrollbars;
diff --git a/core/utils.js b/core/utils.js
index 8d5ccac83..2ec39feee 100644
--- a/core/utils.js
+++ b/core/utils.js
@@ -974,3 +974,32 @@ Blockly.utils.containsNode = function(parent, descendant) {
return !!(parent.compareDocumentPosition(descendant) &
Node.DOCUMENT_POSITION_CONTAINED_BY);
};
+
+/**
+ * Get a map of all the block's descendants mapping their type to the number of
+ * children with that type.
+ * @param {!Blockly.Block} block The block to map.
+ * @param {boolean=} opt_stripFollowing Optionally ignore all following
+ * statements (blocks that are not inside a value or statement input
+ * of the block).
+ * @returns {!Object} Map of types to type counts for descendants of the bock.
+ */
+Blockly.utils.getBlockTypeCounts = function(block, opt_stripFollowing) {
+ var typeCountsMap = Object.create(null);
+ var descendants = block.getDescendants(true);
+ if (opt_stripFollowing) {
+ var nextBlock = block.getNextBlock();
+ if (nextBlock) {
+ var index = descendants.indexOf(nextBlock);
+ descendants.splice(index, descendants.length - index);
+ }
+ }
+ for (var i = 0, checkBlock; checkBlock = descendants[i]; i++) {
+ if (typeCountsMap[checkBlock.type]) {
+ typeCountsMap[checkBlock.type]++;
+ } else {
+ typeCountsMap[checkBlock.type] = 1;
+ }
+ }
+ return typeCountsMap;
+};
diff --git a/core/workspace.js b/core/workspace.js
index 6040f7da4..8d58ff87a 100644
--- a/core/workspace.js
+++ b/core/workspace.js
@@ -85,6 +85,11 @@ Blockly.Workspace = function(opt_options) {
* @private
*/
this.blockDB_ = Object.create(null);
+ /**
+ * @type {!Object}
+ * @private
+ */
+ this.typedBlocksDB_ = Object.create(null);
/**
* A map from variable type to list of variable names. The lists contain all
@@ -147,6 +152,24 @@ Blockly.Workspace.prototype.dispose = function() {
*/
Blockly.Workspace.SCAN_ANGLE = 3;
+/**
+ * Compare function for sorting objects (blocks, comments, etc) by position;
+ * top to bottom (with slight LTR or RTL bias).
+ * @param {!Blockly.Block | !Blockly.WorkspaceComment} a The first object to
+ * compare.
+ * @param {!Blockly.Block | !Blockly.WorkspaceComment} b The second object to
+ * compare.
+ * @returns {number} The comparison value. This tells Array.sort() how to change
+ * object a's index.
+ * @private
+ */
+Blockly.Workspace.prototype.sortObjects_ = function(a, b) {
+ var aXY = a.getRelativeToSurfaceXY();
+ var bXY = b.getRelativeToSurfaceXY();
+ return (aXY.y + Blockly.Workspace.prototype.sortObjects_.offset * aXY.x) -
+ (bXY.y + Blockly.Workspace.prototype.sortObjects_.offset * bXY.x);
+};
+
/**
* Add a block to the list of top blocks.
* @param {!Blockly.Block} block Block to add.
@@ -175,16 +198,58 @@ Blockly.Workspace.prototype.getTopBlocks = function(ordered) {
// Copy the topBlocks_ list.
var blocks = [].concat(this.topBlocks_);
if (ordered && blocks.length > 1) {
- var offset =
+ this.sortObjects_.offset =
Math.sin(Blockly.utils.toRadians(Blockly.Workspace.SCAN_ANGLE));
if (this.RTL) {
- offset *= -1;
+ this.sortObjects_.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);
- });
+ blocks.sort(this.sortObjects_);
+ }
+ return blocks;
+};
+
+/**
+ * Add a block to the list of blocks keyed by type.
+ * @param {!Blockly.Block} block Block to add.
+ */
+Blockly.Workspace.prototype.addTypedBlock = function(block) {
+ if (!this.typedBlocksDB_[block.type]) {
+ this.typedBlocksDB_[block.type] = [];
+ }
+ this.typedBlocksDB_[block.type].push(block);
+};
+
+/**
+ * Remove a block from the list of blocks keyed by type.
+ * @param {!Blockly.Block} block Block to remove.
+ */
+Blockly.Workspace.prototype.removeTypedBlock = function(block) {
+ this.typedBlocksDB_[block.type].splice(this.typedBlocksDB_[block.type]
+ .indexOf(block), 1);
+ if (!this.typedBlocksDB_[block.type].length) {
+ delete this.typedBlocksDB_[block.type];
+ }
+};
+
+/**
+ * Finds the blocks with the associated type and returns them. Blocks are
+ * optionally sorted by position; top to bottom (with slight LTR or RTL bias).
+ * @param {string} type The type of block to search for.
+ * @param {boolean} ordered Sort the list if true.
+ * @return {!Array.} The blocks of the given type.
+ */
+Blockly.Workspace.prototype.getBlocksByType = function(type, ordered) {
+ if (!this.typedBlocksDB_[type]) {
+ return [];
+ }
+ var blocks = this.typedBlocksDB_[type].slice(0);
+ if (ordered && blocks.length > 1) {
+ this.sortObjects_.offset =
+ Math.sign(Blockly.utils.toRadians(Blockly.Workspace.SCAN_ANGLE));
+ if (this.RTL) {
+ this.sortObjects_.offset *= -1;
+ }
+ blocks.sort(this.sortObjects_);
}
return blocks;
};
@@ -232,16 +297,12 @@ Blockly.Workspace.prototype.getTopComments = function(ordered) {
// Copy the topComments_ list.
var comments = [].concat(this.topComments_);
if (ordered && comments.length > 1) {
- var offset =
+ this.sortObjects_.offset =
Math.sin(Blockly.utils.toRadians(Blockly.Workspace.SCAN_ANGLE));
if (this.RTL) {
- offset *= -1;
+ this.sortObjects_.offset *= -1;
}
- comments.sort(function(a, b) {
- var aXY = a.getRelativeToSurfaceXY();
- var bXY = b.getRelativeToSurfaceXY();
- return (aXY.y + offset * aXY.x) - (bXY.y + offset * bXY.x);
- });
+ comments.sort(this.sortObjects_);
}
return comments;
};
@@ -455,6 +516,56 @@ Blockly.Workspace.prototype.remainingCapacity = function() {
return this.options.maxBlocks - this.getAllBlocks().length;
};
+/**
+ * The number of blocks of the given type that may be added to the workspace
+ * before reaching the maxInstances allowed for that type.
+ * @param {string} type Type of block to return capacity for.
+ * @return {number} Number of blocks of type left.
+ */
+Blockly.Workspace.prototype.remainingCapacityOfType = function(type) {
+ if (!this.options.maxInstances) {
+ return Infinity;
+ }
+ return (this.options.maxInstances[type] || Infinity) -
+ this.getBlocksByType(type).length;
+};
+
+/**
+ * Check if there is remaining capacity for blocks of the given counts to be
+ * created. If the total number of blocks represented by the map is more than
+ * the total remaining capacity, it returns false. If a type count is more
+ * than the remaining capacity for that type, it returns false.
+ * @param {!Object} typeCountsMap A map of types to counts (usually representing
+ * blocks to be created).
+ * @returns {boolean} True if there is capacity for the given map,
+ * false otherwise.
+ */
+Blockly.Workspace.prototype.isCapacityAvailable = function(typeCountsMap) {
+ if (!this.hasBlockLimits()) {
+ return true;
+ }
+ var copyableBlocksCount = 0;
+ for (var type in typeCountsMap) {
+ if (typeCountsMap[type] > this.remainingCapacityOfType(type)) {
+ return false;
+ }
+ copyableBlocksCount += typeCountsMap[type];
+ }
+ if (copyableBlocksCount > this.remainingCapacity()) {
+ return false;
+ }
+ return true;
+};
+
+/**
+ * Checks if the workspace has any limits on the maximum number of blocks,
+ * or the maximum number of blocks of specific types.
+ * @returns {boolean} True if it has block limits, false otherwise.
+ */
+Blockly.Workspace.prototype.hasBlockLimits = function() {
+ return this.options.maxBlocks != Infinity || !!this.options.maxInstances;
+};
+
/**
* Undo or redo the previous action.
* @param {boolean} redo False if undo, true if redo.
diff --git a/tests/blocks/test_blocks.js b/tests/blocks/test_blocks.js
index 2698e7e17..f094b1131 100644
--- a/tests/blocks/test_blocks.js
+++ b/tests/blocks/test_blocks.js
@@ -44,6 +44,22 @@ Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT
"output": null,
"colour": 230
},
+ {
+ "type": "limit_instances",
+ "message0": "limit 3 instances %1 %2",
+ "args0": [
+ {
+ "type": "input_dummy"
+ },
+ {
+ "type": "input_statement",
+ "name": "STATEMENT"
+ }
+ ],
+ "previousStatement": null,
+ "nextStatement": null,
+ "colour": 230,
+ },
{
"type": "example_dropdown_long",
"message0": "long: %1",
@@ -145,17 +161,17 @@ Blockly.defineBlocksWithJsonArray([ // BEGIN JSON EXTRACT
"type": "example_angle",
"message0": "angle: %1",
"args0": [
- {
- "type": "field_angle",
- "name": "FIELDNAME",
- "angle": "90",
- "alt":
- {
- "type": "field_label",
- "text": "NO ANGLE FIELD"
- }
- }
- ]
+ {
+ "type": "field_angle",
+ "name": "FIELDNAME",
+ "angle": "90",
+ "alt":
+ {
+ "type": "field_label",
+ "text": "NO ANGLE FIELD"
+ }
+ }
+ ]
},
{
"type": "example_date",
diff --git a/tests/playground.html b/tests/playground.html
index 20a216c73..d2f5d50a7 100644
--- a/tests/playground.html
+++ b/tests/playground.html
@@ -103,6 +103,7 @@ function start() {
},
horizontalLayout: side == 'top' || side == 'bottom',
maxBlocks: Infinity,
+ maxInstances: {'limit_instances': 3},
media: '../media/',
oneBasedIndex: true,
readOnly: false,
@@ -1080,6 +1081,7 @@ h1 {
+