/** * @license * Visual Blocks Editor * * Copyright 2012 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 Dropdown input field. Used for editable titles and variables. * In the interests of a consistent UI, the toolbox shares some functions and * properties with the context menu. * @author fraser@google.com (Neil Fraser) */ 'use strict'; goog.provide('Blockly.FieldDropdown'); goog.require('Blockly.Field'); goog.require('goog.dom'); goog.require('goog.events'); goog.require('goog.style'); goog.require('goog.ui.Menu'); goog.require('goog.ui.MenuItem'); goog.require('goog.userAgent'); /** * Class for an editable dropdown field. * @param {(!Array.>|!Function)} menuGenerator An array of * options for a dropdown list, or a function which generates these options. * @param {Function=} opt_validator A function that is executed when a new * option is selected, with the newly selected value as its sole argument. * If it returns a value, that value (which must be one of the options) will * become selected in place of the newly selected option, unless the return * value is null, in which case the change is aborted. * @extends {Blockly.Field} * @constructor */ Blockly.FieldDropdown = function(menuGenerator, opt_validator) { this.menuGenerator_ = menuGenerator; this.trimOptions_(); var firstTuple = this.getOptions_()[0]; // Call parent's constructor. Blockly.FieldDropdown.superClass_.constructor.call(this, firstTuple[1], opt_validator); }; goog.inherits(Blockly.FieldDropdown, Blockly.Field); /** * Horizontal distance that a checkmark ovehangs the dropdown. */ Blockly.FieldDropdown.CHECKMARK_OVERHANG = 25; /** * Android can't (in 2014) display "▾", so use "▼" instead. */ Blockly.FieldDropdown.ARROW_CHAR = goog.userAgent.ANDROID ? '\u25BC' : '\u25BE'; /** * Mouse cursor style when over the hotspot that initiates the editor. */ Blockly.FieldDropdown.prototype.CURSOR = 'default'; /** * Language-neutral currently selected string. * @type {string} * @private */ Blockly.FieldDropdown.prototype.value_ = ''; /** * Install this dropdown on a block. */ Blockly.FieldDropdown.prototype.init = function() { if (this.fieldGroup_) { // Dropdown has already been initialized once. return; } // Add dropdown arrow: "option ▾" (LTR) or "▾ אופציה" (RTL) this.arrow_ = Blockly.createSvgElement('tspan', {}, null); this.arrow_.appendChild(document.createTextNode(this.sourceBlock_.RTL ? Blockly.FieldDropdown.ARROW_CHAR + ' ' : ' ' + Blockly.FieldDropdown.ARROW_CHAR)); Blockly.FieldDropdown.superClass_.init.call(this); // Force a reset of the text to add the arrow. var text = this.text_; this.text_ = null; this.setText(text); }; /** * Create a dropdown menu under the text. * @private */ Blockly.FieldDropdown.prototype.showEditor_ = function() { Blockly.WidgetDiv.show(this, this.sourceBlock_.RTL, null); var thisField = this; function callback(e) { var menu = this; var menuItem = e.target; if (menuItem) { thisField.onItemSelected(menu, menuItem); } Blockly.WidgetDiv.hideIfOwner(thisField); } var menu = new goog.ui.Menu(); this.internalMenu_ = menu; menu.setRightToLeft(this.sourceBlock_.RTL); var options = this.getOptions_(); for (var i = 0; i < options.length; i++) { var text = options[i][0]; // Human-readable text. var value = options[i][1]; // Language-neutral value. var menuItem = new goog.ui.MenuItem(text); menuItem.setRightToLeft(this.sourceBlock_.RTL); menuItem.setValue(value); menuItem.setCheckable(true); menu.addChild(menuItem, true); menuItem.setChecked(value == this.value_); } // Listen for mouse/keyboard events. goog.events.listen(menu, goog.ui.Component.EventType.ACTION, callback); // Listen for touch events (why doesn't Closure handle this already?). function callbackTouchStart(e) { var control = this.getOwnerControl(/** @type {Node} */ (e.target)); // Highlight the menu item. control.handleMouseDown(e); } function callbackTouchEnd(e) { var control = this.getOwnerControl(/** @type {Node} */ (e.target)); // Activate the menu item. control.performActionInternal(e); } menu.getHandler().listen(menu.getElement(), goog.events.EventType.TOUCHSTART, callbackTouchStart); menu.getHandler().listen(menu.getElement(), goog.events.EventType.TOUCHEND, callbackTouchEnd); // Record windowSize and scrollOffset before adding menu. var windowSize = goog.dom.getViewportSize(); var scrollOffset = goog.style.getViewportPageOffset(document); var xy = this.getAbsoluteXY_(); var borderBBox = this.getScaledBBox_(); var div = Blockly.WidgetDiv.DIV; menu.render(div); var menuDom = menu.getElement(); Blockly.addClass_(menuDom, 'blocklyDropdownMenu'); // Record menuSize after adding menu. var menuSize = goog.style.getSize(menuDom); // Recalculate height for the total content, not only box height. menuSize.height = menuDom.scrollHeight; // Position the menu. // Flip menu vertically if off the bottom. if (xy.y + menuSize.height + borderBBox.height >= windowSize.height + scrollOffset.y) { xy.y -= menuSize.height + 2; } else { xy.y += borderBBox.height; } if (this.sourceBlock_.RTL) { xy.x += borderBBox.width; xy.x += Blockly.FieldDropdown.CHECKMARK_OVERHANG; // Don't go offscreen left. if (xy.x < scrollOffset.x + menuSize.width) { xy.x = scrollOffset.x + menuSize.width; } } else { xy.x -= Blockly.FieldDropdown.CHECKMARK_OVERHANG; // Don't go offscreen right. if (xy.x > windowSize.width + scrollOffset.x - menuSize.width) { xy.x = windowSize.width + scrollOffset.x - menuSize.width; } } Blockly.WidgetDiv.position(xy.x, xy.y, windowSize, scrollOffset, this.sourceBlock_.RTL); menu.setAllowAutoFocus(true); menuDom.focus(); }; /** * Handle the selection of an item in the dropdown menu. * @param {!goog.ui.Menu} menu The Menu component clicked. * @param {!goog.ui.MenuItem} menuItem The MenuItem selected within menu. */ Blockly.FieldDropdown.prototype.onItemSelected = function(menu, menuItem) { var value = menuItem.getValue(); if (this.sourceBlock_) { // Call any validation function, and allow it to override. value = this.callValidator(value); } if (value !== null) { this.setValue(value); } } /** * Factor out common words in statically defined options. * Create prefix and/or suffix labels. * @private */ Blockly.FieldDropdown.prototype.trimOptions_ = function() { this.prefixField = null; this.suffixField = null; var options = this.menuGenerator_; if (!goog.isArray(options) || options.length < 2) { return; } var strings = options.map(function(t) {return t[0];}); var shortest = Blockly.shortestStringLength(strings); var prefixLength = Blockly.commonWordPrefix(strings, shortest); var suffixLength = Blockly.commonWordSuffix(strings, shortest); if (!prefixLength && !suffixLength) { return; } if (shortest <= prefixLength + suffixLength) { // One or more strings will entirely vanish if we proceed. Abort. return; } if (prefixLength) { this.prefixField = strings[0].substring(0, prefixLength - 1); } if (suffixLength) { this.suffixField = strings[0].substr(1 - suffixLength); } // Remove the prefix and suffix from the options. var newOptions = []; for (var i = 0; i < options.length; i++) { var text = options[i][0]; var value = options[i][1]; text = text.substring(prefixLength, text.length - suffixLength); newOptions[i] = [text, value]; } this.menuGenerator_ = newOptions; }; /** * Return a list of the options for this dropdown. * @return {!Array.>} Array of option tuples: * (human-readable text, language-neutral name). * @private */ Blockly.FieldDropdown.prototype.getOptions_ = function() { if (goog.isFunction(this.menuGenerator_)) { return this.menuGenerator_.call(this); } return /** @type {!Array.>} */ (this.menuGenerator_); }; /** * Get the language-neutral value from this dropdown menu. * @return {string} Current text. */ Blockly.FieldDropdown.prototype.getValue = function() { return this.value_; }; /** * Set the language-neutral value for this dropdown menu. * @param {string} newValue New value to set. */ Blockly.FieldDropdown.prototype.setValue = function(newValue) { if (newValue === null || newValue === this.value_) { return; // No change if null. } if (this.sourceBlock_ && Blockly.Events.isEnabled()) { Blockly.Events.fire(new Blockly.Events.Change( this.sourceBlock_, 'field', this.name, this.value_, newValue)); } this.value_ = newValue; // Look up and display the human-readable text. var options = this.getOptions_(); for (var i = 0; i < options.length; i++) { // Options are tuples of human-readable text and language-neutral values. if (options[i][1] == newValue) { this.setText(options[i][0]); return; } } // Value not found. Add it, maybe it will become valid once set // (like variable names). this.setText(newValue); }; /** * Set the text in this field. Trigger a rerender of the source block. * @param {?string} text New text. */ Blockly.FieldDropdown.prototype.setText = function(text) { if (this.sourceBlock_ && this.arrow_) { // Update arrow's colour. this.arrow_.style.fill = this.sourceBlock_.getColour(); } if (text === null || text === this.text_) { // No change if null. return; } this.text_ = text; this.updateTextNode_(); if (this.textElement_) { // Insert dropdown arrow. if (this.sourceBlock_.RTL) { this.textElement_.insertBefore(this.arrow_, this.textElement_.firstChild); } else { this.textElement_.appendChild(this.arrow_); } } if (this.sourceBlock_ && this.sourceBlock_.rendered) { this.sourceBlock_.render(); this.sourceBlock_.bumpNeighbours_(); } }; /** * Close the dropdown menu if this input is being deleted. */ Blockly.FieldDropdown.prototype.dispose = function() { Blockly.WidgetDiv.hideIfOwner(this); Blockly.FieldDropdown.superClass_.dispose.call(this); };