field_dropdown.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. /**
  2. * @license
  3. * Visual Blocks Editor
  4. *
  5. * Copyright 2012 Google Inc.
  6. * https://developers.google.com/blockly/
  7. *
  8. * Licensed under the Apache License, Version 2.0 (the "License");
  9. * you may not use this file except in compliance with the License.
  10. * You may obtain a copy of the License at
  11. *
  12. * http://www.apache.org/licenses/LICENSE-2.0
  13. *
  14. * Unless required by applicable law or agreed to in writing, software
  15. * distributed under the License is distributed on an "AS IS" BASIS,
  16. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  17. * See the License for the specific language governing permissions and
  18. * limitations under the License.
  19. */
  20. /**
  21. * @fileoverview Dropdown input field. Used for editable titles and variables.
  22. * In the interests of a consistent UI, the toolbox shares some functions and
  23. * properties with the context menu.
  24. * @author fraser@google.com (Neil Fraser)
  25. */
  26. 'use strict';
  27. goog.provide('Blockly.FieldDropdown');
  28. goog.require('Blockly.Field');
  29. goog.require('goog.dom');
  30. goog.require('goog.events');
  31. goog.require('goog.style');
  32. goog.require('goog.ui.Menu');
  33. goog.require('goog.ui.MenuItem');
  34. goog.require('goog.userAgent');
  35. /**
  36. * Class for an editable dropdown field.
  37. * @param {(!Array.<!Array.<string>>|!Function)} menuGenerator An array of
  38. * options for a dropdown list, or a function which generates these options.
  39. * @param {Function=} opt_validator A function that is executed when a new
  40. * option is selected, with the newly selected value as its sole argument.
  41. * If it returns a value, that value (which must be one of the options) will
  42. * become selected in place of the newly selected option, unless the return
  43. * value is null, in which case the change is aborted.
  44. * @extends {Blockly.Field}
  45. * @constructor
  46. */
  47. Blockly.FieldDropdown = function(menuGenerator, opt_validator) {
  48. this.menuGenerator_ = menuGenerator;
  49. this.trimOptions_();
  50. var firstTuple = this.getOptions_()[0];
  51. // Call parent's constructor.
  52. Blockly.FieldDropdown.superClass_.constructor.call(this, firstTuple[1],
  53. opt_validator);
  54. };
  55. goog.inherits(Blockly.FieldDropdown, Blockly.Field);
  56. /**
  57. * Horizontal distance that a checkmark ovehangs the dropdown.
  58. */
  59. Blockly.FieldDropdown.CHECKMARK_OVERHANG = 25;
  60. /**
  61. * Android can't (in 2014) display "▾", so use "▼" instead.
  62. */
  63. Blockly.FieldDropdown.ARROW_CHAR = goog.userAgent.ANDROID ? '\u25BC' : '\u25BE';
  64. /**
  65. * Mouse cursor style when over the hotspot that initiates the editor.
  66. */
  67. Blockly.FieldDropdown.prototype.CURSOR = 'default';
  68. /**
  69. * Language-neutral currently selected string.
  70. * @type {string}
  71. * @private
  72. */
  73. Blockly.FieldDropdown.prototype.value_ = '';
  74. /**
  75. * Install this dropdown on a block.
  76. */
  77. Blockly.FieldDropdown.prototype.init = function() {
  78. if (this.fieldGroup_) {
  79. // Dropdown has already been initialized once.
  80. return;
  81. }
  82. // Add dropdown arrow: "option ▾" (LTR) or "▾ אופציה" (RTL)
  83. this.arrow_ = Blockly.createSvgElement('tspan', {}, null);
  84. this.arrow_.appendChild(document.createTextNode(this.sourceBlock_.RTL ?
  85. Blockly.FieldDropdown.ARROW_CHAR + ' ' :
  86. ' ' + Blockly.FieldDropdown.ARROW_CHAR));
  87. Blockly.FieldDropdown.superClass_.init.call(this);
  88. // Force a reset of the text to add the arrow.
  89. var text = this.text_;
  90. this.text_ = null;
  91. this.setText(text);
  92. };
  93. /**
  94. * Create a dropdown menu under the text.
  95. * @private
  96. */
  97. Blockly.FieldDropdown.prototype.showEditor_ = function() {
  98. Blockly.WidgetDiv.show(this, this.sourceBlock_.RTL, null);
  99. var thisField = this;
  100. function callback(e) {
  101. var menu = this;
  102. var menuItem = e.target;
  103. if (menuItem) {
  104. thisField.onItemSelected(menu, menuItem);
  105. }
  106. Blockly.WidgetDiv.hideIfOwner(thisField);
  107. }
  108. var menu = new goog.ui.Menu();
  109. this.internalMenu_ = menu;
  110. menu.setRightToLeft(this.sourceBlock_.RTL);
  111. var options = this.getOptions_();
  112. for (var i = 0; i < options.length; i++) {
  113. var text = options[i][0]; // Human-readable text.
  114. var value = options[i][1]; // Language-neutral value.
  115. var menuItem = new goog.ui.MenuItem(text);
  116. menuItem.setRightToLeft(this.sourceBlock_.RTL);
  117. menuItem.setValue(value);
  118. menuItem.setCheckable(true);
  119. menu.addChild(menuItem, true);
  120. menuItem.setChecked(value == this.value_);
  121. }
  122. // Listen for mouse/keyboard events.
  123. goog.events.listen(menu, goog.ui.Component.EventType.ACTION, callback);
  124. // Listen for touch events (why doesn't Closure handle this already?).
  125. function callbackTouchStart(e) {
  126. var control = this.getOwnerControl(/** @type {Node} */ (e.target));
  127. // Highlight the menu item.
  128. control.handleMouseDown(e);
  129. }
  130. function callbackTouchEnd(e) {
  131. var control = this.getOwnerControl(/** @type {Node} */ (e.target));
  132. // Activate the menu item.
  133. control.performActionInternal(e);
  134. }
  135. menu.getHandler().listen(menu.getElement(), goog.events.EventType.TOUCHSTART,
  136. callbackTouchStart);
  137. menu.getHandler().listen(menu.getElement(), goog.events.EventType.TOUCHEND,
  138. callbackTouchEnd);
  139. // Record windowSize and scrollOffset before adding menu.
  140. var windowSize = goog.dom.getViewportSize();
  141. var scrollOffset = goog.style.getViewportPageOffset(document);
  142. var xy = this.getAbsoluteXY_();
  143. var borderBBox = this.getScaledBBox_();
  144. var div = Blockly.WidgetDiv.DIV;
  145. menu.render(div);
  146. var menuDom = menu.getElement();
  147. Blockly.addClass_(menuDom, 'blocklyDropdownMenu');
  148. // Record menuSize after adding menu.
  149. var menuSize = goog.style.getSize(menuDom);
  150. // Recalculate height for the total content, not only box height.
  151. menuSize.height = menuDom.scrollHeight;
  152. // Position the menu.
  153. // Flip menu vertically if off the bottom.
  154. if (xy.y + menuSize.height + borderBBox.height >=
  155. windowSize.height + scrollOffset.y) {
  156. xy.y -= menuSize.height + 2;
  157. } else {
  158. xy.y += borderBBox.height;
  159. }
  160. if (this.sourceBlock_.RTL) {
  161. xy.x += borderBBox.width;
  162. xy.x += Blockly.FieldDropdown.CHECKMARK_OVERHANG;
  163. // Don't go offscreen left.
  164. if (xy.x < scrollOffset.x + menuSize.width) {
  165. xy.x = scrollOffset.x + menuSize.width;
  166. }
  167. } else {
  168. xy.x -= Blockly.FieldDropdown.CHECKMARK_OVERHANG;
  169. // Don't go offscreen right.
  170. if (xy.x > windowSize.width + scrollOffset.x - menuSize.width) {
  171. xy.x = windowSize.width + scrollOffset.x - menuSize.width;
  172. }
  173. }
  174. Blockly.WidgetDiv.position(xy.x, xy.y, windowSize, scrollOffset,
  175. this.sourceBlock_.RTL);
  176. menu.setAllowAutoFocus(true);
  177. menuDom.focus();
  178. };
  179. /**
  180. * Handle the selection of an item in the dropdown menu.
  181. * @param {!goog.ui.Menu} menu The Menu component clicked.
  182. * @param {!goog.ui.MenuItem} menuItem The MenuItem selected within menu.
  183. */
  184. Blockly.FieldDropdown.prototype.onItemSelected = function(menu, menuItem) {
  185. var value = menuItem.getValue();
  186. if (this.sourceBlock_) {
  187. // Call any validation function, and allow it to override.
  188. value = this.callValidator(value);
  189. }
  190. if (value !== null) {
  191. this.setValue(value);
  192. }
  193. }
  194. /**
  195. * Factor out common words in statically defined options.
  196. * Create prefix and/or suffix labels.
  197. * @private
  198. */
  199. Blockly.FieldDropdown.prototype.trimOptions_ = function() {
  200. this.prefixField = null;
  201. this.suffixField = null;
  202. var options = this.menuGenerator_;
  203. if (!goog.isArray(options) || options.length < 2) {
  204. return;
  205. }
  206. var strings = options.map(function(t) {return t[0];});
  207. var shortest = Blockly.shortestStringLength(strings);
  208. var prefixLength = Blockly.commonWordPrefix(strings, shortest);
  209. var suffixLength = Blockly.commonWordSuffix(strings, shortest);
  210. if (!prefixLength && !suffixLength) {
  211. return;
  212. }
  213. if (shortest <= prefixLength + suffixLength) {
  214. // One or more strings will entirely vanish if we proceed. Abort.
  215. return;
  216. }
  217. if (prefixLength) {
  218. this.prefixField = strings[0].substring(0, prefixLength - 1);
  219. }
  220. if (suffixLength) {
  221. this.suffixField = strings[0].substr(1 - suffixLength);
  222. }
  223. // Remove the prefix and suffix from the options.
  224. var newOptions = [];
  225. for (var i = 0; i < options.length; i++) {
  226. var text = options[i][0];
  227. var value = options[i][1];
  228. text = text.substring(prefixLength, text.length - suffixLength);
  229. newOptions[i] = [text, value];
  230. }
  231. this.menuGenerator_ = newOptions;
  232. };
  233. /**
  234. * Return a list of the options for this dropdown.
  235. * @return {!Array.<!Array.<string>>} Array of option tuples:
  236. * (human-readable text, language-neutral name).
  237. * @private
  238. */
  239. Blockly.FieldDropdown.prototype.getOptions_ = function() {
  240. if (goog.isFunction(this.menuGenerator_)) {
  241. return this.menuGenerator_.call(this);
  242. }
  243. return /** @type {!Array.<!Array.<string>>} */ (this.menuGenerator_);
  244. };
  245. /**
  246. * Get the language-neutral value from this dropdown menu.
  247. * @return {string} Current text.
  248. */
  249. Blockly.FieldDropdown.prototype.getValue = function() {
  250. return this.value_;
  251. };
  252. /**
  253. * Set the language-neutral value for this dropdown menu.
  254. * @param {string} newValue New value to set.
  255. */
  256. Blockly.FieldDropdown.prototype.setValue = function(newValue) {
  257. if (newValue === null || newValue === this.value_) {
  258. return; // No change if null.
  259. }
  260. if (this.sourceBlock_ && Blockly.Events.isEnabled()) {
  261. Blockly.Events.fire(new Blockly.Events.Change(
  262. this.sourceBlock_, 'field', this.name, this.value_, newValue));
  263. }
  264. this.value_ = newValue;
  265. // Look up and display the human-readable text.
  266. var options = this.getOptions_();
  267. for (var i = 0; i < options.length; i++) {
  268. // Options are tuples of human-readable text and language-neutral values.
  269. if (options[i][1] == newValue) {
  270. this.setText(options[i][0]);
  271. return;
  272. }
  273. }
  274. // Value not found. Add it, maybe it will become valid once set
  275. // (like variable names).
  276. this.setText(newValue);
  277. };
  278. /**
  279. * Set the text in this field. Trigger a rerender of the source block.
  280. * @param {?string} text New text.
  281. */
  282. Blockly.FieldDropdown.prototype.setText = function(text) {
  283. if (this.sourceBlock_ && this.arrow_) {
  284. // Update arrow's colour.
  285. this.arrow_.style.fill = this.sourceBlock_.getColour();
  286. }
  287. if (text === null || text === this.text_) {
  288. // No change if null.
  289. return;
  290. }
  291. this.text_ = text;
  292. this.updateTextNode_();
  293. if (this.textElement_) {
  294. // Insert dropdown arrow.
  295. if (this.sourceBlock_.RTL) {
  296. this.textElement_.insertBefore(this.arrow_, this.textElement_.firstChild);
  297. } else {
  298. this.textElement_.appendChild(this.arrow_);
  299. }
  300. }
  301. if (this.sourceBlock_ && this.sourceBlock_.rendered) {
  302. this.sourceBlock_.render();
  303. this.sourceBlock_.bumpNeighbours_();
  304. }
  305. };
  306. /**
  307. * Close the dropdown menu if this input is being deleted.
  308. */
  309. Blockly.FieldDropdown.prototype.dispose = function() {
  310. Blockly.WidgetDiv.hideIfOwner(this);
  311. Blockly.FieldDropdown.superClass_.dispose.call(this);
  312. };