/** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @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); /** * The configuration for the tree. * @type {!Blockly.tree.BaseNode.Config} * @private */ this.config_ = config; /** * Text content of the node label. * @type {string} * @private */ this.content_ = content; /** * @type {string} * @private */ this.iconClass_; /** * @type {string} * @private */ this.expandedIconClass_; /** * @type {Blockly.tree.TreeControl} * @protected */ this.tree; /** * @type {Blockly.tree.BaseNode} * @private */ this.previousSibling_; /** * @type {Blockly.tree.BaseNode} * @private */ this.nextSibling_; /** * @type {Blockly.tree.BaseNode} * @private */ this.firstChild_; /** * @type {Blockly.tree.BaseNode} * @private */ this.lastChild_; /** * Whether the tree item is selected. * @type {boolean} * @private */ this.selected_ = false; /** * Whether the tree node is expanded. * @type {boolean} * @private */ this.expanded_ = false; /** * Tooltip for the tree item * @type {?string} * @private */ this.toolTip_ = null; /** * Whether to allow user to collapse this node. * @type {boolean} * @private */ this.isUserCollapsible_ = true; /** * Nesting depth of this node; cached result of computeDepth_. * -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; } else { this.firstChild_ = child; } if (nextNode) { nextNode.previousSibling_ = child; } else { this.lastChild_ = 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.getExpanded()); if (this.getExpanded()) { 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.getExpanded()); } } } } }; /** * 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) { depth = this.computeDepth_(); this.setDepth_(depth); } return depth; }; /** * Computes the depth of the node in the tree. * Called only by getDepth, when the depth hasn't already been cached. * @return {number} The non-negative depth of this node (the root is zero). * @private */ Blockly.tree.BaseNode.prototype.computeDepth_ = function() { var parent = this.getParent(); if (parent) { return parent.getDepth() + 1; } else { return 0; } }; /** * 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.isRightToLeft()) { 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.tree.BaseNode} 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) { var current = node; while (current) { if (current == this) { return true; } current = current.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.} 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 first child of this node. * @protected */ Blockly.tree.BaseNode.prototype.getFirstChild = function() { return this.getChildAt(0); }; /** * @return {Blockly.tree.BaseNode} The last child of this node. * @protected */ Blockly.tree.BaseNode.prototype.getLastChild = function() { return this.getChildAt(this.getChildCount() - 1); }; /** * @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); } }; /** * Selects the first node. * @protected */ Blockly.tree.BaseNode.prototype.selectFirst = function() { var tree = this.getTree(); if (tree && this.firstChild_) { tree.setSelectedItem(this.firstChild_); } }; /** * Called from the tree to instruct the node change its selection state. * @param {boolean} selected The new selection state. * @protected */ Blockly.tree.BaseNode.prototype.setSelectedInternal = 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()); } } }; /** * @return {boolean} Whether the node is expanded. * @protected */ Blockly.tree.BaseNode.prototype.getExpanded = function() { return this.expanded_; }; /** * Sets the node to be expanded internally, without state change events. * @param {boolean} expanded Whether to expand or close the node. * @protected */ Blockly.tree.BaseNode.prototype.setExpandedInternal = function(expanded) { this.expanded_ = expanded; }; /** * 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 overidden 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 overidden 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.getExpanded()); }; /** * @return {boolean} Whether the node is collapsible by user actions. * @protected */ Blockly.tree.BaseNode.prototype.isUserCollapsible = function() { return this.isUserCollapsible_; }; /** * Creates HTML Element for the node. * @return {!Element} HTML element * @protected */ Blockly.tree.BaseNode.prototype.toDom = function() { var nonEmptyAndExpanded = this.getExpanded() && 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; }; /** * @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); }; /** * @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.isRightToLeft() ? 'right' : 'left')] = this.getPixelIndent_() + 'px'; row.appendChild(this.getIconDom()); row.appendChild(this.getLabelDom()); return row; }; /** * @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.getText(); 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('unimplemented abstract method'); }; /** * @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; }; /** * Gets the icon class for the node. * @return {string} s The icon source. * @protected */ Blockly.tree.BaseNode.prototype.getIconClass = function() { return this.iconClass_; }; /** * Gets the icon class for when the node is expanded. * @return {string} The class. * @protected */ Blockly.tree.BaseNode.prototype.getExpandedIconClass = function() { return this.expandedIconClass_; }; /** * Sets the text of the label. * @param {string} s The plain text of the label. * @protected */ Blockly.tree.BaseNode.prototype.setText = function(s) { this.content_ = s; }; /** * Returns the text of the label. If the text was originally set as HTML, the * return value is unspecified. * @return {string} The plain text of the label. * @package */ Blockly.tree.BaseNode.prototype.getText = function() { return this.content_; }; /** * 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 mouse down event. * @param {!Event} e The browser event. * @protected */ Blockly.tree.BaseNode.prototype.onMouseDown = function(e) { var el = e.target; // expand icon var type = el.getAttribute('type'); if (type == 'expand' && this.hasChildren()) { if (this.isUserCollapsible_) { this.toggle(); } return; } this.select(); this.updateRow(); }; /** * 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: if (e.altKey) { break; } handled = this.selectChild(); break; case Blockly.utils.KeyCodes.LEFT: if (e.altKey) { break; } handled = this.selectParent(); break; case Blockly.utils.KeyCodes.DOWN: handled = this.selectNext(); break; case Blockly.utils.KeyCodes.UP: handled = this.selectPrevious(); 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.getExpanded() && this.isUserCollapsible_) { 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.getExpanded()) { this.setExpanded(true); } else { this.getFirstChild().select(); } return true; } return false; }; /** * @return {Blockly.tree.BaseNode} The last shown descendant. * @protected */ Blockly.tree.BaseNode.prototype.getLastShownDescendant = function() { if (!this.getExpanded() || !this.hasChildren()) { return this; } // we know there is at least 1 child return this.getLastChild().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.getExpanded()) { return this.getFirstChild(); } else { 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); }; /** * @return {!Blockly.tree.BaseNode.Config} The configuration for the tree. * @protected */ Blockly.tree.BaseNode.prototype.getConfig = function() { return this.config_; }; /** * 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); }); } };