/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview A toolbox category used to organize blocks in the toolbox. * @author aschmiedt@google.com (Abby Schmiedt) */ '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(<<>>/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("<<>>/handdelete.cur"), auto; } .blocklyTreeSelected .blocklyTreeLabel { color: #fff; } `); registry.register( registry.Type.TOOLBOX_ITEM, ToolboxCategory.registrationName, ToolboxCategory); exports = ToolboxCategory;