Files
blockly/core/toolbox/category.js
Neil Fraser 90b3f75d82 Remove @author tags (#5601)
Our files are up to a decade old, and have churned so much, that the initial author of the file no longer has much meaning.

Furthermore, this will encourage developers to post to the developer group, rather than emailing Googlers (usually me) directly.
2021-10-15 09:50:46 -07:00

726 lines
20 KiB
JavaScript

/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview A toolbox category used to organize blocks in the toolbox.
*/
'use strict';
/**
* A toolbox category used to organize blocks in the toolbox.
* @class
*/
goog.module('Blockly.ToolboxCategory');
const Css = goog.require('Blockly.Css');
/* eslint-disable-next-line no-unused-vars */
const ICollapsibleToolboxItem = goog.requireType('Blockly.ICollapsibleToolboxItem');
/* eslint-disable-next-line no-unused-vars */
const ISelectableToolboxItem = goog.require('Blockly.ISelectableToolboxItem');
/* eslint-disable-next-line no-unused-vars */
const IToolbox = goog.requireType('Blockly.IToolbox');
const ToolboxItem = goog.require('Blockly.ToolboxItem');
const aria = goog.require('Blockly.utils.aria');
const colourUtils = goog.require('Blockly.utils.colour');
const dom = goog.require('Blockly.utils.dom');
const object = goog.require('Blockly.utils.object');
const registry = goog.require('Blockly.registry');
const toolbox = goog.require('Blockly.utils.toolbox');
const utils = goog.require('Blockly.utils');
/**
* Class for a category in a toolbox.
* @param {!toolbox.CategoryInfo} categoryDef The information needed
* to create a category in the toolbox.
* @param {!IToolbox} toolbox The parent toolbox for the category.
* @param {ICollapsibleToolboxItem=} opt_parent The parent category or null if
* the category does not have a parent.
* @constructor
* @extends {ToolboxItem}
* @implements {ISelectableToolboxItem}
* @alias Blockly.ToolboxCategory
*/
const ToolboxCategory = function(categoryDef, toolbox, opt_parent) {
ToolboxCategory.superClass_.constructor.call(
this, categoryDef, toolbox, opt_parent);
/**
* The name that will be displayed on the category.
* @type {string}
* @protected
*/
this.name_ = utils.replaceMessageReferences(categoryDef['name']);
/**
* The colour of the category.
* @type {string}
* @protected
*/
this.colour_ = this.getColour_(categoryDef);
/**
* The html container for the category.
* @type {?Element}
* @protected
*/
this.htmlDiv_ = null;
/**
* The html element for the category row.
* @type {?Element}
* @protected
*/
this.rowDiv_ = null;
/**
* The html element that holds children elements of the category row.
* @type {?Element}
* @protected
*/
this.rowContents_ = null;
/**
* The html element for the toolbox icon.
* @type {?Element}
* @protected
*/
this.iconDom_ = null;
/**
* The html element for the toolbox label.
* @type {?Element}
* @protected
*/
this.labelDom_ = null;
/**
* All the css class names that are used to create a category.
* @type {!ToolboxCategory.CssConfig}
* @protected
*/
this.cssConfig_ = this.makeDefaultCssConfig_();
const cssConfig = categoryDef['cssconfig'] || categoryDef['cssConfig'];
object.mixin(this.cssConfig_, cssConfig);
/**
* True if the category is meant to be hidden, false otherwise.
* @type {boolean}
* @protected
*/
this.isHidden_ = false;
/**
* True if this category is disabled, false otherwise.
* @type {boolean}
* @protected
*/
this.isDisabled_ = false;
/**
* The flyout items for this category.
* @type {string|!toolbox.FlyoutItemInfoArray}
* @protected
*/
this.flyoutItems_ = [];
this.parseContents_(categoryDef);
};
object.inherits(ToolboxCategory, ToolboxItem);
/**
* All the CSS class names that are used to create a category.
* @typedef {{
* container:(string|undefined),
* row:(string|undefined),
* rowcontentcontainer:(string|undefined),
* icon:(string|undefined),
* label:(string|undefined),
* selected:(string|undefined),
* openicon:(string|undefined),
* closedicon:(string|undefined)
* }}
*/
ToolboxCategory.CssConfig;
/**
* Name used for registering a toolbox category.
* @const {string}
*/
ToolboxCategory.registrationName = 'category';
/**
* The number of pixels to move the category over at each nested level.
* @type {number}
*/
ToolboxCategory.nestedPadding = 19;
/**
* The width in pixels of the strip of colour next to each category.
* @type {number}
*/
ToolboxCategory.borderWidth = 8;
/**
* The default colour of the category. This is used as the background colour of
* the category when it is selected.
* @type {string}
*/
ToolboxCategory.defaultBackgroundColour = '#57e';
/**
* Creates an object holding the default classes for a category.
* @return {!ToolboxCategory.CssConfig} The configuration object holding
* all the CSS classes for a category.
* @protected
*/
ToolboxCategory.prototype.makeDefaultCssConfig_ = function() {
return {
'container': 'blocklyToolboxCategory',
'row': 'blocklyTreeRow',
'rowcontentcontainer': 'blocklyTreeRowContentContainer',
'icon': 'blocklyTreeIcon',
'label': 'blocklyTreeLabel',
'contents': 'blocklyToolboxContents',
'selected': 'blocklyTreeSelected',
'openicon': 'blocklyTreeIconOpen',
'closedicon': 'blocklyTreeIconClosed',
};
};
/**
* Parses the contents array depending on if the category is a dynamic category,
* or if its contents are meant to be shown in the flyout.
* @param {!toolbox.CategoryInfo} categoryDef The information needed
* to create a category.
* @protected
*/
ToolboxCategory.prototype.parseContents_ = function(categoryDef) {
const contents = categoryDef['contents'];
if (categoryDef['custom']) {
this.flyoutItems_ = categoryDef['custom'];
} else if (contents) {
for (let i = 0; i < contents.length; i++) {
const itemDef = contents[i];
const flyoutItem =
/** @type {toolbox.FlyoutItemInfo} */ (itemDef);
this.flyoutItems_.push(flyoutItem);
}
}
};
/**
* @override
*/
ToolboxCategory.prototype.init = function() {
this.createDom_();
if (this.toolboxItemDef_['hidden'] === 'true') {
this.hide();
}
};
/**
* Creates the DOM for the category.
* @return {!Element} The parent element for the category.
* @protected
*/
ToolboxCategory.prototype.createDom_ = function() {
this.htmlDiv_ = this.createContainer_();
aria.setRole(this.htmlDiv_, aria.Role.TREEITEM);
aria.setState(
/** @type {!Element} */ (this.htmlDiv_), aria.State.SELECTED, false);
aria.setState(
/** @type {!Element} */ (this.htmlDiv_), aria.State.LEVEL, this.level_);
this.rowDiv_ = this.createRowContainer_();
this.rowDiv_.style.pointerEvents = 'auto';
this.htmlDiv_.appendChild(this.rowDiv_);
this.rowContents_ = this.createRowContentsContainer_();
this.rowContents_.style.pointerEvents = 'none';
this.rowDiv_.appendChild(this.rowContents_);
this.iconDom_ = this.createIconDom_();
aria.setRole(this.iconDom_, aria.Role.PRESENTATION);
this.rowContents_.appendChild(this.iconDom_);
this.labelDom_ = this.createLabelDom_(this.name_);
this.rowContents_.appendChild(this.labelDom_);
aria.setState(
/** @type {!Element} */ (this.htmlDiv_), aria.State.LABELLEDBY,
this.labelDom_.getAttribute('id'));
this.addColourBorder_(this.colour_);
return this.htmlDiv_;
};
/**
* Creates the container that holds the row and any subcategories.
* @return {!Element} The div that holds the icon and the label.
* @protected
*/
ToolboxCategory.prototype.createContainer_ = function() {
const container = document.createElement('div');
dom.addClass(container, this.cssConfig_['container']);
return container;
};
/**
* Creates the parent of the contents container. All clicks will happen on this
* div.
* @return {!Element} The div that holds the contents container.
* @protected
*/
ToolboxCategory.prototype.createRowContainer_ = function() {
const rowDiv = document.createElement('div');
dom.addClass(rowDiv, this.cssConfig_['row']);
let nestedPadding = ToolboxCategory.nestedPadding * this.getLevel();
nestedPadding = nestedPadding.toString() + 'px';
this.workspace_.RTL ? rowDiv.style.paddingRight = nestedPadding :
rowDiv.style.paddingLeft = nestedPadding;
return rowDiv;
};
/**
* Creates the container for the label and icon.
* This is necessary so we can set all subcategory pointer events to none.
* @return {!Element} The div that holds the icon and the label.
* @protected
*/
ToolboxCategory.prototype.createRowContentsContainer_ = function() {
const contentsContainer = document.createElement('div');
dom.addClass(contentsContainer, this.cssConfig_['rowcontentcontainer']);
return contentsContainer;
};
/**
* Creates the span that holds the category icon.
* @return {!Element} The span that holds the category icon.
* @protected
*/
ToolboxCategory.prototype.createIconDom_ = function() {
const toolboxIcon = document.createElement('span');
if (!this.parentToolbox_.isHorizontal()) {
dom.addClass(toolboxIcon, this.cssConfig_['icon']);
}
toolboxIcon.style.display = 'inline-block';
return toolboxIcon;
};
/**
* Creates the span that holds the category label.
* This should have an ID for accessibility purposes.
* @param {string} name The name of the category.
* @return {!Element} The span that holds the category label.
* @protected
*/
ToolboxCategory.prototype.createLabelDom_ = function(name) {
const toolboxLabel = document.createElement('span');
toolboxLabel.setAttribute('id', this.getId() + '.label');
toolboxLabel.textContent = name;
dom.addClass(toolboxLabel, this.cssConfig_['label']);
return toolboxLabel;
};
/**
* Updates the colour for this category.
* @public
*/
ToolboxCategory.prototype.refreshTheme = function() {
this.colour_ = this.getColour_(/** @type {toolbox.CategoryInfo} **/
(this.toolboxItemDef_));
this.addColourBorder_(this.colour_);
};
/**
* Add the strip of colour to the toolbox category.
* @param {string} colour The category colour.
* @protected
*/
ToolboxCategory.prototype.addColourBorder_ = function(colour) {
if (colour) {
const border =
ToolboxCategory.borderWidth + 'px solid ' + (colour || '#ddd');
if (this.workspace_.RTL) {
this.rowDiv_.style.borderRight = border;
} else {
this.rowDiv_.style.borderLeft = border;
}
}
};
/**
* Gets either the colour or the style for a category.
* @param {!toolbox.CategoryInfo} categoryDef The object holding
* information on the category.
* @return {string} The hex colour for the category.
* @protected
*/
ToolboxCategory.prototype.getColour_ = function(categoryDef) {
const styleName =
categoryDef['categorystyle'] || categoryDef['categoryStyle'];
const colour = categoryDef['colour'];
if (colour && styleName) {
console.warn(
'Toolbox category "' + this.name_ +
'" must not have both a style and a colour');
} else if (styleName) {
return this.getColourfromStyle_(styleName);
} else {
return this.parseColour_(colour);
}
return '';
};
/**
* Sets the colour for the category using the style name and returns the new
* colour as a hex string.
* @param {string} styleName Name of the style.
* @return {string} The hex colour for the category.
* @private
*/
ToolboxCategory.prototype.getColourfromStyle_ = function(styleName) {
const theme = this.workspace_.getTheme();
if (styleName && theme) {
const style = theme.categoryStyles[styleName];
if (style && style.colour) {
return this.parseColour_(style.colour);
} else {
console.warn(
'Style "' + styleName + '" must exist and contain a colour value');
}
}
return '';
};
/**
* Gets the HTML element that is clickable.
* The parent toolbox element receives clicks. The parent toolbox will add an ID
* to this element so it can pass the onClick event to the correct toolboxItem.
* @return {!Element} The HTML element that receives clicks.
* @public
*/
ToolboxCategory.prototype.getClickTarget = function() {
return /** @type {!Element} */ (this.rowDiv_);
};
/**
* Parses the colour on the category.
* @param {number|string} colourValue HSV hue value (0 to 360), #RRGGBB string,
* or a message reference string pointing to one of those two values.
* @return {string} The hex colour for the category.
* @private
*/
ToolboxCategory.prototype.parseColour_ = function(colourValue) {
// Decode the colour for any potential message references
// (eg. `%{BKY_MATH_HUE}`).
const colour = utils.replaceMessageReferences(colourValue);
if (colour === null || colour === '') {
// No attribute. No colour.
return '';
} else {
const hue = Number(colour);
if (!isNaN(hue)) {
return colourUtils.hueToHex(hue);
} else {
const hex = colourUtils.parse(colour);
if (hex) {
return hex;
} else {
console.warn(
'Toolbox category "' + this.name_ +
'" has unrecognized colour attribute: ' + colour);
return '';
}
}
}
};
/**
* Adds appropriate classes to display an open icon.
* @param {?Element} iconDiv The div that holds the icon.
* @protected
*/
ToolboxCategory.prototype.openIcon_ = function(iconDiv) {
if (!iconDiv) {
return;
}
dom.removeClasses(iconDiv, this.cssConfig_['closedicon']);
dom.addClass(iconDiv, this.cssConfig_['openicon']);
};
/**
* Adds appropriate classes to display a closed icon.
* @param {?Element} iconDiv The div that holds the icon.
* @protected
*/
ToolboxCategory.prototype.closeIcon_ = function(iconDiv) {
if (!iconDiv) {
return;
}
dom.removeClasses(iconDiv, this.cssConfig_['openicon']);
dom.addClass(iconDiv, this.cssConfig_['closedicon']);
};
/**
* Sets whether the category is visible or not.
* For a category to be visible its parent category must also be expanded.
* @param {boolean} isVisible True if category should be visible.
* @protected
*/
ToolboxCategory.prototype.setVisible_ = function(isVisible) {
this.htmlDiv_.style.display = isVisible ? 'block' : 'none';
this.isHidden_ = !isVisible;
if (this.parentToolbox_.getSelectedItem() === this) {
this.parentToolbox_.clearSelection();
}
};
/**
* Hide the category.
*/
ToolboxCategory.prototype.hide = function() {
this.setVisible_(false);
};
/**
* Show the category. Category will only appear if its parent category is also
* expanded.
*/
ToolboxCategory.prototype.show = function() {
this.setVisible_(true);
};
/**
* Whether the category is visible.
* A category is only visible if all of its ancestors are expanded and isHidden_
* is false.
* @return {boolean} True if the category is visible, false otherwise.
* @public
*/
ToolboxCategory.prototype.isVisible = function() {
return !this.isHidden_ && this.allAncestorsExpanded_();
};
/**
* Whether all ancestors of a category (parent and parent's parent, etc.) are
* expanded.
* @return {boolean} True only if every ancestor is expanded
* @protected
*/
ToolboxCategory.prototype.allAncestorsExpanded_ = function() {
let category = this;
while (category.getParent()) {
category = category.getParent();
if (!category.isExpanded()) {
return false;
}
}
return true;
};
/**
* @override
*/
ToolboxCategory.prototype.isSelectable = function() {
return this.isVisible() && !this.isDisabled_;
};
/**
* Handles when the toolbox item is clicked.
* @param {!Event} _e Click event to handle.
* @public
*/
ToolboxCategory.prototype.onClick = function(_e) {
// No-op
};
/**
* Sets the current category as selected.
* @param {boolean} isSelected True if this category is selected, false
* otherwise.
* @public
*/
ToolboxCategory.prototype.setSelected = function(isSelected) {
if (isSelected) {
const defaultColour =
this.parseColour_(ToolboxCategory.defaultBackgroundColour);
this.rowDiv_.style.backgroundColor = this.colour_ || defaultColour;
dom.addClass(this.rowDiv_, this.cssConfig_['selected']);
} else {
this.rowDiv_.style.backgroundColor = '';
dom.removeClass(this.rowDiv_, this.cssConfig_['selected']);
}
aria.setState(
/** @type {!Element} */ (this.htmlDiv_), aria.State.SELECTED, isSelected);
};
/**
* Sets whether the category is disabled.
* @param {boolean} isDisabled True to disable the category, false otherwise.
*/
ToolboxCategory.prototype.setDisabled = function(isDisabled) {
this.isDisabled_ = isDisabled;
this.getDiv().setAttribute('disabled', isDisabled);
isDisabled ? this.getDiv().setAttribute('disabled', 'true') :
this.getDiv().removeAttribute('disabled');
};
/**
* Gets the name of the category. Used for emitting events.
* @return {string} The name of the toolbox item.
* @public
*/
ToolboxCategory.prototype.getName = function() {
return this.name_;
};
/**
* @override
*/
ToolboxCategory.prototype.getParent = function() {
return this.parent_;
};
/**
* @override
*/
ToolboxCategory.prototype.getDiv = function() {
return this.htmlDiv_;
};
/**
* Gets the contents of the category. These are items that are meant to be
* displayed in the flyout.
* @return {!toolbox.FlyoutItemInfoArray|string} The definition
* of items to be displayed in the flyout.
* @public
*/
ToolboxCategory.prototype.getContents = function() {
return this.flyoutItems_;
};
/**
* Updates the contents to be displayed in the flyout.
* If the flyout is open when the contents are updated, refreshSelection on the
* toolbox must also be called.
* @param {!toolbox.FlyoutDefinition|string} contents The contents
* to be displayed in the flyout. A string can be supplied to create a
* dynamic category.
* @public
*/
ToolboxCategory.prototype.updateFlyoutContents = function(contents) {
this.flyoutItems_ = [];
if (typeof contents === 'string') {
this.toolboxItemDef_['custom'] = contents;
} else {
// Removes old custom field when contents is updated.
delete this.toolboxItemDef_['custom'];
this.toolboxItemDef_['contents'] =
toolbox.convertFlyoutDefToJsonArray(contents);
}
this.parseContents_(
/** @type {toolbox.CategoryInfo} */ (this.toolboxItemDef_));
};
/**
* @override
*/
ToolboxCategory.prototype.dispose = function() {
dom.removeNode(this.htmlDiv_);
};
/**
* CSS for Toolbox. See css.js for use.
*/
Css.register(`
.blocklyTreeRow:not(.blocklyTreeSelected):hover {
background-color: rgba(255, 255, 255, 0.2);
}
.blocklyToolboxDiv[layout="h"] .blocklyToolboxCategory {
margin: 1px 5px 1px 0;
}
.blocklyToolboxDiv[dir="RTL"][layout="h"] .blocklyToolboxCategory {
margin: 1px 0 1px 5px;
}
.blocklyTreeRow {
height: 22px;
line-height: 22px;
margin-bottom: 3px;
padding-right: 8px;
white-space: nowrap;
}
.blocklyToolboxDiv[dir="RTL"] .blocklyTreeRow {
margin-left: 8px;
padding-right: 0;
}
.blocklyTreeIcon {
background-image: url(<<<PATH>>>/sprites.png);
height: 16px;
vertical-align: middle;
visibility: hidden;
width: 16px;
}
.blocklyTreeIconClosed {
background-position: -32px -1px;
}
.blocklyToolboxDiv[dir="RTL"] .blocklyTreeIconClosed {
background-position: 0 -1px;
}
.blocklyTreeSelected>.blocklyTreeIconClosed {
background-position: -32px -17px;
}
.blocklyToolboxDiv[dir="RTL"] .blocklyTreeSelected>.blocklyTreeIconClosed {
background-position: 0 -17px;
}
.blocklyTreeIconOpen {
background-position: -16px -1px;
}
.blocklyTreeSelected>.blocklyTreeIconOpen {
background-position: -16px -17px;
}
.blocklyTreeLabel {
cursor: default;
font: 16px sans-serif;
padding: 0 3px;
vertical-align: middle;
}
.blocklyToolboxDelete .blocklyTreeLabel {
cursor: url("<<<PATH>>>/handdelete.cur"), auto;
}
.blocklyTreeSelected .blocklyTreeLabel {
color: #fff;
}
`);
registry.register(
registry.Type.TOOLBOX_ITEM, ToolboxCategory.registrationName,
ToolboxCategory);
exports = ToolboxCategory;