Files
blockly/core/components/tree/basenode.js
Neil Fraser a65afdc189 Simplify Closure-sourced code for menus (#3880)
* Remove cargo-culted bloat from CSS

The `goog-menuitem-icon` and `goog-menuitem-noicon` classes are not present in Blockly.  Blockly doesn’t support the CSS compiler, so #noflip has no effect.  Shorten uncompressible warning string.

Also remove the “Copied from Closure” notes.  These were intended so that the CSS could be easily updated as the Closure Library evolved.  We are no longer linked to the Closure Library.

* Fix bug (in prod) where menu highlighting is lost

Previously, open playground.  Right-click on workspace.  Mouse-over “Add comment” (it highlights).  Mouse over “Download screenshot” (disabled option).  Mouse over “Add comment” (highlighting is lost).

Also remove `canHighlightItem` helper function.  In theory this helps abstract the concept of non-highlightable options.  But in practice it was only called in one of the several places that it should have been.  This was a false abstraction.

* Add support for Space/PgUp/PgDn/Home/End to menus

* Eliminate calls to clearHighlighted

The JSDoc for `setHighlightedIndex` specifically states, “If another item was previously highlighted, it is un-highlighted.”  This is not what was implemented, but it should be.  This commit adds the un-highlighting, and removes all the calls previously required to correct this bug.

* Stop wrapping at top or bottom of menu.

Real OS menus don’t wrap when one cursors off the top or bottom.

Also, replace the overly complicated helper function with a simple 1/-1 step value.

* Remove unused menu code

* Simplify menu roles

Remove unneeded sets to RTL on Menu (only MenuItem cares).

* Fix lack of disposal for context menus.

Context menus only disposed properly when an option was clicked.  If they were dismissed by clicking outside the menu there was no disposal.  This might result in a memory leak.
Also un-extract (inject?) several now trivial functions.

* Remove Component dependency from Menu & MenuItem

Component is now only used by the category tree.

* Remove unused functions in Component

These were used by Menu/MenuItem.

* Fix dependencies.

* Record highlighted menu item by object, not index

Less code, simpler.

* Rename CSS classes goog-menu* to blocklyMenu*

Old classes remain in DOM and are deprecated so that any custom CSS will continue to function.

* Remove unused focus tracker in tree.

* Add support for space/enter to toggle tree cats

* Delete unsettable .isUserCollapsible_ from tree

* Change visibility tags throughout menus.

The previous tags were inherited from Closure and don’t reflect current usage in the Blockly codebase.

The core/components/tree files are non-compliant in this regard, but I’m not going to update them since they need to be replaced and there’s no need to create an interim API change.

* Remove property on DOM element linking to JS obj

Performance is slower (O(n) rather than (O(1)), but ’n’ is the number of entries on the menu, so shouldn’t be more than a dozen or so.

* Fixes a compile error (node != element)

Usually we avoid parentElement in Blockly.  That’s because it has very spotty behaviour with SVG.  But in this case we are in pure HTML.
2020-05-06 23:55:17 -04:00

886 lines
21 KiB
JavaScript

/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Definition of the Blockly.tree.BaseNode class.
* This class is similar to Closure's goog.ui.tree.BaseNode class.
* @author samelh@google.com (Sam El-Husseini)
*/
'use strict';
goog.provide('Blockly.tree.BaseNode');
goog.require('Blockly.Component');
goog.require('Blockly.utils.aria');
goog.require('Blockly.utils.object');
goog.require('Blockly.utils.KeyCodes');
goog.require('Blockly.utils.style');
/**
* An abstract base class for a node in the tree.
* Similar to goog.ui.tree.BaseNode
*
* @param {string} content The content of the node label treated as
* plain-text and will be HTML escaped.
* @param {!Blockly.tree.BaseNode.Config} config The configuration for the tree.
* @constructor
* @extends {Blockly.Component}
*/
Blockly.tree.BaseNode = function(content, config) {
Blockly.Component.call(this);
/**
* Text content of the node label.
* @type {string}
* @package
*/
this.content = content;
/**
* @type {string}
* @package
*/
this.iconClass;
/**
* @type {string}
* @package
*/
this.expandedIconClass;
/**
* The configuration for the tree.
* @type {!Blockly.tree.BaseNode.Config}
* @protected
*/
this.config_ = config;
/**
* @type {Blockly.tree.TreeControl}
* @protected
*/
this.tree;
/**
* @type {Blockly.tree.BaseNode}
* @private
*/
this.previousSibling_;
/**
* @type {Blockly.tree.BaseNode}
* @private
*/
this.nextSibling_;
/**
* Whether the tree item is selected.
* @type {boolean}
* @protected
*/
this.selected_ = false;
/**
* Whether the tree node is expanded.
* @type {boolean}
* @protected
*/
this.expanded_ = false;
/**
* Nesting depth of this node; cached result of getDepth.
* -1 if value has not been cached.
* @type {number}
* @private
*/
this.depth_ = -1;
};
Blockly.utils.object.inherits(Blockly.tree.BaseNode, Blockly.Component);
/**
* The config type for the tree.
* @typedef {{
* indentWidth:number,
* cssRoot:string,
* cssHideRoot:string,
* cssTreeRow:string,
* cssItemLabel:string,
* cssTreeIcon:string,
* cssExpandedFolderIcon:string,
* cssCollapsedFolderIcon:string,
* cssFileIcon:string,
* cssSelectedRow:string
* }}
*/
Blockly.tree.BaseNode.Config;
/**
* Map of nodes in existence. Needed to route events to the appropriate nodes.
* Nodes are added to the map at {@link #enterDocument} time and removed at
* {@link #exitDocument} time.
* @type {Object}
* @protected
*/
Blockly.tree.BaseNode.allNodes = {};
/** @override */
Blockly.tree.BaseNode.prototype.disposeInternal = function() {
Blockly.tree.BaseNode.superClass_.disposeInternal.call(this);
if (this.tree) {
this.tree = null;
}
this.setElementInternal(null);
};
/**
* Adds roles and states.
* @protected
*/
Blockly.tree.BaseNode.prototype.initAccessibility = function() {
var el = this.getElement();
if (el) {
// Set an id for the label
var label = this.getLabelElement();
if (label && !label.id) {
label.id = this.getId() + '.label';
}
Blockly.utils.aria.setRole(el, Blockly.utils.aria.Role.TREEITEM);
Blockly.utils.aria.setState(el, Blockly.utils.aria.State.SELECTED, false);
Blockly.utils.aria.setState(el,
Blockly.utils.aria.State.LEVEL, this.getDepth());
if (label) {
Blockly.utils.aria.setState(el,
Blockly.utils.aria.State.LABELLEDBY, label.id);
}
var img = this.getIconElement();
if (img) {
Blockly.utils.aria.setRole(img, Blockly.utils.aria.Role.PRESENTATION);
}
var ce = this.getChildrenElement();
if (ce) {
Blockly.utils.aria.setRole(ce, Blockly.utils.aria.Role.GROUP);
// In case the children will be created lazily.
if (ce.hasChildNodes()) {
// Only set aria-expanded if the node has children (can be expanded).
Blockly.utils.aria.setState(el, Blockly.utils.aria.State.EXPANDED, false);
// do setsize for each child
var count = this.getChildCount();
for (var i = 1; i <= count; i++) {
var child = /** @type {!Element} */ (this.getChildAt(i - 1).getElement());
Blockly.utils.aria.setState(child,
Blockly.utils.aria.State.SETSIZE, count);
Blockly.utils.aria.setState(child,
Blockly.utils.aria.State.POSINSET, i);
}
}
}
}
};
/** @override */
Blockly.tree.BaseNode.prototype.createDom = function() {
var element = document.createElement('div');
element.appendChild(this.toDom());
this.setElementInternal(/** @type {!Element} */ (element));
};
/** @override */
Blockly.tree.BaseNode.prototype.enterDocument = function() {
Blockly.tree.BaseNode.superClass_.enterDocument.call(this);
Blockly.tree.BaseNode.allNodes[this.getId()] = this;
this.initAccessibility();
};
/** @override */
Blockly.tree.BaseNode.prototype.exitDocument = function() {
Blockly.tree.BaseNode.superClass_.exitDocument.call(this);
delete Blockly.tree.BaseNode.allNodes[this.getId()];
};
/**
* The method assumes that the child doesn't have parent node yet.
* @override
*/
Blockly.tree.BaseNode.prototype.addChildAt = function(child, index) {
child = /** @type {Blockly.tree.BaseNode} */ (child);
var prevNode = this.getChildAt(index - 1);
var nextNode = this.getChildAt(index);
Blockly.tree.BaseNode.superClass_.addChildAt.call(this, child, index);
child.previousSibling_ = prevNode;
child.nextSibling_ = nextNode;
if (prevNode) {
prevNode.nextSibling_ = child;
}
if (nextNode) {
nextNode.previousSibling_ = child;
}
var tree = this.getTree();
if (tree) {
child.setTreeInternal(tree);
}
child.setDepth_(this.getDepth() + 1);
var el = this.getElement();
if (el) {
this.updateExpandIcon();
Blockly.utils.aria.setState(
el, Blockly.utils.aria.State.EXPANDED, this.expanded_);
if (this.expanded_) {
var childrenEl = this.getChildrenElement();
if (!child.getElement()) {
child.createDom();
}
var childElement = child.getElement();
var nextElement = nextNode && nextNode.getElement();
childrenEl.insertBefore(childElement, nextElement);
if (this.isInDocument()) {
child.enterDocument();
}
if (!nextNode) {
if (prevNode) {
prevNode.updateExpandIcon();
} else {
Blockly.utils.style.setElementShown(childrenEl, true);
this.setExpanded(this.expanded_);
}
}
}
}
};
/**
* Appends a node as a child to the current node.
* @param {Blockly.tree.BaseNode} child The child to add.
* @package
*/
Blockly.tree.BaseNode.prototype.add = function(child) {
if (child.getParent()) {
throw Error(Blockly.Component.Error.PARENT_UNABLE_TO_BE_SET);
}
this.addChildAt(child, this.getChildCount());
};
/**
* Returns the tree.
* @return {?Blockly.tree.TreeControl} tree
* @protected
*/
Blockly.tree.BaseNode.prototype.getTree = function() {
return null;
};
/**
* Returns the depth of the node in the tree. Should not be overridden.
* @return {number} The non-negative depth of this node (the root is zero).
* @protected
*/
Blockly.tree.BaseNode.prototype.getDepth = function() {
var depth = this.depth_;
if (depth < 0) {
var parent = this.getParent();
if (parent) {
depth = parent.getDepth() + 1;
} else {
depth = 0;
}
this.setDepth_(depth);
}
return depth;
};
/**
* Changes the depth of a node (and all its descendants).
* @param {number} depth The new nesting depth; must be non-negative.
* @private
*/
Blockly.tree.BaseNode.prototype.setDepth_ = function(depth) {
if (depth != this.depth_) {
this.depth_ = depth;
var row = this.getRowElement();
if (row) {
var indent = this.getPixelIndent_() + 'px';
if (this.rightToLeft_) {
row.style.paddingRight = indent;
} else {
row.style.paddingLeft = indent;
}
}
this.forEachChild(function(child) { child.setDepth_(depth + 1); });
}
};
/**
* Returns true if the node is a descendant of this node.
* @param {Blockly.Component} node The node to check.
* @return {boolean} True if the node is a descendant of this node, false
* otherwise.
* @protected
*/
Blockly.tree.BaseNode.prototype.contains = function(node) {
while (node) {
if (node == this) {
return true;
}
node = node.getParent();
}
return false;
};
/**
* This is re-defined here to indicate to the Closure Compiler the correct
* child return type.
* @param {number} index 0-based index.
* @return {Blockly.tree.BaseNode} The child at the given index; null if none.
* @protected
*/
Blockly.tree.BaseNode.prototype.getChildAt;
/**
* Returns the children of this node.
* @return {!Array.<!Blockly.tree.BaseNode>} The children.
* @package
*/
Blockly.tree.BaseNode.prototype.getChildren = function() {
var children = [];
this.forEachChild(function(child) { children.push(child); });
return children;
};
/**
* @return {Blockly.tree.BaseNode} The previous sibling of this node.
* @protected
*/
Blockly.tree.BaseNode.prototype.getPreviousSibling = function() {
return this.previousSibling_;
};
/**
* @return {Blockly.tree.BaseNode} The next sibling of this node.
* @protected
*/
Blockly.tree.BaseNode.prototype.getNextSibling = function() {
return this.nextSibling_;
};
/**
* @return {boolean} Whether the node is the last sibling.
* @protected
*/
Blockly.tree.BaseNode.prototype.isLastSibling = function() {
return !this.nextSibling_;
};
/**
* @return {boolean} Whether the node is selected.
* @protected
*/
Blockly.tree.BaseNode.prototype.isSelected = function() {
return this.selected_;
};
/**
* Selects the node.
* @protected
*/
Blockly.tree.BaseNode.prototype.select = function() {
var tree = this.getTree();
if (tree) {
tree.setSelectedItem(this);
}
};
/**
* Called from the tree to instruct the node change its selection state.
* @param {boolean} selected The new selection state.
* @protected
*/
Blockly.tree.BaseNode.prototype.setSelected = function(selected) {
if (this.selected_ == selected) {
return;
}
this.selected_ = selected;
this.updateRow();
var el = this.getElement();
if (el) {
Blockly.utils.aria.setState(el, Blockly.utils.aria.State.SELECTED, selected);
if (selected) {
var treeElement = /** @type {!Element} */ (this.getTree().getElement());
Blockly.utils.aria.setState(treeElement,
Blockly.utils.aria.State.ACTIVEDESCENDANT, this.getId());
}
}
};
/**
* Sets the node to be expanded.
* @param {boolean} expanded Whether to expand or close the node.
* @package
*/
Blockly.tree.BaseNode.prototype.setExpanded = function(expanded) {
var isStateChange = expanded != this.expanded_;
var ce;
this.expanded_ = expanded;
var tree = this.getTree();
var el = this.getElement();
if (this.hasChildren()) {
if (!expanded && tree && this.contains(tree.getSelectedItem())) {
this.select();
}
if (el) {
ce = this.getChildrenElement();
if (ce) {
Blockly.utils.style.setElementShown(ce, expanded);
Blockly.utils.aria.setState(el, Blockly.utils.aria.State.EXPANDED, expanded);
// Make sure we have the HTML for the children here.
if (expanded && this.isInDocument() && !ce.hasChildNodes()) {
this.forEachChild(function(child) {
ce.appendChild(child.toDom());
});
this.forEachChild(function(child) { child.enterDocument(); });
}
}
this.updateExpandIcon();
}
} else {
ce = this.getChildrenElement();
if (ce) {
Blockly.utils.style.setElementShown(ce, false);
}
}
if (el) {
this.updateIcon_();
}
if (isStateChange) {
if (expanded) {
this.doNodeExpanded();
} else {
this.doNodeCollapsed();
}
}
};
/**
* Used to notify a node of that we have expanded it.
* Can be overridden by subclasses, see Blockly.tree.TreeNode.
* @protected
*/
Blockly.tree.BaseNode.prototype.doNodeExpanded = function() {
// NOP
};
/**
* Used to notify a node that we have collapsed it.
* Can be overridden by subclasses, see Blockly.tree.TreeNode.
* @protected
*/
Blockly.tree.BaseNode.prototype.doNodeCollapsed = function() {
// NOP
};
/**
* Toggles the expanded state of the node.
* @protected
*/
Blockly.tree.BaseNode.prototype.toggle = function() {
this.setExpanded(!this.expanded_);
};
/**
* Creates HTML Element for the node.
* @return {!Element} HTML element
* @protected
*/
Blockly.tree.BaseNode.prototype.toDom = function() {
var nonEmptyAndExpanded = this.expanded_ && this.hasChildren();
var children = document.createElement('div');
children.style.backgroundPosition = this.getBackgroundPosition();
if (!nonEmptyAndExpanded) {
children.style.display = 'none';
}
if (nonEmptyAndExpanded) {
// children
this.forEachChild(function(child) { children.appendChild(child.toDom()); });
}
var node = document.createElement('div');
node.id = this.getId();
node.appendChild(this.getRowDom());
node.appendChild(children);
return node;
};
/**
* Calculates correct padding for each row. Nested categories are indented more.
* @return {number} The pixel indent of the row.
* @private
*/
Blockly.tree.BaseNode.prototype.getPixelIndent_ = function() {
return Math.max(0, (this.getDepth() - 1) * this.config_.indentWidth);
};
/**
* Creates row with icon and label dom.
* @return {!Element} The HTML element for the row.
* @protected
*/
Blockly.tree.BaseNode.prototype.getRowDom = function() {
var row = document.createElement('div');
row.className = this.getRowClassName();
row.style['padding-' + (this.rightToLeft_ ? 'right' : 'left')] =
this.getPixelIndent_() + 'px';
row.appendChild(this.getIconDom());
row.appendChild(this.getLabelDom());
return row;
};
/**
* Adds the selected class name to the default row class name if node is
* selected.
* @return {string} The class name for the row.
* @protected
*/
Blockly.tree.BaseNode.prototype.getRowClassName = function() {
var selectedClass = '';
if (this.isSelected()) {
selectedClass = ' ' + (this.config_.cssSelectedRow || '');
}
return this.config_.cssTreeRow + selectedClass;
};
/**
* @return {!Element} The HTML element for the label.
* @protected
*/
Blockly.tree.BaseNode.prototype.getLabelDom = function() {
var label = document.createElement('span');
label.className = this.config_.cssItemLabel || '';
label.textContent = this.content;
return label;
};
/**
* @return {!Element} The HTML for the icon.
* @protected
*/
Blockly.tree.BaseNode.prototype.getIconDom = function() {
var icon = document.createElement('span');
icon.style.display = 'inline-block';
icon.className = this.getCalculatedIconClass();
return icon;
};
/**
* Gets the calculated icon class.
* @protected
*/
Blockly.tree.BaseNode.prototype.getCalculatedIconClass = function() {
throw Error(Blockly.Component.Error.ABSTRACT_METHOD);
};
/**
* Gets a string containing the x and y position of the node's background.
* @return {string} The background position style value.
* @protected
*/
Blockly.tree.BaseNode.prototype.getBackgroundPosition = function() {
return (this.isLastSibling() ? '-100' : (this.getDepth() - 1) *
this.config_.indentWidth) + 'px 0';
};
/**
* @return {Element} The element for the tree node.
* @override
*/
Blockly.tree.BaseNode.prototype.getElement = function() {
var el = Blockly.tree.BaseNode.superClass_.getElement.call(this);
if (!el) {
el = document.getElementById(this.getId());
this.setElementInternal(el);
}
return el;
};
/**
* @return {Element} The row is the div that is used to draw the node without
* the children.
* @package
*/
Blockly.tree.BaseNode.prototype.getRowElement = function() {
var el = this.getElement();
return el ? /** @type {Element} */ (el.firstChild) : null;
};
/**
* @return {Element} The icon element.
* @protected
*/
Blockly.tree.BaseNode.prototype.getIconElement = function() {
var el = this.getRowElement();
return el ? /** @type {Element} */ (el.firstChild) : null;
};
/**
* @return {Element} The label element.
* @protected
*/
Blockly.tree.BaseNode.prototype.getLabelElement = function() {
var el = this.getRowElement();
return el && el.lastChild ?
/** @type {Element} */ (el.lastChild.previousSibling) :
null;
};
/**
* @return {Element} The div containing the children.
* @protected
*/
Blockly.tree.BaseNode.prototype.getChildrenElement = function() {
var el = this.getElement();
return el ? /** @type {Element} */ (el.lastChild) : null;
};
/**
* Updates the row styles.
* @protected
*/
Blockly.tree.BaseNode.prototype.updateRow = function() {
var rowEl = this.getRowElement();
if (rowEl) {
rowEl.className = this.getRowClassName();
}
};
/**
* Updates the expand icon of the node.
* @protected
*/
Blockly.tree.BaseNode.prototype.updateExpandIcon = function() {
var cel = this.getChildrenElement();
if (cel) {
cel.style.backgroundPosition = this.getBackgroundPosition();
}
};
/**
* Updates the icon of the node. Assumes that this.getElement() is created.
* @private
*/
Blockly.tree.BaseNode.prototype.updateIcon_ = function() {
this.getIconElement().className = this.getCalculatedIconClass();
};
/**
* Handles a click event.
* @param {!Event} e The browser event.
* @protected
*/
Blockly.tree.BaseNode.prototype.onClick_ = function(e) {
e.preventDefault();
};
/**
* Handles a key down event.
* @param {!Event} e The browser event.
* @return {boolean} The handled value.
* @protected
*/
Blockly.tree.BaseNode.prototype.onKeyDown = function(e) {
var handled = true;
switch (e.keyCode) {
case Blockly.utils.KeyCodes.RIGHT:
handled = this.selectChild();
break;
case Blockly.utils.KeyCodes.LEFT:
handled = this.selectParent();
break;
case Blockly.utils.KeyCodes.DOWN:
handled = this.selectNext();
break;
case Blockly.utils.KeyCodes.UP:
handled = this.selectPrevious();
break;
case Blockly.utils.KeyCodes.ENTER:
case Blockly.utils.KeyCodes.SPACE:
this.toggle();
handled = true;
break;
default:
handled = false;
}
if (handled) {
e.preventDefault();
}
return handled;
};
/**
* Select the next node.
* @return {boolean} True if the action has been handled, false otherwise.
* @package
*/
Blockly.tree.BaseNode.prototype.selectNext = function() {
var nextNode = this.getNextShownNode();
if (nextNode) {
nextNode.select();
}
return true;
};
/**
* Select the previous node.
* @return {boolean} True if the action has been handled, false otherwise.
* @package
*/
Blockly.tree.BaseNode.prototype.selectPrevious = function() {
var previousNode = this.getPreviousShownNode();
if (previousNode) {
previousNode.select();
}
return true;
};
/**
* Select the parent node or collapse the current node.
* @return {boolean} True if the action has been handled, false otherwise.
* @package
*/
Blockly.tree.BaseNode.prototype.selectParent = function() {
if (this.hasChildren() && this.expanded_) {
this.setExpanded(false);
} else {
var parent = this.getParent();
var tree = this.getTree();
// don't go to root if hidden
if (parent && (parent != tree)) {
parent.select();
}
}
return true;
};
/**
* Expand the current node if it's not already expanded, or select the
* child node.
* @return {boolean} True if the action has been handled, false otherwise.
* @package
*/
Blockly.tree.BaseNode.prototype.selectChild = function() {
if (this.hasChildren()) {
if (!this.expanded_) {
this.setExpanded(true);
} else {
this.getChildAt(0).select();
}
return true;
}
return false;
};
/**
* @return {Blockly.tree.BaseNode} The last shown descendant.
* @protected
*/
Blockly.tree.BaseNode.prototype.getLastShownDescendant = function() {
if (!this.expanded_ || !this.hasChildren()) {
return this;
}
// we know there is at least 1 child
return this.getChildAt(this.getChildCount() - 1).getLastShownDescendant();
};
/**
* @return {Blockly.tree.BaseNode} The next node to show or null if there isn't
* a next node to show.
* @protected
*/
Blockly.tree.BaseNode.prototype.getNextShownNode = function() {
if (this.hasChildren() && this.expanded_) {
return this.getChildAt(0);
}
var parent = this;
var next;
while (parent != this.getTree()) {
next = parent.getNextSibling();
if (next != null) {
return next;
}
parent = parent.getParent();
}
return null;
};
/**
* @return {Blockly.tree.BaseNode} The previous node to show.
* @protected
*/
Blockly.tree.BaseNode.prototype.getPreviousShownNode = function() {
var ps = this.getPreviousSibling();
if (ps != null) {
return ps.getLastShownDescendant();
}
var parent = this.getParent();
var tree = this.getTree();
if (parent == tree) {
return null;
}
// The root is the first node.
if (this == tree) {
return null;
}
return /** @type {Blockly.tree.BaseNode} */ (parent);
};
/**
* Internal method that is used to set the tree control on the node.
* @param {Blockly.tree.TreeControl} tree The tree control.
* @protected
*/
Blockly.tree.BaseNode.prototype.setTreeInternal = function(tree) {
if (this.tree != tree) {
this.tree = tree;
this.forEachChild(function(child) { child.setTreeInternal(tree); });
}
};