From 1c4ba38300e02d11bbd3e8fbec458b792c8fa912 Mon Sep 17 00:00:00 2001 From: BeksOmega Date: Tue, 27 Nov 2018 16:34:21 -0800 Subject: [PATCH] Added Max Instances Property to Workspace Options (#2130) * Added Max Instances property to Blocks * eslint cleanup * eslint cleanup 2 * Moved maxInstances property from block to workspace (as a map of block type to max instances). isDuplicate() changed to correctly handle siblings/branches. * eslint cleanup * Changed checking types to map. Added hasBlockLimits. Fixed Nits. * Added limit_instances test block. eslint fixes. * fixup! Added limit_instances test block. eslint fixes. * Changed sorting objects to a private static function of the workspace. Fixed nits. Undeleted .eslintrc * Reverted .gitignore file. * Added getBlockTypeCounts() to utils. Added isCapacityAvailable() to workspace. Changed clipboard to save typeCountsMap rather than object. --- .gitignore | 2 +- core/block.js | 17 +++++ core/blockly.js | 18 ++++- core/contextmenu.js | 6 +- core/flyout_base.js | 4 +- core/options.js | 1 + core/utils.js | 29 ++++++++ core/workspace.js | 139 ++++++++++++++++++++++++++++++++---- tests/blocks/test_blocks.js | 38 +++++++--- tests/playground.html | 2 + 10 files changed, 221 insertions(+), 35 deletions(-) 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 { +