feat: add support for defining toolboxes using pure json (#5392)

* feat: add recycling to core

* feat: add support for json block definitions in flyout

* tests: reorganize tests

* tests: add tests for generating contents

* Fixup reycling

* tests: add tests for recycling

* fix: types

* fix: lint

* fix: PR comments

* fix: creating blocks from flyout

* test: add test block to playground

* fix: types

* feat: add support for enabled
This commit is contained in:
Beka Westberg
2021-09-02 16:01:29 +00:00
committed by alschmiedt
parent 6d87b85e6c
commit 410365f4a1
11 changed files with 719 additions and 141 deletions

View File

@@ -34,13 +34,13 @@ const Tooltip = goog.require('Blockly.Tooltip');
const Variables = goog.require('Blockly.Variables');
const WorkspaceSvg = goog.require('Blockly.WorkspaceSvg');
const Xml = goog.require('Blockly.Xml');
const blocks = goog.require('Blockly.serialization.blocks');
const browserEvents = goog.require('Blockly.browserEvents');
const dom = goog.require('Blockly.utils.dom');
const idGenerator = goog.require('Blockly.utils.idGenerator');
const object = goog.require('Blockly.utils.object');
const toolbox = goog.require('Blockly.utils.toolbox');
const utils = goog.require('Blockly.utils');
const utilsXml = goog.require('Blockly.utils.xml');
/** @suppress {extraRequire} */
goog.require('Blockly.blockRendering');
/** @suppress {extraRequire} */
@@ -155,6 +155,13 @@ const Flyout = function(workspaceOptions) {
* @package
*/
this.targetWorkspace = null;
/**
* A list of blocks that can be reused.
* @type {!Array<!BlockSvg>}
* @private
*/
this.recycledBlocks_ = [];
};
object.inherits(Flyout, DeleteArea);
@@ -560,6 +567,7 @@ Flyout.prototype.show = function(flyoutDef) {
this.reflowWrapper_ = this.reflow.bind(this);
this.workspace_.addChangeListener(this.reflowWrapper_);
this.emptyRecycledBlocks_();
};
/**
@@ -591,15 +599,10 @@ Flyout.prototype.createFlyoutInfo_ = function(parsedContent) {
switch (contentInfo['kind'].toUpperCase()) {
case 'BLOCK': {
const blockInfo = /** @type {!toolbox.BlockInfo} */ (contentInfo);
const blockXml = this.getBlockXml_(blockInfo);
const block = this.createBlock_(blockXml);
// This is a deprecated method for adding gap to a block.
// <block type="math_arithmetic" gap="8"></block>
const gap =
parseInt(blockInfo['gap'] || blockXml.getAttribute('gap'), 10);
gaps.push(isNaN(gap) ? defaultGap : gap);
var blockInfo = /** @type {!toolbox.BlockInfo} */ (contentInfo);
var block = this.createFlyoutBlock_(blockInfo);
contents.push({type: 'block', block: block});
this.addBlockGap_(blockInfo, gaps, defaultGap);
break;
}
case 'SEP': {
@@ -630,7 +633,8 @@ Flyout.prototype.createFlyoutInfo_ = function(parsedContent) {
/**
* Gets the flyout definition for the dynamic category.
* @param {string} categoryName The name of the dynamic category.
* @return {!Array<!Element>} The array of flyout items.
* @return {!Blockly.utils.toolbox.FlyoutDefinition} The definition of the
* flyout in one of its many forms.
* @private
*/
Flyout.prototype.getDynamicCategoryContents_ = function(categoryName) {
@@ -643,12 +647,7 @@ Flyout.prototype.getDynamicCategoryContents_ = function(categoryName) {
'Couldn\'t find a callback function when opening' +
' a toolbox category.');
}
const flyoutDef = fnToApply(this.workspace_.targetWorkspace);
if (!Array.isArray(flyoutDef)) {
throw new TypeError(
'Result of toolbox category callback must be an array.');
}
return flyoutDef;
return fnToApply(this.workspace_.targetWorkspace);
};
/**
@@ -674,50 +673,77 @@ Flyout.prototype.createButton_ = function(btnInfo, isLabel) {
/**
* Create a block from the xml and permanently disable any blocks that were
* defined as disabled.
* @param {!Element} blockXml The xml of the block.
* @param {!toolbox.BlockInfo} blockInfo The info of the block.
* @return {!BlockSvg} The block created from the blockXml.
* @protected
* @private
*/
Flyout.prototype.createBlock_ = function(blockXml) {
const curBlock =
/** @type {!BlockSvg} */ (Xml.domToBlock(blockXml, this.workspace_));
if (!curBlock.isEnabled()) {
Flyout.prototype.createFlyoutBlock_ = function(blockInfo) {
let block;
if (blockInfo['blockxml']) {
const xml = typeof blockInfo['blockxml'] === 'string' ?
Xml.textToDom(blockInfo['blockxml']) :
blockInfo['blockxml'];
block = this.getRecycledBlock_(xml.getAttribute('type'));
if (!block) {
block = Xml.domToBlock(xml, this.workspace_);
}
} else {
block = this.getRecycledBlock_(blockInfo['type']);
if (!block) {
if (blockInfo['enabled'] === undefined) {
blockInfo['enabled'] =
blockInfo['disabled'] !== 'true' && blockInfo['disabled'] !== true;
}
block = blocks.load(
/** @type {blocks.State} */ (blockInfo),this.workspace_);
}
}
if (!block.isEnabled()) {
// Record blocks that were initially disabled.
// Do not enable these blocks as a result of capacity filtering.
this.permanentlyDisabled_.push(curBlock);
this.permanentlyDisabled_.push(block);
}
return curBlock;
return /** @type {!BlockSvg} */ (block);
};
/**
* Get the xml from the block info object.
* @param {!toolbox.BlockInfo} blockInfo The object holding
* information about a block.
* @return {!Element} The xml for the block.
* @throws {Error} if the xml is not a valid block definition.
* Returns a block from the array of recycled blocks with the given type, or
* undefined if one cannot be found.
* @param {string} blockType The type of the block to try to recycle.
* @return {(!BlockSvg|undefined)} The recycled block, or undefined if
* one could not be recycled.
* @private
*/
Flyout.prototype.getBlockXml_ = function(blockInfo) {
let blockElement = null;
const blockXml = blockInfo['blockxml'];
if (blockXml && typeof blockXml != 'string') {
blockElement = blockXml;
} else if (blockXml && typeof blockXml == 'string') {
blockElement = Xml.textToDom(blockXml);
blockInfo['blockxml'] = blockElement;
} else if (blockInfo['type']) {
blockElement = utilsXml.createElement('xml');
blockElement.setAttribute('type', blockInfo['type']);
blockElement.setAttribute('disabled', blockInfo['disabled']);
blockInfo['blockxml'] = blockElement;
Flyout.prototype.getRecycledBlock_ = function(blockType) {
let index = -1;
for (let i = 0; i < this.recycledBlocks_.length; i++) {
if (this.recycledBlocks_[i].type == blockType) {
index = i;
break;
}
}
return index == -1 ? undefined : this.recycledBlocks_.splice(index, 1)[0];
};
if (!blockElement) {
throw Error(
'Error: Invalid block definition. Block definition must have blockxml or type.');
/**
* Adds a gap in the flyout based on block info.
* @param {!toolbox.BlockInfo} blockInfo Information about a block.
* @param {!Array<number>} gaps The list of gaps between items in the flyout.
* @param {number} defaultGap The default gap between one element and the next.
* @private
*/
Flyout.prototype.addBlockGap_ = function(blockInfo, gaps, defaultGap) {
let gap;
if (blockInfo['gap']) {
gap = parseInt(blockInfo['gap'], 10);
} else if (blockInfo['blockxml']) {
const xml = typeof blockInfo['blockxml'] === 'string' ?
Xml.textToDom(blockInfo['blockxml']) :
blockInfo['blockxml'];
gap = parseInt(xml.getAttribute('gap'), 10);
}
return blockElement;
gaps.push(isNaN(gap) ? defaultGap : gap);
};
/**
@@ -745,13 +771,15 @@ Flyout.prototype.addSeparatorGap_ = function(sepInfo, gaps, defaultGap) {
/**
* Delete blocks, mats and buttons from a previous showing of the flyout.
* @protected
* @private
*/
Flyout.prototype.clearOldBlocks_ = function() {
// Delete any blocks from a previous showing.
const oldBlocks = this.workspace_.getTopBlocks(false);
for (let i = 0, block; (block = oldBlocks[i]); i++) {
if (block.workspace == this.workspace_) {
if (this.blockIsRecyclable_(block)) {
this.recycleBlock_(block);
} else {
block.dispose(false, false);
}
}
@@ -774,6 +802,41 @@ Flyout.prototype.clearOldBlocks_ = function() {
this.workspace_.getPotentialVariableMap().clear();
};
/**
* Empties all of the recycled blocks, properly disposing of them.
* @private
*/
Flyout.prototype.emptyRecycledBlocks_ = function() {
for (let i = 0; i < this.recycledBlocks_.length; i++) {
this.recycledBlocks_[i].dispose();
}
this.recycledBlocks_ = [];
};
/**
* Returns whether the given block can be recycled or not.
* @param {!BlockSvg} _block The block to check for recyclability.
* @return {boolean} True if the block can be recycled. False otherwise.
* @protected
*/
Flyout.prototype.blockIsRecyclable_ = function(_block) {
// By default, recycling is disabled.
return false;
};
/**
* Puts a previously created block into the recycle bin and moves it to the
* top of the workspace. Used during large workspace swaps to limit the number
* of new DOM elements we need to create.
* @param {!BlockSvg} block The block to recycle.
* @private
*/
Flyout.prototype.recycleBlock_ = function(block) {
const xy = block.getRelativeToSurfaceXY();
block.moveBy(-xy.x, -xy.y);
this.recycledBlocks_.push(block);
};
/**
* Add listeners to a block that has been added to the flyout.
* @param {!SVGElement} root The root node of the SVG group the block is in.
@@ -1010,20 +1073,21 @@ Flyout.prototype.placeNewBlock_ = function(oldBlock) {
throw Error('oldBlock is not rendered.');
}
// Create the new block by cloning the block in the flyout (via XML).
// This cast assumes that the oldBlock can not be an insertion marker.
const xml = /** @type {!Element} */ (Xml.blockToDom(oldBlock, true));
// The target workspace would normally resize during domToBlock, which will
// lead to weird jumps. Save it for terminateDrag.
targetWorkspace.setResizesEnabled(false);
// Using domToBlock instead of domToWorkspace means that the new block will be
// placed at position (0, 0) in main workspace units.
const block = /** @type {!BlockSvg} */
(Xml.domToBlock(xml, targetWorkspace));
const svgRootNew = block.getSvgRoot();
if (!svgRootNew) {
throw Error('block is not rendered.');
let block;
if (oldBlock.mutationToDom && !oldBlock.saveExtraState) {
// Create the new block by cloning the block in the flyout (via XML).
// This cast assumes that the oldBlock can not be an insertion marker.
const xml = /** @type {!Element} */ (Xml.blockToDom(oldBlock, true));
// The target workspace would normally resize during domToBlock, which will
// lead to weird jumps. Save it for terminateDrag.
targetWorkspace.setResizesEnabled(false);
// Using domToBlock instead of domToWorkspace means that the new block will be
// placed at position (0, 0) in main workspace units.
block = /** @type {!BlockSvg} */ (Xml.domToBlock(xml, targetWorkspace));
} else {
const json = /** @type {!blocks.State} */ (blocks.save(oldBlock));
targetWorkspace.setResizesEnabled(false);
block = /** @type {!BlockSvg} */ (blocks.load(json, targetWorkspace));
}
// The offset in pixels between the main workspace's origin and the upper left

View File

@@ -46,14 +46,17 @@ exports.ConnectionState = ConnectionState;
* Represents the state of a given block.
* @typedef {{
* type: string,
* id: string,
* id: (string|undefined),
* x: (number|undefined),
* y: (number|undefined),
* collapsed: (boolean|undefined),
* disabled: (boolean|undefined),
* enabled: (boolean|undefined),
* editable: (boolean|undefined),
* deletable: (boolean|undefined),
* movable: (boolean|undefined),
* inline: (boolean|undefined),
* data: (string|undefined),
* extra-state: *,
* extra-state: (*|undefined),
* icons: (!Object<string, *>|undefined),
* fields: (!Object<string, *>|undefined),
* inputs: (!Object<string, !ConnectionState>|undefined),

View File

@@ -16,6 +16,8 @@
*/
goog.module('Blockly.utils.toolbox');
/* eslint-disable-next-line no-unused-vars */
const {ConnectionState} = goog.requireType('Blockly.serialization.blocks');
/* eslint-disable-next-line no-unused-vars */
const ToolboxCategory = goog.requireType('Blockly.ToolboxCategory');
/* eslint-disable-next-line no-unused-vars */
@@ -25,12 +27,28 @@ const userAgent = goog.require('Blockly.utils.userAgent');
/**
* The information needed to create a block in the toolbox.
* Note that disabled has a different type for backwards compatibility.
* @typedef {{
* kind:string,
* blockxml:(string|!Node|undefined),
* type:(string|undefined),
* gap:(string|number|undefined),
* disabled: (string|boolean|undefined)
* disabled: (string|boolean|undefined),
* enabled: (boolean|undefined),
* id: (string|undefined),
* x: (number|undefined),
* y: (number|undefined),
* collapsed: (boolean|undefined),
* editable: (boolean|undefined),
* deletable: (boolean|undefined),
* movable: (boolean|undefined),
* inline: (boolean|undefined),
* data: (string|undefined),
* extra-state: (*|undefined),
* icons: (!Object<string, *>|undefined),
* fields: (!Object<string, *>|undefined),
* inputs: (!Object<string, !ConnectionState>|undefined),
* next: (!ConnectionState|undefined)
* }}
*/
let BlockInfo;

View File

@@ -200,11 +200,12 @@ const WorkspaceSvg = function(
this.markerManager_ = new MarkerManager(this);
/**
* Map from function names to callbacks, for deciding what to do when a custom
* toolbox category is opened.
* @type {!Object<string, ?function(!Workspace):!Array<!Element>>}
* @private
*/
* Map from function names to callbacks, for deciding what to do when a custom
* toolbox category is opened.
* @type {!Object<string, ?function(!Workspace):
* !toolbox.FlyoutDefinition>}
* @private
*/
this.toolboxCategoryCallbacks_ = Object.create(null);
/**
@@ -2547,7 +2548,7 @@ WorkspaceSvg.prototype.removeButtonCallback = function(key) {
* custom toolbox categories in this workspace. See the variable and procedure
* categories as an example.
* @param {string} key The name to use to look up this function.
* @param {function(!Workspace):!Array<!Element>} func The function to
* @param {function(!Workspace): !toolbox.FlyoutDefinition} func The function to
* call when the given toolbox category is opened.
*/
WorkspaceSvg.prototype.registerToolboxCategoryCallback = function(key, func) {
@@ -2561,7 +2562,7 @@ WorkspaceSvg.prototype.registerToolboxCategoryCallback = function(key, func) {
* Get the callback function associated with a given key, for populating
* custom toolbox categories in this workspace.
* @param {string} key The name to use to look up the function.
* @return {?function(!Workspace):!Array<!Element>} The function
* @return {?function(!Workspace): !toolbox.FlyoutDefinition} The function
* corresponding to the given key for this workspace, or null if no function
* is registered.
*/

View File

@@ -78,7 +78,7 @@ goog.addDependency('../../core/field_number.js', ['Blockly.FieldNumber'], ['Bloc
goog.addDependency('../../core/field_registry.js', ['Blockly.fieldRegistry'], ['Blockly.registry'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/field_textinput.js', ['Blockly.FieldTextInput'], ['Blockly.DropDownDiv', 'Blockly.Events', 'Blockly.Events.BlockChange', 'Blockly.Field', 'Blockly.Msg', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.dialog', 'Blockly.fieldRegistry', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.KeyCodes', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/field_variable.js', ['Blockly.FieldVariable'], ['Blockly.Events.BlockChange', 'Blockly.FieldDropdown', 'Blockly.Msg', 'Blockly.VariableModel', 'Blockly.Variables', 'Blockly.Xml', 'Blockly.fieldRegistry', 'Blockly.internalConstants', 'Blockly.utils', 'Blockly.utils.Size', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_base.js', ['Blockly.Flyout'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events', 'Blockly.Events.BlockCreate', 'Blockly.Events.VarCreate', 'Blockly.FlyoutMetricsManager', 'Blockly.Gesture', 'Blockly.ScrollbarPair', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Variables', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.blockRendering', 'Blockly.browserEvents', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.object', 'Blockly.utils.toolbox', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_base.js', ['Blockly.Flyout'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events', 'Blockly.Events.BlockCreate', 'Blockly.Events.VarCreate', 'Blockly.FlyoutMetricsManager', 'Blockly.Gesture', 'Blockly.ScrollbarPair', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Variables', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.blockRendering', 'Blockly.browserEvents', 'Blockly.serialization.blocks', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_button.js', ['Blockly.FlyoutButton'], ['Blockly.Css', 'Blockly.browserEvents', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.style'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_horizontal.js', ['Blockly.HorizontalFlyout'], ['Blockly.DropDownDiv', 'Blockly.Flyout', 'Blockly.Scrollbar', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.registry', 'Blockly.utils.Rect', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_metrics_manager.js', ['Blockly.FlyoutMetricsManager'], ['Blockly.MetricsManager', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'});

View File

@@ -78,7 +78,7 @@ goog.addDependency('../../core/field_number.js', ['Blockly.FieldNumber'], ['Bloc
goog.addDependency('../../core/field_registry.js', ['Blockly.fieldRegistry'], ['Blockly.registry'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/field_textinput.js', ['Blockly.FieldTextInput'], ['Blockly.DropDownDiv', 'Blockly.Events', 'Blockly.Events.BlockChange', 'Blockly.Field', 'Blockly.Msg', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.dialog', 'Blockly.fieldRegistry', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.KeyCodes', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/field_variable.js', ['Blockly.FieldVariable'], ['Blockly.Events.BlockChange', 'Blockly.FieldDropdown', 'Blockly.Msg', 'Blockly.VariableModel', 'Blockly.Variables', 'Blockly.Xml', 'Blockly.fieldRegistry', 'Blockly.internalConstants', 'Blockly.utils', 'Blockly.utils.Size', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_base.js', ['Blockly.Flyout'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events', 'Blockly.Events.BlockCreate', 'Blockly.Events.VarCreate', 'Blockly.FlyoutMetricsManager', 'Blockly.Gesture', 'Blockly.ScrollbarPair', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Variables', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.blockRendering', 'Blockly.browserEvents', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.object', 'Blockly.utils.toolbox', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_base.js', ['Blockly.Flyout'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events', 'Blockly.Events.BlockCreate', 'Blockly.Events.VarCreate', 'Blockly.FlyoutMetricsManager', 'Blockly.Gesture', 'Blockly.ScrollbarPair', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Variables', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.blockRendering', 'Blockly.browserEvents', 'Blockly.serialization.blocks', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_button.js', ['Blockly.FlyoutButton'], ['Blockly.Css', 'Blockly.browserEvents', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.style'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_horizontal.js', ['Blockly.HorizontalFlyout'], ['Blockly.DropDownDiv', 'Blockly.Flyout', 'Blockly.Scrollbar', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.registry', 'Blockly.utils.Rect', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/flyout_metrics_manager.js', ['Blockly.FlyoutMetricsManager'], ['Blockly.MetricsManager', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'});
@@ -353,7 +353,7 @@ goog.addDependency('../../tests/mocha/serializer_test.js', ['Blockly.test.serial
goog.addDependency('../../tests/mocha/shortcut_registry_test.js', ['Blockly.test.shortcutRegistry'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/test_helpers.js', ['Blockly.test.helpers'], ['Blockly.utils.KeyCodes'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/theme_test.js', ['Blockly.test.theme'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/toolbox_helper.js', ['Blockly.test.toolboxHelpers'], [], {'lang': 'es5', 'module': 'goog'});
goog.addDependency('../../tests/mocha/toolbox_helper.js', ['Blockly.test.toolboxHelpers'], [], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/toolbox_test.js', ['Blockly.test.toolbox'], ['Blockly.test.helpers', 'Blockly.test.toolboxHelpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/tooltip_test.js', ['Blockly.test.tooltip'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/trashcan_test.js', ['Blockly.test.trashcan'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});

View File

@@ -7,7 +7,7 @@
goog.module('Blockly.test.flyout');
const {defineStackBlock, sharedTestSetup, sharedTestTeardown, workspaceTeardown} = goog.require('Blockly.test.helpers');
const {getBasicToolbox, getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, getNonCollapsibleItem, getSeparator, getSimpleJSON, getXmlArray} = goog.require('Blockly.test.toolboxHelpers');
const {getBasicToolbox, getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, getNonCollapsibleItem, getProperSimpleJson, getSeparator, getSimpleJson, getXmlArray} = goog.require('Blockly.test.toolboxHelpers');
suite('Flyout', function() {
@@ -242,74 +242,345 @@ suite('Flyout', function() {
suite('createFlyoutInfo_', function() {
setup(function() {
this.simpleToolboxJSON = getSimpleJSON();
this.flyout = this.workspace.getFlyout();
this.createFlyoutSpy = sinon.spy(this.flyout, 'createFlyoutInfo_');
});
function checkLayoutContents(actual, expected, opt_message) {
chai.assert.equal(actual.length, expected.length, opt_message);
for (var i = 0; i < actual.length; i++) {
chai.assert.equal(actual[i].type, expected[i].type, opt_message);
if (actual[i].type == 'BLOCK') {
chai.assert.typeOf(actual[i]['block'], 'Blockly.Block');
} else if (actual[i].type == 'BUTTON' || actual[i].type == 'LABEL') {
chai.assert.typeOf(actual[i]['block'], 'Blockly.FlyoutButton');
}
}
}
function checkFlyoutInfo(flyoutSpy) {
var expectedContents = [
{type: "block"},
{type: "button"},
{type: "button"}
];
var expectedGaps = [20, 24, 24];
var flyoutInfo = flyoutSpy.returnValues[0];
var contents = flyoutInfo.contents;
var gaps = flyoutInfo.gaps;
var expectedGaps = [20, 24, 24];
chai.assert.deepEqual(gaps, expectedGaps);
checkLayoutContents(contents, expectedContents, 'Contents');
chai.assert.equal(contents.length, 3, 'Contents');
chai.assert.equal(contents[0].type, 'block', 'Contents');
var block = contents[0]['block'];
chai.assert.instanceOf(block, Blockly.BlockSvg);
chai.assert.equal(block.getFieldValue('OP'), 'NEQ');
var childA = block.getInputTargetBlock('A');
var childB = block.getInputTargetBlock('B');
chai.assert.isTrue(childA.isShadow());
chai.assert.isFalse(childB.isShadow());
chai.assert.equal(childA.getFieldValue('NUM'), 1);
chai.assert.equal(childB.getFieldValue('NUM'), 2);
chai.assert.equal(contents[1].type, 'button', 'Contents');
chai.assert.instanceOf(contents[1]['button'], Blockly.FlyoutButton);
chai.assert.equal(contents[2].type, 'button', 'Contents');
chai.assert.instanceOf(contents[2]['button'], Blockly.FlyoutButton);
}
test('Node', function() {
this.flyout.show(this.toolboxXml);
checkFlyoutInfo(this.createFlyoutSpy);
suite('Direct show', function() {
test('Node', function() {
this.flyout.show(this.toolboxXml);
checkFlyoutInfo(this.createFlyoutSpy);
});
test('NodeList', function() {
var nodeList = document.getElementById('toolbox-simple').childNodes;
this.flyout.show(nodeList);
checkFlyoutInfo(this.createFlyoutSpy);
});
test('Array of JSON', function() {
this.flyout.show(getSimpleJson());
checkFlyoutInfo(this.createFlyoutSpy);
});
test('Array of Proper JSON', function() {
this.flyout.show(getProperSimpleJson());
checkFlyoutInfo(this.createFlyoutSpy);
});
test('Array of XML', function() {
this.flyout.show(getXmlArray());
checkFlyoutInfo(this.createFlyoutSpy);
});
});
test('NodeList', function() {
var nodeList = document.getElementById('toolbox-simple').childNodes;
this.flyout.show(nodeList);
checkFlyoutInfo(this.createFlyoutSpy);
suite('Dynamic category', function() {
setup(function() {
this.stubAndAssert = function(val) {
sinon.stub(
this.flyout.workspace_.targetWorkspace,
'getToolboxCategoryCallback')
.returns(function() { return val; });
this.flyout.show('someString');
checkFlyoutInfo(this.createFlyoutSpy);
};
});
test('No category available', function() {
chai.assert.throws(
function() {
this.flyout.show('someString');
}.bind(this),
'Couldn\'t find a callback function when opening ' +
'a toolbox category.');
});
test('Node', function() {
this.stubAndAssert(this.toolboxXml);
});
test('NodeList', function() {
this.stubAndAssert(
document.getElementById('toolbox-simple').childNodes);
});
test('Array of JSON', function() {
this.stubAndAssert(getSimpleJson());
});
test('Array of Proper JSON', function() {
this.stubAndAssert(getProperSimpleJson());
});
test('Array of XML', function() {
this.stubAndAssert(getXmlArray());
});
});
test('Array of JSON', function() {
this.flyout.show(this.simpleToolboxJSON);
checkFlyoutInfo(this.createFlyoutSpy);
});
suite('Creating blocks', function() {
suite('Enabled/Disabled', function() {
setup(function() {
this.flyout = this.workspace.getFlyout();
this.assertDisabled = function(disabled) {
var block = this.flyout.getWorkspace().getTopBlocks(false)[0];
chai.assert.equal(!block.isEnabled(), disabled);
};
});
suite('XML', function() {
test('True string', function() {
var xml = Blockly.Xml.textToDom(
'<xml>' +
'<block type="text_print" disabled="true"></block>' +
'</xml>'
);
this.flyout.show(xml);
this.assertDisabled(true);
});
test('False string', function() {
var xml = Blockly.Xml.textToDom(
'<xml>' +
'<block type="text_print" disabled="false"></block>' +
'</xml>'
);
this.flyout.show(xml);
this.assertDisabled(false);
});
test('Disabled string', function() {
// The XML system supports this for some reason!?
var xml = Blockly.Xml.textToDom(
'<xml>' +
'<block type="text_print" disabled="disabled"></block>' +
'</xml>'
);
this.flyout.show(xml);
this.assertDisabled(true);
});
test('Different string', function() {
var xml = Blockly.Xml.textToDom(
'<xml>' +
'<block type="text_print" disabled="random"></block>' +
'</xml>'
);
this.flyout.show(xml);
this.assertDisabled(false);
});
});
suite('JSON', function() {
test('All undefined', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
}
];
this.flyout.show(json);
this.assertDisabled(false);
});
test('Enabled true', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'enabled': true,
}
];
this.flyout.show(json);
this.assertDisabled(false);
});
test('Enabled false', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'enabled': false,
}
];
this.flyout.show(json);
this.assertDisabled(true);
});
test('Disabled true string', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'disabled': 'true'
}
];
this.flyout.show(json);
this.assertDisabled(true);
});
test('Disabled false string', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'disabled': 'false'
}
];
this.flyout.show(json);
this.assertDisabled(false);
});
test('Disabled string', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'disabled': 'disabled' // This is not respected by the JSON!
}
];
this.flyout.show(json);
this.assertDisabled(false);
});
test('Disabled true value', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'disabled': true
}
];
this.flyout.show(json);
this.assertDisabled(true);
});
test('Disabled false value', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'disabled': false
}
];
this.flyout.show(json);
this.assertDisabled(false);
});
test('Disabled different string', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'disabled': 'random'
}
];
this.flyout.show(json);
this.assertDisabled(false);
});
test('Disabled empty string', function() {
var json = [
{
'kind': 'block',
'type': 'text_print',
'disabled': ''
}
];
this.flyout.show(json);
this.assertDisabled(false);
});
});
});
test('Array of xml', function() {
this.flyout.show(getXmlArray());
checkFlyoutInfo(this.createFlyoutSpy);
});
suite('Recycling', function() {
setup(function() {
this.flyout = this.workspace.getFlyout();
});
test('Custom Toolbox: No Category Available', function() {
chai.assert.throws(function() {
this.flyout.show('someString');
}.bind(this), 'Couldn\'t find a callback function when opening' +
' a toolbox category.');
test('Recycling disabled', function() {
this.flyout.show({
'contents': [
{
'kind': 'BLOCK',
'type': 'math_number',
'fields': {
'NUM': 123
}
}
]
});
this.flyout.show({
'contents': [
{
'kind': 'BLOCK',
'type': 'math_number',
'fields': {
'NUM': 321
}
}
]
});
const block = this.flyout.workspace_.getAllBlocks()[0];
chai.assert.equal(block.getFieldValue('NUM'), 321);
});
test('Custom Toolbox: Function does not return array', function() {
sinon.stub(this.flyout.workspace_.targetWorkspace,
'getToolboxCategoryCallback').returns(function(){return null;});
chai.assert.throws(function() {
this.flyout.show('someString');
}.bind(this), 'Result of toolbox category callback must be an array.');
});
test('Custom Toolbox: Returns Array', function() {
sinon.stub(this.flyout.workspace_.targetWorkspace,
'getToolboxCategoryCallback').returns(function(){return getXmlArray();});
chai.assert.doesNotThrow(function() {
this.flyout.show('someString');
}.bind(this));
test('Recycling enabled', function() {
this.flyout.blockIsRecyclable_ = function() { return true; };
this.flyout.show({
'contents': [
{
'kind': 'BLOCK',
'type': 'math_number',
'fields': {
'NUM': 123
}
}
]
});
this.flyout.show({
'contents': [
{
'kind': 'BLOCK',
'type': 'math_number',
'fields': {
'NUM': 321
}
}
]
});
const block = this.flyout.workspace_.getAllBlocks()[0];
chai.assert.equal(block.getFieldValue('NUM'), 123);
});
});
});

View File

@@ -113,12 +113,26 @@
</script>
<div id="blocklyDiv"></div>
<xml xmlns="https://developers.google.com/blockly/xml" id="toolbox-simple" style="display: none">
<block type="logic_operation"></block>
<block type="logic_compare">
<field name="OP">NEQ</field>
<value name="A">
<shadow type="math_number">
<field name="NUM">1</field>
</shadow>
</value>
<value name="B">
<block type="math_number">
<field name="NUM">2</field>
</block>
</value>
</block>
<sep gap="20"></sep>
<button text="insert" callbackkey="insertConnectionRows"></button>
<label text="tooltips"></label>
</xml>
<xml xmlns="https://developers.google.com/blockly/xml" id="toolbox-categories" style="display: none">
<category name="First" css-container="something">
<block type="basic_block">
@@ -134,6 +148,7 @@
</block>
</category>
</xml>
<xml xmlns="https://developers.google.com/blockly/xml" id="toolbox-test" style="display: none">
<category name="First" expanded="true" categorystyle="logic_category">
<sep gap="-1"></sep>

View File

@@ -49,12 +49,24 @@ exports.getCategoryJSON = getCategoryJSON;
* @return {Blockly.utils.toolbox.ToolboxJson} The array holding information
* for a simple toolbox.
*/
function getSimpleJSON() {
function getSimpleJson() {
return {"contents":[
{
"kind":"BLOCK",
"blockxml": "<block type=\"logic_operation\"></block>",
"type":"logic_operation"
"blockxml":
`<block type="logic_compare">
<field name="OP">NEQ</field>
<value name="A">
<shadow type="math_number">
<field name="NUM">1</field>
</shadow>
</value>
<value name="B">
<block type="math_number">
<field name="NUM">2</field>
</block>
</value>
</block>`,
},
{
"kind":"SEP",
@@ -71,7 +83,52 @@ function getSimpleJSON() {
}
]};
}
exports.getSimpleJSON = getSimpleJSON;
exports.getSimpleJson = getSimpleJson;
function getProperSimpleJson() {
return {
"contents": [
{
"kind":"BLOCK",
"type": "logic_compare",
"fields": {
"OP": "NEQ",
},
"inputs": {
"A": {
"shadow": {
"type": "math_number",
"fields": {
"NUM": 1,
}
}
},
"B": {
"block": {
"type": "math_number",
"fields": {
"NUM": 2,
}
}
}
}
},
{
"kind":"SEP",
"gap":"20"
},
{
"kind":"BUTTON",
"text": "insert",
"callbackkey": "insertConnectionRows"
},
{
"kind":"LABEL",
"text":"tooltips"
}
]};
}
exports.getProperSimpleJson = getProperSimpleJson;
/**
* Get JSON for a toolbox that contains categories that contain categories.
@@ -123,10 +180,20 @@ exports.getDeeplyNestedJSON = getDeeplyNestedJSON;
* @return {Array<Node>} Array holding xml elements for a toolbox.
*/
function getXmlArray() {
// Need to use HTMLElement instead of Element so parser output is
// consistent with other tests
var block = document.createElement('block');
block.setAttribute('type', 'logic_operation');
var block = Blockly.Xml.textToDom(
`<block type="logic_compare">
<field name="OP">NEQ</field>
<value name="A">
<shadow type="math_number">
<field name="NUM">1</field>
</shadow>
</value>
<value name="B">
<block type="math_number">
<field name="NUM">2</field>
</block>
</value>
</block>`);
var separator = Blockly.Xml.textToDom('<sep gap="20"></sep>');
var button = Blockly.Xml.textToDom('<button text="insert" callbackkey="insertConnectionRows"></button>');
var label = Blockly.Xml.textToDom('<label text="tooltips"></label>');

View File

@@ -7,7 +7,7 @@
goog.module('Blockly.test.toolbox');
const {defineStackBlock, sharedTestSetup, sharedTestTeardown} = goog.require('Blockly.test.helpers');
const {getBasicToolbox, getCategoryJSON, getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, getNonCollapsibleItem, getSeparator, getSimpleJSON, getXmlArray} = goog.require('Blockly.test.toolboxHelpers');
const {getBasicToolbox, getCategoryJSON, getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, getNonCollapsibleItem, getSeparator, getSimpleJson, getXmlArray} = goog.require('Blockly.test.toolboxHelpers');
suite('Toolbox', function() {
@@ -494,7 +494,7 @@ suite('Toolbox', function() {
suite('parseMethods', function() {
setup(function() {
this.categoryToolboxJSON = getCategoryJSON();
this.simpleToolboxJSON = getSimpleJSON();
this.simpleToolboxJSON = getSimpleJson();
});
function checkValue(actual, expected, value) {

View File

@@ -196,6 +196,19 @@ function setToolboxDropdown() {
}
function initToolbox(workspace) {
workspace.registerToolboxCategoryCallback('JSON', function() {
return [
{
'kind': 'block',
'type': 'lists_create_with_json'
},
{
'kind': 'block',
'type': 'lists_create_with_json',
'extraState': {'itemCount': 2}
}
]
});
var toolboxSuffix = getToolboxSuffix();
if (toolboxSuffix == 'test-blocks' &&
typeof window.toolboxTestBlocksInit !== 'undefined') {
@@ -447,6 +460,130 @@ var spaghettiXml = [
'next': { }
});
Blockly.Blocks['lists_create_with_json'] = {
/**
* Block for creating a list with any number of elements of any type.
* @this {Blockly.Block}
*/
init: function() {
this.setHelpUrl(Blockly.Msg['LISTS_CREATE_WITH_HELPURL']);
this.setStyle('list_blocks');
this.itemCount_ = 3;
this.updateShape_();
this.setOutput(true, 'Array');
this.setMutator(new Blockly.Mutator(['lists_create_with_item']));
this.setTooltip(Blockly.Msg['LISTS_CREATE_WITH_TOOLTIP']);
},
/**
* Returns the state of this block as a JSON serializable object.
* @return {{itemCount: number}} The state of this block, ie the item count.
*/
saveExtraState: function() {
return {
'itemCount': this.itemCount_,
};
},
/**
* Applies the given state to this block.
* @param {*} state The state to apply to this block, ie the item count.
*/
loadExtraState: function(state) {
this.itemCount_ = state['itemCount'];
this.updateShape_();
},
/**
* Populate the mutator's dialog with this block's components.
* @param {!Blockly.Workspace} workspace Mutator's workspace.
* @return {!Blockly.Block} Root block in mutator.
* @this {Blockly.Block}
*/
decompose: function(workspace) {
var containerBlock = workspace.newBlock('lists_create_with_container');
containerBlock.initSvg();
var connection = containerBlock.getInput('STACK').connection;
for (var i = 0; i < this.itemCount_; i++) {
var itemBlock = workspace.newBlock('lists_create_with_item');
itemBlock.initSvg();
connection.connect(itemBlock.previousConnection);
connection = itemBlock.nextConnection;
}
return containerBlock;
},
/**
* Reconfigure this block based on the mutator dialog's components.
* @param {!Blockly.Block} containerBlock Root block in mutator.
* @this {Blockly.Block}
*/
compose: function(containerBlock) {
var itemBlock = containerBlock.getInputTargetBlock('STACK');
// Count number of inputs.
var connections = [];
while (itemBlock && !itemBlock.isInsertionMarker()) {
connections.push(itemBlock.valueConnection_);
itemBlock = itemBlock.nextConnection &&
itemBlock.nextConnection.targetBlock();
}
// Disconnect any children that don't belong.
for (var i = 0; i < this.itemCount_; i++) {
var connection = this.getInput('ADD' + i).connection.targetConnection;
if (connection && connections.indexOf(connection) == -1) {
connection.disconnect();
}
}
this.itemCount_ = connections.length;
this.updateShape_();
// Reconnect any child blocks.
for (var i = 0; i < this.itemCount_; i++) {
Blockly.Mutator.reconnect(connections[i], this, 'ADD' + i);
}
},
/**
* Store pointers to any connected child blocks.
* @param {!Blockly.Block} containerBlock Root block in mutator.
* @this {Blockly.Block}
*/
saveConnections: function(containerBlock) {
var itemBlock = containerBlock.getInputTargetBlock('STACK');
var i = 0;
while (itemBlock) {
var input = this.getInput('ADD' + i);
itemBlock.valueConnection_ = input && input.connection.targetConnection;
i++;
itemBlock = itemBlock.nextConnection &&
itemBlock.nextConnection.targetBlock();
}
},
/**
* Modify this block to have the correct number of inputs.
* @private
* @this {Blockly.Block}
*/
updateShape_: function() {
if (this.itemCount_ && this.getInput('EMPTY')) {
this.removeInput('EMPTY');
} else if (!this.itemCount_ && !this.getInput('EMPTY')) {
this.appendDummyInput('EMPTY')
.appendField(Blockly.Msg['LISTS_CREATE_EMPTY_TITLE']);
}
// Add new inputs.
for (var i = 0; i < this.itemCount_; i++) {
if (!this.getInput('ADD' + i)) {
var input = this.appendValueInput('ADD' + i)
.setAlign(Blockly.ALIGN_RIGHT);
if (i == 0) {
input.appendField(Blockly.Msg['LISTS_CREATE_WITH_INPUT_WITH']);
}
}
}
// Remove deleted inputs.
while (this.getInput('ADD' + i)) {
this.removeInput('ADD' + i);
i++;
}
}
};
</script>
<style>
@@ -922,6 +1059,8 @@ var spaghettiXml = [
<sep></sep>
<category name="Variables" categorystyle="variable_category" custom="VARIABLE"></category>
<category name="Functions" categorystyle="procedure_category" custom="PROCEDURE"></category>
<sep></sep>
<category name="JSON" categorystyle="procedure_category" custom="JSON"></category>
</xml>
<!-- toolbox-categories-typed-variables has a category menu and an