From dbcdaf0d21a268790d484657a83ae757a3e84f9e Mon Sep 17 00:00:00 2001 From: Sam El-Husseini <16690124+samelhusseini@users.noreply.github.com> Date: Wed, 14 Aug 2019 11:22:21 -0700 Subject: [PATCH] Foundation for no-closure toolbox (#2818) * A subset of closure tree components for building a no-closure toolbox --- core/components/component.js | 812 ++++++++++++++++++ core/components/tree/basenode.js | 1238 +++++++++++++++++++++++++++ core/components/tree/treecontrol.js | 443 ++++++++++ core/components/tree/treenode.js | 188 ++++ 4 files changed, 2681 insertions(+) create mode 100644 core/components/component.js create mode 100644 core/components/tree/basenode.js create mode 100644 core/components/tree/treecontrol.js create mode 100644 core/components/tree/treenode.js diff --git a/core/components/component.js b/core/components/component.js new file mode 100644 index 000000000..f30713614 --- /dev/null +++ b/core/components/component.js @@ -0,0 +1,812 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 Google Inc. + * https://developers.google.com/blockly/ + * + * 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.Component class. + * This class is similar to Closure's goog.ui.Component class. + * @author samelh@google.com (Sam El-Husseini) + */ +'use strict'; + +goog.provide('Blockly.Component'); +goog.provide('Blockly.Component.Error'); +goog.require('Blockly.utils.dom'); +goog.require('Blockly.utils.IdGenerator'); +goog.require('Blockly.utils.style'); + + +/** + * Default implementation of a UI component. + * Similar to Closure's goog.ui.Component. + * + * @constructor + */ +Blockly.Component = function() { + + /** + * Whether the component is rendered right-to-left. Right-to-left is set + * lazily when {@link #isRightToLeft} is called the first time, unless it has + * been set by calling {@link #setRightToLeft} explicitly. + * @private {?boolean} + */ + this.rightToLeft_ = Blockly.Component.defaultRightToLeft_; + + /** + * Unique ID of the component, lazily initialized in {@link + * Blockly.Component#getId} if needed. This property is strictly private and + * must not be accessed directly outside of this class! + * @private {?string} + */ + this.id_ = null; + + /** + * Whether the component is in the document. + * @private {boolean} + */ + this.inDocument_ = false; + + /** + * The DOM element for the component. + * @private {?Element} + */ + this.element_ = null; + + /** + * Parent component to which events will be propagated. This property is + * strictly private and must not be accessed directly outside of this class! + * @private {?Blockly.Component} + */ + this.parent_ = null; + + /** + * Array of child components. Lazily initialized on first use. Must be kept + * in sync with `childIndex_`. This property is strictly private and + * must not be accessed directly outside of this class! + * @private {?Array.} + */ + this.children_ = null; + + /** + * Map of child component IDs to child components. Used for constant-time + * random access to child components by ID. Lazily initialized on first use. + * Must be kept in sync with `children_`. This property is strictly + * private and must not be accessed directly outside of this class! + * + * @private {?Object} + */ + this.childIndex_ = null; +}; + + +/** + * Generator for unique IDs. + * @type {!Blockly.utils.IdGenerator} + * @private + */ +Blockly.Component.prototype.idGenerator_ = Blockly.utils.IdGenerator.getInstance(); + +/** + * The default right to left value. + * @type {?boolean} + * @private + */ +Blockly.Component.defaultRightToLeft_ = false; + +/** + * Errors thrown by the component. + * @enum {string} + */ +Blockly.Component.Error = { + /** + * Error when a method is not supported. + */ + NOT_SUPPORTED: 'Method not supported', + + /** + * Error when the component is already rendered and another render attempt is + * made. + */ + ALREADY_RENDERED: 'Component already rendered', + + /** + * Error when an attempt is made to set the parent of a component in a way + * that would result in an inconsistent object graph. + */ + PARENT_UNABLE_TO_BE_SET: 'Unable to set parent component', + + /** + * Error when an attempt is made to add a child component at an out-of-bounds + * index. We don't support sparse child arrays. + */ + CHILD_INDEX_OUT_OF_BOUNDS: 'Child component index out of bounds', + + /** + * Error when an attempt is made to remove a child component from a component + * other than its parent. + */ + NOT_OUR_CHILD: 'Child is not in parent component', + + /** + * Error when an operation requiring DOM interaction is made when the + * component is not in the document + */ + NOT_IN_DOCUMENT: 'Operation not supported while component is not in document' +}; + +/** + * Set the default right-to-left value. This causes all component's created from + * this point forward to have the given value. This is useful for cases where + * a given page is always in one directionality, avoiding unnecessary + * right to left determinations. + * @param {?boolean} rightToLeft Whether the components should be rendered + * right-to-left. Null iff components should determine their directionality. + * @protected + */ +Blockly.Component.setDefaultRightToLeft = function(rightToLeft) { + Blockly.Component.defaultRightToLeft_ = rightToLeft; +}; + +/** + * Gets the unique ID for the instance of this component. If the instance + * doesn't already have an ID, generates one on the fly. + * @return {string} Unique component ID. + * @protected + */ +Blockly.Component.prototype.getId = function() { + return this.id_ || (this.id_ = this.idGenerator_.getNextUniqueId()); +}; + +/** + * Assigns an ID to this component instance. It is the caller's responsibility + * to guarantee that the ID is unique. If the component is a child of a parent + * component, then the parent component's child index is updated to reflect the + * new ID; this may throw an error if the parent already has a child with an ID + * that conflicts with the new ID. + * @param {string} id Unique component ID. + * @protected + */ +Blockly.Component.prototype.setId = function(id) { + if (this.parent_ && this.parent_.childIndex_) { + // Update the parent's child index. + delete this.parent_.childIndex_[this.id_]; + this.parent_.childIndex_[id] = this; + } + + // Update the component ID. + this.id_ = id; +}; + +/** + * Gets the component's element. + * @return {Element} The element for the component. + * @package + */ +Blockly.Component.prototype.getElement = function() { + return this.element_; +}; + +/** + * Sets the component's root element to the given element. Considered + * protected and final. + * + * This should generally only be called during createDom. Setting the element + * does not actually change which element is rendered, only the element that is + * associated with this UI component. + * + * This should only be used by subclasses and its associated renderers. + * + * @param {Element} element Root element for the component. + * @protected + */ +Blockly.Component.prototype.setElementInternal = function(element) { + this.element_ = element; +}; + +/** + * Sets the parent of this component to use for event bubbling. Throws an error + * if the component already has a parent or if an attempt is made to add a + * component to itself as a child. Callers must use `removeChild` + * or `removeChildAt` to remove components from their containers before + * calling this method. + * @see Blockly.Component#removeChild + * @see Blockly.Component#removeChildAt + * @param {Blockly.Component} parent The parent component. + * @protected + */ +Blockly.Component.prototype.setParent = function(parent) { + if (this == parent) { + // Attempting to add a child to itself is an error. + throw new Error(Blockly.Component.Error.PARENT_UNABLE_TO_BE_SET); + } + + if (parent && this.parent_ && this.id_ && this.parent_.getChild(this.id_) && + this.parent_ != parent) { + // This component is already the child of some parent, so it should be + // removed using removeChild/removeChildAt first. + throw new Error(Blockly.Component.Error.PARENT_UNABLE_TO_BE_SET); + } + + this.parent_ = parent; +}; + +/** + * Returns the component's parent, if any. + * @return {?Blockly.Component} The parent component. + * @protected + */ +Blockly.Component.prototype.getParent = function() { + return this.parent_; +}; + +/** + * Determines whether the component has been added to the document. + * @return {boolean} TRUE if rendered. Otherwise, FALSE. + * @protected + */ +Blockly.Component.prototype.isInDocument = function() { + return this.inDocument_; +}; + +/** + * Creates the initial DOM representation for the component. The default + * implementation is to set this.element_ = div. + * @protected + */ +Blockly.Component.prototype.createDom = function() { + this.element_ = document.createElement('div'); +}; + +/** + * Renders the component. If a parent element is supplied, the component's + * element will be appended to it. If there is no optional parent element and + * the element doesn't have a parentNode then it will be appended to the + * document body. + * + * If this component has a parent component, and the parent component is + * not in the document already, then this will not call `enterDocument` + * on this component. + * + * Throws an Error if the component is already rendered. + * + * @param {Element=} opt_parentElement Optional parent element to render the + * component into. + * @protected + */ +Blockly.Component.prototype.render = function(opt_parentElement) { + this.render_(opt_parentElement); +}; + +/** + * Renders the component before another element. The other element should be in + * the document already. + * + * Throws an Error if the component is already rendered. + * + * @param {Node} sibling Node to render the component before. + * @protected + */ +Blockly.Component.prototype.renderBefore = function(sibling) { + this.render_(/** @type {Element} */ (sibling.parentNode), sibling); +}; + +/** + * Renders the component. If a parent element is supplied, the component's + * element will be appended to it. If there is no optional parent element and + * the element doesn't have a parentNode then it will be appended to the + * document body. + * + * If this component has a parent component, and the parent component is + * not in the document already, then this will not call `enterDocument` + * on this component. + * + * Throws an Error if the component is already rendered. + * + * @param {Element=} opt_parentElement Optional parent element to render the + * component into. + * @param {Node=} opt_beforeNode Node before which the component is to + * be rendered. If left out the node is appended to the parent element. + * @private + */ +Blockly.Component.prototype.render_ = function( + opt_parentElement, opt_beforeNode) { + if (this.inDocument_) { + throw new Error(Blockly.Component.Error.ALREADY_RENDERED); + } + + if (!this.element_) { + this.createDom(); + } + + if (opt_parentElement) { + opt_parentElement.insertBefore(this.element_, opt_beforeNode || null); + } else { + document.body.appendChild(this.element_); + } + + // If this component has a parent component that isn't in the document yet, + // we don't call enterDocument() here. Instead, when the parent component + // enters the document, the enterDocument() call will propagate to its + // children, including this one. If the component doesn't have a parent + // or if the parent is already in the document, we call enterDocument(). + if (!this.parent_ || this.parent_.isInDocument()) { + this.enterDocument(); + } +}; + +/** + * Called when the component's element is known to be in the document. Anything + * using document.getElementById etc. should be done at this stage. + * + * If the component contains child components, this call is propagated to its + * children. + * @protected + */ +Blockly.Component.prototype.enterDocument = function() { + this.inDocument_ = true; + + // Propagate enterDocument to child components that have a DOM, if any. + // If a child was decorated before entering the document (permitted when + // Blockly.Component.ALLOW_DETACHED_DECORATION is true), its enterDocument + // will be called here. + this.forEachChild(function(child) { + if (!child.isInDocument() && child.getElement()) { + child.enterDocument(); + } + }); +}; + +/** + * Called by dispose to clean up the elements and listeners created by a + * component, or by a parent component/application who has removed the + * component from the document but wants to reuse it later. + * + * If the component contains child components, this call is propagated to its + * children. + * + * It should be possible for the component to be rendered again once this method + * has been called. + * @protected + */ +Blockly.Component.prototype.exitDocument = function() { + // Propagate exitDocument to child components that have been rendered, if any. + this.forEachChild(function(child) { + if (child.isInDocument()) { + child.exitDocument(); + } + }); + + this.inDocument_ = false; +}; + +/** + * Disposes of the object. If the object hasn't already been disposed of, calls + * {@link #disposeInternal}. + * @protected + */ +Blockly.Component.prototype.dispose = function() { + if (!this.disposed_) { + // Set disposed_ to true first, in case during the chain of disposal this + // gets disposed recursively. + this.disposed_ = true; + this.disposeInternal(); + } +}; + +/** + * Disposes of the component. Calls `exitDocument`, which is expected to + * remove event handlers and clean up the component. Propagates the call to + * the component's children, if any. Removes the component's DOM from the + * document. + * @protected + */ +Blockly.Component.prototype.disposeInternal = function() { + if (this.inDocument_) { + this.exitDocument(); + } + + // Disposes of the component's children, if any. + this.forEachChild(function(child) { child.dispose(); }); + + // Detach the component's element from the DOM. + if (this.element_) { + Blockly.utils.dom.removeNode(this.element_); + } + + this.children_ = null; + this.childIndex_ = null; + this.element_ = null; + this.parent_ = null; +}; + +/** + * Helper function for subclasses that gets a unique id for a given fragment, + * this can be used by components to generate unique string ids for DOM + * elements. + * @param {string} idFragment A partial id. + * @return {string} Unique element id. + * @protected + */ +Blockly.Component.prototype.makeId = function(idFragment) { + return this.getId() + '.' + idFragment; +}; + +/** + * Makes a collection of ids. This is a convenience method for makeId. The + * object's values are the id fragments and the new values are the generated + * ids. The key will remain the same. + * @param {Object} object The object that will be used to create the ids. + * @return {!Object} An object of id keys to generated ids. + * @protected + */ +Blockly.Component.prototype.makeIds = function(object) { + var ids = {}; + for (var key in object) { + ids[key] = this.makeId(object[key]); + } + return ids; +}; + +/** + * Adds the specified component as the last child of this component. See + * {@link Blockly.Component#addChildAt} for detailed semantics. + * + * @see Blockly.Component#addChildAt + * @param {Blockly.Component} child The new child component. + * @param {boolean=} opt_render If true, the child component will be rendered + * into the parent. + * @protected + */ +Blockly.Component.prototype.addChild = function(child, opt_render) { + this.addChildAt(child, this.getChildCount(), opt_render); +}; + +/** + * Adds the specified component as a child of this component at the given + * 0-based index. + * + * Both `addChild` and `addChildAt` assume the following contract + * between parent and child components: + * + * + * In particular, `parent.addChild(child)` will throw an error if the + * child component is already in the document, but the parent isn't. + * + * Clients of this API may call `addChild` and `addChildAt` with + * `opt_render` set to true. If `opt_render` is true, calling these + * methods will automatically render the child component's element into the + * parent component's element. If the parent does not yet have an element, then + * `createDom` will automatically be invoked on the parent before + * rendering the child. + * + * Invoking {@code parent.addChild(child, true)} will throw an error if the + * child component is already in the document, regardless of the parent's DOM + * state. + * + * If `opt_render` is true and the parent component is not already + * in the document, `enterDocument` will not be called on this component + * at this point. + * + * Finally, this method also throws an error if the new child already has a + * different parent, or the given index is out of bounds. + * + * @see Blockly.Component#addChild + * @param {Blockly.Component} child The new child component. + * @param {number} index 0-based index at which the new child component is to be + * added; must be between 0 and the current child count (inclusive). + * @param {boolean=} opt_render If true, the child component will be rendered + * into the parent. + * @protected + */ +Blockly.Component.prototype.addChildAt = function(child, index, opt_render) { + if (child.inDocument_ && (opt_render || !this.inDocument_)) { + // Adding a child that's already in the document is an error, except if the + // parent is also in the document and opt_render is false (e.g. decorate()). + throw new Error(Blockly.Component.Error.ALREADY_RENDERED); + } + + if (index < 0 || index > this.getChildCount()) { + // Allowing sparse child arrays would lead to strange behavior, so we don't. + throw new Error(Blockly.Component.Error.CHILD_INDEX_OUT_OF_BOUNDS); + } + + // Create the index and the child array on first use. + if (!this.childIndex_ || !this.children_) { + this.childIndex_ = {}; + this.children_ = []; + } + + // Moving child within component, remove old reference. + this.childIndex_[child.getId()] = child; + if (child.getParent() == this) { + // Remove from this.children_ + var i = this.children_.indexOf(child); + if (i > -1) { + this.children_.splice(i, 1); + } + } + + // Set the parent of the child to this component. This throws an error if + // the child is already contained by another component. + child.setParent(this); + this.children_.splice(index, 0, child); + + if (child.inDocument_ && this.inDocument_ && child.getParent() == this) { + // Changing the position of an existing child, move the DOM node (if + // necessary). + var contentElement = this.getContentElement(); + var insertBeforeElement = contentElement.childNodes[index] || null; + if (insertBeforeElement != child.getElement()) { + contentElement.insertBefore(child.getElement(), insertBeforeElement); + } + } else if (opt_render) { + // If this (parent) component doesn't have a DOM yet, call createDom now + // to make sure we render the child component's element into the correct + // parent element (otherwise render_ with a null first argument would + // render the child into the document body, which is almost certainly not + // what we want). + if (!this.element_) { + this.createDom(); + } + // Render the child into the parent at the appropriate location. Note that + // getChildAt(index + 1) returns undefined if inserting at the end. + var sibling = this.getChildAt(index + 1); + // render_() calls enterDocument() if the parent is already in the document. + child.render_(this.getContentElement(), sibling ? sibling.element_ : null); + } else if (this.inDocument_ && !child.inDocument_ && child.element_ && + child.element_.parentNode && + // Under some circumstances, IE8 implicitly creates a Document Fragment + // for detached nodes, so ensure the parent is an Element as it should be. + child.element_.parentNode.nodeType == Blockly.utils.dom.Node.ELEMENT_NODE) { + // We don't touch the DOM, but if the parent is in the document, and the + // child element is in the document but not marked as such, then we call + // enterDocument on the child. + child.enterDocument(); + } +}; + +/** + * Returns the DOM element into which child components are to be rendered, + * or null if the component itself hasn't been rendered yet. This default + * implementation returns the component's root element. Subclasses with + * complex DOM structures must override this method. + * @return {Element} Element to contain child elements (null if none). + * @protected + */ +Blockly.Component.prototype.getContentElement = function() { + return this.element_; +}; + +/** + * Returns true if the component is rendered right-to-left, false otherwise. + * The first time this function is invoked, the right-to-left rendering property + * is set if it has not been already. + * @return {boolean} Whether the control is rendered right-to-left. + * @protected + */ +Blockly.Component.prototype.isRightToLeft = function() { + if (this.rightToLeft_ == null) { + this.rightToLeft_ = Blockly.utils.style.isRightToLeft( + this.inDocument_ ? this.element_ : document.body); + } + return this.rightToLeft_; +}; + +/** + * Set is right-to-left. This function should be used if the component needs + * to know the rendering direction during dom creation (i.e. before + * {@link #enterDocument} is called and is right-to-left is set). + * @param {boolean} rightToLeft Whether the component is rendered + * right-to-left. + * @protected + */ +Blockly.Component.prototype.setRightToLeft = function(rightToLeft) { + if (this.inDocument_) { + throw new Error(Blockly.Component.Error.ALREADY_RENDERED); + } + this.rightToLeft_ = rightToLeft; +}; + +/** + * Returns true if the component has children. + * @return {boolean} True if the component has children. + * @protected + */ +Blockly.Component.prototype.hasChildren = function() { + return !!this.children_ && this.children_.length != 0; +}; + +/** + * Returns the number of children of this component. + * @return {number} The number of children. + * @protected + */ +Blockly.Component.prototype.getChildCount = function() { + return this.children_ ? this.children_.length : 0; +}; + +/** + * Returns an array containing the IDs of the children of this component, or an + * empty array if the component has no children. + * @return {!Array.} Child component IDs. + * @protected + */ +Blockly.Component.prototype.getChildIds = function() { + var ids = []; + + this.forEachChild(function(child) { + ids.push(child.getId()); + }); + + return ids; +}; + +/** + * Returns the child with the given ID, or null if no such child exists. + * @param {string} id Child component ID. + * @return {?Blockly.Component} The child with the given ID; null if none. + * @protected + */ +Blockly.Component.prototype.getChild = function(id) { + // Use childIndex_ for O(1) access by ID. + return (this.childIndex_ && id) ? + /** @type {Blockly.Component} */ ( + this.childIndex_[id]) || + null : null; +}; + +/** + * Returns the child at the given index, or null if the index is out of bounds. + * @param {number} index 0-based index. + * @return {?Blockly.Component} The child at the given index; null if none. + * @protected + */ +Blockly.Component.prototype.getChildAt = function(index) { + // Use children_ for access by index. + return this.children_ ? this.children_[index] || null : null; +}; + +/** + * Calls the given function on each of this component's children in order. If + * `opt_obj` is provided, it will be used as the 'this' object in the + * function when called. The function should take two arguments: the child + * component and its 0-based index. The return value is ignored. + * @param {function(this:T,?,number):?} f The function to call for every + * child component; should take 2 arguments (the child and its index). + * @param {T=} opt_obj Used as the 'this' object in f when called. + * @template T + * @protected + */ +Blockly.Component.prototype.forEachChild = function(f, opt_obj) { + if (this.children_) { + for (var i = 0; i < this.children_.length; i++) { + f.call(/** @type {?} */ (opt_obj), this.children_[i], i); + } + } +}; + +/** + * Returns the 0-based index of the given child component, or -1 if no such + * child is found. + * @param {?Blockly.Component} child The child component. + * @return {number} 0-based index of the child component; -1 if not found. + * @protected + */ +Blockly.Component.prototype.indexOfChild = function(child) { + return (this.children_ && child) ? this.children_.indexOf(child) : + -1; +}; + +/** + * Removes the given child from this component, and returns it. Throws an error + * if the argument is invalid or if the specified child isn't found in the + * parent component. The argument can either be a string (interpreted as the + * ID of the child component to remove) or the child component itself. + * + * If `opt_unrender` is true, calls {@link Blockly.Component#exitDocument} + * on the removed child, and subsequently detaches the child's DOM from the + * document. Otherwise it is the caller's responsibility to clean up the child + * component's DOM. + * + * @see Blockly.Component#removeChildAt + * @param {string|Blockly.Component|null} child The ID of the child to remove, + * or the child component itself. + * @param {boolean=} opt_unrender If true, calls `exitDocument` on the + * removed child component, and detaches its DOM from the document. + * @return {Blockly.Component} The removed component, if any. + * @protected + */ +Blockly.Component.prototype.removeChild = function(child, opt_unrender) { + if (child) { + // Normalize child to be the object and id to be the ID string. This also + // ensures that the child is really ours. + var id = (typeof child === 'string' || child instanceof String) ? + String(child) : child.getId(); + child = this.getChild(id); + + if (id && child) { + delete this.childIndex_[id]; + var index = this.children_.indexOf(child); + if (index > -1) { + this.children_.splice(index, 1); + } + + if (opt_unrender) { + // Remove the child component's DOM from the document. We have to call + // exitDocument first (see documentation). + child.exitDocument(); + if (child.element_) { + Blockly.utils.dom.removeNode(child.element_); + } + } + + // Child's parent must be set to null after exitDocument is called + // so that the child can unlisten to its parent if required. + child.setParent(null); + } + } + + if (!child) { + throw new Error(Blockly.Component.Error.NOT_OUR_CHILD); + } + + return /** @type {!Blockly.Component} */ (child); +}; + +/** + * Removes the child at the given index from this component, and returns it. + * Throws an error if the argument is out of bounds, or if the specified child + * isn't found in the parent. See {@link Blockly.Component#removeChild} for + * detailed semantics. + * + * @see Blockly.Component#removeChild + * @param {number} index 0-based index of the child to remove. + * @param {boolean=} opt_unrender If true, calls `exitDocument` on the + * removed child component, and detaches its DOM from the document. + * @return {Blockly.Component} The removed component, if any. + * @protected + */ +Blockly.Component.prototype.removeChildAt = function(index, opt_unrender) { + // removeChild(null) will throw error. + return this.removeChild(this.getChildAt(index), opt_unrender); +}; + +/** + * Removes every child component attached to this one and returns them. + * + * @see Blockly.Component#removeChild + * @param {boolean=} opt_unrender If true, calls {@link #exitDocument} on the + * removed child components, and detaches their DOM from the document. + * @return {!Array.} The removed components if any. + * @protected + */ +Blockly.Component.prototype.removeChildren = function(opt_unrender) { + var removedChildren = []; + while (this.hasChildren()) { + removedChildren.push(this.removeChildAt(0, opt_unrender)); + } + return removedChildren; +}; diff --git a/core/components/tree/basenode.js b/core/components/tree/basenode.js new file mode 100644 index 000000000..33ae3bf89 --- /dev/null +++ b/core/components/tree/basenode.js @@ -0,0 +1,1238 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 Google Inc. + * https://developers.google.com/blockly/ + * + * 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.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; + + /** @private {string} */ + this.iconClass_; + + /** @private {string} */ + this.expandedIconClass_; + + /** @protected {Blockly.tree.TreeControl} */ + this.tree; + + /** @private {Blockly.tree.BaseNode} */ + this.previousSibling_; + + /** @private {Blockly.tree.BaseNode} */ + this.nextSibling_; + + /** @private {Blockly.tree.BaseNode} */ + this.firstChild_; + + /** @private {Blockly.tree.BaseNode} */ + this.lastChild_; + + /** + * Whether the tree item is selected. + * @private {boolean} + */ + this.selected_ = false; + + /** + * Whether the tree node is expanded. + * @private {boolean} + */ + this.expanded_ = false; + + /** + * Tooltip for the tree item + * @private {?string} + */ + this.toolTip_ = null; + + /** + * Whether to allow user to collapse this node. + * @private {boolean} + */ + this.isUserCollapsible_ = true; + + /** + * Nesting depth of this node; cached result of computeDepth_. + * -1 if value has not been cached. + * @private {number} + */ + this.depth_ = -1; +}; +goog.inherits(Blockly.tree.BaseNode, Blockly.Component); + + +/** + * The config type for the tree. + * @typedef {{ + * indentWidth:number, + * cssRoot:string, + * cssHideRoot:string, + * cssItem:string, + * cssChildren:string, + * cssChildrenNoLines:string, + * cssTreeRow:string, + * cssItemLabel:string, + * cssTreeIcon:string, + * cssExpandTreeIcon:string, + * cssExpandTreeIconPlus:string, + * cssExpandTreeIconMinus:string, + * cssExpandTreeIconTPlus:string, + * cssExpandTreeIconTMinus:string, + * cssExpandTreeIconLPlus:string, + * cssExpandTreeIconLMinus:string, + * cssExpandTreeIconT:string, + * cssExpandTreeIconL:string, + * cssExpandTreeIconBlank:string, + * cssExpandedFolderIcon:string, + * cssCollapsedFolderIcon:string, + * cssFileIcon:string, + * cssExpandedRootIcon:string, + * cssCollapsedRootIcon: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 ei = this.getExpandIconElement(); + if (ei) { + Blockly.utils.aria.setRole(ei, + 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()); + } + } + } + } +}; + +/** + * Adds a node as a child to the current node. + * @param {Blockly.tree.BaseNode} child The child to add. + * @param {Blockly.tree.BaseNode=} opt_before If specified, the new child is + * added as a child before this one. If not specified, it's appended to the + * end. + * @return {!Blockly.tree.BaseNode} The added child. + * @package + */ +Blockly.tree.BaseNode.prototype.add = function(child, opt_before) { + if (child.getParent()) { + child.getParent().removeChild(child); + } + this.addChildAt( + child, opt_before ? this.indexOfChild(opt_before) : this.getChildCount()); + return child; +}; + +/** + * 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); + } +}; + +/** + * 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()); +}; + +/** + * Expands the node. + * @protected + */ +Blockly.tree.BaseNode.prototype.expand = function() { + this.setExpanded(true); +}; +/** + * Collapses the node. + * @protected + */ +Blockly.tree.BaseNode.prototype.collapse = function() { + this.setExpanded(false); +}; + +/** + * Collapses the children of the node. + * @protected + */ +Blockly.tree.BaseNode.prototype.collapseChildren = function() { + this.forEachChild(function(child) { child.collapseAll(); }); +}; + +/** + * Collapses the children and the node. + * @protected + */ +Blockly.tree.BaseNode.prototype.collapseAll = function() { + this.collapseChildren(); + this.collapse(); +}; + +/** + * Expands the children of the node. + * @protected + */ +Blockly.tree.BaseNode.prototype.expandChildren = function() { + this.forEachChild(function(child) { child.expandAll(); }); +}; + +/** + * Expands the children and the node. + * @protected + */ +Blockly.tree.BaseNode.prototype.expandAll = function() { + this.expandChildren(); + this.expand(); +}; + +/** + * Expands the parent chain of this node so that it is visible. + * @protected + */ +Blockly.tree.BaseNode.prototype.reveal = function() { + var parent = this.getParent(); + if (parent) { + parent.setExpanded(true); + parent.reveal(); + } +}; + +/** + * Sets whether the node will allow the user to collapse it. + * @param {boolean} isCollapsible Whether to allow node collapse. + * @protected + */ +Blockly.tree.BaseNode.prototype.setIsUserCollapsible = function(isCollapsible) { + this.isUserCollapsible_ = isCollapsible; + if (!this.isUserCollapsible_) { + this.expand(); + } + if (this.getElement()) { + this.updateExpandIcon(); + } +}; + +/** + * @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 childClass = this.config_.cssChildrenNoLines; + + var nonEmptyAndExpanded = this.getExpanded() && this.hasChildren(); + + var children = document.createElement('div'); + children.setAttribute('class', childClass || ''); + children.setAttribute('style', this.getLineStyle()); + + if (nonEmptyAndExpanded) { + // children + this.forEachChild(function(child) { children.appendChild(child.toDom()); }); + } + + var node = document.createElement('div'); + node.setAttribute('class', this.config_.cssItem || ''); + node.setAttribute('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 style = 'padding-' + (this.isRightToLeft() ? 'right' : 'left') + ':' + + this.getPixelIndent_() + 'px'; + + var row = document.createElement('div'); + row.setAttribute('class', this.getRowClassName()); + row.setAttribute('style', style); + + row.appendChild(this.getExpandIconDom()); + 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.setAttribute('class', this.config_.cssItemLabel || ''); + label.setAttribute('title', this.getToolTip() || ''); + 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.setAttribute('style', 'display: inline-block;'); + icon.setAttribute('class', this.getCalculatedIconClass()); + return icon; +}; + +/** + * Gets the calculated icon class. + * @protected + */ +Blockly.tree.BaseNode.prototype.getCalculatedIconClass = function() { + throw new Error('unimplemented abstract method'); +}; + +/** + * @return {!Element} The source for the icon. + * @protected + */ +Blockly.tree.BaseNode.prototype.getExpandIconDom = function() { + return document.createElement('span'); +}; + +/** + * @return {string} The class names of the icon used for expanding the node. + * @protected + */ +Blockly.tree.BaseNode.prototype.getExpandIconClass = function() { + var config = this.config_; + var sb = ''; + sb += config.cssTreeIcon + ' ' + config.cssExpandTreeIcon + ' '; + + if (this.hasChildren()) { + var bits = 0; + /* + Bitmap used to determine which icon to use + 1 Plus + 2 Minus + 4 T Line + 8 L Line + */ + + switch (bits) { + case 1: + sb += config.cssExpandTreeIconPlus; + break; + case 2: + sb += config.cssExpandTreeIconMinus; + break; + case 4: + sb += config.cssExpandTreeIconL; + break; + case 5: + sb += config.cssExpandTreeIconLPlus; + break; + case 6: + sb += config.cssExpandTreeIconLMinus; + break; + case 8: + sb += config.cssExpandTreeIconT; + break; + case 9: + sb += config.cssExpandTreeIconTPlus; + break; + case 10: + sb += config.cssExpandTreeIconTMinus; + break; + default: // 0 + sb += config.cssExpandTreeIconBlank; + } + } else { + sb += config.cssExpandTreeIconBlank; + } + return sb; +}; + +/** + * @return {string} The line style. + * @protected + */ +Blockly.tree.BaseNode.prototype.getLineStyle = function() { + var nonEmptyAndExpanded = this.getExpanded() && this.hasChildren(); + return 'background-position: ' + this.getBackgroundPosition() + '; ' + + (nonEmptyAndExpanded ? '' : 'display: none'); +}; + +/** + * @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. + * @protected + */ +Blockly.tree.BaseNode.prototype.getRowElement = function() { + var el = this.getElement(); + return el ? /** @type {Element} */ (el.firstChild) : null; +}; + +/** + * @return {Element} The expanded icon element. + * @protected + */ +Blockly.tree.BaseNode.prototype.getExpandIconElement = function() { + var el = this.getRowElement(); + 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.childNodes[1]) : 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 element after the label. + * @protected + */ +Blockly.tree.BaseNode.prototype.getAfterLabelElement = function() { + var el = this.getRowElement(); + return el ? /** @type {Element} */ (el.lastChild) : 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; +}; + +/** + * Sets the icon class for the node. + * @param {string} s The icon class. + * @protected + */ +Blockly.tree.BaseNode.prototype.setIconClass = function(s) { + this.iconClass_ = s; + if (this.isInDocument()) { + this.updateIcon_(); + } +}; + +/** + * Gets the icon class for the node. + * @return {string} s The icon source. + * @protected + */ +Blockly.tree.BaseNode.prototype.getIconClass = function() { + return this.iconClass_; +}; + +/** + * Sets the icon class for when the node is expanded. + * @param {string} s The expanded icon class. + * @protected + */ +Blockly.tree.BaseNode.prototype.setExpandedIconClass = function(s) { + this.expandedIconClass_ = s; + if (this.isInDocument()) { + this.updateIcon_(); + } +}; + +/** + * 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_; +}; + +/** + * Sets the text of the tooltip. + * @param {string} s The tooltip text to set. + * @protected + */ +Blockly.tree.BaseNode.prototype.setToolTip = function(s) { + this.toolTip_ = s; + var el = this.getLabelElement(); + if (el) { + el.title = s; + } +}; + +/** + * Returns the text of the tooltip. + * @return {?string} The tooltip text. + * @protected + */ +Blockly.tree.BaseNode.prototype.getToolTip = function() { + return this.toolTip_; +}; + +/** + * 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 img = this.getExpandIconElement(); + if (img) { + img.className = this.getExpandIconClass(); + } + 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 double click event. + * @param {!Event} e The browser event. + * @protected + */ +Blockly.tree.BaseNode.prototype.onDoubleClick_ = function(e) { + var el = e.target; + // expand icon + var type = el.getAttribute('type'); + if (type == 'expand' && this.hasChildren()) { + return; + } + + if (this.isUserCollapsible_) { + this.toggle(); + } +}; + +/** + * 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; + } + if (this.hasChildren()) { + if (!this.getExpanded()) { + this.setExpanded(true); + } else { + this.getFirstChild().select(); + } + } + break; + + case Blockly.utils.KeyCodes.LEFT: + if (e.altKey) { + break; + } + 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(); + } + } + break; + + case Blockly.utils.KeyCodes.DOWN: + var nextNode = this.getNextShownNode(); + if (nextNode) { + nextNode.select(); + } + break; + + case Blockly.utils.KeyCodes.UP: + var previousNode = this.getPreviousShownNode(); + if (previousNode) { + previousNode.select(); + } + break; + + default: + handled = false; + } + + if (handled) { + e.preventDefault(); + } + + return handled; +}; + +/** + * @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(); + // 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); }); + } +}; diff --git a/core/components/tree/treecontrol.js b/core/components/tree/treecontrol.js new file mode 100644 index 000000000..c8766a8cc --- /dev/null +++ b/core/components/tree/treecontrol.js @@ -0,0 +1,443 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 Google Inc. + * https://developers.google.com/blockly/ + * + * 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.TreeControl class. + * This class is similar to Closure's goog.ui.tree.TreeControl class. + * @author samelh@google.com (Sam El-Husseini) + */ +'use strict'; + +goog.provide('Blockly.tree.TreeControl'); + +goog.require('Blockly.tree.TreeNode'); +goog.require('Blockly.tree.BaseNode'); +goog.require('Blockly.utils.aria'); + + +/** + * An extension of the TreeControl object in closure that provides + * a way to view a hierarchical set of data. + * Similar to Closure's goog.ui.tree.TreeControl + * + * @param {Blockly.Toolbox} toolbox The parent toolbox for this tree. + * @param {Blockly.tree.BaseNode.Config} config The configuration for the tree. + * @constructor + * @extends {Blockly.tree.BaseNode} + */ +Blockly.tree.TreeControl = function(toolbox, config) { + this.toolbox_ = toolbox; + + Blockly.tree.BaseNode.call(this, '', config); + + // The root is open and selected by default. + this.setExpandedInternal(true); + this.setSelectedInternal(true); + + /** + * Currenty selected item. + * @private {Blockly.tree.BaseNode} + */ + this.selectedItem_ = this; +}; +goog.inherits(Blockly.tree.TreeControl, Blockly.tree.BaseNode); + +/** + * Returns the tree. + * @override + */ +Blockly.tree.TreeControl.prototype.getTree = function() { + return this; +}; + +/** + * Returns the assosiated toolbox. + * @return {Blockly.Toolbox} The toolbox. + * @package + */ +Blockly.tree.TreeControl.prototype.getToolbox = function() { + return this.toolbox_; +}; + +/** + * Return node depth. + * @override + */ +Blockly.tree.TreeControl.prototype.getDepth = function() { + return 0; +}; + +/** + * Expands the parent chain of this node so that it is visible. + * @override + */ +Blockly.tree.TreeControl.prototype.reveal = function() { + // always expanded by default + // needs to be overriden so that we don't try to reveal our parent + // which is a generic component +}; + +/** + * Handles focus on the tree. + * @param {!Event} _e The browser event. + * @private + */ +Blockly.tree.TreeControl.prototype.handleFocus_ = function(_e) { + this.focused_ = true; + var el = /** @type {!Element} */ (this.getElement()); + Blockly.utils.dom.addClass(el, 'focused'); + + if (this.selectedItem_) { + this.selectedItem_.select(); + } +}; + +/** + * Handles blur on the tree. + * @param {!Event} _e The browser event. + * @private + */ +Blockly.tree.TreeControl.prototype.handleBlur_ = function(_e) { + this.focused_ = false; + var el = /** @type {!Element} */ (this.getElement()); + Blockly.utils.dom.removeClass(el, 'focused'); +}; + +/** + * Get whether this tree has focus or not. + * @return {boolean} True if it has focus. + * @package + */ +Blockly.tree.TreeControl.prototype.hasFocus = function() { + return this.focused_; +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.getExpanded = function() { + return true; +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.setExpanded = function(expanded) { + this.setExpandedInternal(expanded); +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.getIconElement = function() { + var el = this.getRowElement(); + return el ? /** @type {Element} */ (el.firstChild) : null; +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.getExpandIconElement = function() { + // no expand icon for root element + return null; +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.updateExpandIcon = function() { + // no expand icon +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.getRowClassName = function() { + return Blockly.tree.TreeControl.superClass_.getRowClassName.call(this) + + ' ' + this.getConfig().cssHideRoot; +}; + +/** + * Returns the source for the icon. + * @return {string} Src for the icon. + * @override + */ +Blockly.tree.TreeControl.prototype.getCalculatedIconClass = function() { + var expanded = this.getExpanded(); + var expandedIconClass = this.getExpandedIconClass(); + if (expanded && expandedIconClass) { + return expandedIconClass; + } + var iconClass = this.getIconClass(); + if (!expanded && iconClass) { + return iconClass; + } + + // fall back on default icons + var config = this.getConfig(); + if (expanded && config.cssExpandedRootIcon) { + return config.cssTreeIcon + ' ' + config.cssExpandedRootIcon; + } else if (!expanded && config.cssCollapsedRootIcon) { + return config.cssTreeIcon + ' ' + config.cssCollapsedRootIcon; + } + return ''; +}; + +/** + * Sets the selected item. + * @param {Blockly.tree.BaseNode} node The item to select. + * @package + */ +Blockly.tree.TreeControl.prototype.setSelectedItem = function(node) { + if (node == this.selectedItem_) { + return; + } + + if (this.onBeforeSelected_ && + !this.onBeforeSelected_.call(this.toolbox_, node)) { + return; + } + + var oldNode = this.getSelectedItem(); + + if (this.selectedItem_) { + this.selectedItem_.setSelectedInternal(false); + } + + this.selectedItem_ = node; + + if (node) { + node.setSelectedInternal(true); + } + + if (this.onAfterSelected_) { + this.onAfterSelected_.call(this.toolbox_, oldNode, node); + } +}; + +/** + * Set the handler that's triggered before a node is selected. + * @param {function(Blockly.tree.BaseNode):boolean} fn The handler + * @package + */ +Blockly.tree.TreeControl.prototype.onBeforeSelected = function(fn) { + this.onBeforeSelected_ = fn; +}; + +/** + * Set the handler that's triggered after a node is selected. + * @param {function( + * Blockly.tree.BaseNode, Blockly.tree.BaseNode):?} fn The handler + * @package + */ +Blockly.tree.TreeControl.prototype.onAfterSelected = function(fn) { + this.onAfterSelected_ = fn; +}; + +/** + * Returns the selected item. + * @return {Blockly.tree.BaseNode} The currently selected item. + * @package + */ +Blockly.tree.TreeControl.prototype.getSelectedItem = function() { + return this.selectedItem_; +}; + +/** + * Updates the lines after the tree has been drawn. + * @private + */ +Blockly.tree.TreeControl.prototype.updateLinesAndExpandIcons_ = function() { + var tree = this; + var showLines = false; + var showRootLines = false; + + /** + * Recursively walk through all nodes and update the class names of the + * expand icon and the children element. + * @param {!Blockly.tree.BaseNode} node tree node + */ + function updateShowLines(node) { + var childrenEl = node.getChildrenElement(); + if (childrenEl) { + var hideLines = !showLines || tree == node.getParent() && !showRootLines; + var childClass = hideLines ? node.getConfig().cssChildrenNoLines : + node.getConfig().cssChildren; + childrenEl.className = childClass; + + var expandIconEl = node.getExpandIconElement(); + if (expandIconEl) { + expandIconEl.className = node.getExpandIconClass(); + } + } + node.forEachChild(updateShowLines); + } + updateShowLines(this); +}; + +/** + * Add roles and states. + * @protected + * @override + */ +Blockly.tree.TreeControl.prototype.initAccessibility = function() { + Blockly.tree.TreeControl.superClass_.initAccessibility.call(this); + + var el = /** @type {!Element} */ (this.getElement()); + Blockly.utils.aria.setRole(el, Blockly.utils.aria.Role.TREE); + Blockly.utils.aria.setState(el, + Blockly.utils.aria.State.LABELLEDBY, this.getLabelElement().id); +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.enterDocument = function() { + Blockly.tree.TreeControl.superClass_.enterDocument.call(this); + var el = this.getElement(); + el.className = this.getConfig().cssRoot; + el.setAttribute('hideFocus', 'true'); + this.attachEvents_(); + this.initAccessibility(); +}; + +/** @override */ +Blockly.tree.TreeControl.prototype.exitDocument = function() { + Blockly.tree.TreeControl.superClass_.exitDocument.call(this); + this.detachEvents_(); +}; + +/** + * Adds the event listeners to the tree. + * @private + */ +Blockly.tree.TreeControl.prototype.attachEvents_ = function() { + var el = this.getElement(); + el.tabIndex = 0; + + this.onFocusWrapper_ = Blockly.bindEvent_(el, + 'focus', this, this.handleFocus_); + this.onBlurWrapper_ = Blockly.bindEvent_(el, + 'blur', this, this.handleBlur_); + + this.onMousedownWrapper_ = Blockly.bindEventWithChecks_(el, + 'mousedown', this, this.handleMouseEvent_); + this.onClickWrapper_ = Blockly.bindEventWithChecks_(el, + 'click', this, this.handleMouseEvent_); + + this.onKeydownWrapper_ = Blockly.bindEvent_(el, + 'keydown', this, this.handleKeyEvent_); + + if (Blockly.Touch.TOUCH_ENABLED) { + this.onTouchEndWrapper_ = Blockly.bindEventWithChecks_(el, + 'touchend', this, this.handleTouchEvent_); + } +}; + +/** + * Removes the event listeners from the tree. + * @private + */ +Blockly.tree.TreeControl.prototype.detachEvents_ = function() { + Blockly.unbindEvent_(this.onFocusWrapper_); + Blockly.unbindEvent_(this.onBlurWrapper_); + Blockly.unbindEvent_(this.onMousedownWrapper_); + Blockly.unbindEvent_(this.onClickWrapper_); + Blockly.unbindEvent_(this.onKeydownWrapper_); + if (this.onTouchEndWrapper_) { + Blockly.unbindEvent_(this.onTouchEndWrapper_); + } +}; + +/** + * Handles mouse events. + * @param {!Event} e The browser event. + * @private + */ +Blockly.tree.TreeControl.prototype.handleMouseEvent_ = function(e) { + var node = this.getNodeFromEvent_(e); + if (node) { + switch (e.type) { + case 'mousedown': + node.onMouseDown(e); + break; + case 'click': + node.onClick_(e); + break; + } + } +}; + +/** + * Handles touch events. + * @param {!Event} e The browser event. + * @private + */ +Blockly.tree.TreeControl.prototype.handleTouchEvent_ = function(e) { + var node = this.getNodeFromEvent_(e); + if (node && e.type === 'touchend') { + // Fire asynchronously since onMouseDown takes long enough that the browser + // would fire the default mouse event before this method returns. + setTimeout(function() { + node.onClick_(e); // Same behaviour for click and touch. + }, 1); + } +}; + +/** + * Handles key down on the tree. + * @param {!Event} e The browser event. + * @return {boolean} The handled value. + * @private + */ +Blockly.tree.TreeControl.prototype.handleKeyEvent_ = function(e) { + var handled = false; + + // Handle navigation keystrokes. + handled = (this.selectedItem_ && this.selectedItem_.onKeyDown(e)) || handled; + + if (handled) { + e.preventDefault(); + } + + return handled; +}; + +/** + * Finds the containing node given an event. + * @param {!Event} e The browser event. + * @return {Blockly.tree.BaseNode} The containing node or null if no node is + * found. + * @private + */ +Blockly.tree.TreeControl.prototype.getNodeFromEvent_ = function(e) { + // find the right node + var node = null; + var target = e.target; + while (target != null) { + var id = target.id; + node = Blockly.tree.BaseNode.allNodes[id]; + if (node) { + return node; + } + if (target == this.getElement()) { + break; + } + target = target.parentNode; + } + return null; +}; + +/** + * Creates a new tree node using the same config as the root. + * @param {string=} opt_content The content of the node label. + * @return {!Blockly.tree.TreeNode} The new item. + * @package + */ +Blockly.tree.TreeControl.prototype.createNode = function(opt_content) { + return new Blockly.tree.TreeNode( + this.toolbox_, opt_content || '', this.getConfig()); +}; diff --git a/core/components/tree/treenode.js b/core/components/tree/treenode.js new file mode 100644 index 000000000..4b3bb8e34 --- /dev/null +++ b/core/components/tree/treenode.js @@ -0,0 +1,188 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 Google Inc. + * https://developers.google.com/blockly/ + * + * 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.TreeNode class. + * This class is similar to Closure's goog.ui.tree.TreeNode class. + * @author samelh@google.com (Sam El-Husseini) + */ +'use strict'; + +goog.provide('Blockly.tree.TreeNode'); + +goog.require('Blockly.tree.BaseNode'); +goog.require('Blockly.utils.KeyCodes'); + + +/** + * A single node in the tree, customized for Blockly's UI. + * Similar to Closure's goog.ui.tree.TreeNode + * + * @param {Blockly.Toolbox} toolbox The parent toolbox for this tree. + * @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.tree.BaseNode} + */ +Blockly.tree.TreeNode = function(toolbox, content, config) { + this.toolbox_ = toolbox; + Blockly.tree.BaseNode.call(this, content, config); +}; +goog.inherits(Blockly.tree.TreeNode, Blockly.tree.BaseNode); + +/** + * Returns the tree. + * @return {?Blockly.tree.TreeControl} The tree. + * @override + */ +Blockly.tree.TreeNode.prototype.getTree = function() { + if (this.tree) { + return this.tree; + } + var parent = this.getParent(); + if (parent) { + var tree = parent.getTree(); + if (tree) { + this.setTreeInternal(tree); + return tree; + } + } + return null; +}; + +/** + * Returns the source for the icon. + * @return {string} Src for the icon. + * @override + */ +Blockly.tree.TreeNode.prototype.getCalculatedIconClass = function() { + var expanded = this.getExpanded(); + var expandedIconClass = this.getExpandedIconClass(); + if (expanded && expandedIconClass) { + return expandedIconClass; + } + var iconClass = this.getIconClass(); + if (!expanded && iconClass) { + return iconClass; + } + + // fall back on default icons + var config = this.getConfig(); + if (this.hasChildren()) { + if (expanded && config.cssExpandedFolderIcon) { + return config.cssTreeIcon + ' ' + config.cssExpandedFolderIcon; + } else if (!expanded && config.cssCollapsedFolderIcon) { + return config.cssTreeIcon + ' ' + config.cssCollapsedFolderIcon; + } + } else { + if (config.cssFileIcon) { + return config.cssTreeIcon + ' ' + config.cssFileIcon; + } + } + return ''; +}; + +/** + * Expand or collapse the node on mouse click. + * @param {!Event} _e The browser event. + * @override + */ +Blockly.tree.TreeNode.prototype.onClick_ = function(_e) { + // Expand icon. + if (this.hasChildren() && this.isUserCollapsible()) { + this.toggle(); + this.select(); + } else if (this.isSelected()) { + this.getTree().setSelectedItem(null); + } else { + this.select(); + } + this.updateRow(); +}; + +/** + * Suppress the inherited mouse down behaviour. + * @param {!Event} _e The browser event. + * @override + * @private + */ +Blockly.tree.TreeNode.prototype.onMouseDown = function(_e) { + // NOP +}; + +/** + * Remap event.keyCode in horizontalLayout so that arrow + * keys work properly and call original onKeyDown handler. + * @param {!Event} e The browser event. + * @return {boolean} The handled value. + * @override + * @private + */ +Blockly.tree.TreeNode.prototype.onKeyDown = function(e) { + if (this.tree.toolbox_.horizontalLayout_) { + var map = {}; + var next = Blockly.utils.KeyCodes.DOWN; + var prev = Blockly.utils.KeyCodes.UP; + map[Blockly.utils.KeyCodes.RIGHT] = this.isRightToLeft() ? prev : next; + map[Blockly.utils.KeyCodes.LEFT] = this.isRightToLeft() ? next : prev; + map[Blockly.utils.KeyCodes.UP] = Blockly.utils.KeyCodes.LEFT; + map[Blockly.utils.KeyCodes.DOWN] = Blockly.utils.KeyCodes.RIGHT; + + var newKeyCode = map[e.keyCode]; + Object.defineProperties(e, { + keyCode: {value: newKeyCode || e.keyCode} + }); + } + return Blockly.tree.TreeNode.superClass_.onKeyDown.call(this, e); +}; + +/** + * Set the handler that's triggered when the size of node has changed. + * @param {function():?} fn The handler + * @package + */ +Blockly.tree.TreeNode.prototype.onSizeChanged = function(fn) { + this.onSizeChanged_ = fn; +}; + +/** + * Trigger a size changed event if a handler exists. + * @private + */ +Blockly.tree.TreeNode.prototype.resizeToolbox_ = function() { + if (this.onSizeChanged_) { + this.onSizeChanged_.call(this.toolbox_); + } +}; + +/** + * Resize the toolbox when a node is expanded. + * @override + */ +Blockly.tree.TreeNode.prototype.doNodeExpanded = + Blockly.tree.TreeNode.prototype.resizeToolbox_; + +/** + * Resize the toolbox when a node is collased. + * @override + */ +Blockly.tree.TreeNode.prototype.doNodeCollapsed = + Blockly.tree.TreeNode.prototype.resizeToolbox_;