/** * @license * Visual Blocks Editor * * Copyright 2011 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 Toolbox from whence to create blocks. * @author fraser@google.com (Neil Fraser) */ 'use strict'; goog.provide('Blockly.Toolbox'); goog.require('Blockly.Flyout'); goog.require('goog.dom'); goog.require('goog.dom.TagName'); goog.require('goog.events'); goog.require('goog.events.BrowserFeature'); goog.require('goog.html.SafeHtml'); goog.require('goog.html.SafeStyle'); goog.require('goog.math.Rect'); goog.require('goog.style'); goog.require('goog.ui.tree.TreeControl'); goog.require('goog.ui.tree.TreeNode'); /** * Class for a Toolbox. * Creates the toolbox's DOM. * @param {!Blockly.Workspace} workspace The workspace in which to create new * blocks. * @constructor */ Blockly.Toolbox = function(workspace) { /** * @type {!Blockly.Workspace} * @private */ this.workspace_ = workspace; /** * Is RTL vs LTR. * @type {boolean} */ this.RTL = workspace.options.RTL; /** * Whether the toolbox should be laid out horizontally. * @type {boolean} * @private */ this.horizontalLayout_ = workspace.options.horizontalLayout; /** * Position of the toolbox and flyout relative to the workspace. * @type {number} */ this.toolboxPosition = workspace.options.toolboxPosition; /** * Configuration constants for Closure's tree UI. * @type {Object.} * @private */ this.config_ = { indentWidth: 19, cssRoot: 'blocklyTreeRoot', cssHideRoot: 'blocklyHidden', cssItem: '', cssTreeRow: 'blocklyTreeRow', cssItemLabel: 'blocklyTreeLabel', cssTreeIcon: 'blocklyTreeIcon', cssExpandedFolderIcon: 'blocklyTreeIconOpen', cssFileIcon: 'blocklyTreeIconNone', cssSelectedRow: 'blocklyTreeSelected' }; /** * Configuration constants for tree separator. * @type {Object.} * @private */ this.treeSeparatorConfig_ = { cssTreeRow: 'blocklyTreeSeparator' }; if (this.horizontalLayout_) { this.config_['cssTreeRow'] = this.config_['cssTreeRow'] + (workspace.RTL ? ' blocklyHorizontalTreeRtl' : ' blocklyHorizontalTree'); this.treeSeparatorConfig_['cssTreeRow'] = 'blocklyTreeSeparatorHorizontal ' + (workspace.RTL ? 'blocklyHorizontalTreeRtl' : 'blocklyHorizontalTree'); this.config_['cssTreeIcon'] = ''; } }; /** * Width of the toolbox, which changes only in vertical layout. * @type {number} */ Blockly.Toolbox.prototype.width = 0; /** * Height of the toolbox, which changes only in horizontal layout. * @type {number} */ Blockly.Toolbox.prototype.height = 0; /** * The SVG group currently selected. * @type {SVGGElement} * @private */ Blockly.Toolbox.prototype.selectedOption_ = null; /** * The tree node most recently selected. * @type {goog.ui.tree.BaseNode} * @private */ Blockly.Toolbox.prototype.lastCategory_ = null; /** * Initializes the toolbox. */ Blockly.Toolbox.prototype.init = function() { var workspace = this.workspace_; var svg = this.workspace_.getParentSvg(); // Create an HTML container for the Toolbox menu. this.HtmlDiv = goog.dom.createDom(goog.dom.TagName.DIV, 'blocklyToolboxDiv'); this.HtmlDiv.setAttribute('dir', workspace.RTL ? 'RTL' : 'LTR'); svg.parentNode.insertBefore(this.HtmlDiv, svg); // Clicking on toolbox closes popups. Blockly.bindEvent_(this.HtmlDiv, 'mousedown', this, function(e) { if (Blockly.isRightButton(e) || e.target == this.HtmlDiv) { // Close flyout. Blockly.hideChaff(false); } else { // Just close popups. Blockly.hideChaff(true); } }); var workspaceOptions = { disabledPatternId: workspace.options.disabledPatternId, parentWorkspace: workspace, RTL: workspace.RTL, horizontalLayout: workspace.horizontalLayout, toolboxPosition: workspace.options.toolboxPosition }; /** * @type {!Blockly.Flyout} * @private */ this.flyout_ = new Blockly.Flyout(workspaceOptions); goog.dom.insertSiblingAfter(this.flyout_.createDom(), workspace.svgGroup_); this.flyout_.init(workspace); this.config_['cleardotPath'] = workspace.options.pathToMedia + '1x1.gif'; this.config_['cssCollapsedFolderIcon'] = 'blocklyTreeIconClosed' + (workspace.RTL ? 'Rtl' : 'Ltr'); var tree = new Blockly.Toolbox.TreeControl(this, this.config_); this.tree_ = tree; tree.setShowRootNode(false); tree.setShowLines(false); tree.setShowExpandIcons(false); tree.setSelectedItem(null); var openNode = this.populate_(workspace.options.languageTree); tree.render(this.HtmlDiv); if (openNode) { tree.setSelectedItem(openNode); } this.addColour_(); this.position(); }; /** * Dispose of this toolbox. */ Blockly.Toolbox.prototype.dispose = function() { this.flyout_.dispose(); this.tree_.dispose(); goog.dom.removeNode(this.HtmlDiv); this.workspace_ = null; this.lastCategory_ = null; }; /** * Get the width of the toolbox. * @return {number} The width of the toolbox. */ Blockly.Toolbox.prototype.getWidth = function() { return this.width; }; /** * Get the height of the toolbox. * @return {number} The width of the toolbox. */ Blockly.Toolbox.prototype.getHeight = function() { return this.height; }; /** * Move the toolbox to the edge. */ Blockly.Toolbox.prototype.position = function() { var treeDiv = this.HtmlDiv; if (!treeDiv) { // Not initialized yet. return; } var svg = this.workspace_.getParentSvg(); var svgPosition = goog.style.getPageOffset(svg); var svgSize = Blockly.svgSize(svg); if (this.horizontalLayout_) { treeDiv.style.left = '0'; treeDiv.style.height = 'auto'; treeDiv.style.width = svgSize.width + 'px'; this.height = treeDiv.offsetHeight; if (this.toolboxPosition == Blockly.TOOLBOX_AT_TOP) { // Top treeDiv.style.top = '0'; } else { // Bottom treeDiv.style.bottom = '0'; } } else { if (this.toolboxPosition == Blockly.TOOLBOX_AT_RIGHT) { // Right treeDiv.style.right = '0'; } else { // Left treeDiv.style.left = '0'; } treeDiv.style.height = svgSize.height + 'px'; this.width = treeDiv.offsetWidth; } this.flyout_.position(); }; /** * Fill the toolbox with categories and blocks. * @param {!Node} newTree DOM tree of blocks. * @return {Node} Tree node to open at startup (or null). * @private */ Blockly.Toolbox.prototype.populate_ = function(newTree) { this.tree_.removeChildren(); // Delete any existing content. this.tree_.blocks = []; this.hasColours_ = false; var openNode = this.syncTrees_(newTree, this.tree_, this.workspace_.options.pathToMedia); if (this.tree_.blocks.length) { throw 'Toolbox cannot have both blocks and categories in the root level.'; } // Fire a resize event since the toolbox may have changed width and height. this.workspace_.resizeContents(); return openNode; }; /** * Sync trees of the toolbox. * @param {!Node} treeIn DOM tree of blocks. * @param {!Blockly.Toolbox.TreeControl} treeOut * @param {string} pathToMedia * @return {Node} Tree node to open at startup (or null). * @private */ Blockly.Toolbox.prototype.syncTrees_ = function(treeIn, treeOut, pathToMedia) { var openNode = null; var lastElement = null; for (var i = 0, childIn; childIn = treeIn.childNodes[i]; i++) { if (!childIn.tagName) { // Skip over text. continue; } switch (childIn.tagName.toUpperCase()) { case 'CATEGORY': var childOut = this.tree_.createNode(childIn.getAttribute('name')); childOut.blocks = []; treeOut.add(childOut); var custom = childIn.getAttribute('custom'); if (custom) { // Variables and procedures are special dynamic categories. childOut.blocks = custom; } else { var newOpenNode = this.syncTrees_(childIn, childOut, pathToMedia); if (newOpenNode) { openNode = newOpenNode; } } var colour = childIn.getAttribute('colour'); if (goog.isString(colour)) { if (colour.match(/^#[0-9a-fA-F]{6}$/)) { childOut.hexColour = colour; } else { childOut.hexColour = Blockly.hueToRgb(colour); } this.hasColours_ = true; } else { childOut.hexColour = ''; } if (childIn.getAttribute('expanded') == 'true') { if (childOut.blocks.length) { // This is a category that directly contians blocks. // After the tree is rendered, open this category and show flyout. openNode = childOut; } childOut.setExpanded(true); } else { childOut.setExpanded(false); } lastElement = childIn; break; case 'SEP': if (lastElement) { if (lastElement.tagName.toUpperCase() == 'CATEGORY') { // Separator between two categories. // treeOut.add(new Blockly.Toolbox.TreeSeparator( this.treeSeparatorConfig_)); } else { // Change the gap between two blocks. // // The default gap is 24, can be set larger or smaller. // Note that a deprecated method is to add a gap to a block. // var newGap = parseFloat(childIn.getAttribute('gap')); if (!isNaN(newGap) && lastElement) { lastElement.setAttribute('gap', newGap); } } } break; case 'BLOCK': case 'SHADOW': treeOut.blocks.push(childIn); lastElement = childIn; break; } } return openNode; }; /** * Recursively add colours to this toolbox. * @param {Blockly.Toolbox.TreeNode} opt_tree Starting point of tree. * Defaults to the root node. * @private */ Blockly.Toolbox.prototype.addColour_ = function(opt_tree) { var tree = opt_tree || this.tree_; var children = tree.getChildren(); for (var i = 0, child; child = children[i]; i++) { var element = child.getRowElement(); if (element) { if (this.hasColours_) { var border = '8px solid ' + (child.hexColour || '#ddd'); } else { var border = 'none'; } if (this.workspace_.RTL) { element.style.borderRight = border; } else { element.style.borderLeft = border; } } this.addColour_(child); } }; /** * Unhighlight any previously specified option. */ Blockly.Toolbox.prototype.clearSelection = function() { this.tree_.setSelectedItem(null); }; /** * Return the deletion rectangle for this toolbox. * @return {goog.math.Rect} Rectangle in which to delete. */ Blockly.Toolbox.prototype.getClientRect = function() { if (!this.HtmlDiv) { return null; } // BIG_NUM is offscreen padding so that blocks dragged beyond the toolbox // area are still deleted. Must be smaller than Infinity, but larger than // the largest screen size. var BIG_NUM = 10000000; var toolboxRect = this.HtmlDiv.getBoundingClientRect(); var x = toolboxRect.left; var y = toolboxRect.top; var width = toolboxRect.width; var height = toolboxRect.height; // Assumes that the toolbox is on the SVG edge. If this changes // (e.g. toolboxes in mutators) then this code will need to be more complex. if (this.toolboxPosition == Blockly.TOOLBOX_AT_LEFT) { return new goog.math.Rect(-BIG_NUM, -BIG_NUM, BIG_NUM + x + width, 2 * BIG_NUM); } else if (this.toolboxPosition == Blockly.TOOLBOX_AT_RIGHT) { return new goog.math.Rect(x, -BIG_NUM, BIG_NUM + width, 2 * BIG_NUM); } else if (this.toolboxPosition == Blockly.TOOLBOX_AT_TOP) { return new goog.math.Rect(-BIG_NUM, -BIG_NUM, 2 * BIG_NUM, BIG_NUM + y + height); } else { // Bottom return new goog.math.Rect(0, y, 2 * BIG_NUM, BIG_NUM + width); } }; /** * Update the flyout's contents without closing it. Should be used in response * to a change in one of the dynamic categories, such as variables or * procedures. */ Blockly.Toolbox.prototype.refreshSelection = function() { var selectedItem = this.tree_.getSelectedItem(); if (selectedItem && selectedItem.blocks) { this.flyout_.show(selectedItem.blocks); } }; // Extending Closure's Tree UI. /** * Extention of a TreeControl object that uses a custom tree node. * @param {Blockly.Toolbox} toolbox The parent toolbox for this tree. * @param {Object} config The configuration for the tree. See * goog.ui.tree.TreeControl.DefaultConfig. * @constructor * @extends {goog.ui.tree.TreeControl} */ Blockly.Toolbox.TreeControl = function(toolbox, config) { this.toolbox_ = toolbox; goog.ui.tree.TreeControl.call(this, goog.html.SafeHtml.EMPTY, config); }; goog.inherits(Blockly.Toolbox.TreeControl, goog.ui.tree.TreeControl); /** * Adds touch handling to TreeControl. * @override */ Blockly.Toolbox.TreeControl.prototype.enterDocument = function() { Blockly.Toolbox.TreeControl.superClass_.enterDocument.call(this); // Add touch handler. if (goog.events.BrowserFeature.TOUCH_ENABLED) { var el = this.getElement(); Blockly.bindEvent_(el, goog.events.EventType.TOUCHSTART, this, this.handleTouchEvent_); } }; /** * Handles touch events. * @param {!goog.events.BrowserEvent} e The browser event. * @private */ Blockly.Toolbox.TreeControl.prototype.handleTouchEvent_ = function(e) { e.preventDefault(); var node = this.getNodeFromEvent_(e); if (node && e.type === goog.events.EventType.TOUCHSTART) { // Fire asynchronously since onMouseDown takes long enough that the browser // would fire the default mouse event before this method returns. setTimeout(function() { node.onMouseDown(e); // Same behaviour for click and touch. }, 1); } }; /** * Creates a new tree node using a custom tree node. * @param {string=} opt_html The HTML content of the node label. * @return {!goog.ui.tree.TreeNode} The new item. * @override */ Blockly.Toolbox.TreeControl.prototype.createNode = function(opt_html) { return new Blockly.Toolbox.TreeNode(this.toolbox_, opt_html ? goog.html.SafeHtml.htmlEscape(opt_html) : goog.html.SafeHtml.EMPTY, this.getConfig(), this.getDomHelper()); }; /** * Display/hide the flyout when an item is selected. * @param {goog.ui.tree.BaseNode} node The item to select. * @override */ Blockly.Toolbox.TreeControl.prototype.setSelectedItem = function(node) { var toolbox = this.toolbox_; if (node == this.selectedItem_ || node == toolbox.tree_) { return; } if (toolbox.lastCategory_) { toolbox.lastCategory_.getRowElement().style.backgroundColor = ''; } if (node) { var hexColour = node.hexColour || '#57e'; node.getRowElement().style.backgroundColor = hexColour; // Add colours to child nodes which may have been collapsed and thus // not rendered. toolbox.addColour_(node); } var oldNode = this.getSelectedItem(); goog.ui.tree.TreeControl.prototype.setSelectedItem.call(this, node); if (node && node.blocks && node.blocks.length) { toolbox.flyout_.show(node.blocks); // Scroll the flyout to the top if the category has changed. if (toolbox.lastCategory_ != node) { toolbox.flyout_.scrollToStart(); } } else { // Hide the flyout. toolbox.flyout_.hide(); } if (oldNode != node && oldNode != this) { var event = new Blockly.Events.Ui(null, 'category', oldNode && oldNode.getHtml(), node && node.getHtml()); event.workspaceId = toolbox.workspace_.id; Blockly.Events.fire(event); } if (node) { toolbox.lastCategory_ = node; } }; /** * A single node in the tree, customized for Blockly's UI. * @param {Blockly.Toolbox} toolbox The parent toolbox for this tree. * @param {!goog.html.SafeHtml} html The HTML content of the node label. * @param {Object=} opt_config The configuration for the tree. See * goog.ui.tree.TreeControl.DefaultConfig. If not specified, a default config * will be used. * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper. * @constructor * @extends {goog.ui.tree.TreeNode} */ Blockly.Toolbox.TreeNode = function(toolbox, html, opt_config, opt_domHelper) { goog.ui.tree.TreeNode.call(this, html, opt_config, opt_domHelper); if (toolbox) { this.horizontalLayout_ = toolbox.horizontalLayout_; var resize = function() { // Even though the div hasn't changed size, the visible workspace // surface of the workspace has, so we may need to reposition everything. Blockly.svgResize(toolbox.workspace_); }; // Fire a resize event since the toolbox may have changed width. goog.events.listen(toolbox.tree_, goog.ui.tree.BaseNode.EventType.EXPAND, resize); goog.events.listen(toolbox.tree_, goog.ui.tree.BaseNode.EventType.COLLAPSE, resize); } }; goog.inherits(Blockly.Toolbox.TreeNode, goog.ui.tree.TreeNode); /** * Supress population of the +/- icon. * @return {!goog.html.SafeHtml} The source for the icon. * @override */ Blockly.Toolbox.TreeNode.prototype.getExpandIconSafeHtml = function() { return goog.html.SafeHtml.create('span'); }; /** * Expand or collapse the node on mouse click. * @param {!goog.events.BrowserEvent} e The browser event. * @override */ Blockly.Toolbox.TreeNode.prototype.onMouseDown = 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(); }; /** * Supress the inherited double-click behaviour. * @param {!goog.events.BrowserEvent} e The browser event. * @override * @private */ Blockly.Toolbox.TreeNode.prototype.onDoubleClick_ = function(e) { // NOP. }; /** * Remap event.keyCode in horizontalLayout so that arrow * keys work properly and call original onKeyDown handler. * @param {!goog.events.BrowserEvent} e The browser event. * @return {boolean} The handled value. * @override * @private */ Blockly.Toolbox.TreeNode.prototype.onKeyDown = function(e) { if (this.horizontalLayout_) { var map = {}; map[goog.events.KeyCodes.RIGHT] = goog.events.KeyCodes.DOWN; map[goog.events.KeyCodes.LEFT] = goog.events.KeyCodes.UP; map[goog.events.KeyCodes.UP] = goog.events.KeyCodes.LEFT; map[goog.events.KeyCodes.DOWN] = goog.events.KeyCodes.RIGHT; var newKeyCode = map[e.keyCode]; e.keyCode = newKeyCode || e.keyCode; } return Blockly.Toolbox.TreeNode.superClass_.onKeyDown.call(this, e); }; /** * A blank separator node in the tree. * @param {Object=} config The configuration for the tree. See * goog.ui.tree.TreeControl.DefaultConfig. If not specified, a default config * will be used. * @constructor * @extends {Blockly.Toolbox.TreeNode} */ Blockly.Toolbox.TreeSeparator = function(config) { Blockly.Toolbox.TreeNode.call(this, null, '', config); }; goog.inherits(Blockly.Toolbox.TreeSeparator, Blockly.Toolbox.TreeNode);