Skip to content

Commit

Permalink
feat: add support for defining toolboxes using pure json (google#5392)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
BeksOmega authored and alschmiedt committed Sep 20, 2021
1 parent 6d87b85 commit 410365f
Show file tree
Hide file tree
Showing 11 changed files with 719 additions and 141 deletions.
190 changes: 127 additions & 63 deletions core/flyout_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -560,6 +567,7 @@ Flyout.prototype.show = function(flyoutDef) {

this.reflowWrapper_ = this.reflow.bind(this);
this.workspace_.addChangeListener(this.reflowWrapper_);
this.emptyRecycledBlocks_();
};

/**
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
};

/**
Expand All @@ -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);
};

/**
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions core/serialization/blocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
20 changes: 19 additions & 1 deletion core/utils/toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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;
Expand Down
15 changes: 8 additions & 7 deletions core/workspace_svg.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion tests/deps.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 410365f

Please sign in to comment.