From cb4521b645735c6cb55f8f80fcbef83930377d8a Mon Sep 17 00:00:00 2001 From: Beka Westberg Date: Mon, 28 Feb 2022 08:59:33 -0800 Subject: [PATCH] refactor: Convert fields to ES6 classes (#5943) * refactor: Initial test at refactoring fields to ES6 * refact: reorganize text input and descendants to call super first * refact: run conversion script on text input field and subclasses * clean: cleanup fields post-conversion script * refact: reorganize dropdown and variable fields to call super first * refact: run class conversion script on dropdown and variable * clean: clean fields post conversion script * refact: reorganize misc fields to call super first * refact: run conversion script on misc fields * clean: cleanup misc fields after conversion * fix: add setting the value and whatnot back to the base field. Pass sentinel conistently * format * refact: work on making debug compiler happy * clean: finish making debug build happy * fix: work on making tests happy * fix: finish making tests happy * Fix: fixup angle and multiline fields * clean: format * fix: move default value back to DEFAULT_VALUE * fix: change SENTINEL to SKIP_SETUP * fix: inline docs * fix: some misc PR comments * fix: format * fix: make compiler hapy with new.target * fix: types in FieldDropdown * fix: add @final annotations to Field * feat: move Sentinel to a utils file * fix: remove ImageProperties from external API * clean: cleanup chunks and deps --- core/extensions.js | 3 +- core/field.js | 2247 ++++++++++++++-------------- core/field_angle.js | 864 +++++------ core/field_checkbox.js | 398 ++--- core/field_colour.js | 1000 +++++++------ core/field_dropdown.js | 1267 ++++++++-------- core/field_image.js | 473 +++--- core/field_label.js | 197 +-- core/field_label_serializable.js | 6 +- core/field_multilineinput.js | 35 +- core/field_number.js | 559 +++---- core/field_textinput.js | 1074 ++++++------- core/field_variable.js | 924 ++++++------ core/utils/sentinel.js | 24 + scripts/gulpfiles/chunks.json | 93 +- tests/deps.js | 25 +- tests/mocha/field_registry_test.js | 14 +- tests/mocha/field_test.js | 58 +- 18 files changed, 4765 insertions(+), 4496 deletions(-) create mode 100644 core/utils/sentinel.js diff --git a/core/extensions.js b/core/extensions.js index d0c219d4f..4043ba95c 100644 --- a/core/extensions.js +++ b/core/extensions.js @@ -24,6 +24,7 @@ goog.module('Blockly.Extensions'); const parsing = goog.require('Blockly.utils.parsing'); /* eslint-disable-next-line no-unused-vars */ const {Block} = goog.requireType('Blockly.Block'); +const {FieldDropdown} = goog.require('Blockly.FieldDropdown'); goog.requireType('Blockly.Mutator'); @@ -454,7 +455,7 @@ exports.buildTooltipForDropdown = buildTooltipForDropdown; const checkDropdownOptionsInTable = function(block, dropdownName, lookupTable) { // Validate all dropdown options have values. const dropdown = block.getField(dropdownName); - if (!dropdown.isOptionListDynamic()) { + if (dropdown instanceof FieldDropdown && !dropdown.isOptionListDynamic()) { const options = dropdown.getOptions(); for (let i = 0; i < options.length; i++) { const optionKey = options[i][1]; // label, then value diff --git a/core/field.js b/core/field.js index f1fd06b2f..7b8daf0de 100644 --- a/core/field.js +++ b/core/field.js @@ -50,6 +50,7 @@ const {IRegistrable} = goog.require('Blockly.IRegistrable'); const {Input} = goog.requireType('Blockly.Input'); const {MarkerManager} = goog.require('Blockly.MarkerManager'); const {Rect} = goog.require('Blockly.utils.Rect'); +const {Sentinel} = goog.require('Blockly.utils.Sentinel'); /* eslint-disable-next-line no-unused-vars */ const {ShortcutRegistry} = goog.requireType('Blockly.ShortcutRegistry'); const {Size} = goog.require('Blockly.utils.Size'); @@ -64,114 +65,1193 @@ goog.require('Blockly.Gesture'); /** * Abstract class for an editable field. - * @param {*} value The initial value of the field. - * @param {?Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a value & returns a validated - * value, or null to abort the change. - * @param {Object=} opt_config A map of options used to configure the field. See - * the individual field's documentation for a list of properties this - * parameter supports. - * @constructor - * @abstract * @implements {IASTNodeLocationSvg} * @implements {IASTNodeLocationWithBlock} * @implements {IKeyboardAccessible} * @implements {IRegistrable} - * @alias Blockly.Field + * @abstract */ -const Field = function(value, opt_validator, opt_config) { +class Field { /** - * A generic value possessed by the field. - * Should generally be non-null, only null when the field is created. - * @type {*} - * @protected + * @param {*} value The initial value of the field. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {?Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a value & returns a validated + * value, or null to abort the change. + * @param {Object=} opt_config A map of options used to configure the field. + * Refer to the individual field's documentation for a list of properties + * this parameter supports. + * @alias Blockly.Field */ - this.value_ = this.DEFAULT_VALUE; + constructor(value, opt_validator, opt_config) { + /** + * Name of field. Unique within each block. + * Static labels are usually unnamed. + * @type {string|undefined} + */ + this.name = undefined; + + /** + * A generic value possessed by the field. + * Should generally be non-null, only null when the field is created. + * @type {*} + * @protected + */ + this.value_ = + /** @type {typeof Field} */ (new.target).prototype.DEFAULT_VALUE; + + /** + * Validation function called when user edits an editable field. + * @type {Function} + * @protected + */ + this.validator_ = null; + + /** + * Used to cache the field's tooltip value if setTooltip is called when the + * field is not yet initialized. Is *not* guaranteed to be accurate. + * @type {?Tooltip.TipInfo} + * @private + */ + this.tooltip_ = null; + + /** + * The size of the area rendered by the field. + * @type {!Size} + * @protected + */ + this.size_ = new Size(0, 0); + + /** + * Holds the cursors svg element when the cursor is attached to the field. + * This is null if there is no cursor on the field. + * @type {SVGElement} + * @private + */ + this.cursorSvg_ = null; + + /** + * Holds the markers svg element when the marker is attached to the field. + * This is null if there is no marker on the field. + * @type {SVGElement} + * @private + */ + this.markerSvg_ = null; + + /** + * The rendered field's SVG group element. + * @type {SVGGElement} + * @protected + */ + this.fieldGroup_ = null; + + /** + * The rendered field's SVG border element. + * @type {SVGRectElement} + * @protected + */ + this.borderRect_ = null; + + /** + * The rendered field's SVG text element. + * @type {SVGTextElement} + * @protected + */ + this.textElement_ = null; + + /** + * The rendered field's text content element. + * @type {Text} + * @protected + */ + this.textContent_ = null; + + /** + * Mouse down event listener data. + * @type {?browserEvents.Data} + * @private + */ + this.mouseDownWrapper_ = null; + + /** + * Constants associated with the source block's renderer. + * @type {ConstantProvider} + * @protected + */ + this.constants_ = null; + + /** + * Has this field been disposed of? + * @type {boolean} + * @package + */ + this.disposed = false; + + /** + * Maximum characters of text to display before adding an ellipsis. + * @type {number} + */ + this.maxDisplayLength = 50; + + /** + * Block this field is attached to. Starts as null, then set in init. + * @type {Block} + * @protected + */ + this.sourceBlock_ = null; + + /** + * Does this block need to be re-rendered? + * @type {boolean} + * @protected + */ + this.isDirty_ = true; + + /** + * Is the field visible, or hidden due to the block being collapsed? + * @type {boolean} + * @protected + */ + this.visible_ = true; + + /** + * Can the field value be changed using the editor on an editable block? + * @type {boolean} + * @protected + */ + this.enabled_ = true; + + /** + * The element the click handler is bound to. + * @type {Element} + * @protected + */ + this.clickTarget_ = null; + + /** + * The prefix field. + * @type {?string} + * @package + */ + this.prefixField = null; + + /** + * The suffix field. + * @type {?string} + * @package + */ + this.suffixField = null; + + /** + * Editable fields usually show some sort of UI indicating they are + * editable. They will also be saved by the serializer. + * @type {boolean} + */ + this.EDITABLE = true; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. This is not the + * case by default so that SERIALIZABLE is backwards compatible. + * @type {boolean} + */ + this.SERIALIZABLE = false; + + /** + * Mouse cursor style when over the hotspot that initiates the editor. + * @type {string} + */ + this.CURSOR = ''; + + if (value === Field.SKIP_SETUP) return; + if (opt_config) this.configure_(opt_config); + this.setValue(value); + if (opt_validator) this.setValidator(opt_validator); + } /** - * Validation function called when user edits an editable field. - * @type {Function} + * Process the configuration map passed to the field. + * @param {!Object} config A map of options used to configure the field. See + * the individual field's documentation for a list of properties this + * parameter supports. * @protected */ - this.validator_ = null; + configure_(config) { + let tooltip = config['tooltip']; + if (typeof tooltip === 'string') { + tooltip = parsing.replaceMessageReferences(config['tooltip']); + } + tooltip && this.setTooltip(tooltip); + + // TODO (#2884): Possibly add CSS class config option. + // TODO (#2885): Possibly add cursor config option. + } /** - * Used to cache the field's tooltip value if setTooltip is called when the - * field is not yet initialized. Is *not* guaranteed to be accurate. - * @type {?Tooltip.TipInfo} + * Attach this field to a block. + * @param {!Block} block The block containing this field. + */ + setSourceBlock(block) { + if (this.sourceBlock_) { + throw Error('Field already bound to a block'); + } + this.sourceBlock_ = block; + } + + /** + * Get the renderer constant provider. + * @return {?ConstantProvider} The renderer constant + * provider. + */ + getConstants() { + if (!this.constants_ && this.sourceBlock_ && this.sourceBlock_.workspace && + this.sourceBlock_.workspace.rendered) { + this.constants_ = + this.sourceBlock_.workspace.getRenderer().getConstants(); + } + return this.constants_; + } + + /** + * Get the block this field is attached to. + * @return {Block} The block containing this field. + */ + getSourceBlock() { + return this.sourceBlock_; + } + + /** + * Initialize everything to render this field. Override + * methods initModel and initView rather than this method. + * @package + * @final + */ + init() { + if (this.fieldGroup_) { + // Field has already been initialized once. + return; + } + this.fieldGroup_ = dom.createSvgElement(Svg.G, {}, null); + if (!this.isVisible()) { + this.fieldGroup_.style.display = 'none'; + } + const sourceBlockSvg = /** @type {!BlockSvg} **/ (this.sourceBlock_); + sourceBlockSvg.getSvgRoot().appendChild(this.fieldGroup_); + this.initView(); + this.updateEditable(); + this.setTooltip(this.tooltip_); + this.bindEvents_(); + this.initModel(); + } + + /** + * Create the block UI for this field. + * @package + */ + initView() { + this.createBorderRect_(); + this.createTextElement_(); + } + + /** + * Initializes the model of the field after it has been installed on a block. + * No-op by default. + * @package + */ + initModel() {} + + /** + * Create a field border rect element. Not to be overridden by subclasses. + * Instead modify the result of the function inside initView, or create a + * separate function to call. + * @protected + */ + createBorderRect_() { + this.borderRect_ = dom.createSvgElement( + Svg.RECT, { + 'rx': this.getConstants().FIELD_BORDER_RECT_RADIUS, + 'ry': this.getConstants().FIELD_BORDER_RECT_RADIUS, + 'x': 0, + 'y': 0, + 'height': this.size_.height, + 'width': this.size_.width, + 'class': 'blocklyFieldRect', + }, + this.fieldGroup_); + } + + /** + * Create a field text element. Not to be overridden by subclasses. Instead + * modify the result of the function inside initView, or create a separate + * function to call. + * @protected + */ + createTextElement_() { + this.textElement_ = dom.createSvgElement( + Svg.TEXT, { + 'class': 'blocklyText', + }, + this.fieldGroup_); + if (this.getConstants().FIELD_TEXT_BASELINE_CENTER) { + this.textElement_.setAttribute('dominant-baseline', 'central'); + } + this.textContent_ = document.createTextNode(''); + this.textElement_.appendChild(this.textContent_); + } + + /** + * Bind events to the field. Can be overridden by subclasses if they need to + * do custom input handling. + * @protected + */ + bindEvents_() { + Tooltip.bindMouseEvents(this.getClickTarget_()); + this.mouseDownWrapper_ = browserEvents.conditionalBind( + this.getClickTarget_(), 'mousedown', this, this.onMouseDown_); + } + + /** + * Sets the field's value based on the given XML element. Should only be + * called by Blockly.Xml. + * @param {!Element} fieldElement The element containing info about the + * field's state. + * @package + */ + fromXml(fieldElement) { + this.setValue(fieldElement.textContent); + } + + /** + * Serializes this field's value to XML. Should only be called by Blockly.Xml. + * @param {!Element} fieldElement The element to populate with info about the + * field's state. + * @return {!Element} The element containing info about the field's state. + * @package + */ + toXml(fieldElement) { + fieldElement.textContent = this.getValue(); + return fieldElement; + } + + /** + * Saves this fields value as something which can be serialized to JSON. + * Should only be called by the serialization system. + * @param {boolean=} _doFullSerialization If true, this signals to the field + * that if it normally just saves a reference to some state (eg variable + * fields) it should instead serialize the full state of the thing being + * referenced. + * @return {*} JSON serializable state. + * @package + */ + saveState(_doFullSerialization) { + const legacyState = this.saveLegacyState(Field); + if (legacyState !== null) { + return legacyState; + } + return this.getValue(); + } + + /** + * Sets the field's state based on the given state value. Should only be + * called by the serialization system. + * @param {*} state The state we want to apply to the field. + * @package + */ + loadState(state) { + if (this.loadLegacyState(Field, state)) { + return; + } + this.setValue(state); + } + + /** + * Returns a stringified version of the XML state, if it should be used. + * Otherwise this returns null, to signal the field should use its own + * serialization. + * @param {*} callingClass The class calling this method. + * Used to see if `this` has overridden any relevant hooks. + * @return {?string} The stringified version of the XML state, or null. + * @protected + */ + saveLegacyState(callingClass) { + if (callingClass.prototype.saveState === this.saveState && + callingClass.prototype.toXml !== this.toXml) { + const elem = utilsXml.createElement('field'); + elem.setAttribute('name', this.name || ''); + const text = Xml.domToText(this.toXml(elem)); + return text.replace( + ' xmlns="https://developers.google.com/blockly/xml"', ''); + } + // Either they called this on purpose from their saveState, or they have + // no implementations of either hook. Just do our thing. + return null; + } + + /** + * Loads the given state using either the old XML hoooks, if they should be + * used. Returns true to indicate loading has been handled, false otherwise. + * @param {*} callingClass The class calling this method. + * Used to see if `this` has overridden any relevant hooks. + * @param {*} state The state to apply to the field. + * @return {boolean} Whether the state was applied or not. + */ + loadLegacyState(callingClass, state) { + if (callingClass.prototype.loadState === this.loadState && + callingClass.prototype.fromXml !== this.fromXml) { + this.fromXml(Xml.textToDom(/** @type {string} */ (state))); + return true; + } + // Either they called this on purpose from their loadState, or they have + // no implementations of either hook. Just do our thing. + return false; + } + + /** + * Dispose of all DOM objects and events belonging to this editable field. + * @package + */ + dispose() { + DropDownDiv.hideIfOwner(this); + WidgetDiv.hideIfOwner(this); + Tooltip.unbindMouseEvents(this.getClickTarget_()); + + if (this.mouseDownWrapper_) { + browserEvents.unbind(this.mouseDownWrapper_); + } + + dom.removeNode(this.fieldGroup_); + + this.disposed = true; + } + + /** + * Add or remove the UI indicating if this field is editable or not. + */ + updateEditable() { + const group = this.fieldGroup_; + if (!this.EDITABLE || !group) { + return; + } + if (this.enabled_ && this.sourceBlock_.isEditable()) { + dom.addClass(group, 'blocklyEditableText'); + dom.removeClass(group, 'blocklyNonEditableText'); + group.style.cursor = this.CURSOR; + } else { + dom.addClass(group, 'blocklyNonEditableText'); + dom.removeClass(group, 'blocklyEditableText'); + group.style.cursor = ''; + } + } + + /** + * Set whether this field's value can be changed using the editor when the + * source block is editable. + * @param {boolean} enabled True if enabled. + */ + setEnabled(enabled) { + this.enabled_ = enabled; + this.updateEditable(); + } + + /** + * Check whether this field's value can be changed using the editor when the + * source block is editable. + * @return {boolean} Whether this field is enabled. + */ + isEnabled() { + return this.enabled_; + } + + /** + * Check whether this field defines the showEditor_ function. + * @return {boolean} Whether this field is clickable. + */ + isClickable() { + return this.enabled_ && !!this.sourceBlock_ && + this.sourceBlock_.isEditable() && + this.showEditor_ !== Field.prototype.showEditor_; + } + + /** + * Check whether this field is currently editable. Some fields are never + * EDITABLE (e.g. text labels). Other fields may be EDITABLE but may exist on + * non-editable blocks or be currently disabled. + * @return {boolean} Whether this field is currently enabled, editable and on + * an editable block. + */ + isCurrentlyEditable() { + return this.enabled_ && this.EDITABLE && !!this.sourceBlock_ && + this.sourceBlock_.isEditable(); + } + + /** + * Check whether this field should be serialized by the XML renderer. + * Handles the logic for backwards compatibility and incongruous states. + * @return {boolean} Whether this field should be serialized or not. + */ + isSerializable() { + let isSerializable = false; + if (this.name) { + if (this.SERIALIZABLE) { + isSerializable = true; + } else if (this.EDITABLE) { + console.warn( + 'Detected an editable field that was not serializable.' + + ' Please define SERIALIZABLE property as true on all editable custom' + + ' fields. Proceeding with serialization.'); + isSerializable = true; + } + } + return isSerializable; + } + + /** + * Gets whether this editable field is visible or not. + * @return {boolean} True if visible. + */ + isVisible() { + return this.visible_; + } + + /** + * Sets whether this editable field is visible or not. Should only be called + * by input.setVisible. + * @param {boolean} visible True if visible. + * @package + */ + setVisible(visible) { + if (this.visible_ === visible) { + return; + } + this.visible_ = visible; + const root = this.getSvgRoot(); + if (root) { + root.style.display = visible ? 'block' : 'none'; + } + } + + /** + * Sets a new validation function for editable fields, or clears a previously + * set validator. + * + * The validator function takes in the new field value, and returns + * validated value. The validated value could be the input value, a modified + * version of the input value, or null to abort the change. + * + * If the function does not return anything (or returns undefined) the new + * value is accepted as valid. This is to allow for fields using the + * validated function as a field-level change event notification. + * + * @param {Function} handler The validator function + * or null to clear a previous validator. + */ + setValidator(handler) { + this.validator_ = handler; + } + + /** + * Gets the validation function for editable fields, or null if not set. + * @return {?Function} Validation function, or null. + */ + getValidator() { + return this.validator_; + } + + /** + * Gets the group element for this editable field. + * Used for measuring the size and for positioning. + * @return {!SVGGElement} The group element. + */ + getSvgRoot() { + return /** @type {!SVGGElement} */ (this.fieldGroup_); + } + + /** + * Updates the field to match the colour/style of the block. Should only be + * called by BlockSvg.applyColour(). + * @package + */ + applyColour() { + // Non-abstract sub-classes may wish to implement this. See FieldDropdown. + } + + /** + * Used by getSize() to move/resize any DOM elements, and get the new size. + * + * All rendering that has an effect on the size/shape of the block should be + * done here, and should be triggered by getSize(). + * @protected + */ + render_() { + if (this.textContent_) { + this.textContent_.nodeValue = this.getDisplayText_(); + } + this.updateSize_(); + } + + /** + * Calls showEditor_ when the field is clicked if the field is clickable. + * Do not override. + * @param {Event=} opt_e Optional mouse event that triggered the field to + * open, or undefined if triggered programmatically. + * @package + * @final + */ + showEditor(opt_e) { + if (this.isClickable()) { + this.showEditor_(opt_e); + } + } + + /** + * A developer hook to create an editor for the field. This is no-op by + * default, and must be overriden to create an editor. + * @param {Event=} _e Optional mouse event that triggered the field to + * open, or undefined if triggered programmatically. + * @return {void} + * @protected + */ + showEditor_(_e) { + // NOP + } + + /** + * Updates the size of the field based on the text. + * @param {number=} opt_margin margin to use when positioning the text + * element. + * @protected + */ + updateSize_(opt_margin) { + const constants = this.getConstants(); + const xOffset = opt_margin !== undefined ? + opt_margin : + (this.borderRect_ ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : + 0); + let totalWidth = xOffset * 2; + let totalHeight = constants.FIELD_TEXT_HEIGHT; + + let contentWidth = 0; + if (this.textElement_) { + contentWidth = dom.getFastTextWidth( + this.textElement_, constants.FIELD_TEXT_FONTSIZE, + constants.FIELD_TEXT_FONTWEIGHT, constants.FIELD_TEXT_FONTFAMILY); + totalWidth += contentWidth; + } + if (this.borderRect_) { + totalHeight = Math.max(totalHeight, constants.FIELD_BORDER_RECT_HEIGHT); + } + + this.size_.height = totalHeight; + this.size_.width = totalWidth; + + this.positionTextElement_(xOffset, contentWidth); + this.positionBorderRect_(); + } + + /** + * Position a field's text element after a size change. This handles both LTR + * and RTL positioning. + * @param {number} xOffset x offset to use when positioning the text element. + * @param {number} contentWidth The content width. + * @protected + */ + positionTextElement_(xOffset, contentWidth) { + if (!this.textElement_) { + return; + } + const constants = this.getConstants(); + const halfHeight = this.size_.height / 2; + + this.textElement_.setAttribute( + 'x', + this.sourceBlock_.RTL ? this.size_.width - contentWidth - xOffset : + xOffset); + this.textElement_.setAttribute( + 'y', + constants.FIELD_TEXT_BASELINE_CENTER ? + halfHeight : + halfHeight - constants.FIELD_TEXT_HEIGHT / 2 + + constants.FIELD_TEXT_BASELINE); + } + + /** + * Position a field's border rect after a size change. + * @protected + */ + positionBorderRect_() { + if (!this.borderRect_) { + return; + } + this.borderRect_.setAttribute('width', this.size_.width); + this.borderRect_.setAttribute('height', this.size_.height); + this.borderRect_.setAttribute( + 'rx', this.getConstants().FIELD_BORDER_RECT_RADIUS); + this.borderRect_.setAttribute( + 'ry', this.getConstants().FIELD_BORDER_RECT_RADIUS); + } + + /** + * Returns the height and width of the field. + * + * This should *in general* be the only place render_ gets called from. + * @return {!Size} Height and width. + */ + getSize() { + if (!this.isVisible()) { + return new Size(0, 0); + } + + if (this.isDirty_) { + this.render_(); + this.isDirty_ = false; + } else if (this.visible_ && this.size_.width === 0) { + // If the field is not visible the width will be 0 as well, one of the + // problems with the old system. + console.warn( + 'Deprecated use of setting size_.width to 0 to rerender a' + + ' field. Set field.isDirty_ to true instead.'); + this.render_(); + } + return this.size_; + } + + /** + * Returns the bounding box of the rendered field, accounting for workspace + * scaling. + * @return {!Rect} An object with top, bottom, left, and right in + * pixels relative to the top left corner of the page (window + * coordinates). + * @package + */ + getScaledBBox() { + let scaledWidth; + let scaledHeight; + let xy; + if (!this.borderRect_) { + // Browsers are inconsistent in what they return for a bounding box. + // - Webkit / Blink: fill-box / object bounding box + // - Gecko / Triden / EdgeHTML: stroke-box + const bBox = this.sourceBlock_.getHeightWidth(); + const scale = this.sourceBlock_.workspace.scale; + xy = this.getAbsoluteXY_(); + scaledWidth = bBox.width * scale; + scaledHeight = bBox.height * scale; + + if (userAgent.GECKO) { + xy.x += 1.5 * scale; + xy.y += 1.5 * scale; + scaledWidth += 1 * scale; + scaledHeight += 1 * scale; + } else { + if (!userAgent.EDGE && !userAgent.IE) { + xy.x -= 0.5 * scale; + xy.y -= 0.5 * scale; + } + scaledWidth += 1 * scale; + scaledHeight += 1 * scale; + } + } else { + const bBox = this.borderRect_.getBoundingClientRect(); + xy = style.getPageOffset(this.borderRect_); + scaledWidth = bBox.width; + scaledHeight = bBox.height; + } + return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth); + } + + /** + * Get the text from this field to display on the block. May differ from + * ``getText`` due to ellipsis, and other formatting. + * @return {string} Text to display. + * @protected + */ + getDisplayText_() { + let text = this.getText(); + if (!text) { + // Prevent the field from disappearing if empty. + return Field.NBSP; + } + if (text.length > this.maxDisplayLength) { + // Truncate displayed string and add an ellipsis ('...'). + text = text.substring(0, this.maxDisplayLength - 2) + '\u2026'; + } + // Replace whitespace with non-breaking spaces so the text doesn't collapse. + text = text.replace(/\s/g, Field.NBSP); + if (this.sourceBlock_ && this.sourceBlock_.RTL) { + // The SVG is LTR, force text to be RTL. + text += '\u200F'; + } + return text; + } + + /** + * Get the text from this field. + * Override getText_ to provide a different behavior than simply casting the + * value to a string. + * @return {string} Current text. + * @final + */ + getText() { + // this.getText_ was intended so that devs don't have to remember to call + // super when overriding how the text of the field is generated. (#2910) + const text = this.getText_(); + if (text !== null) return String(text); + return String(this.getValue()); + } + + /** + * A developer hook to override the returned text of this field. + * Override if the text representation of the value of this field + * is not just a string cast of its value. + * Return null to resort to a string cast. + * @return {?string} Current text or null. + * @protected + */ + getText_() { + return null; + } + + /** + * Force a rerender of the block that this field is installed on, which will + * rerender this field and adjust for any sizing changes. + * Other fields on the same block will not rerender, because their sizes have + * already been recorded. + * @package + */ + markDirty() { + this.isDirty_ = true; + this.constants_ = null; + } + + /** + * Force a rerender of the block that this field is installed on, which will + * rerender this field and adjust for any sizing changes. + * Other fields on the same block will not rerender, because their sizes have + * already been recorded. + * @package + */ + forceRerender() { + this.isDirty_ = true; + if (this.sourceBlock_ && this.sourceBlock_.rendered) { + this.sourceBlock_.render(); + this.sourceBlock_.bumpNeighbours(); + this.updateMarkers_(); + } + } + + /** + * Used to change the value of the field. Handles validation and events. + * Subclasses should override doClassValidation_ and doValueUpdate_ rather + * than this method. + * @param {*} newValue New value. + * @final + */ + setValue(newValue) { + const doLogging = false; + if (newValue === null) { + doLogging && console.log('null, return'); + // Not a valid value to check. + return; + } + + let validatedValue = this.doClassValidation_(newValue); + // Class validators might accidentally forget to return, we'll ignore that. + newValue = this.processValidation_(newValue, validatedValue); + if (newValue instanceof Error) { + doLogging && console.log('invalid class validation, return'); + return; + } + + const localValidator = this.getValidator(); + if (localValidator) { + validatedValue = localValidator.call(this, newValue); + // Local validators might accidentally forget to return, we'll ignore + // that. + newValue = this.processValidation_(newValue, validatedValue); + if (newValue instanceof Error) { + doLogging && console.log('invalid local validation, return'); + return; + } + } + const source = this.sourceBlock_; + if (source && source.disposed) { + doLogging && console.log('source disposed, return'); + return; + } + const oldValue = this.getValue(); + if (oldValue === newValue) { + doLogging && console.log('same, doValueUpdate_, return'); + this.doValueUpdate_(newValue); + return; + } + + this.doValueUpdate_(newValue); + if (source && eventUtils.isEnabled()) { + eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( + source, 'field', this.name || null, oldValue, newValue)); + } + if (this.isDirty_) { + this.forceRerender(); + } + doLogging && console.log(this.value_); + } + + /** + * Process the result of validation. + * @param {*} newValue New value. + * @param {*} validatedValue Validated value. + * @return {*} New value, or an Error object. * @private */ - this.tooltip_ = null; + processValidation_(newValue, validatedValue) { + if (validatedValue === null) { + this.doValueInvalid_(newValue); + if (this.isDirty_) { + this.forceRerender(); + } + return Error(); + } + if (validatedValue !== undefined) { + newValue = validatedValue; + } + return newValue; + } /** - * The size of the area rendered by the field. - * @type {!Size} + * Get the current value of the field. + * @return {*} Current value. + */ + getValue() { + return this.value_; + } + + /** + * Used to validate a value. Returns input by default. Can be overridden by + * subclasses, see FieldDropdown. + * @param {*=} opt_newValue The value to be validated. + * @return {*} The validated value, same as input by default. * @protected */ - this.size_ = new Size(0, 0); + doClassValidation_(opt_newValue) { + if (opt_newValue === null || opt_newValue === undefined) { + return null; + } + return opt_newValue; + } /** - * Holds the cursors svg element when the cursor is attached to the field. - * This is null if there is no cursor on the field. - * @type {SVGElement} - * @private - */ - this.cursorSvg_ = null; - - /** - * Holds the markers svg element when the marker is attached to the field. - * This is null if there is no marker on the field. - * @type {SVGElement} - * @private - */ - this.markerSvg_ = null; - - /** - * The rendered field's SVG group element. - * @type {SVGGElement} + * Used to update the value of a field. Can be overridden by subclasses to do + * custom storage of values/updating of external things. + * @param {*} newValue The value to be saved. * @protected */ - this.fieldGroup_ = null; + doValueUpdate_(newValue) { + this.value_ = newValue; + this.isDirty_ = true; + } /** - * The rendered field's SVG border element. - * @type {SVGRectElement} + * Used to notify the field an invalid value was input. Can be overridden by + * subclasses, see FieldTextInput. + * No-op by default. + * @param {*} _invalidValue The input value that was determined to be invalid. * @protected */ - this.borderRect_ = null; + doValueInvalid_(_invalidValue) { + // NOP + } /** - * The rendered field's SVG text element. - * @type {SVGTextElement} + * Handle a mouse down event on a field. + * @param {!Event} e Mouse down event. * @protected */ - this.textElement_ = null; + onMouseDown_(e) { + if (!this.sourceBlock_ || !this.sourceBlock_.workspace) { + return; + } + const gesture = this.sourceBlock_.workspace.getGesture(e); + if (gesture) { + gesture.setStartField(this); + } + } /** - * The rendered field's text content element. - * @type {Text} + * Sets the tooltip for this field. + * @param {?Tooltip.TipInfo} newTip The + * text for the tooltip, a function that returns the text for the tooltip, + * a parent object whose tooltip will be used, or null to display the tooltip + * of the parent block. To not display a tooltip pass the empty string. + */ + setTooltip(newTip) { + if (!newTip && newTip !== '') { // If null or undefined. + newTip = this.sourceBlock_; + } + const clickTarget = this.getClickTarget_(); + if (clickTarget) { + clickTarget.tooltip = newTip; + } else { + // Field has not been initialized yet. + this.tooltip_ = newTip; + } + } + + /** + * Returns the tooltip text for this field. + * @return {string} The tooltip text for this field. + */ + getTooltip() { + const clickTarget = this.getClickTarget_(); + if (clickTarget) { + return Tooltip.getTooltipOfObject(clickTarget); + } + // Field has not been initialized yet. Return stashed this.tooltip_ value. + return Tooltip.getTooltipOfObject({tooltip: this.tooltip_}); + } + + /** + * The element to bind the click handler to. If not set explicitly, defaults + * to the SVG root of the field. When this element is + * clicked on an editable field, the editor will open. + * @return {!Element} Element to bind click handler to. * @protected */ - this.textContent_ = null; + getClickTarget_() { + return this.clickTarget_ || this.getSvgRoot(); + } /** - * Mouse down event listener data. - * @type {?browserEvents.Data} - * @private - */ - this.mouseDownWrapper_ = null; - - /** - * Constants associated with the source block's renderer. - * @type {ConstantProvider} + * Return the absolute coordinates of the top-left corner of this field. + * The origin (0,0) is the top-left corner of the page body. + * @return {!Coordinate} Object with .x and .y properties. * @protected */ - this.constants_ = null; + getAbsoluteXY_() { + return style.getPageOffset( + /** @type {!SVGRectElement} */ (this.getClickTarget_())); + } - opt_config && this.configure_(opt_config); - this.setValue(value); - opt_validator && this.setValidator(opt_validator); -}; + /** + * Whether this field references any Blockly variables. If true it may need + * to be handled differently during serialization and deserialization. + * Subclasses may override this. + * @return {boolean} True if this field has any variable references. + * @package + */ + referencesVariables() { + return false; + } + + /** + * Refresh the variable name referenced by this field if this field references + * variables. + * @package + */ + refreshVariableName() { + // NOP + } + + /** + * Search through the list of inputs and their fields in order to find the + * parent input of a field. + * @return {Input} The input that the field belongs to. + * @package + */ + getParentInput() { + let parentInput = null; + const block = this.sourceBlock_; + const inputs = block.inputList; + + for (let idx = 0; idx < block.inputList.length; idx++) { + const input = inputs[idx]; + const fieldRows = input.fieldRow; + for (let j = 0; j < fieldRows.length; j++) { + if (fieldRows[j] === this) { + parentInput = input; + break; + } + } + } + return parentInput; + } + + /** + * Returns whether or not we should flip the field in RTL. + * @return {boolean} True if we should flip in RTL. + */ + getFlipRtl() { + return false; + } + + /** + * Returns whether or not the field is tab navigable. + * @return {boolean} True if the field is tab navigable. + */ + isTabNavigable() { + return false; + } + + /** + * Handles the given keyboard shortcut. + * @param {!ShortcutRegistry.KeyboardShortcut} _shortcut The shortcut to be + * handled. + * @return {boolean} True if the shortcut has been handled, false otherwise. + * @public + */ + onShortcut(_shortcut) { + return false; + } + + /** + * Add the cursor SVG to this fields SVG group. + * @param {SVGElement} cursorSvg The SVG root of the cursor to be added to the + * field group. + * @package + */ + setCursorSvg(cursorSvg) { + if (!cursorSvg) { + this.cursorSvg_ = null; + return; + } + + this.fieldGroup_.appendChild(cursorSvg); + this.cursorSvg_ = cursorSvg; + } + + /** + * Add the marker SVG to this fields SVG group. + * @param {SVGElement} markerSvg The SVG root of the marker to be added to the + * field group. + * @package + */ + setMarkerSvg(markerSvg) { + if (!markerSvg) { + this.markerSvg_ = null; + return; + } + + this.fieldGroup_.appendChild(markerSvg); + this.markerSvg_ = markerSvg; + } + + /** + * Redraw any attached marker or cursor svgs if needed. + * @protected + */ + updateMarkers_() { + const workspace = + /** @type {!WorkspaceSvg} */ (this.sourceBlock_.workspace); + if (workspace.keyboardAccessibilityMode && this.cursorSvg_) { + workspace.getCursor().draw(); + } + if (workspace.keyboardAccessibilityMode && this.markerSvg_) { + // TODO(#4592): Update all markers on the field. + workspace.getMarker(MarkerManager.LOCAL_MARKER).draw(); + } + } +} /** * The default value for this field. @@ -180,82 +1260,6 @@ const Field = function(value, opt_validator, opt_config) { */ Field.prototype.DEFAULT_VALUE = null; -/** - * Name of field. Unique within each block. - * Static labels are usually unnamed. - * @type {string|undefined} - */ -Field.prototype.name = undefined; - -/** - * Has this field been disposed of? - * @type {boolean} - * @package - */ -Field.prototype.disposed = false; - -/** - * Maximum characters of text to display before adding an ellipsis. - * @type {number} - */ -Field.prototype.maxDisplayLength = 50; - -/** - * Block this field is attached to. Starts as null, then set in init. - * @type {Block} - * @protected - */ -Field.prototype.sourceBlock_ = null; - -/** - * Does this block need to be re-rendered? - * @type {boolean} - * @protected - */ -Field.prototype.isDirty_ = true; - -/** - * Is the field visible, or hidden due to the block being collapsed? - * @type {boolean} - * @protected - */ -Field.prototype.visible_ = true; - -/** - * Can the field value be changed using the editor on an editable block? - * @type {boolean} - * @protected - */ -Field.prototype.enabled_ = true; - -/** - * The element the click handler is bound to. - * @type {Element} - * @protected - */ -Field.prototype.clickTarget_ = null; - -/** - * A developer hook to override the returned text of this field. - * Override if the text representation of the value of this field - * is not just a string cast of its value. - * Return null to resort to a string cast. - * @return {?string} Current text. Return null to resort to a string cast. - * @protected - */ -Field.prototype.getText_; - -/** - * An optional method that can be defined to show an editor when the field is - * clicked. Blockly will automatically set the field as clickable if this - * method is defined. - * @param {Event=} opt_e Optional mouse event that triggered the field to open, - * or undefined if triggered programmatically. - * @return {void} - * @protected - */ -Field.prototype.showEditor_; - /** * Non-breaking space. * @const @@ -263,956 +1267,11 @@ Field.prototype.showEditor_; Field.NBSP = '\u00A0'; /** - * Editable fields usually show some sort of UI indicating they are editable. - * They will also be saved by the XML renderer. - * @type {boolean} + * A value used to signal when a field's constructor should *not* set the + * field's value or run configure_, and should allow a subclass to do that + * instead. + * @const */ -Field.prototype.EDITABLE = true; - -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. This is not the - * case by default so that SERIALIZABLE is backwards compatible. - * @type {boolean} - */ -Field.prototype.SERIALIZABLE = false; - -/** - * Process the configuration map passed to the field. - * @param {!Object} config A map of options used to configure the field. See - * the individual field's documentation for a list of properties this - * parameter supports. - * @protected - */ -Field.prototype.configure_ = function(config) { - let tooltip = config['tooltip']; - if (typeof tooltip === 'string') { - tooltip = parsing.replaceMessageReferences(config['tooltip']); - } - tooltip && this.setTooltip(tooltip); - - // TODO (#2884): Possibly add CSS class config option. - // TODO (#2885): Possibly add cursor config option. -}; - -/** - * Attach this field to a block. - * @param {!Block} block The block containing this field. - */ -Field.prototype.setSourceBlock = function(block) { - if (this.sourceBlock_) { - throw Error('Field already bound to a block'); - } - this.sourceBlock_ = block; -}; - -/** - * Get the renderer constant provider. - * @return {?ConstantProvider} The renderer constant - * provider. - */ -Field.prototype.getConstants = function() { - if (!this.constants_ && this.sourceBlock_ && this.sourceBlock_.workspace && - this.sourceBlock_.workspace.rendered) { - this.constants_ = this.sourceBlock_.workspace.getRenderer().getConstants(); - } - return this.constants_; -}; - -/** - * Get the block this field is attached to. - * @return {Block} The block containing this field. - */ -Field.prototype.getSourceBlock = function() { - return this.sourceBlock_; -}; - -/** - * Initialize everything to render this field. Override - * methods initModel and initView rather than this method. - * @package - */ -Field.prototype.init = function() { - if (this.fieldGroup_) { - // Field has already been initialized once. - return; - } - this.fieldGroup_ = dom.createSvgElement(Svg.G, {}, null); - if (!this.isVisible()) { - this.fieldGroup_.style.display = 'none'; - } - const sourceBlockSvg = /** @type {!BlockSvg} **/ (this.sourceBlock_); - sourceBlockSvg.getSvgRoot().appendChild(this.fieldGroup_); - this.initView(); - this.updateEditable(); - this.setTooltip(this.tooltip_); - this.bindEvents_(); - this.initModel(); -}; - -/** - * Create the block UI for this field. - * @package - */ -Field.prototype.initView = function() { - this.createBorderRect_(); - this.createTextElement_(); -}; - -/** - * Initializes the model of the field after it has been installed on a block. - * No-op by default. - * @package - */ -Field.prototype.initModel = function() {}; - -/** - * Create a field border rect element. Not to be overridden by subclasses. - * Instead modify the result of the function inside initView, or create a - * separate function to call. - * @protected - */ -Field.prototype.createBorderRect_ = function() { - this.borderRect_ = dom.createSvgElement( - Svg.RECT, { - 'rx': this.getConstants().FIELD_BORDER_RECT_RADIUS, - 'ry': this.getConstants().FIELD_BORDER_RECT_RADIUS, - 'x': 0, - 'y': 0, - 'height': this.size_.height, - 'width': this.size_.width, - 'class': 'blocklyFieldRect', - }, - this.fieldGroup_); -}; - -/** - * Create a field text element. Not to be overridden by subclasses. Instead - * modify the result of the function inside initView, or create a separate - * function to call. - * @protected - */ -Field.prototype.createTextElement_ = function() { - this.textElement_ = dom.createSvgElement( - Svg.TEXT, { - 'class': 'blocklyText', - }, - this.fieldGroup_); - if (this.getConstants().FIELD_TEXT_BASELINE_CENTER) { - this.textElement_.setAttribute('dominant-baseline', 'central'); - } - this.textContent_ = document.createTextNode(''); - this.textElement_.appendChild(this.textContent_); -}; - -/** - * Bind events to the field. Can be overridden by subclasses if they need to do - * custom input handling. - * @protected - */ -Field.prototype.bindEvents_ = function() { - Tooltip.bindMouseEvents(this.getClickTarget_()); - this.mouseDownWrapper_ = browserEvents.conditionalBind( - this.getClickTarget_(), 'mousedown', this, this.onMouseDown_); -}; - -/** - * Sets the field's value based on the given XML element. Should only be called - * by Blockly.Xml. - * @param {!Element} fieldElement The element containing info about the - * field's state. - * @package - */ -Field.prototype.fromXml = function(fieldElement) { - this.setValue(fieldElement.textContent); -}; - -/** - * Serializes this field's value to XML. Should only be called by Blockly.Xml. - * @param {!Element} fieldElement The element to populate with info about the - * field's state. - * @return {!Element} The element containing info about the field's state. - * @package - */ -Field.prototype.toXml = function(fieldElement) { - fieldElement.textContent = this.getValue(); - return fieldElement; -}; - -/** - * Saves this fields value as something which can be serialized to JSON. Should - * only be called by the serialization system. - * @param {boolean=} _doFullSerialization If true, this signals to the field - * that if it normally just saves a reference to some state (eg variable - * fields) it should instead serialize the full state of the thing being - * referenced. - * @return {*} JSON serializable state. - * @package - */ -Field.prototype.saveState = function(_doFullSerialization) { - const legacyState = this.saveLegacyState(Field); - if (legacyState !== null) { - return legacyState; - } - return this.getValue(); -}; - -/** - * Sets the field's state based on the given state value. Should only be called - * by the serialization system. - * @param {*} state The state we want to apply to the field. - * @package - */ -Field.prototype.loadState = function(state) { - if (this.loadLegacyState(Field, state)) { - return; - } - this.setValue(state); -}; - -/** - * Returns a stringified version of the XML state, if it should be used. - * Otherwise this returns null, to signal the field should use its own - * serialization. - * @param {*} callingClass The class calling this method. - * Used to see if `this` has overridden any relevant hooks. - * @return {?string} The stringified version of the XML state, or null. - * @protected - */ -Field.prototype.saveLegacyState = function(callingClass) { - if (callingClass.prototype.saveState === this.saveState && - callingClass.prototype.toXml !== this.toXml) { - const elem = utilsXml.createElement('field'); - elem.setAttribute('name', this.name || ''); - const text = Xml.domToText(this.toXml(elem)); - return text.replace( - ' xmlns="https://developers.google.com/blockly/xml"', ''); - } - // Either they called this on purpose from their saveState, or they have - // no implementations of either hook. Just do our thing. - return null; -}; - -/** - * Loads the given state using either the old XML hoooks, if they should be - * used. Returns true to indicate loading has been handled, false otherwise. - * @param {*} callingClass The class calling this method. - * Used to see if `this` has overridden any relevant hooks. - * @param {*} state The state to apply to the field. - * @return {boolean} Whether the state was applied or not. - */ -Field.prototype.loadLegacyState = function(callingClass, state) { - if (callingClass.prototype.loadState === this.loadState && - callingClass.prototype.fromXml !== this.fromXml) { - this.fromXml(Xml.textToDom(/** @type {string} */ (state))); - return true; - } - // Either they called this on purpose from their loadState, or they have - // no implementations of either hook. Just do our thing. - return false; -}; - -/** - * Dispose of all DOM objects and events belonging to this editable field. - * @package - */ -Field.prototype.dispose = function() { - DropDownDiv.hideIfOwner(this); - WidgetDiv.hideIfOwner(this); - Tooltip.unbindMouseEvents(this.getClickTarget_()); - - if (this.mouseDownWrapper_) { - browserEvents.unbind(this.mouseDownWrapper_); - } - - dom.removeNode(this.fieldGroup_); - - this.disposed = true; -}; - -/** - * Add or remove the UI indicating if this field is editable or not. - */ -Field.prototype.updateEditable = function() { - const group = this.fieldGroup_; - if (!this.EDITABLE || !group) { - return; - } - if (this.enabled_ && this.sourceBlock_.isEditable()) { - dom.addClass(group, 'blocklyEditableText'); - dom.removeClass(group, 'blocklyNonEditableText'); - group.style.cursor = this.CURSOR; - } else { - dom.addClass(group, 'blocklyNonEditableText'); - dom.removeClass(group, 'blocklyEditableText'); - group.style.cursor = ''; - } -}; - -/** - * Set whether this field's value can be changed using the editor when the - * source block is editable. - * @param {boolean} enabled True if enabled. - */ -Field.prototype.setEnabled = function(enabled) { - this.enabled_ = enabled; - this.updateEditable(); -}; - -/** - * Check whether this field's value can be changed using the editor when the - * source block is editable. - * @return {boolean} Whether this field is enabled. - */ -Field.prototype.isEnabled = function() { - return this.enabled_; -}; - -/** - * Check whether this field defines the showEditor_ function. - * @return {boolean} Whether this field is clickable. - */ -Field.prototype.isClickable = function() { - return this.enabled_ && !!this.sourceBlock_ && - this.sourceBlock_.isEditable() && !!this.showEditor_ && - (typeof this.showEditor_ === 'function'); -}; - -/** - * Check whether this field is currently editable. Some fields are never - * EDITABLE (e.g. text labels). Other fields may be EDITABLE but may exist on - * non-editable blocks or be currently disabled. - * @return {boolean} Whether this field is currently enabled, editable and on - * an editable block. - */ -Field.prototype.isCurrentlyEditable = function() { - return this.enabled_ && this.EDITABLE && !!this.sourceBlock_ && - this.sourceBlock_.isEditable(); -}; - -/** - * Check whether this field should be serialized by the XML renderer. - * Handles the logic for backwards compatibility and incongruous states. - * @return {boolean} Whether this field should be serialized or not. - */ -Field.prototype.isSerializable = function() { - let isSerializable = false; - if (this.name) { - if (this.SERIALIZABLE) { - isSerializable = true; - } else if (this.EDITABLE) { - console.warn( - 'Detected an editable field that was not serializable.' + - ' Please define SERIALIZABLE property as true on all editable custom' + - ' fields. Proceeding with serialization.'); - isSerializable = true; - } - } - return isSerializable; -}; - -/** - * Gets whether this editable field is visible or not. - * @return {boolean} True if visible. - */ -Field.prototype.isVisible = function() { - return this.visible_; -}; - -/** - * Sets whether this editable field is visible or not. Should only be called - * by input.setVisible. - * @param {boolean} visible True if visible. - * @package - */ -Field.prototype.setVisible = function(visible) { - if (this.visible_ === visible) { - return; - } - this.visible_ = visible; - const root = this.getSvgRoot(); - if (root) { - root.style.display = visible ? 'block' : 'none'; - } -}; - -/** - * Sets a new validation function for editable fields, or clears a previously - * set validator. - * - * The validator function takes in the new field value, and returns - * validated value. The validated value could be the input value, a modified - * version of the input value, or null to abort the change. - * - * If the function does not return anything (or returns undefined) the new - * value is accepted as valid. This is to allow for fields using the - * validated function as a field-level change event notification. - * - * @param {Function} handler The validator function - * or null to clear a previous validator. - */ -Field.prototype.setValidator = function(handler) { - this.validator_ = handler; -}; - -/** - * Gets the validation function for editable fields, or null if not set. - * @return {?Function} Validation function, or null. - */ -Field.prototype.getValidator = function() { - return this.validator_; -}; - -/** - * Gets the group element for this editable field. - * Used for measuring the size and for positioning. - * @return {!SVGGElement} The group element. - */ -Field.prototype.getSvgRoot = function() { - return /** @type {!SVGGElement} */ (this.fieldGroup_); -}; - -/** - * Updates the field to match the colour/style of the block. Should only be - * called by BlockSvg.applyColour(). - * @package - */ -Field.prototype.applyColour = function() { - // Non-abstract sub-classes may wish to implement this. See FieldDropdown. -}; - -/** - * Used by getSize() to move/resize any DOM elements, and get the new size. - * - * All rendering that has an effect on the size/shape of the block should be - * done here, and should be triggered by getSize(). - * @protected - */ -Field.prototype.render_ = function() { - if (this.textContent_) { - this.textContent_.nodeValue = this.getDisplayText_(); - } - this.updateSize_(); -}; - -/** - * Show an editor when the field is clicked only if the field is clickable. - * @param {Event=} opt_e Optional mouse event that triggered the field to open, - * or undefined if triggered programmatically. - * @package - */ -Field.prototype.showEditor = function(opt_e) { - if (this.isClickable()) { - this.showEditor_(opt_e); - } -}; - -/** - * Updates the size of the field based on the text. - * @param {number=} opt_margin margin to use when positioning the text element. - * @protected - */ -Field.prototype.updateSize_ = function(opt_margin) { - const constants = this.getConstants(); - const xOffset = opt_margin !== undefined ? - opt_margin : - (this.borderRect_ ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0); - let totalWidth = xOffset * 2; - let totalHeight = constants.FIELD_TEXT_HEIGHT; - - let contentWidth = 0; - if (this.textElement_) { - contentWidth = dom.getFastTextWidth( - this.textElement_, constants.FIELD_TEXT_FONTSIZE, - constants.FIELD_TEXT_FONTWEIGHT, constants.FIELD_TEXT_FONTFAMILY); - totalWidth += contentWidth; - } - if (this.borderRect_) { - totalHeight = Math.max(totalHeight, constants.FIELD_BORDER_RECT_HEIGHT); - } - - this.size_.height = totalHeight; - this.size_.width = totalWidth; - - this.positionTextElement_(xOffset, contentWidth); - this.positionBorderRect_(); -}; - -/** - * Position a field's text element after a size change. This handles both LTR - * and RTL positioning. - * @param {number} xOffset x offset to use when positioning the text element. - * @param {number} contentWidth The content width. - * @protected - */ -Field.prototype.positionTextElement_ = function(xOffset, contentWidth) { - if (!this.textElement_) { - return; - } - const constants = this.getConstants(); - const halfHeight = this.size_.height / 2; - - this.textElement_.setAttribute( - 'x', - this.sourceBlock_.RTL ? this.size_.width - contentWidth - xOffset : - xOffset); - this.textElement_.setAttribute( - 'y', - constants.FIELD_TEXT_BASELINE_CENTER ? halfHeight : - halfHeight - - constants.FIELD_TEXT_HEIGHT / 2 + constants.FIELD_TEXT_BASELINE); -}; - -/** - * Position a field's border rect after a size change. - * @protected - */ -Field.prototype.positionBorderRect_ = function() { - if (!this.borderRect_) { - return; - } - this.borderRect_.setAttribute('width', this.size_.width); - this.borderRect_.setAttribute('height', this.size_.height); - this.borderRect_.setAttribute( - 'rx', this.getConstants().FIELD_BORDER_RECT_RADIUS); - this.borderRect_.setAttribute( - 'ry', this.getConstants().FIELD_BORDER_RECT_RADIUS); -}; - - -/** - * Returns the height and width of the field. - * - * This should *in general* be the only place render_ gets called from. - * @return {!Size} Height and width. - */ -Field.prototype.getSize = function() { - if (!this.isVisible()) { - return new Size(0, 0); - } - - if (this.isDirty_) { - this.render_(); - this.isDirty_ = false; - } else if (this.visible_ && this.size_.width === 0) { - // If the field is not visible the width will be 0 as well, one of the - // problems with the old system. - console.warn( - 'Deprecated use of setting size_.width to 0 to rerender a' + - ' field. Set field.isDirty_ to true instead.'); - this.render_(); - } - return this.size_; -}; - -/** - * Returns the bounding box of the rendered field, accounting for workspace - * scaling. - * @return {!Rect} An object with top, bottom, left, and right in - * pixels relative to the top left corner of the page (window coordinates). - * @package - */ -Field.prototype.getScaledBBox = function() { - let scaledWidth; - let scaledHeight; - let xy; - if (!this.borderRect_) { - // Browsers are inconsistent in what they return for a bounding box. - // - Webkit / Blink: fill-box / object bounding box - // - Gecko / Triden / EdgeHTML: stroke-box - const bBox = this.sourceBlock_.getHeightWidth(); - const scale = this.sourceBlock_.workspace.scale; - xy = this.getAbsoluteXY_(); - scaledWidth = bBox.width * scale; - scaledHeight = bBox.height * scale; - - if (userAgent.GECKO) { - xy.x += 1.5 * scale; - xy.y += 1.5 * scale; - scaledWidth += 1 * scale; - scaledHeight += 1 * scale; - } else { - if (!userAgent.EDGE && !userAgent.IE) { - xy.x -= 0.5 * scale; - xy.y -= 0.5 * scale; - } - scaledWidth += 1 * scale; - scaledHeight += 1 * scale; - } - } else { - const bBox = this.borderRect_.getBoundingClientRect(); - xy = style.getPageOffset(this.borderRect_); - scaledWidth = bBox.width; - scaledHeight = bBox.height; - } - return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth); -}; - -/** - * Get the text from this field to display on the block. May differ from - * ``getText`` due to ellipsis, and other formatting. - * @return {string} Text to display. - * @protected - */ -Field.prototype.getDisplayText_ = function() { - let text = this.getText(); - if (!text) { - // Prevent the field from disappearing if empty. - return Field.NBSP; - } - if (text.length > this.maxDisplayLength) { - // Truncate displayed string and add an ellipsis ('...'). - text = text.substring(0, this.maxDisplayLength - 2) + '\u2026'; - } - // Replace whitespace with non-breaking spaces so the text doesn't collapse. - text = text.replace(/\s/g, Field.NBSP); - if (this.sourceBlock_ && this.sourceBlock_.RTL) { - // The SVG is LTR, force text to be RTL. - text += '\u200F'; - } - return text; -}; - -/** - * Get the text from this field. - * @return {string} Current text. - */ -Field.prototype.getText = function() { - if (this.getText_) { - const text = this.getText_.call(this); - if (text !== null) { - return String(text); - } - } - return String(this.getValue()); -}; - -/** - * Force a rerender of the block that this field is installed on, which will - * rerender this field and adjust for any sizing changes. - * Other fields on the same block will not rerender, because their sizes have - * already been recorded. - * @package - */ -Field.prototype.markDirty = function() { - this.isDirty_ = true; - this.constants_ = null; -}; - -/** - * Force a rerender of the block that this field is installed on, which will - * rerender this field and adjust for any sizing changes. - * Other fields on the same block will not rerender, because their sizes have - * already been recorded. - * @package - */ -Field.prototype.forceRerender = function() { - this.isDirty_ = true; - if (this.sourceBlock_ && this.sourceBlock_.rendered) { - this.sourceBlock_.render(); - this.sourceBlock_.bumpNeighbours(); - this.updateMarkers_(); - } -}; - -/** - * Used to change the value of the field. Handles validation and events. - * Subclasses should override doClassValidation_ and doValueUpdate_ rather - * than this method. - * @param {*} newValue New value. - */ -Field.prototype.setValue = function(newValue) { - const doLogging = false; - if (newValue === null) { - doLogging && console.log('null, return'); - // Not a valid value to check. - return; - } - - let validatedValue = this.doClassValidation_(newValue); - // Class validators might accidentally forget to return, we'll ignore that. - newValue = this.processValidation_(newValue, validatedValue); - if (newValue instanceof Error) { - doLogging && console.log('invalid class validation, return'); - return; - } - - const localValidator = this.getValidator(); - if (localValidator) { - validatedValue = localValidator.call(this, newValue); - // Local validators might accidentally forget to return, we'll ignore that. - newValue = this.processValidation_(newValue, validatedValue); - if (newValue instanceof Error) { - doLogging && console.log('invalid local validation, return'); - return; - } - } - const source = this.sourceBlock_; - if (source && source.disposed) { - doLogging && console.log('source disposed, return'); - return; - } - const oldValue = this.getValue(); - if (oldValue === newValue) { - doLogging && console.log('same, doValueUpdate_, return'); - this.doValueUpdate_(newValue); - return; - } - - this.doValueUpdate_(newValue); - if (source && eventUtils.isEnabled()) { - eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( - source, 'field', this.name || null, oldValue, newValue)); - } - if (this.isDirty_) { - this.forceRerender(); - } - doLogging && console.log(this.value_); -}; - -/** - * Process the result of validation. - * @param {*} newValue New value. - * @param {*} validatedValue Validated value. - * @return {*} New value, or an Error object. - * @private - */ -Field.prototype.processValidation_ = function(newValue, validatedValue) { - if (validatedValue === null) { - this.doValueInvalid_(newValue); - if (this.isDirty_) { - this.forceRerender(); - } - return Error(); - } - if (validatedValue !== undefined) { - newValue = validatedValue; - } - return newValue; -}; - -/** - * Get the current value of the field. - * @return {*} Current value. - */ -Field.prototype.getValue = function() { - return this.value_; -}; - -/** - * Used to validate a value. Returns input by default. Can be overridden by - * subclasses, see FieldDropdown. - * @param {*=} opt_newValue The value to be validated. - * @return {*} The validated value, same as input by default. - * @protected - */ -Field.prototype.doClassValidation_ = function(opt_newValue) { - if (opt_newValue === null || opt_newValue === undefined) { - return null; - } - return opt_newValue; -}; - -/** - * Used to update the value of a field. Can be overridden by subclasses to do - * custom storage of values/updating of external things. - * @param {*} newValue The value to be saved. - * @protected - */ -Field.prototype.doValueUpdate_ = function(newValue) { - this.value_ = newValue; - this.isDirty_ = true; -}; - -/** - * Used to notify the field an invalid value was input. Can be overridden by - * subclasses, see FieldTextInput. - * No-op by default. - * @param {*} _invalidValue The input value that was determined to be invalid. - * @protected - */ -Field.prototype.doValueInvalid_ = function(_invalidValue) { - // NOP -}; - -/** - * Handle a mouse down event on a field. - * @param {!Event} e Mouse down event. - * @protected - */ -Field.prototype.onMouseDown_ = function(e) { - if (!this.sourceBlock_ || !this.sourceBlock_.workspace) { - return; - } - const gesture = this.sourceBlock_.workspace.getGesture(e); - if (gesture) { - gesture.setStartField(this); - } -}; - -/** - * Sets the tooltip for this field. - * @param {?Tooltip.TipInfo} newTip The - * text for the tooltip, a function that returns the text for the tooltip, a - * parent object whose tooltip will be used, or null to display the tooltip - * of the parent block. To not display a tooltip pass the empty string. - */ -Field.prototype.setTooltip = function(newTip) { - if (!newTip && newTip !== '') { // If null or undefined. - newTip = this.sourceBlock_; - } - const clickTarget = this.getClickTarget_(); - if (clickTarget) { - clickTarget.tooltip = newTip; - } else { - // Field has not been initialized yet. - this.tooltip_ = newTip; - } -}; - -/** - * Returns the tooltip text for this field. - * @return {string} The tooltip text for this field. - */ -Field.prototype.getTooltip = function() { - const clickTarget = this.getClickTarget_(); - if (clickTarget) { - return Tooltip.getTooltipOfObject(clickTarget); - } - // Field has not been initialized yet. Return stashed this.tooltip_ value. - return Tooltip.getTooltipOfObject({tooltip: this.tooltip_}); -}; - -/** - * The element to bind the click handler to. If not set explicitly, defaults - * to the SVG root of the field. When this element is - * clicked on an editable field, the editor will open. - * @return {!Element} Element to bind click handler to. - * @protected - */ -Field.prototype.getClickTarget_ = function() { - return this.clickTarget_ || this.getSvgRoot(); -}; - -/** - * Return the absolute coordinates of the top-left corner of this field. - * The origin (0,0) is the top-left corner of the page body. - * @return {!Coordinate} Object with .x and .y properties. - * @protected - */ -Field.prototype.getAbsoluteXY_ = function() { - return style.getPageOffset( - /** @type {!SVGRectElement} */ (this.getClickTarget_())); -}; - -/** - * Whether this field references any Blockly variables. If true it may need to - * be handled differently during serialization and deserialization. Subclasses - * may override this. - * @return {boolean} True if this field has any variable references. - * @package - */ -Field.prototype.referencesVariables = function() { - return false; -}; - -/** - * Search through the list of inputs and their fields in order to find the - * parent input of a field. - * @return {Input} The input that the field belongs to. - * @package - */ -Field.prototype.getParentInput = function() { - let parentInput = null; - const block = this.sourceBlock_; - const inputs = block.inputList; - - for (let idx = 0; idx < block.inputList.length; idx++) { - const input = inputs[idx]; - const fieldRows = input.fieldRow; - for (let j = 0; j < fieldRows.length; j++) { - if (fieldRows[j] === this) { - parentInput = input; - break; - } - } - } - return parentInput; -}; - -/** - * Returns whether or not we should flip the field in RTL. - * @return {boolean} True if we should flip in RTL. - */ -Field.prototype.getFlipRtl = function() { - return false; -}; - -/** - * Returns whether or not the field is tab navigable. - * @return {boolean} True if the field is tab navigable. - */ -Field.prototype.isTabNavigable = function() { - return false; -}; - -/** - * Handles the given keyboard shortcut. - * @param {!ShortcutRegistry.KeyboardShortcut} _shortcut The shortcut to be - * handled. - * @return {boolean} True if the shortcut has been handled, false otherwise. - * @public - */ -Field.prototype.onShortcut = function(_shortcut) { - return false; -}; - -/** - * Add the cursor SVG to this fields SVG group. - * @param {SVGElement} cursorSvg The SVG root of the cursor to be added to the - * field group. - * @package - */ -Field.prototype.setCursorSvg = function(cursorSvg) { - if (!cursorSvg) { - this.cursorSvg_ = null; - return; - } - - this.fieldGroup_.appendChild(cursorSvg); - this.cursorSvg_ = cursorSvg; -}; - -/** - * Add the marker SVG to this fields SVG group. - * @param {SVGElement} markerSvg The SVG root of the marker to be added to the - * field group. - * @package - */ -Field.prototype.setMarkerSvg = function(markerSvg) { - if (!markerSvg) { - this.markerSvg_ = null; - return; - } - - this.fieldGroup_.appendChild(markerSvg); - this.markerSvg_ = markerSvg; -}; - -/** - * Redraw any attached marker or cursor svgs if needed. - * @protected - */ -Field.prototype.updateMarkers_ = function() { - const workspace = - /** @type {!WorkspaceSvg} */ (this.sourceBlock_.workspace); - if (workspace.keyboardAccessibilityMode && this.cursorSvg_) { - workspace.getCursor().draw(); - } - if (workspace.keyboardAccessibilityMode && this.markerSvg_) { - // TODO(#4592): Update all markers on the field. - workspace.getMarker(MarkerManager.LOCAL_MARKER).draw(); - } -}; +Field.SKIP_SETUP = new Sentinel(); exports.Field = Field; diff --git a/core/field_angle.js b/core/field_angle.js index bfc5843ea..ea498fcad 100644 --- a/core/field_angle.js +++ b/core/field_angle.js @@ -21,108 +21,491 @@ const browserEvents = goog.require('Blockly.browserEvents'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); const math = goog.require('Blockly.utils.math'); -const object = goog.require('Blockly.utils.object'); const userAgent = goog.require('Blockly.utils.userAgent'); const {DropDownDiv} = goog.require('Blockly.DropDownDiv'); +const {Field} = goog.require('Blockly.Field'); const {FieldTextInput} = goog.require('Blockly.FieldTextInput'); const {KeyCodes} = goog.require('Blockly.utils.KeyCodes'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); const {Svg} = goog.require('Blockly.utils.Svg'); /** * Class for an editable angle field. - * @param {string|number=} opt_value The initial value of the field. Should cast - * to a number. Defaults to 0. - * @param {Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a number & returns a - * validated number, or null to abort the change. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation} - * for a list of properties this parameter supports. * @extends {FieldTextInput} - * @constructor - * @alias Blockly.FieldAngle */ -const FieldAngle = function(opt_value, opt_validator, opt_config) { +class FieldAngle extends FieldTextInput { /** - * Should the angle increase as the angle picker is moved clockwise (true) - * or counterclockwise (false) - * @see FieldAngle.CLOCKWISE - * @type {boolean} + * @param {(string|number|!Sentinel)=} opt_value The initial value of + * the field. Should cast to a number. Defaults to 0. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a number & returns a + * validated number, or null to abort the change. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldAngle + */ + constructor(opt_value, opt_validator, opt_config) { + super(Field.SKIP_SETUP); + + /** + * Should the angle increase as the angle picker is moved clockwise (true) + * or counterclockwise (false) + * @see FieldAngle.CLOCKWISE + * @type {boolean} + * @private + */ + this.clockwise_ = FieldAngle.CLOCKWISE; + + /** + * The offset of zero degrees (and all other angles). + * @see FieldAngle.OFFSET + * @type {number} + * @private + */ + this.offset_ = FieldAngle.OFFSET; + + /** + * The maximum angle to allow before wrapping. + * @see FieldAngle.WRAP + * @type {number} + * @private + */ + this.wrap_ = FieldAngle.WRAP; + + /** + * The amount to round angles to when using a mouse or keyboard nav input. + * @see FieldAngle.ROUND + * @type {number} + * @private + */ + this.round_ = FieldAngle.ROUND; + + /** + * The angle picker's SVG element. + * @type {?SVGElement} + * @private + */ + this.editor_ = null; + + /** + * The angle picker's gauge path depending on the value. + * @type {?SVGElement} + */ + this.gauge_ = null; + + /** + * The angle picker's line drawn representing the value's angle. + * @type {?SVGElement} + */ + this.line_ = null; + + /** + * The degree symbol for this field. + * @type {SVGTSpanElement} + * @protected + */ + this.symbol_ = null; + + /** + * Wrapper click event data. + * @type {?browserEvents.Data} + * @private + */ + this.clickWrapper_ = null; + + /** + * Surface click event data. + * @type {?browserEvents.Data} + * @private + */ + this.clickSurfaceWrapper_ = null; + + /** + * Surface mouse move event data. + * @type {?browserEvents.Data} + * @private + */ + this.moveSurfaceWrapper_ = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + * @type {boolean} + */ + this.SERIALIZABLE = true; + + if (opt_value === Field.SKIP_SETUP) return; + if (opt_config) this.configure_(opt_config); + this.setValue(opt_value); + if (opt_validator) this.setValidator(opt_validator); + } + + /** + * Configure the field based on the given map of options. + * @param {!Object} config A map of options to configure the field based on. + * @protected + * @override + */ + configure_(config) { + super.configure_(config); + + switch (config['mode']) { + case 'compass': + this.clockwise_ = true; + this.offset_ = 90; + break; + case 'protractor': + // This is the default mode, so we could do nothing. But just to + // future-proof, we'll set it anyway. + this.clockwise_ = false; + this.offset_ = 0; + break; + } + + // Allow individual settings to override the mode setting. + const clockwise = config['clockwise']; + if (typeof clockwise === 'boolean') { + this.clockwise_ = clockwise; + } + + // If these are passed as null then we should leave them on the default. + let offset = config['offset']; + if (offset !== null) { + offset = Number(offset); + if (!isNaN(offset)) { + this.offset_ = offset; + } + } + let wrap = config['wrap']; + if (wrap !== null) { + wrap = Number(wrap); + if (!isNaN(wrap)) { + this.wrap_ = wrap; + } + } + let round = config['round']; + if (round !== null) { + round = Number(round); + if (!isNaN(round)) { + this.round_ = round; + } + } + } + + /** + * Create the block UI for this field. + * @package + */ + initView() { + super.initView(); + // Add the degree symbol to the left of the number, even in RTL (issue + // #2380) + this.symbol_ = dom.createSvgElement(Svg.TSPAN, {}, null); + this.symbol_.appendChild(document.createTextNode('\u00B0')); + this.textElement_.appendChild(this.symbol_); + } + + /** + * Updates the graph when the field rerenders. + * @protected + * @override + */ + render_() { + super.render_(); + this.updateGraph_(); + } + + /** + * Create and show the angle field's editor. + * @param {Event=} opt_e Optional mouse event that triggered the field to + * open, or undefined if triggered programmatically. + * @protected + */ + showEditor_(opt_e) { + // Mobile browsers have issues with in-line textareas (focus & keyboards). + const noFocus = userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD; + super.showEditor_(opt_e, noFocus); + + this.dropdownCreate_(); + DropDownDiv.getContentDiv().appendChild(this.editor_); + + DropDownDiv.setColour( + this.sourceBlock_.style.colourPrimary, + this.sourceBlock_.style.colourTertiary); + + DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); + + this.updateGraph_(); + } + + /** + * Create the angle dropdown editor. * @private */ - this.clockwise_ = FieldAngle.CLOCKWISE; + dropdownCreate_() { + const svg = dom.createSvgElement( + Svg.SVG, { + 'xmlns': dom.SVG_NS, + 'xmlns:html': dom.HTML_NS, + 'xmlns:xlink': dom.XLINK_NS, + 'version': '1.1', + 'height': (FieldAngle.HALF * 2) + 'px', + 'width': (FieldAngle.HALF * 2) + 'px', + 'style': 'touch-action: none', + }, + null); + const circle = dom.createSvgElement( + Svg.CIRCLE, { + 'cx': FieldAngle.HALF, + 'cy': FieldAngle.HALF, + 'r': FieldAngle.RADIUS, + 'class': 'blocklyAngleCircle', + }, + svg); + this.gauge_ = + dom.createSvgElement(Svg.PATH, {'class': 'blocklyAngleGauge'}, svg); + this.line_ = dom.createSvgElement( + Svg.LINE, { + 'x1': FieldAngle.HALF, + 'y1': FieldAngle.HALF, + 'class': 'blocklyAngleLine', + }, + svg); + // Draw markers around the edge. + for (let angle = 0; angle < 360; angle += 15) { + dom.createSvgElement( + Svg.LINE, { + 'x1': FieldAngle.HALF + FieldAngle.RADIUS, + 'y1': FieldAngle.HALF, + 'x2': FieldAngle.HALF + FieldAngle.RADIUS - + (angle % 45 === 0 ? 10 : 5), + 'y2': FieldAngle.HALF, + 'class': 'blocklyAngleMarks', + 'transform': 'rotate(' + angle + ',' + FieldAngle.HALF + ',' + + FieldAngle.HALF + ')', + }, + svg); + } + + // The angle picker is different from other fields in that it updates on + // mousemove even if it's not in the middle of a drag. In future we may + // change this behaviour. + this.clickWrapper_ = + browserEvents.conditionalBind(svg, 'click', this, this.hide_); + // On touch devices, the picker's value is only updated with a drag. Add + // a click handler on the drag surface to update the value if the surface + // is clicked. + this.clickSurfaceWrapper_ = browserEvents.conditionalBind( + circle, 'click', this, this.onMouseMove_, true, true); + this.moveSurfaceWrapper_ = browserEvents.conditionalBind( + circle, 'mousemove', this, this.onMouseMove_, true, true); + this.editor_ = svg; + } /** - * The offset of zero degrees (and all other angles). - * @see FieldAngle.OFFSET - * @type {number} + * Disposes of events and DOM-references belonging to the angle editor. * @private */ - this.offset_ = FieldAngle.OFFSET; + dropdownDispose_() { + if (this.clickWrapper_) { + browserEvents.unbind(this.clickWrapper_); + this.clickWrapper_ = null; + } + if (this.clickSurfaceWrapper_) { + browserEvents.unbind(this.clickSurfaceWrapper_); + this.clickSurfaceWrapper_ = null; + } + if (this.moveSurfaceWrapper_) { + browserEvents.unbind(this.moveSurfaceWrapper_); + this.moveSurfaceWrapper_ = null; + } + this.gauge_ = null; + this.line_ = null; + } /** - * The maximum angle to allow before wrapping. - * @see FieldAngle.WRAP - * @type {number} + * Hide the editor. * @private */ - this.wrap_ = FieldAngle.WRAP; + hide_() { + DropDownDiv.hideIfOwner(this); + WidgetDiv.hide(); + } /** - * The amount to round angles to when using a mouse or keyboard nav input. - * @see FieldAngle.ROUND - * @type {number} + * Set the angle to match the mouse's position. + * @param {!Event} e Mouse move event. + * @protected + */ + onMouseMove_(e) { + // Calculate angle. + const bBox = this.gauge_.ownerSVGElement.getBoundingClientRect(); + const dx = e.clientX - bBox.left - FieldAngle.HALF; + const dy = e.clientY - bBox.top - FieldAngle.HALF; + let angle = Math.atan(-dy / dx); + if (isNaN(angle)) { + // This shouldn't happen, but let's not let this error propagate further. + return; + } + angle = math.toDegrees(angle); + // 0: East, 90: North, 180: West, 270: South. + if (dx < 0) { + angle += 180; + } else if (dy > 0) { + angle += 360; + } + + // Do offsetting. + if (this.clockwise_) { + angle = this.offset_ + 360 - angle; + } else { + angle = 360 - (this.offset_ - angle); + } + + this.displayMouseOrKeyboardValue_(angle); + } + + /** + * Handles and displays values that are input via mouse or arrow key input. + * These values need to be rounded and wrapped before being displayed so + * that the text input's value is appropriate. + * @param {number} angle New angle. * @private */ - this.round_ = FieldAngle.ROUND; - - FieldAngle.superClass_.constructor.call( - this, opt_value, opt_validator, opt_config); + displayMouseOrKeyboardValue_(angle) { + if (this.round_) { + angle = Math.round(angle / this.round_) * this.round_; + } + angle = this.wrapValue_(angle); + if (angle !== this.value_) { + this.setEditorValue_(angle); + } + } /** - * The angle picker's SVG element. - * @type {?SVGElement} + * Redraw the graph with the current angle. * @private */ - this.editor_ = null; + updateGraph_() { + if (!this.gauge_) { + return; + } + // Always display the input (i.e. getText) even if it is invalid. + let angleDegrees = Number(this.getText()) + this.offset_; + angleDegrees %= 360; + let angleRadians = math.toRadians(angleDegrees); + const path = ['M ', FieldAngle.HALF, ',', FieldAngle.HALF]; + let x2 = FieldAngle.HALF; + let y2 = FieldAngle.HALF; + if (!isNaN(angleRadians)) { + const clockwiseFlag = Number(this.clockwise_); + const angle1 = math.toRadians(this.offset_); + const x1 = Math.cos(angle1) * FieldAngle.RADIUS; + const y1 = Math.sin(angle1) * -FieldAngle.RADIUS; + if (clockwiseFlag) { + angleRadians = 2 * angle1 - angleRadians; + } + x2 += Math.cos(angleRadians) * FieldAngle.RADIUS; + y2 -= Math.sin(angleRadians) * FieldAngle.RADIUS; + // Don't ask how the flag calculations work. They just do. + let largeFlag = + Math.abs(Math.floor((angleRadians - angle1) / Math.PI) % 2); + if (clockwiseFlag) { + largeFlag = 1 - largeFlag; + } + path.push( + ' l ', x1, ',', y1, ' A ', FieldAngle.RADIUS, ',', FieldAngle.RADIUS, + ' 0 ', largeFlag, ' ', clockwiseFlag, ' ', x2, ',', y2, ' z'); + } + this.gauge_.setAttribute('d', path.join('')); + this.line_.setAttribute('x2', x2); + this.line_.setAttribute('y2', y2); + } /** - * The angle picker's gauge path depending on the value. - * @type {?SVGElement} + * Handle key down to the editor. + * @param {!Event} e Keyboard event. + * @protected + * @override */ - this.gauge_ = null; + onHtmlInputKeyDown_(e) { + super.onHtmlInputKeyDown_(e); + + let multiplier; + if (e.keyCode === KeyCodes.LEFT) { + // decrement (increment in RTL) + multiplier = this.sourceBlock_.RTL ? 1 : -1; + } else if (e.keyCode === KeyCodes.RIGHT) { + // increment (decrement in RTL) + multiplier = this.sourceBlock_.RTL ? -1 : 1; + } else if (e.keyCode === KeyCodes.DOWN) { + // decrement + multiplier = -1; + } else if (e.keyCode === KeyCodes.UP) { + // increment + multiplier = 1; + } + if (multiplier) { + const value = /** @type {number} */ (this.getValue()); + this.displayMouseOrKeyboardValue_(value + (multiplier * this.round_)); + e.preventDefault(); + e.stopPropagation(); + } + } /** - * The angle picker's line drawn representing the value's angle. - * @type {?SVGElement} + * Ensure that the input value is a valid angle. + * @param {*=} opt_newValue The input value. + * @return {?number} A valid angle, or null if invalid. + * @protected + * @override */ - this.line_ = null; + doClassValidation_(opt_newValue) { + const value = Number(opt_newValue); + if (isNaN(value) || !isFinite(value)) { + return null; + } + return this.wrapValue_(value); + } /** - * Wrapper click event data. - * @type {?browserEvents.Data} + * Wraps the value so that it is in the range (-360 + wrap, wrap). + * @param {number} value The value to wrap. + * @return {number} The wrapped value. * @private */ - this.clickWrapper_ = null; + wrapValue_(value) { + value %= 360; + if (value < 0) { + value += 360; + } + if (value > this.wrap_) { + value -= 360; + } + return value; + } /** - * Surface click event data. - * @type {?browserEvents.Data} - * @private + * Construct a FieldAngle from a JSON arg object. + * @param {!Object} options A JSON object with options (angle). + * @return {!FieldAngle} The new field instance. + * @package + * @nocollapse + * @override */ - this.clickSurfaceWrapper_ = null; - - /** - * Surface mouse move event data. - * @type {?browserEvents.Data} - * @private - */ - this.moveSurfaceWrapper_ = null; -}; -object.inherits(FieldAngle, FieldTextInput); - + static fromJson(options) { + // `this` might be a subclass of FieldAngle if that class doesn't override + // the static fromJson method. + return new this(options['angle'], undefined, options); + } +} /** * The default value for this field. @@ -131,26 +514,6 @@ object.inherits(FieldAngle, FieldTextInput); */ FieldAngle.prototype.DEFAULT_VALUE = 0; -/** - * Construct a FieldAngle from a JSON arg object. - * @param {!Object} options A JSON object with options (angle). - * @return {!FieldAngle} The new field instance. - * @package - * @nocollapse - */ -FieldAngle.fromJson = function(options) { - // `this` might be a subclass of FieldAngle if that class doesn't override - // the static fromJson method. - return new this(options['angle'], undefined, options); -}; - -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. - * @type {boolean} - */ -FieldAngle.prototype.SERIALIZABLE = true; - /** * The default amount to round angles to when using a mouse or keyboard nav * input. Must be a positive integer to support keyboard navigation. @@ -193,349 +556,6 @@ FieldAngle.WRAP = 360; */ FieldAngle.RADIUS = FieldAngle.HALF - 1; -/** - * Configure the field based on the given map of options. - * @param {!Object} config A map of options to configure the field based on. - * @protected - * @override - */ -FieldAngle.prototype.configure_ = function(config) { - FieldAngle.superClass_.configure_.call(this, config); - - switch (config['mode']) { - case 'compass': - this.clockwise_ = true; - this.offset_ = 90; - break; - case 'protractor': - // This is the default mode, so we could do nothing. But just to - // future-proof, we'll set it anyway. - this.clockwise_ = false; - this.offset_ = 0; - break; - } - - // Allow individual settings to override the mode setting. - const clockwise = config['clockwise']; - if (typeof clockwise === 'boolean') { - this.clockwise_ = clockwise; - } - - // If these are passed as null then we should leave them on the default. - let offset = config['offset']; - if (offset !== null) { - offset = Number(offset); - if (!isNaN(offset)) { - this.offset_ = offset; - } - } - let wrap = config['wrap']; - if (wrap !== null) { - wrap = Number(wrap); - if (!isNaN(wrap)) { - this.wrap_ = wrap; - } - } - let round = config['round']; - if (round !== null) { - round = Number(round); - if (!isNaN(round)) { - this.round_ = round; - } - } -}; - -/** - * Create the block UI for this field. - * @package - */ -FieldAngle.prototype.initView = function() { - FieldAngle.superClass_.initView.call(this); - // Add the degree symbol to the left of the number, even in RTL (issue #2380) - this.symbol_ = dom.createSvgElement(Svg.TSPAN, {}, null); - this.symbol_.appendChild(document.createTextNode('\u00B0')); - this.textElement_.appendChild(this.symbol_); -}; - -/** - * Updates the graph when the field rerenders. - * @protected - * @override - */ -FieldAngle.prototype.render_ = function() { - FieldAngle.superClass_.render_.call(this); - this.updateGraph_(); -}; - -/** - * Create and show the angle field's editor. - * @param {Event=} opt_e Optional mouse event that triggered the field to open, - * or undefined if triggered programmatically. - * @protected - */ -FieldAngle.prototype.showEditor_ = function(opt_e) { - // Mobile browsers have issues with in-line textareas (focus & keyboards). - const noFocus = userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD; - FieldAngle.superClass_.showEditor_.call(this, opt_e, noFocus); - - this.dropdownCreate_(); - DropDownDiv.getContentDiv().appendChild(this.editor_); - - DropDownDiv.setColour( - this.sourceBlock_.style.colourPrimary, - this.sourceBlock_.style.colourTertiary); - - DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); - - this.updateGraph_(); -}; - -/** - * Create the angle dropdown editor. - * @private - */ -FieldAngle.prototype.dropdownCreate_ = function() { - const svg = dom.createSvgElement( - Svg.SVG, { - 'xmlns': dom.SVG_NS, - 'xmlns:html': dom.HTML_NS, - 'xmlns:xlink': dom.XLINK_NS, - 'version': '1.1', - 'height': (FieldAngle.HALF * 2) + 'px', - 'width': (FieldAngle.HALF * 2) + 'px', - 'style': 'touch-action: none', - }, - null); - const circle = dom.createSvgElement( - Svg.CIRCLE, { - 'cx': FieldAngle.HALF, - 'cy': FieldAngle.HALF, - 'r': FieldAngle.RADIUS, - 'class': 'blocklyAngleCircle', - }, - svg); - this.gauge_ = - dom.createSvgElement(Svg.PATH, {'class': 'blocklyAngleGauge'}, svg); - this.line_ = dom.createSvgElement( - Svg.LINE, { - 'x1': FieldAngle.HALF, - 'y1': FieldAngle.HALF, - 'class': 'blocklyAngleLine', - }, - svg); - // Draw markers around the edge. - for (let angle = 0; angle < 360; angle += 15) { - dom.createSvgElement( - Svg.LINE, { - 'x1': FieldAngle.HALF + FieldAngle.RADIUS, - 'y1': FieldAngle.HALF, - 'x2': - FieldAngle.HALF + FieldAngle.RADIUS - (angle % 45 === 0 ? 10 : 5), - 'y2': FieldAngle.HALF, - 'class': 'blocklyAngleMarks', - 'transform': 'rotate(' + angle + ',' + FieldAngle.HALF + ',' + - FieldAngle.HALF + ')', - }, - svg); - } - - // The angle picker is different from other fields in that it updates on - // mousemove even if it's not in the middle of a drag. In future we may - // change this behaviour. - this.clickWrapper_ = - browserEvents.conditionalBind(svg, 'click', this, this.hide_); - // On touch devices, the picker's value is only updated with a drag. Add - // a click handler on the drag surface to update the value if the surface - // is clicked. - this.clickSurfaceWrapper_ = browserEvents.conditionalBind( - circle, 'click', this, this.onMouseMove_, true, true); - this.moveSurfaceWrapper_ = browserEvents.conditionalBind( - circle, 'mousemove', this, this.onMouseMove_, true, true); - this.editor_ = svg; -}; - -/** - * Disposes of events and DOM-references belonging to the angle editor. - * @private - */ -FieldAngle.prototype.dropdownDispose_ = function() { - if (this.clickWrapper_) { - browserEvents.unbind(this.clickWrapper_); - this.clickWrapper_ = null; - } - if (this.clickSurfaceWrapper_) { - browserEvents.unbind(this.clickSurfaceWrapper_); - this.clickSurfaceWrapper_ = null; - } - if (this.moveSurfaceWrapper_) { - browserEvents.unbind(this.moveSurfaceWrapper_); - this.moveSurfaceWrapper_ = null; - } - this.gauge_ = null; - this.line_ = null; -}; - -/** - * Hide the editor. - * @private - */ -FieldAngle.prototype.hide_ = function() { - DropDownDiv.hideIfOwner(this); - WidgetDiv.hide(); -}; - -/** - * Set the angle to match the mouse's position. - * @param {!Event} e Mouse move event. - * @protected - */ -FieldAngle.prototype.onMouseMove_ = function(e) { - // Calculate angle. - const bBox = this.gauge_.ownerSVGElement.getBoundingClientRect(); - const dx = e.clientX - bBox.left - FieldAngle.HALF; - const dy = e.clientY - bBox.top - FieldAngle.HALF; - let angle = Math.atan(-dy / dx); - if (isNaN(angle)) { - // This shouldn't happen, but let's not let this error propagate further. - return; - } - angle = math.toDegrees(angle); - // 0: East, 90: North, 180: West, 270: South. - if (dx < 0) { - angle += 180; - } else if (dy > 0) { - angle += 360; - } - - // Do offsetting. - if (this.clockwise_) { - angle = this.offset_ + 360 - angle; - } else { - angle = 360 - (this.offset_ - angle); - } - - this.displayMouseOrKeyboardValue_(angle); -}; - -/** - * Handles and displays values that are input via mouse or arrow key input. - * These values need to be rounded and wrapped before being displayed so - * that the text input's value is appropriate. - * @param {number} angle New angle. - * @private - */ -FieldAngle.prototype.displayMouseOrKeyboardValue_ = function(angle) { - if (this.round_) { - angle = Math.round(angle / this.round_) * this.round_; - } - angle = this.wrapValue_(angle); - if (angle !== this.value_) { - this.setEditorValue_(angle); - } -}; - -/** - * Redraw the graph with the current angle. - * @private - */ -FieldAngle.prototype.updateGraph_ = function() { - if (!this.gauge_) { - return; - } - // Always display the input (i.e. getText) even if it is invalid. - let angleDegrees = Number(this.getText()) + this.offset_; - angleDegrees %= 360; - let angleRadians = math.toRadians(angleDegrees); - const path = ['M ', FieldAngle.HALF, ',', FieldAngle.HALF]; - let x2 = FieldAngle.HALF; - let y2 = FieldAngle.HALF; - if (!isNaN(angleRadians)) { - const clockwiseFlag = Number(this.clockwise_); - const angle1 = math.toRadians(this.offset_); - const x1 = Math.cos(angle1) * FieldAngle.RADIUS; - const y1 = Math.sin(angle1) * -FieldAngle.RADIUS; - if (clockwiseFlag) { - angleRadians = 2 * angle1 - angleRadians; - } - x2 += Math.cos(angleRadians) * FieldAngle.RADIUS; - y2 -= Math.sin(angleRadians) * FieldAngle.RADIUS; - // Don't ask how the flag calculations work. They just do. - let largeFlag = Math.abs(Math.floor((angleRadians - angle1) / Math.PI) % 2); - if (clockwiseFlag) { - largeFlag = 1 - largeFlag; - } - path.push( - ' l ', x1, ',', y1, ' A ', FieldAngle.RADIUS, ',', FieldAngle.RADIUS, - ' 0 ', largeFlag, ' ', clockwiseFlag, ' ', x2, ',', y2, ' z'); - } - this.gauge_.setAttribute('d', path.join('')); - this.line_.setAttribute('x2', x2); - this.line_.setAttribute('y2', y2); -}; - -/** - * Handle key down to the editor. - * @param {!Event} e Keyboard event. - * @protected - * @override - */ -FieldAngle.prototype.onHtmlInputKeyDown_ = function(e) { - FieldAngle.superClass_.onHtmlInputKeyDown_.call(this, e); - - let multiplier; - if (e.keyCode === KeyCodes.LEFT) { - // decrement (increment in RTL) - multiplier = this.sourceBlock_.RTL ? 1 : -1; - } else if (e.keyCode === KeyCodes.RIGHT) { - // increment (decrement in RTL) - multiplier = this.sourceBlock_.RTL ? -1 : 1; - } else if (e.keyCode === KeyCodes.DOWN) { - // decrement - multiplier = -1; - } else if (e.keyCode === KeyCodes.UP) { - // increment - multiplier = 1; - } - if (multiplier) { - const value = /** @type {number} */ (this.getValue()); - this.displayMouseOrKeyboardValue_(value + (multiplier * this.round_)); - e.preventDefault(); - e.stopPropagation(); - } -}; - -/** - * Ensure that the input value is a valid angle. - * @param {*=} opt_newValue The input value. - * @return {?number} A valid angle, or null if invalid. - * @protected - * @override - */ -FieldAngle.prototype.doClassValidation_ = function(opt_newValue) { - const value = Number(opt_newValue); - if (isNaN(value) || !isFinite(value)) { - return null; - } - return this.wrapValue_(value); -}; - -/** - * Wraps the value so that it is in the range (-360 + wrap, wrap). - * @param {number} value The value to wrap. - * @return {number} The wrapped value. - * @private - */ -FieldAngle.prototype.wrapValue_ = function(value) { - value %= 360; - if (value < 0) { - value += 360; - } - if (value > this.wrap_) { - value -= 360; - } - return value; -}; - /** * CSS for angle field. See css.js for use. */ diff --git a/core/field_checkbox.js b/core/field_checkbox.js index ddbe38711..2e2cdc481 100644 --- a/core/field_checkbox.js +++ b/core/field_checkbox.js @@ -17,41 +17,224 @@ goog.module('Blockly.FieldCheckbox'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); const {Field} = goog.require('Blockly.Field'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.BlockChange'); /** * Class for a checkbox field. - * @param {string|boolean=} opt_value The initial value of the field. Should - * either be 'TRUE', 'FALSE' or a boolean. Defaults to 'FALSE'. - * @param {Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a value ('TRUE' or 'FALSE') & - * returns a validated value ('TRUE' or 'FALSE'), or null to abort the - * change. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/checkbox#creation} - * for a list of properties this parameter supports. * @extends {Field} - * @constructor - * @alias Blockly.FieldCheckbox */ -const FieldCheckbox = function(opt_value, opt_validator, opt_config) { +class FieldCheckbox extends Field { /** - * Character for the check mark. Used to apply a different check mark - * character to individual fields. - * @type {?string} + * @param {(string|boolean|!Sentinel)=} opt_value The initial value of + * the field. Should either be 'TRUE', 'FALSE' or a boolean. Defaults to + * 'FALSE'. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a value ('TRUE' or 'FALSE') & + * returns a validated value ('TRUE' or 'FALSE'), or null to abort the + * change. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/checkbox#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldCheckbox + */ + constructor(opt_value, opt_validator, opt_config) { + super(Field.SKIP_SETUP); + + /** + * Character for the check mark. Used to apply a different check mark + * character to individual fields. + * @type {string} + * @private + */ + this.checkChar_ = FieldCheckbox.CHECK_CHAR; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + * @type {boolean} + */ + this.SERIALIZABLE = true; + + /** + * Mouse cursor style when over the hotspot that initiates editability. + * @type {string} + */ + this.CURSOR = 'default'; + + if (opt_value === Field.SKIP_SETUP) return; + if (opt_config) this.configure_(opt_config); + this.setValue(opt_value); + if (opt_validator) this.setValidator(opt_validator); + } + + /** + * Configure the field based on the given map of options. + * @param {!Object} config A map of options to configure the field based on. + * @protected + * @override + */ + configure_(config) { + super.configure_(config); + if (config['checkCharacter']) { + this.checkChar_ = config['checkCharacter']; + } + } + + /** + * Saves this field's value. + * @return {*} The boolean value held by this field. + * @override + * @package + */ + saveState() { + const legacyState = this.saveLegacyState(FieldCheckbox); + if (legacyState !== null) { + return legacyState; + } + return this.getValueBoolean(); + } + + /** + * Create the block UI for this checkbox. + * @package + */ + initView() { + super.initView(); + + dom.addClass( + /** @type {!SVGTextElement} **/ (this.textElement_), 'blocklyCheckbox'); + this.textElement_.style.display = this.value_ ? 'block' : 'none'; + } + + /** + * @override + */ + render_() { + if (this.textContent_) { + this.textContent_.nodeValue = this.getDisplayText_(); + } + this.updateSize_(this.getConstants().FIELD_CHECKBOX_X_OFFSET); + } + + /** + * @override + */ + getDisplayText_() { + return this.checkChar_; + } + + /** + * Set the character used for the check mark. + * @param {?string} character The character to use for the check mark, or + * null to use the default. + */ + setCheckCharacter(character) { + this.checkChar_ = character || FieldCheckbox.CHECK_CHAR; + this.forceRerender(); + } + + /** + * Toggle the state of the checkbox on click. + * @protected + */ + showEditor_() { + this.setValue(!this.value_); + } + + /** + * Ensure that the input value is valid ('TRUE' or 'FALSE'). + * @param {*=} opt_newValue The input value. + * @return {?string} A valid value ('TRUE' or 'FALSE), or null if invalid. + * @protected + */ + doClassValidation_(opt_newValue) { + if (opt_newValue === true || opt_newValue === 'TRUE') { + return 'TRUE'; + } + if (opt_newValue === false || opt_newValue === 'FALSE') { + return 'FALSE'; + } + return null; + } + + /** + * Update the value of the field, and update the checkElement. + * @param {*} newValue The value to be saved. The default validator guarantees + * that this is a either 'TRUE' or 'FALSE'. + * @protected + */ + doValueUpdate_(newValue) { + this.value_ = this.convertValueToBool_(newValue); + // Update visual. + if (this.textElement_) { + this.textElement_.style.display = this.value_ ? 'block' : 'none'; + } + } + + /** + * Get the value of this field, either 'TRUE' or 'FALSE'. + * @return {string} The value of this field. + */ + getValue() { + return this.value_ ? 'TRUE' : 'FALSE'; + } + + /** + * Get the boolean value of this field. + * @return {boolean} The boolean value of this field. + */ + getValueBoolean() { + return /** @type {boolean} */ (this.value_); + } + + /** + * Get the text of this field. Used when the block is collapsed. + * @return {string} Text representing the value of this field + * ('true' or 'false'). + */ + getText() { + return String(this.convertValueToBool_(this.value_)); + } + + /** + * Convert a value into a pure boolean. + * + * Converts 'TRUE' to true and 'FALSE' to false correctly, everything else + * is cast to a boolean. + * @param {*} value The value to convert. + * @return {boolean} The converted value. * @private */ - this.checkChar_ = null; + convertValueToBool_(value) { + if (typeof value === 'string') { + return value === 'TRUE'; + } else { + return !!value; + } + } - FieldCheckbox.superClass_.constructor.call( - this, opt_value, opt_validator, opt_config); -}; -object.inherits(FieldCheckbox, Field); + /** + * Construct a FieldCheckbox from a JSON arg object. + * @param {!Object} options A JSON object with options (checked). + * @return {!FieldCheckbox} The new field instance. + * @package + * @nocollapse + */ + static fromJson(options) { + // `this` might be a subclass of FieldCheckbox if that class doesn't + // 'override' the static fromJson method. + return new this(options['checked'], undefined, options); + } +} /** * The default value for this field. @@ -60,19 +243,6 @@ object.inherits(FieldCheckbox, Field); */ FieldCheckbox.prototype.DEFAULT_VALUE = false; -/** - * Construct a FieldCheckbox from a JSON arg object. - * @param {!Object} options A JSON object with options (checked). - * @return {!FieldCheckbox} The new field instance. - * @package - * @nocollapse - */ -FieldCheckbox.fromJson = function(options) { - // `this` might be a subclass of FieldCheckbox if that class doesn't override - // the static fromJson method. - return new this(options['checked'], undefined, options); -}; - /** * Default character for the checkmark. * @type {string} @@ -80,164 +250,6 @@ FieldCheckbox.fromJson = function(options) { */ FieldCheckbox.CHECK_CHAR = '\u2713'; -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. - * @type {boolean} - */ -FieldCheckbox.prototype.SERIALIZABLE = true; - -/** - * Mouse cursor style when over the hotspot that initiates editability. - */ -FieldCheckbox.prototype.CURSOR = 'default'; - -/** - * Configure the field based on the given map of options. - * @param {!Object} config A map of options to configure the field based on. - * @protected - * @override - */ -FieldCheckbox.prototype.configure_ = function(config) { - FieldCheckbox.superClass_.configure_.call(this, config); - if (config['checkCharacter']) { - this.checkChar_ = config['checkCharacter']; - } -}; - -/** - * Saves this field's value. - * @return {*} The boolean value held by this field. - * @override - * @package - */ -FieldCheckbox.prototype.saveState = function() { - const legacyState = this.saveLegacyState(FieldCheckbox); - if (legacyState !== null) { - return legacyState; - } - return this.getValueBoolean(); -}; - -/** - * Create the block UI for this checkbox. - * @package - */ -FieldCheckbox.prototype.initView = function() { - FieldCheckbox.superClass_.initView.call(this); - - dom.addClass( - /** @type {!SVGTextElement} **/ (this.textElement_), 'blocklyCheckbox'); - this.textElement_.style.display = this.value_ ? 'block' : 'none'; -}; - -/** - * @override - */ -FieldCheckbox.prototype.render_ = function() { - if (this.textContent_) { - this.textContent_.nodeValue = this.getDisplayText_(); - } - this.updateSize_(this.getConstants().FIELD_CHECKBOX_X_OFFSET); -}; - -/** - * @override - */ -FieldCheckbox.prototype.getDisplayText_ = function() { - return this.checkChar_ || FieldCheckbox.CHECK_CHAR; -}; - -/** - * Set the character used for the check mark. - * @param {?string} character The character to use for the check mark, or - * null to use the default. - */ -FieldCheckbox.prototype.setCheckCharacter = function(character) { - this.checkChar_ = character; - this.forceRerender(); -}; - -/** - * Toggle the state of the checkbox on click. - * @protected - */ -FieldCheckbox.prototype.showEditor_ = function() { - this.setValue(!this.value_); -}; - -/** - * Ensure that the input value is valid ('TRUE' or 'FALSE'). - * @param {*=} opt_newValue The input value. - * @return {?string} A valid value ('TRUE' or 'FALSE), or null if invalid. - * @protected - */ -FieldCheckbox.prototype.doClassValidation_ = function(opt_newValue) { - if (opt_newValue === true || opt_newValue === 'TRUE') { - return 'TRUE'; - } - if (opt_newValue === false || opt_newValue === 'FALSE') { - return 'FALSE'; - } - return null; -}; - -/** - * Update the value of the field, and update the checkElement. - * @param {*} newValue The value to be saved. The default validator guarantees - * that this is a either 'TRUE' or 'FALSE'. - * @protected - */ -FieldCheckbox.prototype.doValueUpdate_ = function(newValue) { - this.value_ = this.convertValueToBool_(newValue); - // Update visual. - if (this.textElement_) { - this.textElement_.style.display = this.value_ ? 'block' : 'none'; - } -}; - -/** - * Get the value of this field, either 'TRUE' or 'FALSE'. - * @return {string} The value of this field. - */ -FieldCheckbox.prototype.getValue = function() { - return this.value_ ? 'TRUE' : 'FALSE'; -}; - -/** - * Get the boolean value of this field. - * @return {boolean} The boolean value of this field. - */ -FieldCheckbox.prototype.getValueBoolean = function() { - return /** @type {boolean} */ (this.value_); -}; - -/** - * Get the text of this field. Used when the block is collapsed. - * @return {string} Text representing the value of this field - * ('true' or 'false'). - */ -FieldCheckbox.prototype.getText = function() { - return String(this.convertValueToBool_(this.value_)); -}; - -/** - * Convert a value into a pure boolean. - * - * Converts 'TRUE' to true and 'FALSE' to false correctly, everything else - * is cast to a boolean. - * @param {*} value The value to convert. - * @return {boolean} The converted value. - * @private - */ -FieldCheckbox.prototype.convertValueToBool_ = function(value) { - if (typeof value === 'string') { - return value === 'TRUE'; - } else { - return !!value; - } -}; - fieldRegistry.register('field_checkbox', FieldCheckbox); exports.FieldCheckbox = FieldCheckbox; diff --git a/core/field_colour.js b/core/field_colour.js index a769844ef..a2d11c3f2 100644 --- a/core/field_colour.js +++ b/core/field_colour.js @@ -22,10 +22,11 @@ const colour = goog.require('Blockly.utils.colour'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); const idGenerator = goog.require('Blockly.utils.idGenerator'); -const object = goog.require('Blockly.utils.object'); const {DropDownDiv} = goog.require('Blockly.DropDownDiv'); const {Field} = goog.require('Blockly.Field'); const {KeyCodes} = goog.require('Blockly.utils.KeyCodes'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); const {Size} = goog.require('Blockly.utils.Size'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.BlockChange'); @@ -33,219 +34,539 @@ goog.require('Blockly.Events.BlockChange'); /** * Class for a colour input field. - * @param {string=} opt_value The initial value of the field. Should be in - * '#rrggbb' format. Defaults to the first value in the default colour array. - * @param {Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a colour string & returns a - * validated colour string ('#rrggbb' format), or null to abort the - * change.Blockly. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour} - * for a list of properties this parameter supports. * @extends {Field} - * @constructor - * @alias Blockly.FieldColour */ -const FieldColour = function(opt_value, opt_validator, opt_config) { - FieldColour.superClass_.constructor.call( - this, opt_value, opt_validator, opt_config); - +class FieldColour extends Field { /** - * The field's colour picker element. - * @type {?Element} - * @private + * @param {(string|!Sentinel)=} opt_value The initial value of the + * field. Should be in '#rrggbb' format. Defaults to the first value in + * the default colour array. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a colour string & returns a + * validated colour string ('#rrggbb' format), or null to abort the + * change.Blockly. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour} + * for a list of properties this parameter supports. + * @alias Blockly.FieldColour */ - this.picker_ = null; + constructor(opt_value, opt_validator, opt_config) { + super(Field.SKIP_SETUP); - /** - * Index of the currently highlighted element. - * @type {?number} - * @private - */ - this.highlightedIndex_ = null; + /** + * The field's colour picker element. + * @type {?Element} + * @private + */ + this.picker_ = null; - /** - * Mouse click event data. - * @type {?browserEvents.Data} - * @private - */ - this.onClickWrapper_ = null; + /** + * Index of the currently highlighted element. + * @type {?number} + * @private + */ + this.highlightedIndex_ = null; - /** - * Mouse move event data. - * @type {?browserEvents.Data} - * @private - */ - this.onMouseMoveWrapper_ = null; + /** + * Mouse click event data. + * @type {?browserEvents.Data} + * @private + */ + this.onClickWrapper_ = null; - /** - * Mouse enter event data. - * @type {?browserEvents.Data} - * @private - */ - this.onMouseEnterWrapper_ = null; + /** + * Mouse move event data. + * @type {?browserEvents.Data} + * @private + */ + this.onMouseMoveWrapper_ = null; - /** - * Mouse leave event data. - * @type {?browserEvents.Data} - * @private - */ - this.onMouseLeaveWrapper_ = null; + /** + * Mouse enter event data. + * @type {?browserEvents.Data} + * @private + */ + this.onMouseEnterWrapper_ = null; - /** - * Key down event data. - * @type {?browserEvents.Data} - * @private - */ - this.onKeyDownWrapper_ = null; -}; -object.inherits(FieldColour, Field); + /** + * Mouse leave event data. + * @type {?browserEvents.Data} + * @private + */ + this.onMouseLeaveWrapper_ = null; -/** - * Construct a FieldColour from a JSON arg object. - * @param {!Object} options A JSON object with options (colour). - * @return {!FieldColour} The new field instance. - * @package - * @nocollapse - */ -FieldColour.fromJson = function(options) { - // `this` might be a subclass of FieldColour if that class doesn't override - // the static fromJson method. - return new this(options['colour'], undefined, options); -}; + /** + * Key down event data. + * @type {?browserEvents.Data} + * @private + */ + this.onKeyDownWrapper_ = null; -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. - * @type {boolean} - */ -FieldColour.prototype.SERIALIZABLE = true; + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + * @type {boolean} + */ + this.SERIALIZABLE = true; -/** - * Mouse cursor style when over the hotspot that initiates the editor. - */ -FieldColour.prototype.CURSOR = 'default'; + /** + * Mouse cursor style when over the hotspot that initiates the editor. + * @type {string} + */ + this.CURSOR = 'default'; -/** - * Used to tell if the field needs to be rendered the next time the block is - * rendered. Colour fields are statically sized, and only need to be - * rendered at initialization. - * @type {boolean} - * @protected - */ -FieldColour.prototype.isDirty_ = false; + /** + * Used to tell if the field needs to be rendered the next time the block is + * rendered. Colour fields are statically sized, and only need to be + * rendered at initialization. + * @type {boolean} + * @protected + */ + this.isDirty_ = false; -/** - * Array of colours used by this field. If null, use the global list. - * @type {Array} - * @private - */ -FieldColour.prototype.colours_ = null; + /** + * Array of colours used by this field. If null, use the global list. + * @type {Array} + * @private + */ + this.colours_ = null; -/** - * Array of colour tooltips used by this field. If null, use the global list. - * @type {Array} - * @private - */ -FieldColour.prototype.titles_ = null; + /** + * Array of colour tooltips used by this field. If null, use the global + * list. + * @type {Array} + * @private + */ + this.titles_ = null; -/** - * Number of colour columns used by this field. If 0, use the global setting. - * By default use the global constants for columns. - * @type {number} - * @private - */ -FieldColour.prototype.columns_ = 0; + /** + * Number of colour columns used by this field. If 0, use the global + * setting. By default use the global constants for columns. + * @type {number} + * @private + */ + this.columns_ = 0; -/** - * Configure the field based on the given map of options. - * @param {!Object} config A map of options to configure the field based on. - * @protected - * @override - */ -FieldColour.prototype.configure_ = function(config) { - FieldColour.superClass_.configure_.call(this, config); - if (config['colourOptions']) { - this.colours_ = config['colourOptions']; - this.titles_ = config['colourTitles']; + if (opt_value === Field.SKIP_SETUP) return; + if (opt_config) this.configure_(opt_config); + this.setValue(opt_value); + if (opt_validator) this.setValidator(opt_validator); } - if (config['columns']) { - this.columns_ = config['columns']; - } -}; -/** - * Create the block UI for this colour field. - * @package - */ -FieldColour.prototype.initView = function() { - this.size_ = new Size( - this.getConstants().FIELD_COLOUR_DEFAULT_WIDTH, - this.getConstants().FIELD_COLOUR_DEFAULT_HEIGHT); - if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) { - this.createBorderRect_(); - this.borderRect_.style['fillOpacity'] = '1'; - } else { - this.clickTarget_ = this.sourceBlock_.getSvgRoot(); - } -}; - -/** - * @override - */ -FieldColour.prototype.applyColour = function() { - if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) { - if (this.borderRect_) { - this.borderRect_.style.fill = /** @type {string} */ (this.getValue()); + /** + * Configure the field based on the given map of options. + * @param {!Object} config A map of options to configure the field based on. + * @protected + * @override + */ + configure_(config) { + super.configure_(config); + if (config['colourOptions']) { + this.colours_ = config['colourOptions']; + this.titles_ = config['colourTitles']; + } + if (config['columns']) { + this.columns_ = config['columns']; } - } else { - this.sourceBlock_.pathObject.svgPath.setAttribute('fill', this.getValue()); - this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff'); } -}; -/** - * Ensure that the input value is a valid colour. - * @param {*=} opt_newValue The input value. - * @return {?string} A valid colour, or null if invalid. - * @protected - */ -FieldColour.prototype.doClassValidation_ = function(opt_newValue) { - if (typeof opt_newValue !== 'string') { - return null; + /** + * Create the block UI for this colour field. + * @package + */ + initView() { + this.size_ = new Size( + this.getConstants().FIELD_COLOUR_DEFAULT_WIDTH, + this.getConstants().FIELD_COLOUR_DEFAULT_HEIGHT); + if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) { + this.createBorderRect_(); + this.borderRect_.style['fillOpacity'] = '1'; + } else { + this.clickTarget_ = this.sourceBlock_.getSvgRoot(); + } } - return colour.parse(opt_newValue); -}; -/** - * Update the value of this colour field, and update the displayed colour. - * @param {*} newValue The value to be saved. The default validator guarantees - * that this is a colour in '#rrggbb' format. - * @protected - */ -FieldColour.prototype.doValueUpdate_ = function(newValue) { - this.value_ = newValue; - if (this.borderRect_) { - this.borderRect_.style.fill = /** @type {string} */ (newValue); - } else if (this.sourceBlock_ && this.sourceBlock_.rendered) { - this.sourceBlock_.pathObject.svgPath.setAttribute('fill', newValue); - this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff'); + /** + * @override + */ + applyColour() { + if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) { + if (this.borderRect_) { + this.borderRect_.style.fill = /** @type {string} */ (this.getValue()); + } + } else { + this.sourceBlock_.pathObject.svgPath.setAttribute( + 'fill', this.getValue()); + this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff'); + } } -}; -/** - * Get the text for this field. Used when the block is collapsed. - * @return {string} Text representing the value of this field. - */ -FieldColour.prototype.getText = function() { - let colour = /** @type {string} */ (this.value_); - // Try to use #rgb format if possible, rather than #rrggbb. - if (/^#(.)\1(.)\2(.)\3$/.test(colour)) { - colour = '#' + colour[1] + colour[3] + colour[5]; + /** + * Ensure that the input value is a valid colour. + * @param {*=} opt_newValue The input value. + * @return {?string} A valid colour, or null if invalid. + * @protected + */ + doClassValidation_(opt_newValue) { + if (typeof opt_newValue !== 'string') { + return null; + } + return colour.parse(opt_newValue); } - return colour; -}; + + /** + * Update the value of this colour field, and update the displayed colour. + * @param {*} newValue The value to be saved. The default validator guarantees + * that this is a colour in '#rrggbb' format. + * @protected + */ + doValueUpdate_(newValue) { + this.value_ = newValue; + if (this.borderRect_) { + this.borderRect_.style.fill = /** @type {string} */ (newValue); + } else if (this.sourceBlock_ && this.sourceBlock_.rendered) { + this.sourceBlock_.pathObject.svgPath.setAttribute('fill', newValue); + this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff'); + } + } + + /** + * Get the text for this field. Used when the block is collapsed. + * @return {string} Text representing the value of this field. + */ + getText() { + let colour = /** @type {string} */ (this.value_); + // Try to use #rgb format if possible, rather than #rrggbb. + if (/^#(.)\1(.)\2(.)\3$/.test(colour)) { + colour = '#' + colour[1] + colour[3] + colour[5]; + } + return colour; + } + + /** + * Set a custom colour grid for this field. + * @param {Array} colours Array of colours for this block, + * or null to use default (FieldColour.COLOURS). + * @param {Array=} opt_titles Optional array of colour tooltips, + * or null to use default (FieldColour.TITLES). + * @return {!FieldColour} Returns itself (for method chaining). + */ + setColours(colours, opt_titles) { + this.colours_ = colours; + if (opt_titles) { + this.titles_ = opt_titles; + } + return this; + } + + /** + * Set a custom grid size for this field. + * @param {number} columns Number of columns for this block, + * or 0 to use default (FieldColour.COLUMNS). + * @return {!FieldColour} Returns itself (for method chaining). + */ + setColumns(columns) { + this.columns_ = columns; + return this; + } + + /** + * Create and show the colour field's editor. + * @protected + */ + showEditor_() { + this.dropdownCreate_(); + DropDownDiv.getContentDiv().appendChild(this.picker_); + + DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); + + // Focus so we can start receiving keyboard events. + this.picker_.focus({preventScroll: true}); + } + + /** + * Handle a click on a colour cell. + * @param {!MouseEvent} e Mouse event. + * @private + */ + onClick_(e) { + const cell = /** @type {!Element} */ (e.target); + const colour = cell && cell.label; + if (colour !== null) { + this.setValue(colour); + DropDownDiv.hideIfOwner(this); + } + } + + /** + * Handle a key down event. Navigate around the grid with the + * arrow keys. Enter selects the highlighted colour. + * @param {!KeyboardEvent} e Keyboard event. + * @private + */ + onKeyDown_(e) { + let handled = false; + if (e.keyCode === KeyCodes.UP) { + this.moveHighlightBy_(0, -1); + handled = true; + } else if (e.keyCode === KeyCodes.DOWN) { + this.moveHighlightBy_(0, 1); + handled = true; + } else if (e.keyCode === KeyCodes.LEFT) { + this.moveHighlightBy_(-1, 0); + handled = true; + } else if (e.keyCode === KeyCodes.RIGHT) { + this.moveHighlightBy_(1, 0); + handled = true; + } else if (e.keyCode === KeyCodes.ENTER) { + // Select the highlighted colour. + const highlighted = this.getHighlighted_(); + if (highlighted) { + const colour = highlighted && highlighted.label; + if (colour !== null) { + this.setValue(colour); + } + } + DropDownDiv.hideWithoutAnimation(); + handled = true; + } + if (handled) { + e.stopPropagation(); + } + } + + /** + * Move the currently highlighted position by dx and dy. + * @param {number} dx Change of x + * @param {number} dy Change of y + * @private + */ + moveHighlightBy_(dx, dy) { + const colours = this.colours_ || FieldColour.COLOURS; + const columns = this.columns_ || FieldColour.COLUMNS; + + // Get the current x and y coordinates + let x = this.highlightedIndex_ % columns; + let y = Math.floor(this.highlightedIndex_ / columns); + + // Add the offset + x += dx; + y += dy; + + if (dx < 0) { + // Move left one grid cell, even in RTL. + // Loop back to the end of the previous row if we have room. + if (x < 0 && y > 0) { + x = columns - 1; + y--; + } else if (x < 0) { + x = 0; + } + } else if (dx > 0) { + // Move right one grid cell, even in RTL. + // Loop to the start of the next row, if there's room. + if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) { + x = 0; + y++; + } else if (x > columns - 1) { + x--; + } + } else if (dy < 0) { + // Move up one grid cell, stop at the top. + if (y < 0) { + y = 0; + } + } else if (dy > 0) { + // Move down one grid cell, stop at the bottom. + if (y > Math.floor(colours.length / columns) - 1) { + y = Math.floor(colours.length / columns) - 1; + } + } + + // Move the highlight to the new coordinates. + const cell = + /** @type {!Element} */ (this.picker_.childNodes[y].childNodes[x]); + const index = (y * columns) + x; + this.setHighlightedCell_(cell, index); + } + + /** + * Handle a mouse move event. Highlight the hovered colour. + * @param {!MouseEvent} e Mouse event. + * @private + */ + onMouseMove_(e) { + const cell = /** @type {!Element} */ (e.target); + const index = cell && Number(cell.getAttribute('data-index')); + if (index !== null && index !== this.highlightedIndex_) { + this.setHighlightedCell_(cell, index); + } + } + + /** + * Handle a mouse enter event. Focus the picker. + * @private + */ + onMouseEnter_() { + this.picker_.focus({preventScroll: true}); + } + + /** + * Handle a mouse leave event. Blur the picker and unhighlight + * the currently highlighted colour. + * @private + */ + onMouseLeave_() { + this.picker_.blur(); + const highlighted = this.getHighlighted_(); + if (highlighted) { + dom.removeClass(highlighted, 'blocklyColourHighlighted'); + } + } + + /** + * Returns the currently highlighted item (if any). + * @return {?HTMLElement} Highlighted item (null if none). + * @private + */ + getHighlighted_() { + const columns = this.columns_ || FieldColour.COLUMNS; + const x = this.highlightedIndex_ % columns; + const y = Math.floor(this.highlightedIndex_ / columns); + const row = this.picker_.childNodes[y]; + if (!row) { + return null; + } + const col = /** @type {HTMLElement} */ (row.childNodes[x]); + return col; + } + + /** + * Update the currently highlighted cell. + * @param {!Element} cell the new cell to highlight + * @param {number} index the index of the new cell + * @private + */ + setHighlightedCell_(cell, index) { + // Unhighlight the current item. + const highlighted = this.getHighlighted_(); + if (highlighted) { + dom.removeClass(highlighted, 'blocklyColourHighlighted'); + } + // Highlight new item. + dom.addClass(cell, 'blocklyColourHighlighted'); + // Set new highlighted index. + this.highlightedIndex_ = index; + + // Update accessibility roles. + aria.setState( + /** @type {!Element} */ (this.picker_), aria.State.ACTIVEDESCENDANT, + cell.getAttribute('id')); + } + + /** + * Create a colour picker dropdown editor. + * @private + */ + dropdownCreate_() { + const columns = this.columns_ || FieldColour.COLUMNS; + const colours = this.colours_ || FieldColour.COLOURS; + const titles = this.titles_ || FieldColour.TITLES; + const selectedColour = this.getValue(); + // Create the palette. + const table = document.createElement('table'); + table.className = 'blocklyColourTable'; + table.tabIndex = 0; + table.dir = 'ltr'; + aria.setRole(table, aria.Role.GRID); + aria.setState(table, aria.State.EXPANDED, true); + aria.setState( + table, aria.State.ROWCOUNT, Math.floor(colours.length / columns)); + aria.setState(table, aria.State.COLCOUNT, columns); + let row; + for (let i = 0; i < colours.length; i++) { + if (i % columns === 0) { + row = document.createElement('tr'); + aria.setRole(row, aria.Role.ROW); + table.appendChild(row); + } + const cell = document.createElement('td'); + row.appendChild(cell); + cell.label = colours[i]; // This becomes the value, if clicked. + cell.title = titles[i] || colours[i]; + cell.id = idGenerator.getNextUniqueId(); + cell.setAttribute('data-index', i); + aria.setRole(cell, aria.Role.GRIDCELL); + aria.setState(cell, aria.State.LABEL, colours[i]); + aria.setState(cell, aria.State.SELECTED, colours[i] === selectedColour); + cell.style.backgroundColor = colours[i]; + if (colours[i] === selectedColour) { + cell.className = 'blocklyColourSelected'; + this.highlightedIndex_ = i; + } + } + + // Configure event handler on the table to listen for any event in a cell. + this.onClickWrapper_ = browserEvents.conditionalBind( + table, 'click', this, this.onClick_, true); + this.onMouseMoveWrapper_ = browserEvents.conditionalBind( + table, 'mousemove', this, this.onMouseMove_, true); + this.onMouseEnterWrapper_ = browserEvents.conditionalBind( + table, 'mouseenter', this, this.onMouseEnter_, true); + this.onMouseLeaveWrapper_ = browserEvents.conditionalBind( + table, 'mouseleave', this, this.onMouseLeave_, true); + this.onKeyDownWrapper_ = + browserEvents.conditionalBind(table, 'keydown', this, this.onKeyDown_); + + this.picker_ = table; + } + + /** + * Disposes of events and DOM-references belonging to the colour editor. + * @private + */ + dropdownDispose_() { + if (this.onClickWrapper_) { + browserEvents.unbind(this.onClickWrapper_); + this.onClickWrapper_ = null; + } + if (this.onMouseMoveWrapper_) { + browserEvents.unbind(this.onMouseMoveWrapper_); + this.onMouseMoveWrapper_ = null; + } + if (this.onMouseEnterWrapper_) { + browserEvents.unbind(this.onMouseEnterWrapper_); + this.onMouseEnterWrapper_ = null; + } + if (this.onMouseLeaveWrapper_) { + browserEvents.unbind(this.onMouseLeaveWrapper_); + this.onMouseLeaveWrapper_ = null; + } + if (this.onKeyDownWrapper_) { + browserEvents.unbind(this.onKeyDownWrapper_); + this.onKeyDownWrapper_ = null; + } + this.picker_ = null; + this.highlightedIndex_ = null; + } + + /** + * Construct a FieldColour from a JSON arg object. + * @param {!Object} options A JSON object with options (colour). + * @return {!FieldColour} The new field instance. + * @package + * @nocollapse + */ + static fromJson(options) { + // `this` might be a subclass of FieldColour if that class doesn't override + // the static fromJson method. + return new this(options['colour'], undefined, options); + } +} /** * An array of colour strings for the palette. @@ -357,313 +678,6 @@ FieldColour.TITLES = []; */ FieldColour.COLUMNS = 7; -/** - * Set a custom colour grid for this field. - * @param {Array} colours Array of colours for this block, - * or null to use default (FieldColour.COLOURS). - * @param {Array=} opt_titles Optional array of colour tooltips, - * or null to use default (FieldColour.TITLES). - * @return {!FieldColour} Returns itself (for method chaining). - */ -FieldColour.prototype.setColours = function(colours, opt_titles) { - this.colours_ = colours; - if (opt_titles) { - this.titles_ = opt_titles; - } - return this; -}; - -/** - * Set a custom grid size for this field. - * @param {number} columns Number of columns for this block, - * or 0 to use default (FieldColour.COLUMNS). - * @return {!FieldColour} Returns itself (for method chaining). - */ -FieldColour.prototype.setColumns = function(columns) { - this.columns_ = columns; - return this; -}; - -/** - * Create and show the colour field's editor. - * @protected - */ -FieldColour.prototype.showEditor_ = function() { - this.dropdownCreate_(); - DropDownDiv.getContentDiv().appendChild(this.picker_); - - DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); - - // Focus so we can start receiving keyboard events. - this.picker_.focus({preventScroll: true}); -}; - -/** - * Handle a click on a colour cell. - * @param {!MouseEvent} e Mouse event. - * @private - */ -FieldColour.prototype.onClick_ = function(e) { - const cell = /** @type {!Element} */ (e.target); - const colour = cell && cell.label; - if (colour !== null) { - this.setValue(colour); - DropDownDiv.hideIfOwner(this); - } -}; - -/** - * Handle a key down event. Navigate around the grid with the - * arrow keys. Enter selects the highlighted colour. - * @param {!KeyboardEvent} e Keyboard event. - * @private - */ -FieldColour.prototype.onKeyDown_ = function(e) { - let handled = false; - if (e.keyCode === KeyCodes.UP) { - this.moveHighlightBy_(0, -1); - handled = true; - } else if (e.keyCode === KeyCodes.DOWN) { - this.moveHighlightBy_(0, 1); - handled = true; - } else if (e.keyCode === KeyCodes.LEFT) { - this.moveHighlightBy_(-1, 0); - handled = true; - } else if (e.keyCode === KeyCodes.RIGHT) { - this.moveHighlightBy_(1, 0); - handled = true; - } else if (e.keyCode === KeyCodes.ENTER) { - // Select the highlighted colour. - const highlighted = this.getHighlighted_(); - if (highlighted) { - const colour = highlighted && highlighted.label; - if (colour !== null) { - this.setValue(colour); - } - } - DropDownDiv.hideWithoutAnimation(); - handled = true; - } - if (handled) { - e.stopPropagation(); - } -}; - -/** - * Move the currently highlighted position by dx and dy. - * @param {number} dx Change of x - * @param {number} dy Change of y - * @private - */ -FieldColour.prototype.moveHighlightBy_ = function(dx, dy) { - const colours = this.colours_ || FieldColour.COLOURS; - const columns = this.columns_ || FieldColour.COLUMNS; - - // Get the current x and y coordinates - let x = this.highlightedIndex_ % columns; - let y = Math.floor(this.highlightedIndex_ / columns); - - // Add the offset - x += dx; - y += dy; - - if (dx < 0) { - // Move left one grid cell, even in RTL. - // Loop back to the end of the previous row if we have room. - if (x < 0 && y > 0) { - x = columns - 1; - y--; - } else if (x < 0) { - x = 0; - } - } else if (dx > 0) { - // Move right one grid cell, even in RTL. - // Loop to the start of the next row, if there's room. - if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) { - x = 0; - y++; - } else if (x > columns - 1) { - x--; - } - } else if (dy < 0) { - // Move up one grid cell, stop at the top. - if (y < 0) { - y = 0; - } - } else if (dy > 0) { - // Move down one grid cell, stop at the bottom. - if (y > Math.floor(colours.length / columns) - 1) { - y = Math.floor(colours.length / columns) - 1; - } - } - - // Move the highlight to the new coordinates. - const cell = - /** @type {!Element} */ (this.picker_.childNodes[y].childNodes[x]); - const index = (y * columns) + x; - this.setHighlightedCell_(cell, index); -}; - -/** - * Handle a mouse move event. Highlight the hovered colour. - * @param {!MouseEvent} e Mouse event. - * @private - */ -FieldColour.prototype.onMouseMove_ = function(e) { - const cell = /** @type {!Element} */ (e.target); - const index = cell && Number(cell.getAttribute('data-index')); - if (index !== null && index !== this.highlightedIndex_) { - this.setHighlightedCell_(cell, index); - } -}; - -/** - * Handle a mouse enter event. Focus the picker. - * @private - */ -FieldColour.prototype.onMouseEnter_ = function() { - this.picker_.focus({preventScroll: true}); -}; - -/** - * Handle a mouse leave event. Blur the picker and unhighlight - * the currently highlighted colour. - * @private - */ -FieldColour.prototype.onMouseLeave_ = function() { - this.picker_.blur(); - const highlighted = this.getHighlighted_(); - if (highlighted) { - dom.removeClass(highlighted, 'blocklyColourHighlighted'); - } -}; - -/** - * Returns the currently highlighted item (if any). - * @return {?HTMLElement} Highlighted item (null if none). - * @private - */ -FieldColour.prototype.getHighlighted_ = function() { - const columns = this.columns_ || FieldColour.COLUMNS; - const x = this.highlightedIndex_ % columns; - const y = Math.floor(this.highlightedIndex_ / columns); - const row = this.picker_.childNodes[y]; - if (!row) { - return null; - } - const col = /** @type {HTMLElement} */ (row.childNodes[x]); - return col; -}; - -/** - * Update the currently highlighted cell. - * @param {!Element} cell the new cell to highlight - * @param {number} index the index of the new cell - * @private - */ -FieldColour.prototype.setHighlightedCell_ = function(cell, index) { - // Unhighlight the current item. - const highlighted = this.getHighlighted_(); - if (highlighted) { - dom.removeClass(highlighted, 'blocklyColourHighlighted'); - } - // Highlight new item. - dom.addClass(cell, 'blocklyColourHighlighted'); - // Set new highlighted index. - this.highlightedIndex_ = index; - - // Update accessibility roles. - aria.setState( - /** @type {!Element} */ (this.picker_), aria.State.ACTIVEDESCENDANT, - cell.getAttribute('id')); -}; - -/** - * Create a colour picker dropdown editor. - * @private - */ -FieldColour.prototype.dropdownCreate_ = function() { - const columns = this.columns_ || FieldColour.COLUMNS; - const colours = this.colours_ || FieldColour.COLOURS; - const titles = this.titles_ || FieldColour.TITLES; - const selectedColour = this.getValue(); - // Create the palette. - const table = document.createElement('table'); - table.className = 'blocklyColourTable'; - table.tabIndex = 0; - table.dir = 'ltr'; - aria.setRole(table, aria.Role.GRID); - aria.setState(table, aria.State.EXPANDED, true); - aria.setState( - table, aria.State.ROWCOUNT, Math.floor(colours.length / columns)); - aria.setState(table, aria.State.COLCOUNT, columns); - let row; - for (let i = 0; i < colours.length; i++) { - if (i % columns === 0) { - row = document.createElement('tr'); - aria.setRole(row, aria.Role.ROW); - table.appendChild(row); - } - const cell = document.createElement('td'); - row.appendChild(cell); - cell.label = colours[i]; // This becomes the value, if clicked. - cell.title = titles[i] || colours[i]; - cell.id = idGenerator.getNextUniqueId(); - cell.setAttribute('data-index', i); - aria.setRole(cell, aria.Role.GRIDCELL); - aria.setState(cell, aria.State.LABEL, colours[i]); - aria.setState(cell, aria.State.SELECTED, colours[i] === selectedColour); - cell.style.backgroundColor = colours[i]; - if (colours[i] === selectedColour) { - cell.className = 'blocklyColourSelected'; - this.highlightedIndex_ = i; - } - } - - // Configure event handler on the table to listen for any event in a cell. - this.onClickWrapper_ = - browserEvents.conditionalBind(table, 'click', this, this.onClick_, true); - this.onMouseMoveWrapper_ = browserEvents.conditionalBind( - table, 'mousemove', this, this.onMouseMove_, true); - this.onMouseEnterWrapper_ = browserEvents.conditionalBind( - table, 'mouseenter', this, this.onMouseEnter_, true); - this.onMouseLeaveWrapper_ = browserEvents.conditionalBind( - table, 'mouseleave', this, this.onMouseLeave_, true); - this.onKeyDownWrapper_ = - browserEvents.conditionalBind(table, 'keydown', this, this.onKeyDown_); - - this.picker_ = table; -}; - -/** - * Disposes of events and DOM-references belonging to the colour editor. - * @private - */ -FieldColour.prototype.dropdownDispose_ = function() { - if (this.onClickWrapper_) { - browserEvents.unbind(this.onClickWrapper_); - this.onClickWrapper_ = null; - } - if (this.onMouseMoveWrapper_) { - browserEvents.unbind(this.onMouseMoveWrapper_); - this.onMouseMoveWrapper_ = null; - } - if (this.onMouseEnterWrapper_) { - browserEvents.unbind(this.onMouseEnterWrapper_); - this.onMouseEnterWrapper_ = null; - } - if (this.onMouseLeaveWrapper_) { - browserEvents.unbind(this.onMouseLeaveWrapper_); - this.onMouseLeaveWrapper_ = null; - } - if (this.onKeyDownWrapper_) { - browserEvents.unbind(this.onKeyDownWrapper_); - this.onKeyDownWrapper_ = null; - } - this.picker_ = null; - this.highlightedIndex_ = null; -}; - /** * CSS for colour picker. See css.js for use. */ diff --git a/core/field_dropdown.js b/core/field_dropdown.js index 618ecd7b6..307578db9 100644 --- a/core/field_dropdown.js +++ b/core/field_dropdown.js @@ -22,7 +22,6 @@ goog.module('Blockly.FieldDropdown'); const aria = goog.require('Blockly.utils.aria'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); const parsing = goog.require('Blockly.utils.parsing'); const userAgent = goog.require('Blockly.utils.userAgent'); const utilsString = goog.require('Blockly.utils.string'); @@ -31,111 +30,682 @@ const {DropDownDiv} = goog.require('Blockly.DropDownDiv'); const {Field} = goog.require('Blockly.Field'); const {MenuItem} = goog.require('Blockly.MenuItem'); const {Menu} = goog.require('Blockly.Menu'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); const {Svg} = goog.require('Blockly.utils.Svg'); /** * Class for an editable dropdown field. - * @param {(!Array|!Function)} menuGenerator A non-empty array of - * options for a dropdown list, or a function which generates these options. - * @param {Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a language-neutral dropdown - * option & returns a validated language-neutral dropdown option, or null to - * abort the change. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation} - * for a list of properties this parameter supports. * @extends {Field} - * @constructor - * @throws {TypeError} If `menuGenerator` options are incorrectly structured. - * @alias Blockly.FieldDropdown */ -const FieldDropdown = function(menuGenerator, opt_validator, opt_config) { - if (typeof menuGenerator !== 'function') { - validateOptions(menuGenerator); +class FieldDropdown extends Field { + /** + * @param {(!Array|!Function|!Sentinel)} menuGenerator + * A non-empty array of options for a dropdown list, or a function which + * generates these options. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a language-neutral dropdown + * option & returns a validated language-neutral dropdown option, or null + * to abort the change. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation} + * for a list of properties this parameter supports. + * @throws {TypeError} If `menuGenerator` options are incorrectly structured. + * @alias Blockly.FieldDropdown + */ + constructor(menuGenerator, opt_validator, opt_config) { + super(Field.SKIP_SETUP); + + /** + * A reference to the currently selected menu item. + * @type {?MenuItem} + * @private + */ + this.selectedMenuItem_ = null; + + /** + * The dropdown menu. + * @type {?Menu} + * @protected + */ + this.menu_ = null; + + /** + * SVG image element if currently selected option is an image, or null. + * @type {?SVGImageElement} + * @private + */ + this.imageElement_ = null; + + /** + * Tspan based arrow element. + * @type {?SVGTSpanElement} + * @private + */ + this.arrow_ = null; + + /** + * SVG based arrow element. + * @type {?SVGElement} + * @private + */ + this.svgArrow_ = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + * @type {boolean} + */ + this.SERIALIZABLE = true; + + /** + * Mouse cursor style when over the hotspot that initiates the editor. + * @type {string} + */ + this.CURSOR = 'default'; + + + // If we pass SKIP_SETUP, don't do *anything* with the menu generator. + if (menuGenerator === Field.SKIP_SETUP) return; + + if (Array.isArray(menuGenerator)) { + validateOptions(menuGenerator); + } + + /** + * An array of options for a dropdown list, + * or a function which generates these options. + * @type {(!Array|!function(this:FieldDropdown): !Array)} + * @protected + */ + this.menuGenerator_ = + /** + * @type {(!Array| + * !function(this:FieldDropdown):!Array)} + */ + (menuGenerator); + + /** + * A cache of the most recently generated options. + * @type {Array>} + * @private + */ + this.generatedOptions_ = null; + + /** + * The prefix field label, of common words set after options are trimmed. + * @type {?string} + * @package + */ + this.prefixField = null; + + /** + * The suffix field label, of common words set after options are trimmed. + * @type {?string} + * @package + */ + this.suffixField = null; + + this.trimOptions_(); + + /** + * The currently selected option. The field is initialized with the + * first option selected. + * @type {!Array} + * @private + */ + this.selectedOption_ = this.getOptions(false)[0]; + + if (opt_config) this.configure_(opt_config); + this.setValue(this.selectedOption_[1]); + if (opt_validator) this.setValidator(opt_validator); } /** - * An array of options for a dropdown list, - * or a function which generates these options. - * @type {(!Array| - * !function(this:FieldDropdown): !Array)} - * @protected - */ - this.menuGenerator_ = menuGenerator; - - /** - * A cache of the most recently generated options. - * @type {Array>} - * @private - */ - this.generatedOptions_ = null; - - /** - * The prefix field label, of common words set after options are trimmed. - * @type {?string} + * Sets the field's value based on the given XML element. Should only be + * called by Blockly.Xml. + * @param {!Element} fieldElement The element containing info about the + * field's state. * @package */ - this.prefixField = null; + fromXml(fieldElement) { + if (this.isOptionListDynamic()) { + this.getOptions(false); + } + this.setValue(fieldElement.textContent); + } /** - * The suffix field label, of common words set after options are trimmed. - * @type {?string} + * Sets the field's value based on the given state. + * @param {*} state The state to apply to the dropdown field. + * @override * @package */ - this.suffixField = null; - - this.trimOptions_(); + loadState(state) { + if (this.loadLegacyState(FieldDropdown, state)) { + return; + } + if (this.isOptionListDynamic()) { + this.getOptions(false); + } + this.setValue(state); + } /** - * The currently selected option. The field is initialized with the - * first option selected. - * @type {!Object} - * @private + * Create the block UI for this dropdown. + * @package */ - this.selectedOption_ = this.getOptions(false)[0]; + initView() { + if (this.shouldAddBorderRect_()) { + this.createBorderRect_(); + } else { + this.clickTarget_ = this.sourceBlock_.getSvgRoot(); + } + this.createTextElement_(); - // Call parent's constructor. - FieldDropdown.superClass_.constructor.call( - this, this.selectedOption_[1], opt_validator, opt_config); + this.imageElement_ = dom.createSvgElement(Svg.IMAGE, {}, this.fieldGroup_); + + if (this.getConstants().FIELD_DROPDOWN_SVG_ARROW) { + this.createSVGArrow_(); + } else { + this.createTextArrow_(); + } + + if (this.borderRect_) { + dom.addClass(this.borderRect_, 'blocklyDropdownRect'); + } + } /** - * A reference to the currently selected menu item. - * @type {?MenuItem} - * @private - */ - this.selectedMenuItem_ = null; - - /** - * The dropdown menu. - * @type {?Menu} + * Whether or not the dropdown should add a border rect. + * @return {boolean} True if the dropdown field should add a border rect. * @protected */ - this.menu_ = null; + shouldAddBorderRect_() { + return !this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW || + (this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW && + !this.sourceBlock_.isShadow()); + } /** - * SVG image element if currently selected option is an image, or null. - * @type {?SVGImageElement} - * @private + * Create a tspan based arrow. + * @protected */ - this.imageElement_ = null; + createTextArrow_() { + this.arrow_ = dom.createSvgElement(Svg.TSPAN, {}, this.textElement_); + this.arrow_.appendChild(document.createTextNode( + this.sourceBlock_.RTL ? FieldDropdown.ARROW_CHAR + ' ' : + ' ' + FieldDropdown.ARROW_CHAR)); + if (this.sourceBlock_.RTL) { + this.textElement_.insertBefore(this.arrow_, this.textContent_); + } else { + this.textElement_.appendChild(this.arrow_); + } + } /** - * Tspan based arrow element. - * @type {?SVGTSpanElement} - * @private + * Create an SVG based arrow. + * @protected */ - this.arrow_ = null; + createSVGArrow_() { + this.svgArrow_ = dom.createSvgElement( + Svg.IMAGE, { + 'height': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px', + 'width': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px', + }, + this.fieldGroup_); + this.svgArrow_.setAttributeNS( + dom.XLINK_NS, 'xlink:href', + this.getConstants().FIELD_DROPDOWN_SVG_ARROW_DATAURI); + } /** - * SVG based arrow element. - * @type {?SVGElement} + * Create a dropdown menu under the text. + * @param {Event=} opt_e Optional mouse event that triggered the field to + * open, or undefined if triggered programmatically. + * @protected + */ + showEditor_(opt_e) { + this.dropdownCreate_(); + if (opt_e && typeof opt_e.clientX === 'number') { + this.menu_.openingCoords = new Coordinate(opt_e.clientX, opt_e.clientY); + } else { + this.menu_.openingCoords = null; + } + + // Remove any pre-existing elements in the dropdown. + DropDownDiv.clearContent(); + // Element gets created in render. + this.menu_.render(DropDownDiv.getContentDiv()); + const menuElement = /** @type {!Element} */ (this.menu_.getElement()); + dom.addClass(menuElement, 'blocklyDropdownMenu'); + + if (this.getConstants().FIELD_DROPDOWN_COLOURED_DIV) { + const primaryColour = (this.sourceBlock_.isShadow()) ? + this.sourceBlock_.getParent().getColour() : + this.sourceBlock_.getColour(); + const borderColour = (this.sourceBlock_.isShadow()) ? + this.sourceBlock_.getParent().style.colourTertiary : + this.sourceBlock_.style.colourTertiary; + DropDownDiv.setColour(primaryColour, borderColour); + } + + DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); + + // Focusing needs to be handled after the menu is rendered and positioned. + // Otherwise it will cause a page scroll to get the misplaced menu in + // view. See issue #1329. + this.menu_.focus(); + + if (this.selectedMenuItem_) { + this.menu_.setHighlighted(this.selectedMenuItem_); + } + + this.applyColour(); + } + + /** + * Create the dropdown editor. * @private */ - this.svgArrow_ = null; -}; -object.inherits(FieldDropdown, Field); + dropdownCreate_() { + const menu = new Menu(); + menu.setRole(aria.Role.LISTBOX); + this.menu_ = menu; + + const options = this.getOptions(false); + this.selectedMenuItem_ = null; + for (let i = 0; i < options.length; i++) { + let content = options[i][0]; // Human-readable text or image. + const value = options[i][1]; // Language-neutral value. + if (typeof content === 'object') { + // An image, not text. + const image = new Image(content['width'], content['height']); + image.src = content['src']; + image.alt = content['alt'] || ''; + content = image; + } + const menuItem = new MenuItem(content, value); + menuItem.setRole(aria.Role.OPTION); + menuItem.setRightToLeft(this.sourceBlock_.RTL); + menuItem.setCheckable(true); + menu.addChild(menuItem); + menuItem.setChecked(value === this.value_); + if (value === this.value_) { + this.selectedMenuItem_ = menuItem; + } + menuItem.onAction(this.handleMenuActionEvent_, this); + } + } + + /** + * Disposes of events and DOM-references belonging to the dropdown editor. + * @private + */ + dropdownDispose_() { + if (this.menu_) { + this.menu_.dispose(); + } + this.menu_ = null; + this.selectedMenuItem_ = null; + this.applyColour(); + } + + /** + * Handle an action in the dropdown menu. + * @param {!MenuItem} menuItem The MenuItem selected within menu. + * @private + */ + handleMenuActionEvent_(menuItem) { + DropDownDiv.hideIfOwner(this, true); + this.onItemSelected_(/** @type {!Menu} */ (this.menu_), menuItem); + } + + /** + * Handle the selection of an item in the dropdown menu. + * @param {!Menu} menu The Menu component clicked. + * @param {!MenuItem} menuItem The MenuItem selected within menu. + * @protected + */ + onItemSelected_(menu, menuItem) { + this.setValue(menuItem.getValue()); + } + + /** + * Factor out common words in statically defined options. + * Create prefix and/or suffix labels. + * @private + */ + trimOptions_() { + const options = this.menuGenerator_; + if (!Array.isArray(options)) { + return; + } + let hasImages = false; + + // Localize label text and image alt text. + for (let i = 0; i < options.length; i++) { + const label = options[i][0]; + if (typeof label === 'string') { + options[i][0] = parsing.replaceMessageReferences(label); + } else { + if (label.alt !== null) { + options[i][0].alt = parsing.replaceMessageReferences(label.alt); + } + hasImages = true; + } + } + if (hasImages || options.length < 2) { + return; // Do nothing if too few items or at least one label is an image. + } + const strings = []; + for (let i = 0; i < options.length; i++) { + strings.push(options[i][0]); + } + const shortest = utilsString.shortestStringLength(strings); + const prefixLength = utilsString.commonWordPrefix(strings, shortest); + const suffixLength = utilsString.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); + } + + this.menuGenerator_ = + FieldDropdown.applyTrim_(options, prefixLength, suffixLength); + } + + /** + * @return {boolean} True if the option list is generated by a function. + * Otherwise false. + */ + isOptionListDynamic() { + return typeof this.menuGenerator_ === 'function'; + } + + /** + * Return a list of the options for this dropdown. + * @param {boolean=} opt_useCache For dynamic options, whether or not to use + * the cached options or to re-generate them. + * @return {!Array} A non-empty array of option tuples: + * (human-readable text or image, language-neutral name). + * @throws {TypeError} If generated options are incorrectly structured. + */ + getOptions(opt_useCache) { + if (this.isOptionListDynamic()) { + if (!this.generatedOptions_ || !opt_useCache) { + this.generatedOptions_ = this.menuGenerator_.call(this); + validateOptions(this.generatedOptions_); + } + return this.generatedOptions_; + } + return /** @type {!Array>} */ (this.menuGenerator_); + } + + /** + * Ensure that the input value is a valid language-neutral option. + * @param {*=} opt_newValue The input value. + * @return {?string} A valid language-neutral option, or null if invalid. + * @protected + */ + doClassValidation_(opt_newValue) { + let isValueValid = false; + const options = this.getOptions(true); + for (let i = 0, option; (option = options[i]); i++) { + // Options are tuples of human-readable text and language-neutral values. + if (option[1] === opt_newValue) { + isValueValid = true; + break; + } + } + if (!isValueValid) { + if (this.sourceBlock_) { + console.warn( + 'Cannot set the dropdown\'s value to an unavailable option.' + + ' Block type: ' + this.sourceBlock_.type + + ', Field name: ' + this.name + ', Value: ' + opt_newValue); + } + return null; + } + return /** @type {string} */ (opt_newValue); + } + + /** + * Update the value of this dropdown field. + * @param {*} newValue The value to be saved. The default validator guarantees + * that this is one of the valid dropdown options. + * @protected + */ + doValueUpdate_(newValue) { + super.doValueUpdate_(newValue); + const options = this.getOptions(true); + for (let i = 0, option; (option = options[i]); i++) { + if (option[1] === this.value_) { + this.selectedOption_ = option; + } + } + } + + /** + * Updates the dropdown arrow to match the colour/style of the block. + * @package + */ + applyColour() { + if (this.borderRect_) { + this.borderRect_.setAttribute( + 'stroke', this.sourceBlock_.style.colourTertiary); + if (this.menu_) { + this.borderRect_.setAttribute( + 'fill', this.sourceBlock_.style.colourTertiary); + } else { + this.borderRect_.setAttribute('fill', 'transparent'); + } + } + // Update arrow's colour. + if (this.sourceBlock_ && this.arrow_) { + if (this.sourceBlock_.isShadow()) { + this.arrow_.style.fill = this.sourceBlock_.style.colourSecondary; + } else { + this.arrow_.style.fill = this.sourceBlock_.style.colourPrimary; + } + } + } + + /** + * Draws the border with the correct width. + * @protected + */ + render_() { + // Hide both elements. + this.textContent_.nodeValue = ''; + this.imageElement_.style.display = 'none'; + + // Show correct element. + const option = this.selectedOption_ && this.selectedOption_[0]; + if (option && typeof option === 'object') { + this.renderSelectedImage_( + /** @type {!ImageProperties} */ (option)); + } else { + this.renderSelectedText_(); + } + + this.positionBorderRect_(); + } + + /** + * Renders the selected option, which must be an image. + * @param {!ImageProperties} imageJson Selected + * option that must be an image. + * @private + */ + renderSelectedImage_(imageJson) { + this.imageElement_.style.display = ''; + this.imageElement_.setAttributeNS( + dom.XLINK_NS, 'xlink:href', imageJson.src); + this.imageElement_.setAttribute('height', imageJson.height); + this.imageElement_.setAttribute('width', imageJson.width); + + const imageHeight = Number(imageJson.height); + const imageWidth = Number(imageJson.width); + + // Height and width include the border rect. + const hasBorder = !!this.borderRect_; + const height = Math.max( + hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, + imageHeight + IMAGE_Y_PADDING); + const xPadding = + hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0; + let arrowWidth = 0; + if (this.svgArrow_) { + arrowWidth = this.positionSVGArrow_( + imageWidth + xPadding, + height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2); + } else { + arrowWidth = dom.getFastTextWidth( + /** @type {!SVGTSpanElement} */ (this.arrow_), + this.getConstants().FIELD_TEXT_FONTSIZE, + this.getConstants().FIELD_TEXT_FONTWEIGHT, + this.getConstants().FIELD_TEXT_FONTFAMILY); + } + this.size_.width = imageWidth + arrowWidth + xPadding * 2; + this.size_.height = height; + + let arrowX = 0; + if (this.sourceBlock_.RTL) { + const imageX = xPadding + arrowWidth; + this.imageElement_.setAttribute('x', imageX); + } else { + arrowX = imageWidth + arrowWidth; + this.textElement_.setAttribute('text-anchor', 'end'); + this.imageElement_.setAttribute('x', xPadding); + } + this.imageElement_.setAttribute('y', height / 2 - imageHeight / 2); + + this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth); + } + + /** + * Renders the selected option, which must be text. + * @private + */ + renderSelectedText_() { + // Retrieves the selected option to display through getText_. + this.textContent_.nodeValue = this.getDisplayText_(); + dom.addClass( + /** @type {!Element} */ (this.textElement_), 'blocklyDropdownText'); + this.textElement_.setAttribute('text-anchor', 'start'); + + // Height and width include the border rect. + const hasBorder = !!this.borderRect_; + const height = Math.max( + hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, + this.getConstants().FIELD_TEXT_HEIGHT); + const textWidth = dom.getFastTextWidth( + this.textElement_, this.getConstants().FIELD_TEXT_FONTSIZE, + this.getConstants().FIELD_TEXT_FONTWEIGHT, + this.getConstants().FIELD_TEXT_FONTFAMILY); + const xPadding = + hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0; + let arrowWidth = 0; + if (this.svgArrow_) { + arrowWidth = this.positionSVGArrow_( + textWidth + xPadding, + height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2); + } + this.size_.width = textWidth + arrowWidth + xPadding * 2; + this.size_.height = height; + + this.positionTextElement_(xPadding, textWidth); + } + + /** + * Position a drop-down arrow at the appropriate location at render-time. + * @param {number} x X position the arrow is being rendered at, in px. + * @param {number} y Y position the arrow is being rendered at, in px. + * @return {number} Amount of space the arrow is taking up, in px. + * @private + */ + positionSVGArrow_(x, y) { + if (!this.svgArrow_) { + return 0; + } + const hasBorder = !!this.borderRect_; + const xPadding = + hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0; + const textPadding = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_PADDING; + const svgArrowSize = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE; + const arrowX = this.sourceBlock_.RTL ? xPadding : x + textPadding; + this.svgArrow_.setAttribute( + 'transform', 'translate(' + arrowX + ',' + y + ')'); + return svgArrowSize + textPadding; + } + + /** + * Use the `getText_` developer hook to override the field's text + * representation. Get the selected option text. If the selected option is an + * image we return the image alt text. + * @return {?string} Selected option text. + * @protected + * @override + */ + getText_() { + if (!this.selectedOption_) { + return null; + } + const option = this.selectedOption_[0]; + if (typeof option === 'object') { + return option['alt']; + } + return option; + } + + /** + * Construct a FieldDropdown from a JSON arg object. + * @param {!Object} options A JSON object with options (options). + * @return {!FieldDropdown} The new field instance. + * @package + * @nocollapse + */ + static fromJson(options) { + // `this` might be a subclass of FieldDropdown if that class doesn't + // override the static fromJson method. + return new this(options['options'], undefined, options); + } + + /** + * Use the calculated prefix and suffix lengths to trim all of the options in + * the given array. + * @param {!Array} options Array of option tuples: + * (human-readable text or image, language-neutral name). + * @param {number} prefixLength The length of the common prefix. + * @param {number} suffixLength The length of the common suffix + * @return {!Array} A new array with all of the option text trimmed. + */ + static applyTrim_(options, prefixLength, suffixLength) { + const newOptions = []; + // Remove the prefix and suffix from the options. + for (let i = 0; i < options.length; i++) { + let text = options[i][0]; + const value = options[i][1]; + text = text.substring(prefixLength, text.length - suffixLength); + newOptions[i] = [text, value]; + } + return newOptions; + } +} /** * Dropdown image properties. @@ -146,57 +716,7 @@ object.inherits(FieldDropdown, Field); * height:number * }} */ -FieldDropdown.ImageProperties; - -/** - * Construct a FieldDropdown from a JSON arg object. - * @param {!Object} options A JSON object with options (options). - * @return {!FieldDropdown} The new field instance. - * @package - * @nocollapse - */ -FieldDropdown.fromJson = function(options) { - // `this` might be a subclass of FieldDropdown if that class doesn't override - // the static fromJson method. - return new this(options['options'], undefined, options); -}; - -/** - * Sets the field's value based on the given XML element. Should only be - * called by Blockly.Xml. - * @param {!Element} fieldElement The element containing info about the - * field's state. - * @package - */ -FieldDropdown.prototype.fromXml = function(fieldElement) { - if (this.isOptionListDynamic()) { - this.getOptions(false); - } - this.setValue(fieldElement.textContent); -}; - -/** - * Sets the field's value based on the given state. - * @param {*} state The state to apply to the dropdown field. - * @override - * @package - */ -FieldDropdown.prototype.loadState = function(state) { - if (this.loadLegacyState(FieldDropdown, state)) { - return; - } - if (this.isOptionListDynamic()) { - this.getOptions(false); - } - this.setValue(state); -}; - -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. - * @type {boolean} - */ -FieldDropdown.prototype.SERIALIZABLE = true; +let ImageProperties; // eslint-disable-line no-unused-vars /** * Horizontal distance that a checkmark overhangs the dropdown. @@ -228,507 +748,6 @@ const IMAGE_Y_PADDING = IMAGE_Y_OFFSET * 2; */ FieldDropdown.ARROW_CHAR = userAgent.ANDROID ? '\u25BC' : '\u25BE'; -/** - * Mouse cursor style when over the hotspot that initiates the editor. - */ -FieldDropdown.prototype.CURSOR = 'default'; - -/** - * Create the block UI for this dropdown. - * @package - */ -FieldDropdown.prototype.initView = function() { - if (this.shouldAddBorderRect_()) { - this.createBorderRect_(); - } else { - this.clickTarget_ = this.sourceBlock_.getSvgRoot(); - } - this.createTextElement_(); - - this.imageElement_ = dom.createSvgElement(Svg.IMAGE, {}, this.fieldGroup_); - - if (this.getConstants().FIELD_DROPDOWN_SVG_ARROW) { - this.createSVGArrow_(); - } else { - this.createTextArrow_(); - } - - if (this.borderRect_) { - dom.addClass(this.borderRect_, 'blocklyDropdownRect'); - } -}; - -/** - * Whether or not the dropdown should add a border rect. - * @return {boolean} True if the dropdown field should add a border rect. - * @protected - */ -FieldDropdown.prototype.shouldAddBorderRect_ = function() { - return !this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW || - (this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW && - !this.sourceBlock_.isShadow()); -}; - -/** - * Create a tspan based arrow. - * @protected - */ -FieldDropdown.prototype.createTextArrow_ = function() { - this.arrow_ = dom.createSvgElement(Svg.TSPAN, {}, this.textElement_); - this.arrow_.appendChild(document.createTextNode( - this.sourceBlock_.RTL ? FieldDropdown.ARROW_CHAR + ' ' : - ' ' + FieldDropdown.ARROW_CHAR)); - if (this.sourceBlock_.RTL) { - this.textElement_.insertBefore(this.arrow_, this.textContent_); - } else { - this.textElement_.appendChild(this.arrow_); - } -}; - -/** - * Create an SVG based arrow. - * @protected - */ -FieldDropdown.prototype.createSVGArrow_ = function() { - this.svgArrow_ = dom.createSvgElement( - Svg.IMAGE, { - 'height': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px', - 'width': this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px', - }, - this.fieldGroup_); - this.svgArrow_.setAttributeNS( - dom.XLINK_NS, 'xlink:href', - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_DATAURI); -}; - -/** - * Create a dropdown menu under the text. - * @param {Event=} opt_e Optional mouse event that triggered the field to open, - * or undefined if triggered programmatically. - * @protected - */ -FieldDropdown.prototype.showEditor_ = function(opt_e) { - this.dropdownCreate_(); - if (opt_e && typeof opt_e.clientX === 'number') { - this.menu_.openingCoords = new Coordinate(opt_e.clientX, opt_e.clientY); - } else { - this.menu_.openingCoords = null; - } - - // Remove any pre-existing elements in the dropdown. - DropDownDiv.clearContent(); - // Element gets created in render. - this.menu_.render(DropDownDiv.getContentDiv()); - const menuElement = /** @type {!Element} */ (this.menu_.getElement()); - dom.addClass(menuElement, 'blocklyDropdownMenu'); - - if (this.getConstants().FIELD_DROPDOWN_COLOURED_DIV) { - const primaryColour = (this.sourceBlock_.isShadow()) ? - this.sourceBlock_.getParent().getColour() : - this.sourceBlock_.getColour(); - const borderColour = (this.sourceBlock_.isShadow()) ? - this.sourceBlock_.getParent().style.colourTertiary : - this.sourceBlock_.style.colourTertiary; - DropDownDiv.setColour(primaryColour, borderColour); - } - - DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); - - // Focusing needs to be handled after the menu is rendered and positioned. - // Otherwise it will cause a page scroll to get the misplaced menu in - // view. See issue #1329. - this.menu_.focus(); - - if (this.selectedMenuItem_) { - this.menu_.setHighlighted(this.selectedMenuItem_); - } - - this.applyColour(); -}; - -/** - * Create the dropdown editor. - * @private - */ -FieldDropdown.prototype.dropdownCreate_ = function() { - const menu = new Menu(); - menu.setRole(aria.Role.LISTBOX); - this.menu_ = menu; - - const options = this.getOptions(false); - this.selectedMenuItem_ = null; - for (let i = 0; i < options.length; i++) { - let content = options[i][0]; // Human-readable text or image. - const value = options[i][1]; // Language-neutral value. - if (typeof content === 'object') { - // An image, not text. - const image = new Image(content['width'], content['height']); - image.src = content['src']; - image.alt = content['alt'] || ''; - content = image; - } - const menuItem = new MenuItem(content, value); - menuItem.setRole(aria.Role.OPTION); - menuItem.setRightToLeft(this.sourceBlock_.RTL); - menuItem.setCheckable(true); - menu.addChild(menuItem); - menuItem.setChecked(value === this.value_); - if (value === this.value_) { - this.selectedMenuItem_ = menuItem; - } - menuItem.onAction(this.handleMenuActionEvent_, this); - } -}; - -/** - * Disposes of events and DOM-references belonging to the dropdown editor. - * @private - */ -FieldDropdown.prototype.dropdownDispose_ = function() { - if (this.menu_) { - this.menu_.dispose(); - } - this.menu_ = null; - this.selectedMenuItem_ = null; - this.applyColour(); -}; - -/** - * Handle an action in the dropdown menu. - * @param {!MenuItem} menuItem The MenuItem selected within menu. - * @private - */ -FieldDropdown.prototype.handleMenuActionEvent_ = function(menuItem) { - DropDownDiv.hideIfOwner(this, true); - this.onItemSelected_(/** @type {!Menu} */ (this.menu_), menuItem); -}; - -/** - * Handle the selection of an item in the dropdown menu. - * @param {!Menu} menu The Menu component clicked. - * @param {!MenuItem} menuItem The MenuItem selected within menu. - * @protected - */ -FieldDropdown.prototype.onItemSelected_ = function(menu, menuItem) { - this.setValue(menuItem.getValue()); -}; - -/** - * Factor out common words in statically defined options. - * Create prefix and/or suffix labels. - * @private - */ -FieldDropdown.prototype.trimOptions_ = function() { - const options = this.menuGenerator_; - if (!Array.isArray(options)) { - return; - } - let hasImages = false; - - // Localize label text and image alt text. - for (let i = 0; i < options.length; i++) { - const label = options[i][0]; - if (typeof label === 'string') { - options[i][0] = parsing.replaceMessageReferences(label); - } else { - if (label.alt !== null) { - options[i][0].alt = parsing.replaceMessageReferences(label.alt); - } - hasImages = true; - } - } - if (hasImages || options.length < 2) { - return; // Do nothing if too few items or at least one label is an image. - } - const strings = []; - for (let i = 0; i < options.length; i++) { - strings.push(options[i][0]); - } - const shortest = utilsString.shortestStringLength(strings); - const prefixLength = utilsString.commonWordPrefix(strings, shortest); - const suffixLength = utilsString.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); - } - - this.menuGenerator_ = - FieldDropdown.applyTrim_(options, prefixLength, suffixLength); -}; - -/** - * Use the calculated prefix and suffix lengths to trim all of the options in - * the given array. - * @param {!Array} options Array of option tuples: - * (human-readable text or image, language-neutral name). - * @param {number} prefixLength The length of the common prefix. - * @param {number} suffixLength The length of the common suffix - * @return {!Array} A new array with all of the option text trimmed. - */ -FieldDropdown.applyTrim_ = function(options, prefixLength, suffixLength) { - const newOptions = []; - // Remove the prefix and suffix from the options. - for (let i = 0; i < options.length; i++) { - let text = options[i][0]; - const value = options[i][1]; - text = text.substring(prefixLength, text.length - suffixLength); - newOptions[i] = [text, value]; - } - return newOptions; -}; - -/** - * @return {boolean} True if the option list is generated by a function. - * Otherwise false. - */ -FieldDropdown.prototype.isOptionListDynamic = function() { - return typeof this.menuGenerator_ === 'function'; -}; - -/** - * Return a list of the options for this dropdown. - * @param {boolean=} opt_useCache For dynamic options, whether or not to use the - * cached options or to re-generate them. - * @return {!Array} A non-empty array of option tuples: - * (human-readable text or image, language-neutral name). - * @throws {TypeError} If generated options are incorrectly structured. - */ -FieldDropdown.prototype.getOptions = function(opt_useCache) { - if (this.isOptionListDynamic()) { - if (!this.generatedOptions_ || !opt_useCache) { - this.generatedOptions_ = this.menuGenerator_.call(this); - validateOptions(this.generatedOptions_); - } - return this.generatedOptions_; - } - return /** @type {!Array>} */ (this.menuGenerator_); -}; - -/** - * Ensure that the input value is a valid language-neutral option. - * @param {*=} opt_newValue The input value. - * @return {?string} A valid language-neutral option, or null if invalid. - * @protected - */ -FieldDropdown.prototype.doClassValidation_ = function(opt_newValue) { - let isValueValid = false; - const options = this.getOptions(true); - for (let i = 0, option; (option = options[i]); i++) { - // Options are tuples of human-readable text and language-neutral values. - if (option[1] === opt_newValue) { - isValueValid = true; - break; - } - } - if (!isValueValid) { - if (this.sourceBlock_) { - console.warn( - 'Cannot set the dropdown\'s value to an unavailable option.' + - ' Block type: ' + this.sourceBlock_.type + - ', Field name: ' + this.name + ', Value: ' + opt_newValue); - } - return null; - } - return /** @type {string} */ (opt_newValue); -}; - -/** - * Update the value of this dropdown field. - * @param {*} newValue The value to be saved. The default validator guarantees - * that this is one of the valid dropdown options. - * @protected - */ -FieldDropdown.prototype.doValueUpdate_ = function(newValue) { - FieldDropdown.superClass_.doValueUpdate_.call(this, newValue); - const options = this.getOptions(true); - for (let i = 0, option; (option = options[i]); i++) { - if (option[1] === this.value_) { - this.selectedOption_ = option; - } - } -}; - -/** - * Updates the dropdown arrow to match the colour/style of the block. - * @package - */ -FieldDropdown.prototype.applyColour = function() { - if (this.borderRect_) { - this.borderRect_.setAttribute( - 'stroke', this.sourceBlock_.style.colourTertiary); - if (this.menu_) { - this.borderRect_.setAttribute( - 'fill', this.sourceBlock_.style.colourTertiary); - } else { - this.borderRect_.setAttribute('fill', 'transparent'); - } - } - // Update arrow's colour. - if (this.sourceBlock_ && this.arrow_) { - if (this.sourceBlock_.isShadow()) { - this.arrow_.style.fill = this.sourceBlock_.style.colourSecondary; - } else { - this.arrow_.style.fill = this.sourceBlock_.style.colourPrimary; - } - } -}; - -/** - * Draws the border with the correct width. - * @protected - */ -FieldDropdown.prototype.render_ = function() { - // Hide both elements. - this.textContent_.nodeValue = ''; - this.imageElement_.style.display = 'none'; - - // Show correct element. - const option = this.selectedOption_ && this.selectedOption_[0]; - if (option && typeof option === 'object') { - this.renderSelectedImage_( - /** @type {!FieldDropdown.ImageProperties} */ (option)); - } else { - this.renderSelectedText_(); - } - - this.positionBorderRect_(); -}; - -/** - * Renders the selected option, which must be an image. - * @param {!FieldDropdown.ImageProperties} imageJson Selected - * option that must be an image. - * @private - */ -FieldDropdown.prototype.renderSelectedImage_ = function(imageJson) { - this.imageElement_.style.display = ''; - this.imageElement_.setAttributeNS(dom.XLINK_NS, 'xlink:href', imageJson.src); - this.imageElement_.setAttribute('height', imageJson.height); - this.imageElement_.setAttribute('width', imageJson.width); - - const imageHeight = Number(imageJson.height); - const imageWidth = Number(imageJson.width); - - // Height and width include the border rect. - const hasBorder = !!this.borderRect_; - const height = Math.max( - hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, - imageHeight + IMAGE_Y_PADDING); - const xPadding = - hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0; - let arrowWidth = 0; - if (this.svgArrow_) { - arrowWidth = this.positionSVGArrow_( - imageWidth + xPadding, - height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2); - } else { - arrowWidth = dom.getFastTextWidth( - /** @type {!SVGTSpanElement} */ (this.arrow_), - this.getConstants().FIELD_TEXT_FONTSIZE, - this.getConstants().FIELD_TEXT_FONTWEIGHT, - this.getConstants().FIELD_TEXT_FONTFAMILY); - } - this.size_.width = imageWidth + arrowWidth + xPadding * 2; - this.size_.height = height; - - let arrowX = 0; - if (this.sourceBlock_.RTL) { - const imageX = xPadding + arrowWidth; - this.imageElement_.setAttribute('x', imageX); - } else { - arrowX = imageWidth + arrowWidth; - this.textElement_.setAttribute('text-anchor', 'end'); - this.imageElement_.setAttribute('x', xPadding); - } - this.imageElement_.setAttribute('y', height / 2 - imageHeight / 2); - - this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth); -}; - -/** - * Renders the selected option, which must be text. - * @private - */ -FieldDropdown.prototype.renderSelectedText_ = function() { - // Retrieves the selected option to display through getText_. - this.textContent_.nodeValue = this.getDisplayText_(); - dom.addClass( - /** @type {!Element} */ (this.textElement_), 'blocklyDropdownText'); - this.textElement_.setAttribute('text-anchor', 'start'); - - // Height and width include the border rect. - const hasBorder = !!this.borderRect_; - const height = Math.max( - hasBorder ? this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, - this.getConstants().FIELD_TEXT_HEIGHT); - const textWidth = dom.getFastTextWidth( - this.textElement_, this.getConstants().FIELD_TEXT_FONTSIZE, - this.getConstants().FIELD_TEXT_FONTWEIGHT, - this.getConstants().FIELD_TEXT_FONTFAMILY); - const xPadding = - hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0; - let arrowWidth = 0; - if (this.svgArrow_) { - arrowWidth = this.positionSVGArrow_( - textWidth + xPadding, - height / 2 - this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE / 2); - } - this.size_.width = textWidth + arrowWidth + xPadding * 2; - this.size_.height = height; - - this.positionTextElement_(xPadding, textWidth); -}; - -/** - * Position a drop-down arrow at the appropriate location at render-time. - * @param {number} x X position the arrow is being rendered at, in px. - * @param {number} y Y position the arrow is being rendered at, in px. - * @return {number} Amount of space the arrow is taking up, in px. - * @private - */ -FieldDropdown.prototype.positionSVGArrow_ = function(x, y) { - if (!this.svgArrow_) { - return 0; - } - const hasBorder = !!this.borderRect_; - const xPadding = - hasBorder ? this.getConstants().FIELD_BORDER_RECT_X_PADDING : 0; - const textPadding = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_PADDING; - const svgArrowSize = this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE; - const arrowX = this.sourceBlock_.RTL ? xPadding : x + textPadding; - this.svgArrow_.setAttribute( - 'transform', 'translate(' + arrowX + ',' + y + ')'); - return svgArrowSize + textPadding; -}; - -/** - * Use the `getText_` developer hook to override the field's text - * representation. Get the selected option text. If the selected option is an - * image we return the image alt text. - * @return {?string} Selected option text. - * @protected - * @override - */ -FieldDropdown.prototype.getText_ = function() { - if (!this.selectedOption_) { - return null; - } - const option = this.selectedOption_[0]; - if (typeof option === 'object') { - return option['alt']; - } - return option; -}; - /** * Validates the data structure to be processed as an options list. * @param {?} options The proposed dropdown options. diff --git a/core/field_image.js b/core/field_image.js index 25cf1c5d1..de44cfdae 100644 --- a/core/field_image.js +++ b/core/field_image.js @@ -17,108 +17,272 @@ goog.module('Blockly.FieldImage'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); const parsing = goog.require('Blockly.utils.parsing'); const {Field} = goog.require('Blockly.Field'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); const {Size} = goog.require('Blockly.utils.Size'); const {Svg} = goog.require('Blockly.utils.Svg'); /** * Class for an image on a block. - * @param {string} src The URL of the image. - * @param {!(string|number)} width Width of the image. - * @param {!(string|number)} height Height of the image. - * @param {string=} opt_alt Optional alt text for when block is collapsed. - * @param {function(!FieldImage)=} opt_onClick Optional function to be - * called when the image is clicked. If opt_onClick is defined, opt_alt must - * also be defined. - * @param {boolean=} opt_flipRtl Whether to flip the icon in RTL. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/image#creation} - * for a list of properties this parameter supports. * @extends {Field} - * @constructor - * @alias Blockly.FieldImage */ -const FieldImage = function( - src, width, height, opt_alt, opt_onClick, opt_flipRtl, opt_config) { - // Return early. - if (!src) { - throw Error('Src value of an image field is required'); - } - src = parsing.replaceMessageReferences(src); - const imageHeight = Number(parsing.replaceMessageReferences(height)); - const imageWidth = Number(parsing.replaceMessageReferences(width)); - if (isNaN(imageHeight) || isNaN(imageWidth)) { - throw Error( - 'Height and width values of an image field must cast to' + - ' numbers.'); - } - if (imageHeight <= 0 || imageWidth <= 0) { - throw Error( - 'Height and width values of an image field must be greater' + - ' than 0.'); - } - - // Initialize configurable properties. +class FieldImage extends Field { /** - * Whether to flip this image in RTL. - * @type {boolean} - * @private + * @param {string|!Sentinel} src The URL of the image. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {!(string|number)} width Width of the image. + * @param {!(string|number)} height Height of the image. + * @param {string=} opt_alt Optional alt text for when block is collapsed. + * @param {function(!FieldImage)=} opt_onClick Optional function to be + * called when the image is clicked. If opt_onClick is defined, opt_alt + * must also be defined. + * @param {boolean=} opt_flipRtl Whether to flip the icon in RTL. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/image#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldImage */ - this.flipRtl_ = false; + constructor( + src, width, height, opt_alt, opt_onClick, opt_flipRtl, opt_config) { + super(Field.SKIP_SETUP); - /** - * Alt text of this image. - * @type {string} - * @private - */ - this.altText_ = ''; + // Return early. + if (!src) { + throw Error('Src value of an image field is required'); + } + const imageHeight = Number(parsing.replaceMessageReferences(height)); + const imageWidth = Number(parsing.replaceMessageReferences(width)); + if (isNaN(imageHeight) || isNaN(imageWidth)) { + throw Error( + 'Height and width values of an image field must cast to' + + ' numbers.'); + } + if (imageHeight <= 0 || imageWidth <= 0) { + throw Error( + 'Height and width values of an image field must be greater' + + ' than 0.'); + } - FieldImage.superClass_.constructor.call(this, src, null, opt_config); + /** + * The size of the area rendered by the field. + * @type {Size} + * @protected + * @override + */ + this.size_ = new Size(imageWidth, imageHeight + FieldImage.Y_PADDING); - if (!opt_config) { // If the config wasn't passed, do old configuration. - this.flipRtl_ = !!opt_flipRtl; - this.altText_ = parsing.replaceMessageReferences(opt_alt) || ''; + /** + * Store the image height, since it is different from the field height. + * @type {number} + * @private + */ + this.imageHeight_ = imageHeight; + + /** + * The function to be called when this field is clicked. + * @type {?function(!FieldImage)} + * @private + */ + this.clickHandler_ = null; + + if (typeof opt_onClick === 'function') { + this.clickHandler_ = opt_onClick; + } + + /** + * The rendered field's image element. + * @type {SVGImageElement} + * @private + */ + this.imageElement_ = null; + + /** + * Editable fields usually show some sort of UI indicating they are + * editable. This field should not. + * @type {boolean} + * @const + */ + this.EDITABLE = false; + + /** + * Used to tell if the field needs to be rendered the next time the block is + * rendered. Image fields are statically sized, and only need to be + * rendered at initialization. + * @type {boolean} + * @protected + */ + this.isDirty_ = false; + + /** + * Whether to flip this image in RTL. + * @type {boolean} + * @private + */ + this.flipRtl_ = false; + + /** + * Alt text of this image. + * @type {string} + * @private + */ + this.altText_ = ''; + + if (src === Field.SKIP_SETUP) return; + + if (opt_config) { + this.configure_(opt_config); + } else { + this.flipRtl_ = !!opt_flipRtl; + this.altText_ = parsing.replaceMessageReferences(opt_alt) || ''; + } + this.setValue(parsing.replaceMessageReferences(src)); } - // Initialize other properties. /** - * The size of the area rendered by the field. - * @type {Size} + * Configure the field based on the given map of options. + * @param {!Object} config A map of options to configure the field based on. * @protected * @override */ - this.size_ = new Size(imageWidth, imageHeight + FieldImage.Y_PADDING); - - /** - * Store the image height, since it is different from the field height. - * @type {number} - * @private - */ - this.imageHeight_ = imageHeight; - - /** - * The function to be called when this field is clicked. - * @type {?function(!FieldImage)} - * @private - */ - this.clickHandler_ = null; - - if (typeof opt_onClick === 'function') { - this.clickHandler_ = opt_onClick; + configure_(config) { + super.configure_(config); + this.flipRtl_ = !!config['flipRtl']; + this.altText_ = parsing.replaceMessageReferences(config['alt']) || ''; } /** - * The rendered field's image element. - * @type {SVGImageElement} - * @private + * Create the block UI for this image. + * @package */ - this.imageElement_ = null; -}; -object.inherits(FieldImage, Field); + initView() { + this.imageElement_ = dom.createSvgElement( + Svg.IMAGE, { + 'height': this.imageHeight_ + 'px', + 'width': this.size_.width + 'px', + 'alt': this.altText_, + }, + this.fieldGroup_); + this.imageElement_.setAttributeNS( + dom.XLINK_NS, 'xlink:href', /** @type {string} */ (this.value_)); + + if (this.clickHandler_) { + this.imageElement_.style.cursor = 'pointer'; + } + } + + /** + * @override + */ + updateSize_() { + // NOP + } + + /** + * Ensure that the input value (the source URL) is a string. + * @param {*=} opt_newValue The input value. + * @return {?string} A string, or null if invalid. + * @protected + */ + doClassValidation_(opt_newValue) { + if (typeof opt_newValue !== 'string') { + return null; + } + return opt_newValue; + } + + /** + * Update the value of this image field, and update the displayed image. + * @param {*} newValue The value to be saved. The default validator guarantees + * that this is a string. + * @protected + */ + doValueUpdate_(newValue) { + this.value_ = newValue; + if (this.imageElement_) { + this.imageElement_.setAttributeNS( + dom.XLINK_NS, 'xlink:href', String(this.value_)); + } + } + + /** + * Get whether to flip this image in RTL + * @return {boolean} True if we should flip in RTL. + * @override + */ + getFlipRtl() { + return this.flipRtl_; + } + + /** + * Set the alt text of this image. + * @param {?string} alt New alt text. + * @public + */ + setAlt(alt) { + if (alt === this.altText_) { + return; + } + this.altText_ = alt || ''; + if (this.imageElement_) { + this.imageElement_.setAttribute('alt', this.altText_); + } + } + + /** + * If field click is called, and click handler defined, + * call the handler. + * @protected + */ + showEditor_() { + if (this.clickHandler_) { + this.clickHandler_(this); + } + } + + /** + * Set the function that is called when this image is clicked. + * @param {?function(!FieldImage)} func The function that is called + * when the image is clicked, or null to remove. + */ + setOnClickHandler(func) { + this.clickHandler_ = func; + } + + /** + * Use the `getText_` developer hook to override the field's text + * representation. + * Return the image alt text instead. + * @return {?string} The image alt text. + * @protected + * @override + */ + getText_() { + return this.altText_; + } + + /** + * Construct a FieldImage from a JSON arg object, + * dereferencing any string table references. + * @param {!Object} options A JSON object with options (src, width, height, + * alt, and flipRtl). + * @return {!FieldImage} The new field instance. + * @package + * @nocollapse + */ + static fromJson(options) { + // `this` might be a subclass of FieldImage if that class doesn't override + // the static fromJson method. + return new this( + options['src'], options['width'], options['height'], undefined, + undefined, undefined, options); + } +} /** * The default value for this field. @@ -127,23 +291,6 @@ object.inherits(FieldImage, Field); */ FieldImage.prototype.DEFAULT_VALUE = ''; -/** - * Construct a FieldImage from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (src, width, height, - * alt, and flipRtl). - * @return {!FieldImage} The new field instance. - * @package - * @nocollapse - */ -FieldImage.fromJson = function(options) { - // `this` might be a subclass of FieldImage if that class doesn't override - // the static fromJson method. - return new this( - options['src'], options['width'], options['height'], undefined, undefined, - undefined, options); -}; - /** * Vertical padding below the image, which is included in the reported height of * the field. @@ -152,144 +299,6 @@ FieldImage.fromJson = function(options) { */ FieldImage.Y_PADDING = 1; -/** - * Editable fields usually show some sort of UI indicating they are - * editable. This field should not. - * @type {boolean} - */ -FieldImage.prototype.EDITABLE = false; - -/** - * Used to tell if the field needs to be rendered the next time the block is - * rendered. Image fields are statically sized, and only need to be - * rendered at initialization. - * @type {boolean} - * @protected - */ -FieldImage.prototype.isDirty_ = false; - -/** - * Configure the field based on the given map of options. - * @param {!Object} config A map of options to configure the field based on. - * @protected - * @override - */ -FieldImage.prototype.configure_ = function(config) { - FieldImage.superClass_.configure_.call(this, config); - this.flipRtl_ = !!config['flipRtl']; - this.altText_ = parsing.replaceMessageReferences(config['alt']) || ''; -}; - -/** - * Create the block UI for this image. - * @package - */ -FieldImage.prototype.initView = function() { - this.imageElement_ = dom.createSvgElement( - Svg.IMAGE, { - 'height': this.imageHeight_ + 'px', - 'width': this.size_.width + 'px', - 'alt': this.altText_, - }, - this.fieldGroup_); - this.imageElement_.setAttributeNS( - dom.XLINK_NS, 'xlink:href', /** @type {string} */ (this.value_)); - - if (this.clickHandler_) { - this.imageElement_.style.cursor = 'pointer'; - } -}; - -/** - * @override - */ -FieldImage.prototype.updateSize_ = function() { - // NOP -}; - -/** - * Ensure that the input value (the source URL) is a string. - * @param {*=} opt_newValue The input value. - * @return {?string} A string, or null if invalid. - * @protected - */ -FieldImage.prototype.doClassValidation_ = function(opt_newValue) { - if (typeof opt_newValue !== 'string') { - return null; - } - return opt_newValue; -}; - -/** - * Update the value of this image field, and update the displayed image. - * @param {*} newValue The value to be saved. The default validator guarantees - * that this is a string. - * @protected - */ -FieldImage.prototype.doValueUpdate_ = function(newValue) { - this.value_ = newValue; - if (this.imageElement_) { - this.imageElement_.setAttributeNS( - dom.XLINK_NS, 'xlink:href', String(this.value_)); - } -}; - -/** - * Get whether to flip this image in RTL - * @return {boolean} True if we should flip in RTL. - * @override - */ -FieldImage.prototype.getFlipRtl = function() { - return this.flipRtl_; -}; - -/** - * Set the alt text of this image. - * @param {?string} alt New alt text. - * @public - */ -FieldImage.prototype.setAlt = function(alt) { - if (alt === this.altText_) { - return; - } - this.altText_ = alt || ''; - if (this.imageElement_) { - this.imageElement_.setAttribute('alt', this.altText_); - } -}; - -/** - * If field click is called, and click handler defined, - * call the handler. - * @protected - */ -FieldImage.prototype.showEditor_ = function() { - if (this.clickHandler_) { - this.clickHandler_(this); - } -}; - -/** - * Set the function that is called when this image is clicked. - * @param {?function(!FieldImage)} func The function that is called - * when the image is clicked, or null to remove. - */ -FieldImage.prototype.setOnClickHandler = function(func) { - this.clickHandler_ = func; -}; - -/** - * Use the `getText_` developer hook to override the field's text - * representation. - * Return the image alt text instead. - * @return {?string} The image alt text. - * @protected - * @override - */ -FieldImage.prototype.getText_ = function() { - return this.altText_; -}; - fieldRegistry.register('field_image', FieldImage); exports.FieldImage = FieldImage; diff --git a/core/field_label.js b/core/field_label.js index b9195d803..edbb2e100 100644 --- a/core/field_label.js +++ b/core/field_label.js @@ -19,39 +19,123 @@ goog.module('Blockly.FieldLabel'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); const parsing = goog.require('Blockly.utils.parsing'); const {Field} = goog.require('Blockly.Field'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); /** * Class for a non-editable, non-serializable text field. - * @param {string=} opt_value The initial value of the field. Should cast to a - * string. Defaults to an empty string if null or undefined. - * @param {string=} opt_class Optional CSS class for the field's text. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label#creation} - * for a list of properties this parameter supports. * @extends {Field} - * @constructor - * @alias Blockly.FieldLabel */ -const FieldLabel = function(opt_value, opt_class, opt_config) { +class FieldLabel extends Field { /** - * The html class name to use for this field. - * @type {?string} - * @private + * @param {(string|!Sentinel)=} opt_value The initial value of the + * field. Should cast to a string. Defaults to an empty string if null or + * undefined. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {string=} opt_class Optional CSS class for the field's text. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldLabel */ - this.class_ = null; + constructor(opt_value, opt_class, opt_config) { + super(Field.SKIP_SETUP); - FieldLabel.superClass_.constructor.call(this, opt_value, null, opt_config); + /** + * The html class name to use for this field. + * @type {?string} + * @private + */ + this.class_ = null; - if (!opt_config) { // If the config was not passed use old configuration. - this.class_ = opt_class || null; + /** + * Editable fields usually show some sort of UI indicating they are + * editable. This field should not. + * @type {boolean} + */ + this.EDITABLE = false; + + if (opt_value === Field.SKIP_SETUP) return; + if (opt_config) { + this.configure_(opt_config); + } else { + this.class_ = opt_class || null; + } + this.setValue(opt_value); } -}; -object.inherits(FieldLabel, Field); + + /** + * @override + */ + configure_(config) { + super.configure_(config); + this.class_ = config['class']; + } + + /** + * Create block UI for this label. + * @package + */ + initView() { + this.createTextElement_(); + if (this.class_) { + dom.addClass( + /** @type {!SVGTextElement} */ (this.textElement_), this.class_); + } + } + + /** + * Ensure that the input value casts to a valid string. + * @param {*=} opt_newValue The input value. + * @return {?string} A valid string, or null if invalid. + * @protected + */ + doClassValidation_(opt_newValue) { + if (opt_newValue === null || opt_newValue === undefined) { + return null; + } + return String(opt_newValue); + } + + /** + * Set the CSS class applied to the field's textElement_. + * @param {?string} cssClass The new CSS class name, or null to remove. + */ + setClass(cssClass) { + if (this.textElement_) { + // This check isn't necessary, but it's faster than letting removeClass + // figure it out. + if (this.class_) { + dom.removeClass(this.textElement_, this.class_); + } + if (cssClass) { + dom.addClass(this.textElement_, cssClass); + } + } + this.class_ = cssClass; + } + + /** + * Construct a FieldLabel from a JSON arg object, + * dereferencing any string table references. + * @param {!Object} options A JSON object with options (text, and class). + * @return {!FieldLabel} The new field instance. + * @package + * @nocollapse + */ + static fromJson(options) { + const text = parsing.replaceMessageReferences(options['text']); + // `this` might be a subclass of FieldLabel if that class doesn't override + // the static fromJson method. + return new this(text, undefined, options); + } +} /** * The default value for this field. @@ -60,79 +144,6 @@ object.inherits(FieldLabel, Field); */ FieldLabel.prototype.DEFAULT_VALUE = ''; -/** - * Construct a FieldLabel from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (text, and class). - * @return {!FieldLabel} The new field instance. - * @package - * @nocollapse - */ -FieldLabel.fromJson = function(options) { - const text = parsing.replaceMessageReferences(options['text']); - // `this` might be a subclass of FieldLabel if that class doesn't override - // the static fromJson method. - return new this(text, undefined, options); -}; - -/** - * Editable fields usually show some sort of UI indicating they are - * editable. This field should not. - * @type {boolean} - */ -FieldLabel.prototype.EDITABLE = false; - -/** - * @override - */ -FieldLabel.prototype.configure_ = function(config) { - FieldLabel.superClass_.configure_.call(this, config); - this.class_ = config['class']; -}; - -/** - * Create block UI for this label. - * @package - */ -FieldLabel.prototype.initView = function() { - this.createTextElement_(); - if (this.class_) { - dom.addClass( - /** @type {!SVGTextElement} */ (this.textElement_), this.class_); - } -}; - -/** - * Ensure that the input value casts to a valid string. - * @param {*=} opt_newValue The input value. - * @return {?string} A valid string, or null if invalid. - * @protected - */ -FieldLabel.prototype.doClassValidation_ = function(opt_newValue) { - if (opt_newValue === null || opt_newValue === undefined) { - return null; - } - return String(opt_newValue); -}; - -/** - * Set the CSS class applied to the field's textElement_. - * @param {?string} cssClass The new CSS class name, or null to remove. - */ -FieldLabel.prototype.setClass = function(cssClass) { - if (this.textElement_) { - // This check isn't necessary, but it's faster than letting removeClass - // figure it out. - if (this.class_) { - dom.removeClass(this.textElement_, this.class_); - } - if (cssClass) { - dom.addClass(this.textElement_, cssClass); - } - } - this.class_ = cssClass; -}; - fieldRegistry.register('field_label', FieldLabel); exports.FieldLabel = FieldLabel; diff --git a/core/field_label_serializable.js b/core/field_label_serializable.js index b8d3e027a..d9e6109f0 100644 --- a/core/field_label_serializable.js +++ b/core/field_label_serializable.js @@ -30,19 +30,17 @@ const {FieldLabel} = goog.require('Blockly.FieldLabel'); */ class FieldLabelSerializable extends FieldLabel { /** - * @param {*} opt_value The initial value of the field. Should cast to a + * @param {string=} opt_value The initial value of the field. Should cast to a * string. Defaults to an empty string if null or undefined. * @param {string=} opt_class Optional CSS class for the field's text. * @param {Object=} opt_config A map of options used to configure the field. * See the [field creation documentation]{@link * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/label-serializable#creation} * for a list of properties this parameter supports. - * * @alias Blockly.FieldLabelSerializable */ constructor(opt_value, opt_class, opt_config) { - const stringValue = opt_value == undefined ? '' : String(opt_value); - super(stringValue, opt_class, opt_config); + super(String(opt_value ?? ''), opt_class, opt_config); /** * Editable fields usually show some sort of UI indicating they are diff --git a/core/field_multilineinput.js b/core/field_multilineinput.js index b03dc2d2b..1b065cdd8 100644 --- a/core/field_multilineinput.js +++ b/core/field_multilineinput.js @@ -25,6 +25,8 @@ const userAgent = goog.require('Blockly.utils.userAgent'); const {FieldTextInput} = goog.require('Blockly.FieldTextInput'); const {Field} = goog.require('Blockly.Field'); const {KeyCodes} = goog.require('Blockly.utils.KeyCodes'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); const {Svg} = goog.require('Blockly.utils.Svg'); @@ -34,22 +36,24 @@ const {Svg} = goog.require('Blockly.utils.Svg'); */ class FieldMultilineInput extends FieldTextInput { /** - * @param {string=} opt_value The initial content of the field. Should cast to - * a - * string. Defaults to an empty string if null or undefined. + * @param {(string|!Sentinel)=} opt_value The initial content of the + * field. Should cast to a string. Defaults to an empty string if null or + * undefined. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). * @param {Function=} opt_validator An optional function that is called * to validate any constraints on what the user entered. Takes the new * text as an argument and returns either the accepted text, a replacement * text, or null to abort the change. * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation} - * for a list of properties this parameter supports. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation} + * for a list of properties this parameter supports. * @alias Blockly.FieldMultilineInput */ constructor(opt_value, opt_validator, opt_config) { - const stringValue = opt_value == undefined ? '' : String(opt_value); - super(stringValue, opt_validator, opt_config); + super(Field.SKIP_SETUP); /** * The SVG group element that will contain a text element for each text row @@ -73,17 +77,10 @@ class FieldMultilineInput extends FieldTextInput { */ this.isOverflowedY_ = false; - /** - * @type {boolean} - * @private - */ - this.isBeingEdited_ = false; - - /** - * @type {boolean} - * @private - */ - this.isTextValid_ = false; + if (opt_value === Field.SKIP_SETUP) return; + if (opt_config) this.configure_(opt_config); + this.setValue(opt_value); + if (opt_validator) this.setValidator(opt_validator); } /** diff --git a/core/field_number.js b/core/field_number.js index afa9ee89b..c1ceeadc7 100644 --- a/core/field_number.js +++ b/core/field_number.js @@ -17,67 +17,316 @@ goog.module('Blockly.FieldNumber'); const aria = goog.require('Blockly.utils.aria'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); +const {Field} = goog.require('Blockly.Field'); const {FieldTextInput} = goog.require('Blockly.FieldTextInput'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); /** * Class for an editable number field. - * @param {string|number=} opt_value The initial value of the field. Should cast - * to a number. Defaults to 0. - * @param {?(string|number)=} opt_min Minimum value. - * @param {?(string|number)=} opt_max Maximum value. - * @param {?(string|number)=} opt_precision Precision for value. - * @param {?Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a number & returns a validated - * number, or null to abort the change. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/number#creation} - * for a list of properties this parameter supports. * @extends {FieldTextInput} - * @constructor - * @alias Blockly.FieldNumber */ -const FieldNumber = function( - opt_value, opt_min, opt_max, opt_precision, opt_validator, opt_config) { +class FieldNumber extends FieldTextInput { /** - * The minimum value this number field can contain. - * @type {number} - * @protected + * @param {(string|number|!Sentinel)=} opt_value The initial value of + * the field. Should cast to a number. Defaults to 0. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {?(string|number)=} opt_min Minimum value. Will only be used if + * opt_config is not provided. + * @param {?(string|number)=} opt_max Maximum value. Will only be used if + * opt_config is not provided. + * @param {?(string|number)=} opt_precision Precision for value. Will only be + * used if opt_config is not provided. + * @param {?Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a number & returns a validated + * number, or null to abort the change. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/number#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldNumber */ - this.min_ = -Infinity; + constructor( + opt_value, opt_min, opt_max, opt_precision, opt_validator, opt_config) { + // Pass SENTINEL so that we can define properties before value validation. + super(Field.SKIP_SETUP); + + /** + * The minimum value this number field can contain. + * @type {number} + * @protected + */ + this.min_ = -Infinity; + + /** + * The maximum value this number field can contain. + * @type {number} + * @protected + */ + this.max_ = Infinity; + + /** + * The multiple to which this fields value is rounded. + * @type {number} + * @protected + */ + this.precision_ = 0; + + /** + * The number of decimal places to allow, or null to allow any number of + * decimal digits. + * @type {?number} + * @private + */ + this.decimalPlaces_ = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + * @type {boolean} + */ + this.SERIALIZABLE = true; + + if (opt_value === Field.SKIP_SETUP) return; + if (opt_config) { + this.configure_(opt_config); + } else { + this.setConstraints(opt_min, opt_max, opt_precision); + } + this.setValue(opt_value); + if (opt_validator) this.setValidator(opt_validator); + } /** - * The maximum value this number field can contain. - * @type {number} + * Configure the field based on the given map of options. + * @param {!Object} config A map of options to configure the field based on. * @protected + * @override */ - this.max_ = Infinity; + configure_(config) { + super.configure_(config); + this.setMinInternal_(config['min']); + this.setMaxInternal_(config['max']); + this.setPrecisionInternal_(config['precision']); + } /** - * The multiple to which this fields value is rounded. - * @type {number} - * @protected + * Set the maximum, minimum and precision constraints on this field. + * Any of these properties may be undefined or NaN to be disabled. + * Setting precision (usually a power of 10) enforces a minimum step between + * values. That is, the user's value will rounded to the closest multiple of + * precision. The least significant digit place is inferred from the + * precision. Integers values can be enforces by choosing an integer + * precision. + * @param {?(number|string|undefined)} min Minimum value. + * @param {?(number|string|undefined)} max Maximum value. + * @param {?(number|string|undefined)} precision Precision for value. */ - this.precision_ = 0; + setConstraints(min, max, precision) { + this.setMinInternal_(min); + this.setMaxInternal_(max); + this.setPrecisionInternal_(precision); + this.setValue(this.getValue()); + } /** - * The number of decimal places to allow, or null to allow any number of - * decimal digits. - * @type {?number} + * Sets the minimum value this field can contain. Updates the value to + * reflect. + * @param {?(number|string|undefined)} min Minimum value. + */ + setMin(min) { + this.setMinInternal_(min); + this.setValue(this.getValue()); + } + + /** + * Sets the minimum value this field can contain. Called internally to avoid + * value updates. + * @param {?(number|string|undefined)} min Minimum value. * @private */ - this.decimalPlaces_ = null; - - FieldNumber.superClass_.constructor.call( - this, opt_value, opt_validator, opt_config); - - if (!opt_config) { // Only do one kind of configuration or the other. - this.setConstraints(opt_min, opt_max, opt_precision); + setMinInternal_(min) { + if (min == null) { + this.min_ = -Infinity; + } else { + min = Number(min); + if (!isNaN(min)) { + this.min_ = min; + } + } } -}; -object.inherits(FieldNumber, FieldTextInput); + + /** + * Returns the current minimum value this field can contain. Default is + * -Infinity. + * @return {number} The current minimum value this field can contain. + */ + getMin() { + return this.min_; + } + + /** + * Sets the maximum value this field can contain. Updates the value to + * reflect. + * @param {?(number|string|undefined)} max Maximum value. + */ + setMax(max) { + this.setMaxInternal_(max); + this.setValue(this.getValue()); + } + + /** + * Sets the maximum value this field can contain. Called internally to avoid + * value updates. + * @param {?(number|string|undefined)} max Maximum value. + * @private + */ + setMaxInternal_(max) { + if (max == null) { + this.max_ = Infinity; + } else { + max = Number(max); + if (!isNaN(max)) { + this.max_ = max; + } + } + } + + /** + * Returns the current maximum value this field can contain. Default is + * Infinity. + * @return {number} The current maximum value this field can contain. + */ + getMax() { + return this.max_; + } + + /** + * Sets the precision of this field's value, i.e. the number to which the + * value is rounded. Updates the field to reflect. + * @param {?(number|string|undefined)} precision The number to which the + * field's value is rounded. + */ + setPrecision(precision) { + this.setPrecisionInternal_(precision); + this.setValue(this.getValue()); + } + + /** + * Sets the precision of this field's value. Called internally to avoid + * value updates. + * @param {?(number|string|undefined)} precision The number to which the + * field's value is rounded. + * @private + */ + setPrecisionInternal_(precision) { + this.precision_ = Number(precision) || 0; + let precisionString = String(this.precision_); + if (precisionString.indexOf('e') !== -1) { + // String() is fast. But it turns .0000001 into '1e-7'. + // Use the much slower toLocaleString to access all the digits. + precisionString = + this.precision_.toLocaleString('en-US', {maximumFractionDigits: 20}); + } + const decimalIndex = precisionString.indexOf('.'); + if (decimalIndex === -1) { + // If the precision is 0 (float) allow any number of decimals, + // otherwise allow none. + this.decimalPlaces_ = precision ? 0 : null; + } else { + this.decimalPlaces_ = precisionString.length - decimalIndex - 1; + } + } + + /** + * Returns the current precision of this field. The precision being the + * number to which the field's value is rounded. A precision of 0 means that + * the value is not rounded. + * @return {number} The number to which this field's value is rounded. + */ + getPrecision() { + return this.precision_; + } + + /** + * Ensure that the input value is a valid number (must fulfill the + * constraints placed on the field). + * @param {*=} opt_newValue The input value. + * @return {?number} A valid number, or null if invalid. + * @protected + * @override + */ + doClassValidation_(opt_newValue) { + if (opt_newValue === null) { + return null; + } + // Clean up text. + let newValue = String(opt_newValue); + // TODO: Handle cases like 'ten', '1.203,14', etc. + // 'O' is sometimes mistaken for '0' by inexperienced users. + newValue = newValue.replace(/O/ig, '0'); + // Strip out thousands separators. + newValue = newValue.replace(/,/g, ''); + // Ignore case of 'Infinity'. + newValue = newValue.replace(/infinity/i, 'Infinity'); + + // Clean up number. + let n = Number(newValue || 0); + if (isNaN(n)) { + // Invalid number. + return null; + } + // Get the value in range. + n = Math.min(Math.max(n, this.min_), this.max_); + // Round to nearest multiple of precision. + if (this.precision_ && isFinite(n)) { + n = Math.round(n / this.precision_) * this.precision_; + } + // Clean up floating point errors. + if (this.decimalPlaces_ !== null) { + n = Number(n.toFixed(this.decimalPlaces_)); + } + return n; + } + + /** + * Create the number input editor widget. + * @return {!HTMLElement} The newly created number input editor. + * @protected + * @override + */ + widgetCreate_() { + const htmlInput = super.widgetCreate_(); + + // Set the accessibility state + if (this.min_ > -Infinity) { + aria.setState(htmlInput, aria.State.VALUEMIN, this.min_); + } + if (this.max_ < Infinity) { + aria.setState(htmlInput, aria.State.VALUEMAX, this.max_); + } + return htmlInput; + } + + /** + * Construct a FieldNumber from a JSON arg object. + * @param {!Object} options A JSON object with options (value, min, max, and + * precision). + * @return {!FieldNumber} The new field instance. + * @package + * @nocollapse + * @override + */ + static fromJson(options) { + // `this` might be a subclass of FieldNumber if that class doesn't override + // the static fromJson method. + return new this( + options['value'], undefined, undefined, undefined, undefined, options); + } +} /** * The default value for this field. @@ -86,236 +335,6 @@ object.inherits(FieldNumber, FieldTextInput); */ FieldNumber.prototype.DEFAULT_VALUE = 0; -/** - * Construct a FieldNumber from a JSON arg object. - * @param {!Object} options A JSON object with options (value, min, max, and - * precision). - * @return {!FieldNumber} The new field instance. - * @package - * @nocollapse - */ -FieldNumber.fromJson = function(options) { - // `this` might be a subclass of FieldNumber if that class doesn't override - // the static fromJson method. - return new this( - options['value'], undefined, undefined, undefined, undefined, options); -}; - -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. - * @type {boolean} - */ -FieldNumber.prototype.SERIALIZABLE = true; - -/** - * Configure the field based on the given map of options. - * @param {!Object} config A map of options to configure the field based on. - * @protected - * @override - */ -FieldNumber.prototype.configure_ = function(config) { - FieldNumber.superClass_.configure_.call(this, config); - this.setMinInternal_(config['min']); - this.setMaxInternal_(config['max']); - this.setPrecisionInternal_(config['precision']); -}; - -/** - * Set the maximum, minimum and precision constraints on this field. - * Any of these properties may be undefined or NaN to be disabled. - * Setting precision (usually a power of 10) enforces a minimum step between - * values. That is, the user's value will rounded to the closest multiple of - * precision. The least significant digit place is inferred from the precision. - * Integers values can be enforces by choosing an integer precision. - * @param {?(number|string|undefined)} min Minimum value. - * @param {?(number|string|undefined)} max Maximum value. - * @param {?(number|string|undefined)} precision Precision for value. - */ -FieldNumber.prototype.setConstraints = function(min, max, precision) { - this.setMinInternal_(min); - this.setMaxInternal_(max); - this.setPrecisionInternal_(precision); - this.setValue(this.getValue()); -}; - -/** - * Sets the minimum value this field can contain. Updates the value to reflect. - * @param {?(number|string|undefined)} min Minimum value. - */ -FieldNumber.prototype.setMin = function(min) { - this.setMinInternal_(min); - this.setValue(this.getValue()); -}; - -/** - * Sets the minimum value this field can contain. Called internally to avoid - * value updates. - * @param {?(number|string|undefined)} min Minimum value. - * @private - */ -FieldNumber.prototype.setMinInternal_ = function(min) { - if (min == null) { - this.min_ = -Infinity; - } else { - min = Number(min); - if (!isNaN(min)) { - this.min_ = min; - } - } -}; - -/** - * Returns the current minimum value this field can contain. Default is - * -Infinity. - * @return {number} The current minimum value this field can contain. - */ -FieldNumber.prototype.getMin = function() { - return this.min_; -}; - -/** - * Sets the maximum value this field can contain. Updates the value to reflect. - * @param {?(number|string|undefined)} max Maximum value. - */ -FieldNumber.prototype.setMax = function(max) { - this.setMaxInternal_(max); - this.setValue(this.getValue()); -}; - -/** - * Sets the maximum value this field can contain. Called internally to avoid - * value updates. - * @param {?(number|string|undefined)} max Maximum value. - * @private - */ -FieldNumber.prototype.setMaxInternal_ = function(max) { - if (max == null) { - this.max_ = Infinity; - } else { - max = Number(max); - if (!isNaN(max)) { - this.max_ = max; - } - } -}; - -/** - * Returns the current maximum value this field can contain. Default is - * Infinity. - * @return {number} The current maximum value this field can contain. - */ -FieldNumber.prototype.getMax = function() { - return this.max_; -}; - -/** - * Sets the precision of this field's value, i.e. the number to which the - * value is rounded. Updates the field to reflect. - * @param {?(number|string|undefined)} precision The number to which the - * field's value is rounded. - */ -FieldNumber.prototype.setPrecision = function(precision) { - this.setPrecisionInternal_(precision); - this.setValue(this.getValue()); -}; - -/** - * Sets the precision of this field's value. Called internally to avoid - * value updates. - * @param {?(number|string|undefined)} precision The number to which the - * field's value is rounded. - * @private - */ -FieldNumber.prototype.setPrecisionInternal_ = function(precision) { - this.precision_ = Number(precision) || 0; - let precisionString = String(this.precision_); - if (precisionString.indexOf('e') !== -1) { - // String() is fast. But it turns .0000001 into '1e-7'. - // Use the much slower toLocaleString to access all the digits. - precisionString = - this.precision_.toLocaleString('en-US', {maximumFractionDigits: 20}); - } - const decimalIndex = precisionString.indexOf('.'); - if (decimalIndex === -1) { - // If the precision is 0 (float) allow any number of decimals, - // otherwise allow none. - this.decimalPlaces_ = precision ? 0 : null; - } else { - this.decimalPlaces_ = precisionString.length - decimalIndex - 1; - } -}; - -/** - * Returns the current precision of this field. The precision being the - * number to which the field's value is rounded. A precision of 0 means that - * the value is not rounded. - * @return {number} The number to which this field's value is rounded. - */ -FieldNumber.prototype.getPrecision = function() { - return this.precision_; -}; - -/** - * Ensure that the input value is a valid number (must fulfill the - * constraints placed on the field). - * @param {*=} opt_newValue The input value. - * @return {?number} A valid number, or null if invalid. - * @protected - * @override - */ -FieldNumber.prototype.doClassValidation_ = function(opt_newValue) { - if (opt_newValue === null) { - return null; - } - // Clean up text. - let newValue = String(opt_newValue); - // TODO: Handle cases like 'ten', '1.203,14', etc. - // 'O' is sometimes mistaken for '0' by inexperienced users. - newValue = newValue.replace(/O/ig, '0'); - // Strip out thousands separators. - newValue = newValue.replace(/,/g, ''); - // Ignore case of 'Infinity'. - newValue = newValue.replace(/infinity/i, 'Infinity'); - - // Clean up number. - let n = Number(newValue || 0); - if (isNaN(n)) { - // Invalid number. - return null; - } - // Get the value in range. - n = Math.min(Math.max(n, this.min_), this.max_); - // Round to nearest multiple of precision. - if (this.precision_ && isFinite(n)) { - n = Math.round(n / this.precision_) * this.precision_; - } - // Clean up floating point errors. - if (this.decimalPlaces_ !== null) { - n = Number(n.toFixed(this.decimalPlaces_)); - } - return n; -}; - -/** - * Create the number input editor widget. - * @return {!HTMLElement} The newly created number input editor. - * @protected - * @override - */ -FieldNumber.prototype.widgetCreate_ = function() { - const htmlInput = FieldNumber.superClass_.widgetCreate_.call(this); - - // Set the accessibility state - if (this.min_ > -Infinity) { - aria.setState(htmlInput, aria.State.VALUEMIN, this.min_); - } - if (this.max_ < Infinity) { - aria.setState(htmlInput, aria.State.VALUEMAX, this.max_); - } - return htmlInput; -}; - fieldRegistry.register('field_number', FieldNumber); exports.FieldNumber = FieldNumber; diff --git a/core/field_textinput.js b/core/field_textinput.js index dfb6f055f..ddda5e728 100644 --- a/core/field_textinput.js +++ b/core/field_textinput.js @@ -22,7 +22,6 @@ const dialog = goog.require('Blockly.dialog'); const dom = goog.require('Blockly.utils.dom'); const eventUtils = goog.require('Blockly.Events.utils'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); -const object = goog.require('Blockly.utils.object'); const parsing = goog.require('Blockly.utils.parsing'); const userAgent = goog.require('Blockly.utils.userAgent'); /* eslint-disable-next-line no-unused-vars */ @@ -33,6 +32,8 @@ const {Field} = goog.require('Blockly.Field'); const {KeyCodes} = goog.require('Blockly.utils.KeyCodes'); const {Msg} = goog.require('Blockly.Msg'); /* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); +/* eslint-disable-next-line no-unused-vars */ const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.BlockChange'); @@ -40,65 +41,565 @@ goog.require('Blockly.Events.BlockChange'); /** * Class for an editable text field. - * @param {string=} opt_value The initial value of the field. Should cast to a - * string. Defaults to an empty string if null or undefined. - * @param {?Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a string & returns a validated - * string, or null to abort the change. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/text-input#creation} - * for a list of properties this parameter supports. - * @extends {Field} - * @constructor - * @alias Blockly.FieldTextInput */ -const FieldTextInput = function(opt_value, opt_validator, opt_config) { +class FieldTextInput extends Field { /** - * Allow browser to spellcheck this field. - * @type {boolean} + * @param {(string|!Sentinel)=} opt_value The initial value of the + * field. Should cast to a string. Defaults to an empty string if null or + * undefined. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {?Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a string & returns a validated + * string, or null to abort the change. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/text-input#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldTextInput + */ + constructor(opt_value, opt_validator, opt_config) { + super(Field.SKIP_SETUP); + + /** + * Allow browser to spellcheck this field. + * @type {boolean} + * @protected + */ + this.spellcheck_ = true; + + /** + * The HTML input element. + * @type {HTMLElement} + * @protected + */ + this.htmlInput_ = null; + + /** + * True if the field's value is currently being edited via the UI. + * @type {boolean} + * @private + */ + this.isBeingEdited_ = false; + + /** + * True if the value currently displayed in the field's editory UI is valid. + * @type {boolean} + * @private + */ + this.isTextValid_ = false; + + /** + * Key down event data. + * @type {?browserEvents.Data} + * @private + */ + this.onKeyDownWrapper_ = null; + + /** + * Key input event data. + * @type {?browserEvents.Data} + * @private + */ + this.onKeyInputWrapper_ = null; + + /** + * Whether the field should consider the whole parent block to be its click + * target. + * @type {?boolean} + */ + this.fullBlockClickTarget_ = false; + + /** + * The workspace that this field belongs to. + * @type {?WorkspaceSvg} + * @protected + */ + this.workspace_ = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + * @type {boolean} + */ + this.SERIALIZABLE = true; + + /** + * Mouse cursor style when over the hotspot that initiates the editor. + * @type {string} + */ + this.CURSOR = 'text'; + + if (opt_value === Field.SKIP_SETUP) return; + if (opt_config) this.configure_(opt_config); + this.setValue(opt_value); + if (opt_validator) this.setValidator(opt_validator); + } + + /** + * @override + */ + configure_(config) { + super.configure_(config); + if (typeof config['spellcheck'] === 'boolean') { + this.spellcheck_ = config['spellcheck']; + } + } + + /** + * @override + */ + initView() { + if (this.getConstants().FULL_BLOCK_FIELDS) { + // Step one: figure out if this is the only field on this block. + // Rendering is quite different in that case. + let nFields = 0; + let nConnections = 0; + + // Count the number of fields, excluding text fields + for (let i = 0, input; (input = this.sourceBlock_.inputList[i]); i++) { + for (let j = 0; (input.fieldRow[j]); j++) { + nFields++; + } + if (input.connection) { + nConnections++; + } + } + // The special case is when this is the only non-label field on the block + // and it has an output but no inputs. + this.fullBlockClickTarget_ = + nFields <= 1 && this.sourceBlock_.outputConnection && !nConnections; + } else { + this.fullBlockClickTarget_ = false; + } + + if (this.fullBlockClickTarget_) { + this.clickTarget_ = this.sourceBlock_.getSvgRoot(); + } else { + this.createBorderRect_(); + } + this.createTextElement_(); + } + + /** + * Ensure that the input value casts to a valid string. + * @param {*=} opt_newValue The input value. + * @return {*} A valid string, or null if invalid. * @protected */ - this.spellcheck_ = true; - - FieldTextInput.superClass_.constructor.call( - this, opt_value, opt_validator, opt_config); + doClassValidation_(opt_newValue) { + if (opt_newValue === null || opt_newValue === undefined) { + return null; + } + return String(opt_newValue); + } /** - * The HTML input element. - * @type {HTMLElement} - */ - this.htmlInput_ = null; - - /** - * Key down event data. - * @type {?browserEvents.Data} - * @private - */ - this.onKeyDownWrapper_ = null; - - /** - * Key input event data. - * @type {?browserEvents.Data} - * @private - */ - this.onKeyInputWrapper_ = null; - - /** - * Whether the field should consider the whole parent block to be its click - * target. - * @type {?boolean} - */ - this.fullBlockClickTarget_ = false; - - /** - * The workspace that this field belongs to. - * @type {?WorkspaceSvg} + * Called by setValue if the text input is not valid. If the field is + * currently being edited it reverts value of the field to the previous + * value while allowing the display text to be handled by the htmlInput_. + * @param {*} _invalidValue The input value that was determined to be invalid. + * This is not used by the text input because its display value is stored + * on the htmlInput_. * @protected */ - this.workspace_ = null; -}; -object.inherits(FieldTextInput, Field); + doValueInvalid_(_invalidValue) { + if (this.isBeingEdited_) { + this.isTextValid_ = false; + const oldValue = this.value_; + // Revert value when the text becomes invalid. + this.value_ = this.htmlInput_.untypedDefaultValue_; + if (this.sourceBlock_ && eventUtils.isEnabled()) { + eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( + this.sourceBlock_, 'field', this.name || null, oldValue, + this.value_)); + } + } + } + + /** + * Called by setValue if the text input is valid. Updates the value of the + * field, and updates the text of the field if it is not currently being + * edited (i.e. handled by the htmlInput_). + * @param {*} newValue The value to be saved. The default validator guarantees + * that this is a string. + * @protected + */ + doValueUpdate_(newValue) { + this.isTextValid_ = true; + this.value_ = newValue; + if (!this.isBeingEdited_) { + // This should only occur if setValue is triggered programmatically. + this.isDirty_ = true; + } + } + + /** + * Updates text field to match the colour/style of the block. + * @package + */ + applyColour() { + if (this.sourceBlock_ && this.getConstants().FULL_BLOCK_FIELDS) { + if (this.borderRect_) { + this.borderRect_.setAttribute( + 'stroke', this.sourceBlock_.style.colourTertiary); + } else { + this.sourceBlock_.pathObject.svgPath.setAttribute( + 'fill', this.getConstants().FIELD_BORDER_RECT_COLOUR); + } + } + } + + /** + * Updates the colour of the htmlInput given the current validity of the + * field's value. + * @protected + */ + render_() { + super.render_(); + // This logic is done in render_ rather than doValueInvalid_ or + // doValueUpdate_ so that the code is more centralized. + if (this.isBeingEdited_) { + this.resizeEditor_(); + const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_); + if (!this.isTextValid_) { + dom.addClass(htmlInput, 'blocklyInvalidInput'); + aria.setState(htmlInput, aria.State.INVALID, true); + } else { + dom.removeClass(htmlInput, 'blocklyInvalidInput'); + aria.setState(htmlInput, aria.State.INVALID, false); + } + } + } + + /** + * Set whether this field is spellchecked by the browser. + * @param {boolean} check True if checked. + */ + setSpellcheck(check) { + if (check === this.spellcheck_) { + return; + } + this.spellcheck_ = check; + if (this.htmlInput_) { + this.htmlInput_.setAttribute('spellcheck', this.spellcheck_); + } + } + + /** + * Show the inline free-text editor on top of the text. + * @param {Event=} _opt_e Optional mouse event that triggered the field to + * open, or undefined if triggered programmatically. + * @param {boolean=} opt_quietInput True if editor should be created without + * focus. Defaults to false. + * @protected + */ + showEditor_(_opt_e, opt_quietInput) { + this.workspace_ = (/** @type {!BlockSvg} */ (this.sourceBlock_)).workspace; + const quietInput = opt_quietInput || false; + if (!quietInput && + (userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD)) { + this.showPromptEditor_(); + } else { + this.showInlineEditor_(quietInput); + } + } + + /** + * Create and show a text input editor that is a prompt (usually a popup). + * Mobile browsers have issues with in-line textareas (focus and keyboards). + * @private + */ + showPromptEditor_() { + dialog.prompt(Msg['CHANGE_VALUE_TITLE'], this.getText(), function(text) { + // Text is null if user pressed cancel button. + if (text !== null) { + this.setValue(this.getValueFromEditorText_(text)); + } + }.bind(this)); + } + + /** + * Create and show a text input editor that sits directly over the text input. + * @param {boolean} quietInput True if editor should be created without + * focus. + * @private + */ + showInlineEditor_(quietInput) { + WidgetDiv.show(this, this.sourceBlock_.RTL, this.widgetDispose_.bind(this)); + this.htmlInput_ = this.widgetCreate_(); + this.isBeingEdited_ = true; + + if (!quietInput) { + this.htmlInput_.focus({preventScroll: true}); + this.htmlInput_.select(); + } + } + + /** + * Create the text input editor widget. + * @return {!HTMLElement} The newly created text input editor. + * @protected + */ + widgetCreate_() { + eventUtils.setGroup(true); + const div = WidgetDiv.getDiv(); + + dom.addClass(this.getClickTarget_(), 'editing'); + + const htmlInput = + /** @type {HTMLInputElement} */ (document.createElement('input')); + htmlInput.className = 'blocklyHtmlInput'; + htmlInput.setAttribute('spellcheck', this.spellcheck_); + const scale = this.workspace_.getScale(); + const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt'; + div.style.fontSize = fontSize; + htmlInput.style.fontSize = fontSize; + let borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px'; + + if (this.fullBlockClickTarget_) { + const bBox = this.getScaledBBox(); + + // Override border radius. + borderRadius = (bBox.bottom - bBox.top) / 2 + 'px'; + // Pull stroke colour from the existing shadow block + const strokeColour = this.sourceBlock_.getParent() ? + this.sourceBlock_.getParent().style.colourTertiary : + this.sourceBlock_.style.colourTertiary; + htmlInput.style.border = (1 * scale) + 'px solid ' + strokeColour; + div.style.borderRadius = borderRadius; + div.style.transition = 'box-shadow 0.25s ease 0s'; + if (this.getConstants().FIELD_TEXTINPUT_BOX_SHADOW) { + div.style.boxShadow = + 'rgba(255, 255, 255, 0.3) 0 0 0 ' + (4 * scale) + 'px'; + } + } + htmlInput.style.borderRadius = borderRadius; + + div.appendChild(htmlInput); + + htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); + htmlInput.untypedDefaultValue_ = this.value_; + htmlInput.oldValue_ = null; + + this.resizeEditor_(); + + this.bindInputEvents_(htmlInput); + + return htmlInput; + } + + /** + * Closes the editor, saves the results, and disposes of any events or + * DOM-references belonging to the editor. + * @protected + */ + widgetDispose_() { + // Non-disposal related things that we do when the editor closes. + this.isBeingEdited_ = false; + this.isTextValid_ = true; + // Make sure the field's node matches the field's internal value. + this.forceRerender(); + this.onFinishEditing_(this.value_); + eventUtils.setGroup(false); + + // Actual disposal. + this.unbindInputEvents_(); + const style = WidgetDiv.getDiv().style; + style.width = 'auto'; + style.height = 'auto'; + style.fontSize = ''; + style.transition = ''; + style.boxShadow = ''; + this.htmlInput_ = null; + + dom.removeClass(this.getClickTarget_(), 'editing'); + } + + /** + * A callback triggered when the user is done editing the field via the UI. + * @param {*} _value The new value of the field. + */ + onFinishEditing_(_value) { + // NOP by default. + // TODO(#2496): Support people passing a func into the field. + } + + /** + * Bind handlers for user input on the text input field's editor. + * @param {!HTMLElement} htmlInput The htmlInput to which event + * handlers will be bound. + * @protected + */ + bindInputEvents_(htmlInput) { + // Trap Enter without IME and Esc to hide. + this.onKeyDownWrapper_ = browserEvents.conditionalBind( + htmlInput, 'keydown', this, this.onHtmlInputKeyDown_); + // Resize after every input change. + this.onKeyInputWrapper_ = browserEvents.conditionalBind( + htmlInput, 'input', this, this.onHtmlInputChange_); + } + + /** + * Unbind handlers for user input and workspace size changes. + * @protected + */ + unbindInputEvents_() { + if (this.onKeyDownWrapper_) { + browserEvents.unbind(this.onKeyDownWrapper_); + this.onKeyDownWrapper_ = null; + } + if (this.onKeyInputWrapper_) { + browserEvents.unbind(this.onKeyInputWrapper_); + this.onKeyInputWrapper_ = null; + } + } + + /** + * Handle key down to the editor. + * @param {!Event} e Keyboard event. + * @protected + */ + onHtmlInputKeyDown_(e) { + if (e.keyCode === KeyCodes.ENTER) { + WidgetDiv.hide(); + DropDownDiv.hideWithoutAnimation(); + } else if (e.keyCode === KeyCodes.ESC) { + this.setValue(this.htmlInput_.untypedDefaultValue_); + WidgetDiv.hide(); + DropDownDiv.hideWithoutAnimation(); + } else if (e.keyCode === KeyCodes.TAB) { + WidgetDiv.hide(); + DropDownDiv.hideWithoutAnimation(); + this.sourceBlock_.tab(this, !e.shiftKey); + e.preventDefault(); + } + } + + /** + * Handle a change to the editor. + * @param {!Event} _e Keyboard event. + * @private + */ + onHtmlInputChange_(_e) { + const text = this.htmlInput_.value; + if (text !== this.htmlInput_.oldValue_) { + this.htmlInput_.oldValue_ = text; + + const value = this.getValueFromEditorText_(text); + this.setValue(value); + this.forceRerender(); + this.resizeEditor_(); + } + } + + /** + * Set the HTML input value and the field's internal value. The difference + * between this and ``setValue`` is that this also updates the HTML input + * value whilst editing. + * @param {*} newValue New value. + * @protected + */ + setEditorValue_(newValue) { + this.isDirty_ = true; + if (this.isBeingEdited_) { + // In the case this method is passed an invalid value, we still + // pass it through the transformation method `getEditorText` to deal + // with. Otherwise, the internal field's state will be inconsistent + // with what's shown to the user. + this.htmlInput_.value = this.getEditorText_(newValue); + } + this.setValue(newValue); + } + + /** + * Resize the editor to fit the text. + * @protected + */ + resizeEditor_() { + const div = WidgetDiv.getDiv(); + const bBox = this.getScaledBBox(); + div.style.width = bBox.right - bBox.left + 'px'; + div.style.height = bBox.bottom - bBox.top + 'px'; + + // In RTL mode block fields and LTR input fields the left edge moves, + // whereas the right edge is fixed. Reposition the editor. + const x = this.sourceBlock_.RTL ? bBox.right - div.offsetWidth : bBox.left; + const xy = new Coordinate(x, bBox.top); + + div.style.left = xy.x + 'px'; + div.style.top = xy.y + 'px'; + } + + /** + * Returns whether or not the field is tab navigable. + * @return {boolean} True if the field is tab navigable. + * @override + */ + isTabNavigable() { + return true; + } + + /** + * Use the `getText_` developer hook to override the field's text + * representation. When we're currently editing, return the current HTML value + * instead. Otherwise, return null which tells the field to use the default + * behaviour (which is a string cast of the field's value). + * @return {?string} The HTML value if we're editing, otherwise null. + * @protected + * @override + */ + getText_() { + if (this.isBeingEdited_ && this.htmlInput_) { + // We are currently editing, return the HTML input value instead. + return this.htmlInput_.value; + } + return null; + } + + /** + * Transform the provided value into a text to show in the HTML input. + * Override this method if the field's HTML input representation is different + * than the field's value. This should be coupled with an override of + * `getValueFromEditorText_`. + * @param {*} value The value stored in this field. + * @return {string} The text to show on the HTML input. + * @protected + */ + getEditorText_(value) { + return String(value); + } + + /** + * Transform the text received from the HTML input into a value to store + * in this field. + * Override this method if the field's HTML input representation is different + * than the field's value. This should be coupled with an override of + * `getEditorText_`. + * @param {string} text Text received from the HTML input. + * @return {*} The value to store. + * @protected + */ + getValueFromEditorText_(text) { + return text; + } + + /** + * Construct a FieldTextInput from a JSON arg object, + * dereferencing any string table references. + * @param {!Object} options A JSON object with options (text, and spellcheck). + * @return {!FieldTextInput} The new field instance. + * @package + * @nocollapse + */ + static fromJson(options) { + const text = parsing.replaceMessageReferences(options['text']); + // `this` might be a subclass of FieldTextInput if that class doesn't + // override the static fromJson method. + return new this(text, undefined, options); + } +} /** * The default value for this field. @@ -107,481 +608,12 @@ object.inherits(FieldTextInput, Field); */ FieldTextInput.prototype.DEFAULT_VALUE = ''; -/** - * Construct a FieldTextInput from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (text, and spellcheck). - * @return {!FieldTextInput} The new field instance. - * @package - * @nocollapse - */ -FieldTextInput.fromJson = function(options) { - const text = parsing.replaceMessageReferences(options['text']); - // `this` might be a subclass of FieldTextInput if that class doesn't override - // the static fromJson method. - return new this(text, undefined, options); -}; - -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. - * @type {boolean} - */ -FieldTextInput.prototype.SERIALIZABLE = true; - /** * Pixel size of input border radius. * Should match blocklyText's border-radius in CSS. */ FieldTextInput.BORDERRADIUS = 4; -/** - * Mouse cursor style when over the hotspot that initiates the editor. - */ -FieldTextInput.prototype.CURSOR = 'text'; - -/** - * @override - */ -FieldTextInput.prototype.configure_ = function(config) { - FieldTextInput.superClass_.configure_.call(this, config); - if (typeof config['spellcheck'] === 'boolean') { - this.spellcheck_ = config['spellcheck']; - } -}; - -/** - * @override - */ -FieldTextInput.prototype.initView = function() { - if (this.getConstants().FULL_BLOCK_FIELDS) { - // Step one: figure out if this is the only field on this block. - // Rendering is quite different in that case. - let nFields = 0; - let nConnections = 0; - - // Count the number of fields, excluding text fields - for (let i = 0, input; (input = this.sourceBlock_.inputList[i]); i++) { - for (let j = 0; (input.fieldRow[j]); j++) { - nFields++; - } - if (input.connection) { - nConnections++; - } - } - // The special case is when this is the only non-label field on the block - // and it has an output but no inputs. - this.fullBlockClickTarget_ = - nFields <= 1 && this.sourceBlock_.outputConnection && !nConnections; - } else { - this.fullBlockClickTarget_ = false; - } - - if (this.fullBlockClickTarget_) { - this.clickTarget_ = this.sourceBlock_.getSvgRoot(); - } else { - this.createBorderRect_(); - } - this.createTextElement_(); -}; - -/** - * Ensure that the input value casts to a valid string. - * @param {*=} opt_newValue The input value. - * @return {*} A valid string, or null if invalid. - * @protected - */ -FieldTextInput.prototype.doClassValidation_ = function(opt_newValue) { - if (opt_newValue === null || opt_newValue === undefined) { - return null; - } - return String(opt_newValue); -}; - -/** - * Called by setValue if the text input is not valid. If the field is - * currently being edited it reverts value of the field to the previous - * value while allowing the display text to be handled by the htmlInput_. - * @param {*} _invalidValue The input value that was determined to be invalid. - * This is not used by the text input because its display value is stored on - * the htmlInput_. - * @protected - */ -FieldTextInput.prototype.doValueInvalid_ = function(_invalidValue) { - if (this.isBeingEdited_) { - this.isTextValid_ = false; - const oldValue = this.value_; - // Revert value when the text becomes invalid. - this.value_ = this.htmlInput_.untypedDefaultValue_; - if (this.sourceBlock_ && eventUtils.isEnabled()) { - eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CHANGE))( - this.sourceBlock_, 'field', this.name || null, oldValue, - this.value_)); - } - } -}; - -/** - * Called by setValue if the text input is valid. Updates the value of the - * field, and updates the text of the field if it is not currently being - * edited (i.e. handled by the htmlInput_). - * @param {*} newValue The value to be saved. The default validator guarantees - * that this is a string. - * @protected - */ -FieldTextInput.prototype.doValueUpdate_ = function(newValue) { - this.isTextValid_ = true; - this.value_ = newValue; - if (!this.isBeingEdited_) { - // This should only occur if setValue is triggered programmatically. - this.isDirty_ = true; - } -}; - -/** - * Updates text field to match the colour/style of the block. - * @package - */ -FieldTextInput.prototype.applyColour = function() { - if (this.sourceBlock_ && this.getConstants().FULL_BLOCK_FIELDS) { - if (this.borderRect_) { - this.borderRect_.setAttribute( - 'stroke', this.sourceBlock_.style.colourTertiary); - } else { - this.sourceBlock_.pathObject.svgPath.setAttribute( - 'fill', this.getConstants().FIELD_BORDER_RECT_COLOUR); - } - } -}; - -/** - * Updates the colour of the htmlInput given the current validity of the - * field's value. - * @protected - */ -FieldTextInput.prototype.render_ = function() { - FieldTextInput.superClass_.render_.call(this); - // This logic is done in render_ rather than doValueInvalid_ or - // doValueUpdate_ so that the code is more centralized. - if (this.isBeingEdited_) { - this.resizeEditor_(); - const htmlInput = /** @type {!HTMLElement} */ (this.htmlInput_); - if (!this.isTextValid_) { - dom.addClass(htmlInput, 'blocklyInvalidInput'); - aria.setState(htmlInput, aria.State.INVALID, true); - } else { - dom.removeClass(htmlInput, 'blocklyInvalidInput'); - aria.setState(htmlInput, aria.State.INVALID, false); - } - } -}; - -/** - * Set whether this field is spellchecked by the browser. - * @param {boolean} check True if checked. - */ -FieldTextInput.prototype.setSpellcheck = function(check) { - if (check === this.spellcheck_) { - return; - } - this.spellcheck_ = check; - if (this.htmlInput_) { - this.htmlInput_.setAttribute('spellcheck', this.spellcheck_); - } -}; - -/** - * Show the inline free-text editor on top of the text. - * @param {Event=} _opt_e Optional mouse event that triggered the field to open, - * or undefined if triggered programmatically. - * @param {boolean=} opt_quietInput True if editor should be created without - * focus. Defaults to false. - * @protected - */ -FieldTextInput.prototype.showEditor_ = function(_opt_e, opt_quietInput) { - this.workspace_ = (/** @type {!BlockSvg} */ (this.sourceBlock_)).workspace; - const quietInput = opt_quietInput || false; - if (!quietInput && - (userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD)) { - this.showPromptEditor_(); - } else { - this.showInlineEditor_(quietInput); - } -}; - -/** - * Create and show a text input editor that is a prompt (usually a popup). - * Mobile browsers have issues with in-line textareas (focus and keyboards). - * @private - */ -FieldTextInput.prototype.showPromptEditor_ = function() { - dialog.prompt(Msg['CHANGE_VALUE_TITLE'], this.getText(), function(text) { - // Text is null if user pressed cancel button. - if (text !== null) { - this.setValue(this.getValueFromEditorText_(text)); - } - }.bind(this)); -}; - -/** - * Create and show a text input editor that sits directly over the text input. - * @param {boolean} quietInput True if editor should be created without - * focus. - * @private - */ -FieldTextInput.prototype.showInlineEditor_ = function(quietInput) { - WidgetDiv.show(this, this.sourceBlock_.RTL, this.widgetDispose_.bind(this)); - this.htmlInput_ = this.widgetCreate_(); - this.isBeingEdited_ = true; - - if (!quietInput) { - this.htmlInput_.focus({preventScroll: true}); - this.htmlInput_.select(); - } -}; - -/** - * Create the text input editor widget. - * @return {!HTMLElement} The newly created text input editor. - * @protected - */ -FieldTextInput.prototype.widgetCreate_ = function() { - eventUtils.setGroup(true); - const div = WidgetDiv.getDiv(); - - dom.addClass(this.getClickTarget_(), 'editing'); - - const htmlInput = - /** @type {HTMLInputElement} */ (document.createElement('input')); - htmlInput.className = 'blocklyHtmlInput'; - htmlInput.setAttribute('spellcheck', this.spellcheck_); - const scale = this.workspace_.getScale(); - const fontSize = (this.getConstants().FIELD_TEXT_FONTSIZE * scale) + 'pt'; - div.style.fontSize = fontSize; - htmlInput.style.fontSize = fontSize; - let borderRadius = (FieldTextInput.BORDERRADIUS * scale) + 'px'; - - if (this.fullBlockClickTarget_) { - const bBox = this.getScaledBBox(); - - // Override border radius. - borderRadius = (bBox.bottom - bBox.top) / 2 + 'px'; - // Pull stroke colour from the existing shadow block - const strokeColour = this.sourceBlock_.getParent() ? - this.sourceBlock_.getParent().style.colourTertiary : - this.sourceBlock_.style.colourTertiary; - htmlInput.style.border = (1 * scale) + 'px solid ' + strokeColour; - div.style.borderRadius = borderRadius; - div.style.transition = 'box-shadow 0.25s ease 0s'; - if (this.getConstants().FIELD_TEXTINPUT_BOX_SHADOW) { - div.style.boxShadow = - 'rgba(255, 255, 255, 0.3) 0 0 0 ' + (4 * scale) + 'px'; - } - } - htmlInput.style.borderRadius = borderRadius; - - div.appendChild(htmlInput); - - htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); - htmlInput.untypedDefaultValue_ = this.value_; - htmlInput.oldValue_ = null; - - this.resizeEditor_(); - - this.bindInputEvents_(htmlInput); - - return htmlInput; -}; - -/** - * Closes the editor, saves the results, and disposes of any events or - * DOM-references belonging to the editor. - * @protected - */ -FieldTextInput.prototype.widgetDispose_ = function() { - // Non-disposal related things that we do when the editor closes. - this.isBeingEdited_ = false; - this.isTextValid_ = true; - // Make sure the field's node matches the field's internal value. - this.forceRerender(); - // TODO(#2496): Make this less of a hack. - if (this.onFinishEditing_) { - this.onFinishEditing_(this.value_); - } - eventUtils.setGroup(false); - - // Actual disposal. - this.unbindInputEvents_(); - const style = WidgetDiv.getDiv().style; - style.width = 'auto'; - style.height = 'auto'; - style.fontSize = ''; - style.transition = ''; - style.boxShadow = ''; - this.htmlInput_ = null; - - dom.removeClass(this.getClickTarget_(), 'editing'); -}; - -/** - * Bind handlers for user input on the text input field's editor. - * @param {!HTMLElement} htmlInput The htmlInput to which event - * handlers will be bound. - * @protected - */ -FieldTextInput.prototype.bindInputEvents_ = function(htmlInput) { - // Trap Enter without IME and Esc to hide. - this.onKeyDownWrapper_ = browserEvents.conditionalBind( - htmlInput, 'keydown', this, this.onHtmlInputKeyDown_); - // Resize after every input change. - this.onKeyInputWrapper_ = browserEvents.conditionalBind( - htmlInput, 'input', this, this.onHtmlInputChange_); -}; - -/** - * Unbind handlers for user input and workspace size changes. - * @protected - */ -FieldTextInput.prototype.unbindInputEvents_ = function() { - if (this.onKeyDownWrapper_) { - browserEvents.unbind(this.onKeyDownWrapper_); - this.onKeyDownWrapper_ = null; - } - if (this.onKeyInputWrapper_) { - browserEvents.unbind(this.onKeyInputWrapper_); - this.onKeyInputWrapper_ = null; - } -}; - -/** - * Handle key down to the editor. - * @param {!Event} e Keyboard event. - * @protected - */ -FieldTextInput.prototype.onHtmlInputKeyDown_ = function(e) { - if (e.keyCode === KeyCodes.ENTER) { - WidgetDiv.hide(); - DropDownDiv.hideWithoutAnimation(); - } else if (e.keyCode === KeyCodes.ESC) { - this.setValue(this.htmlInput_.untypedDefaultValue_); - WidgetDiv.hide(); - DropDownDiv.hideWithoutAnimation(); - } else if (e.keyCode === KeyCodes.TAB) { - WidgetDiv.hide(); - DropDownDiv.hideWithoutAnimation(); - this.sourceBlock_.tab(this, !e.shiftKey); - e.preventDefault(); - } -}; - -/** - * Handle a change to the editor. - * @param {!Event} _e Keyboard event. - * @private - */ -FieldTextInput.prototype.onHtmlInputChange_ = function(_e) { - const text = this.htmlInput_.value; - if (text !== this.htmlInput_.oldValue_) { - this.htmlInput_.oldValue_ = text; - - const value = this.getValueFromEditorText_(text); - this.setValue(value); - this.forceRerender(); - this.resizeEditor_(); - } -}; - -/** - * Set the HTML input value and the field's internal value. The difference - * between this and ``setValue`` is that this also updates the HTML input - * value whilst editing. - * @param {*} newValue New value. - * @protected - */ -FieldTextInput.prototype.setEditorValue_ = function(newValue) { - this.isDirty_ = true; - if (this.isBeingEdited_) { - // In the case this method is passed an invalid value, we still - // pass it through the transformation method `getEditorText` to deal - // with. Otherwise, the internal field's state will be inconsistent - // with what's shown to the user. - this.htmlInput_.value = this.getEditorText_(newValue); - } - this.setValue(newValue); -}; - -/** - * Resize the editor to fit the text. - * @protected - */ -FieldTextInput.prototype.resizeEditor_ = function() { - const div = WidgetDiv.getDiv(); - const bBox = this.getScaledBBox(); - div.style.width = bBox.right - bBox.left + 'px'; - div.style.height = bBox.bottom - bBox.top + 'px'; - - // In RTL mode block fields and LTR input fields the left edge moves, - // whereas the right edge is fixed. Reposition the editor. - const x = this.sourceBlock_.RTL ? bBox.right - div.offsetWidth : bBox.left; - const xy = new Coordinate(x, bBox.top); - - div.style.left = xy.x + 'px'; - div.style.top = xy.y + 'px'; -}; - -/** - * Returns whether or not the field is tab navigable. - * @return {boolean} True if the field is tab navigable. - * @override - */ -FieldTextInput.prototype.isTabNavigable = function() { - return true; -}; - -/** - * Use the `getText_` developer hook to override the field's text - * representation. When we're currently editing, return the current HTML value - * instead. Otherwise, return null which tells the field to use the default - * behaviour (which is a string cast of the field's value). - * @return {?string} The HTML value if we're editing, otherwise null. - * @protected - * @override - */ -FieldTextInput.prototype.getText_ = function() { - if (this.isBeingEdited_ && this.htmlInput_) { - // We are currently editing, return the HTML input value instead. - return this.htmlInput_.value; - } - return null; -}; - -/** - * Transform the provided value into a text to show in the HTML input. - * Override this method if the field's HTML input representation is different - * than the field's value. This should be coupled with an override of - * `getValueFromEditorText_`. - * @param {*} value The value stored in this field. - * @return {string} The text to show on the HTML input. - * @protected - */ -FieldTextInput.prototype.getEditorText_ = function(value) { - return String(value); -}; - -/** - * Transform the text received from the HTML input into a value to store - * in this field. - * Override this method if the field's HTML input representation is different - * than the field's value. This should be coupled with an override of - * `getEditorText_`. - * @param {string} text Text received from the HTML input. - * @return {*} The value to store. - * @protected - */ -FieldTextInput.prototype.getValueFromEditorText_ = function(text) { - return text; -}; - fieldRegistry.register('field_input', FieldTextInput); exports.FieldTextInput = FieldTextInput; diff --git a/core/field_variable.js b/core/field_variable.js index af43f02a5..62cc09ed1 100644 --- a/core/field_variable.js +++ b/core/field_variable.js @@ -19,16 +19,18 @@ const Variables = goog.require('Blockly.Variables'); const Xml = goog.require('Blockly.Xml'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); const internalConstants = goog.require('Blockly.internalConstants'); -const object = goog.require('Blockly.utils.object'); const parsing = goog.require('Blockly.utils.parsing'); /* eslint-disable-next-line no-unused-vars */ const {Block} = goog.requireType('Blockly.Block'); +const {Field} = goog.require('Blockly.Field'); const {FieldDropdown} = goog.require('Blockly.FieldDropdown'); /* eslint-disable-next-line no-unused-vars */ const {MenuItem} = goog.requireType('Blockly.MenuItem'); /* eslint-disable-next-line no-unused-vars */ const {Menu} = goog.requireType('Blockly.Menu'); const {Msg} = goog.require('Blockly.Msg'); +/* eslint-disable-next-line no-unused-vars */ +const {Sentinel} = goog.requireType('Blockly.utils.Sentinel'); const {Size} = goog.require('Blockly.utils.Size'); const {VariableModel} = goog.require('Blockly.VariableModel'); /** @suppress {extraRequire} */ @@ -37,483 +39,519 @@ goog.require('Blockly.Events.BlockChange'); /** * Class for a variable's dropdown field. - * @param {?string} varName The default name for the variable. If null, - * a unique variable name will be generated. - * @param {Function=} opt_validator A function that is called to validate - * changes to the field's value. Takes in a variable ID & returns a - * validated variable ID, or null to abort the change. - * @param {Array=} opt_variableTypes A list of the types of variables - * to include in the dropdown. - * @param {string=} opt_defaultType The type of variable to create if this - * field's value is not explicitly set. Defaults to ''. - * @param {Object=} opt_config A map of options used to configure the field. - * See the [field creation documentation]{@link - * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/variable#creation} - * for a list of properties this parameter supports. * @extends {FieldDropdown} - * @constructor - * @alias Blockly.FieldVariable */ -const FieldVariable = function( - varName, opt_validator, opt_variableTypes, opt_defaultType, opt_config) { - // The FieldDropdown constructor expects the field's initial value to be - // the first entry in the menu generator, which it may or may not be. - // Just do the relevant parts of the constructor. +class FieldVariable extends FieldDropdown { + /** + * @param {?string|!Sentinel} varName The default name for the variable. + * If null, a unique variable name will be generated. + * Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param {Function=} opt_validator A function that is called to validate + * changes to the field's value. Takes in a variable ID & returns a + * validated variable ID, or null to abort the change. + * @param {Array=} opt_variableTypes A list of the types of variables + * to include in the dropdown. Will only be used if opt_config is not + * provided. + * @param {string=} opt_defaultType The type of variable to create if this + * field's value is not explicitly set. Defaults to ''. Will only be used + * if opt_config is not provided. + * @param {Object=} opt_config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/variable#creation} + * for a list of properties this parameter supports. + * @alias Blockly.FieldVariable + */ + constructor( + varName, opt_validator, opt_variableTypes, opt_defaultType, opt_config) { + super(Field.SKIP_SETUP); + + /** + * An array of options for a dropdown list, + * or a function which generates these options. + * @type {(!Array| + * !function(this:FieldDropdown): !Array)} + * @protected + */ + this.menuGenerator_ = FieldVariable.dropdownCreate; + + /** + * The initial variable name passed to this field's constructor, or an + * empty string if a name wasn't provided. Used to create the initial + * variable. + * @type {string} + */ + this.defaultVariableName = typeof varName === 'string' ? varName : ''; + + /** + * The type of the default variable for this field. + * @type {string} + * @private + */ + this.defaultType_ = ''; + + /** + * All of the types of variables that will be available in this field's + * dropdown. + * @type {?Array} + */ + this.variableTypes = []; + + /** + * The size of the area rendered by the field. + * @type {Size} + * @protected + * @override + */ + this.size_ = new Size(0, 0); + + /** + * The variable model associated with this field. + * @type {?VariableModel} + * @private + */ + this.variable_ = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + * @type {boolean} + */ + this.SERIALIZABLE = true; + + if (varName === Field.SKIP_SETUP) return; + + if (opt_config) { + this.configure_(opt_config); + } else { + this.setTypes_(opt_variableTypes, opt_defaultType); + } + if (opt_validator) this.setValidator(opt_validator); + } /** - * An array of options for a dropdown list, - * or a function which generates these options. - * @type {(!Array| - * !function(this:FieldDropdown): !Array)} + * Configure the field based on the given map of options. + * @param {!Object} config A map of options to configure the field based on. * @protected */ - this.menuGenerator_ = FieldVariable.dropdownCreate; + configure_(config) { + super.configure_(config); + this.setTypes_(config['variableTypes'], config['defaultType']); + } /** - * The initial variable name passed to this field's constructor, or an - * empty string if a name wasn't provided. Used to create the initial - * variable. - * @type {string} + * Initialize the model for this field if it has not already been initialized. + * If the value has not been set to a variable by the first render, we make up + * a variable rather than let the value be invalid. + * @package */ - this.defaultVariableName = typeof varName === 'string' ? varName : ''; + initModel() { + if (this.variable_) { + return; // Initialization already happened. + } + const variable = Variables.getOrCreateVariablePackage( + this.sourceBlock_.workspace, null, this.defaultVariableName, + this.defaultType_); + + // Don't call setValue because we don't want to cause a rerender. + this.doValueUpdate_(variable.getId()); + } /** - * The size of the area rendered by the field. - * @type {Size} - * @protected * @override */ - this.size_ = new Size(0, 0); - - opt_config && this.configure_(opt_config); - opt_validator && this.setValidator(opt_validator); - - if (!opt_config) { // Only do one kind of configuration or the other. - this.setTypes_(opt_variableTypes, opt_defaultType); - } -}; -object.inherits(FieldVariable, FieldDropdown); - -/** - * Construct a FieldVariable from a JSON arg object, - * dereferencing any string table references. - * @param {!Object} options A JSON object with options (variable, - * variableTypes, and defaultType). - * @return {!FieldVariable} The new field instance. - * @package - * @nocollapse - */ -FieldVariable.fromJson = function(options) { - const varName = parsing.replaceMessageReferences(options['variable']); - // `this` might be a subclass of FieldVariable if that class doesn't override - // the static fromJson method. - return new this(varName, undefined, undefined, undefined, options); -}; - -/** - * Serializable fields are saved by the XML renderer, non-serializable fields - * are not. Editable fields should also be serializable. - * @type {boolean} - */ -FieldVariable.prototype.SERIALIZABLE = true; - -/** - * Configure the field based on the given map of options. - * @param {!Object} config A map of options to configure the field based on. - * @protected - */ -FieldVariable.prototype.configure_ = function(config) { - FieldVariable.superClass_.configure_.call(this, config); - this.setTypes_(config['variableTypes'], config['defaultType']); -}; - -/** - * Initialize the model for this field if it has not already been initialized. - * If the value has not been set to a variable by the first render, we make up a - * variable rather than let the value be invalid. - * @package - */ -FieldVariable.prototype.initModel = function() { - if (this.variable_) { - return; // Initialization already happened. - } - const variable = Variables.getOrCreateVariablePackage( - this.sourceBlock_.workspace, null, this.defaultVariableName, - this.defaultType_); - - // Don't call setValue because we don't want to cause a rerender. - this.doValueUpdate_(variable.getId()); -}; - -/** - * @override - */ -FieldVariable.prototype.shouldAddBorderRect_ = function() { - return FieldVariable.superClass_.shouldAddBorderRect_.call(this) && - (!this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW || - this.sourceBlock_.type !== 'variables_get'); -}; - -/** - * Initialize this field based on the given XML. - * @param {!Element} fieldElement The element containing information about the - * variable field's state. - */ -FieldVariable.prototype.fromXml = function(fieldElement) { - const id = fieldElement.getAttribute('id'); - const variableName = fieldElement.textContent; - // 'variabletype' should be lowercase, but until July 2019 it was sometimes - // recorded as 'variableType'. Thus we need to check for both. - const variableType = fieldElement.getAttribute('variabletype') || - fieldElement.getAttribute('variableType') || ''; - - const variable = Variables.getOrCreateVariablePackage( - this.sourceBlock_.workspace, id, variableName, variableType); - - // This should never happen :) - if (variableType !== null && variableType !== variable.type) { - throw Error( - 'Serialized variable type with id \'' + variable.getId() + - '\' had type ' + variable.type + ', and ' + - 'does not match variable field that references it: ' + - Xml.domToText(fieldElement) + '.'); + shouldAddBorderRect_() { + return super.shouldAddBorderRect_() && + (!this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW || + this.sourceBlock_.type !== 'variables_get'); } - this.setValue(variable.getId()); -}; + /** + * Initialize this field based on the given XML. + * @param {!Element} fieldElement The element containing information about the + * variable field's state. + */ + fromXml(fieldElement) { + const id = fieldElement.getAttribute('id'); + const variableName = fieldElement.textContent; + // 'variabletype' should be lowercase, but until July 2019 it was sometimes + // recorded as 'variableType'. Thus we need to check for both. + const variableType = fieldElement.getAttribute('variabletype') || + fieldElement.getAttribute('variableType') || ''; -/** - * Serialize this field to XML. - * @param {!Element} fieldElement The element to populate with info about the - * field's state. - * @return {!Element} The element containing info about the field's state. - */ -FieldVariable.prototype.toXml = function(fieldElement) { - // Make sure the variable is initialized. - this.initModel(); + const variable = Variables.getOrCreateVariablePackage( + this.sourceBlock_.workspace, id, variableName, variableType); - fieldElement.id = this.variable_.getId(); - fieldElement.textContent = this.variable_.name; - if (this.variable_.type) { - fieldElement.setAttribute('variabletype', this.variable_.type); - } - return fieldElement; -}; - -/** - * Saves this field's value. - * @param {boolean=} doFullSerialization If true, the variable field will - * serialize the full state of the field being referenced (ie ID, name, - * and type) rather than just a reference to it (ie ID). - * @return {*} The state of the variable field. - * @override - * @package - */ -FieldVariable.prototype.saveState = function(doFullSerialization) { - const legacyState = this.saveLegacyState(FieldVariable); - if (legacyState !== null) { - return legacyState; - } - // Make sure the variable is initialized. - this.initModel(); - const state = {'id': this.variable_.getId()}; - if (doFullSerialization) { - state['name'] = this.variable_.name; - state['type'] = this.variable_.type; - } - return state; -}; - -/** - * Sets the field's value based on the given state. - * @param {*} state The state of the variable to assign to this variable field. - * @override - * @package - */ -FieldVariable.prototype.loadState = function(state) { - if (this.loadLegacyState(FieldVariable, state)) { - return; - } - // This is necessary so that blocks in the flyout can have custom var names. - const variable = Variables.getOrCreateVariablePackage( - this.sourceBlock_.workspace, state['id'] || null, state['name'], - state['type'] || ''); - this.setValue(variable.getId()); -}; - -/** - * Attach this field to a block. - * @param {!Block} block The block containing this field. - */ -FieldVariable.prototype.setSourceBlock = function(block) { - if (block.isShadow()) { - throw Error('Variable fields are not allowed to exist on shadow blocks.'); - } - FieldVariable.superClass_.setSourceBlock.call(this, block); -}; - -/** - * Get the variable's ID. - * @return {string} Current variable's ID. - */ -FieldVariable.prototype.getValue = function() { - return this.variable_ ? this.variable_.getId() : null; -}; - -/** - * Get the text from this field, which is the selected variable's name. - * @return {string} The selected variable's name, or the empty string if no - * variable is selected. - */ -FieldVariable.prototype.getText = function() { - return this.variable_ ? this.variable_.name : ''; -}; - -/** - * Get the variable model for the selected variable. - * Not guaranteed to be in the variable map on the workspace (e.g. if accessed - * after the variable has been deleted). - * @return {?VariableModel} The selected variable, or null if none was - * selected. - * @package - */ -FieldVariable.prototype.getVariable = function() { - return this.variable_; -}; - -/** - * Gets the validation function for this field, or null if not set. - * Returns null if the variable is not set, because validators should not - * run on the initial setValue call, because the field won't be attached to - * a block and workspace at that point. - * @return {?Function} Validation function, or null. - */ -FieldVariable.prototype.getValidator = function() { - // Validators shouldn't operate on the initial setValue call. - // Normally this is achieved by calling setValidator after setValue, but - // this is not a possibility with variable fields. - if (this.variable_) { - return this.validator_; - } - return null; -}; - -/** - * Ensure that the ID belongs to a valid variable of an allowed type. - * @param {*=} opt_newValue The ID of the new variable to set. - * @return {?string} The validated ID, or null if invalid. - * @protected - */ -FieldVariable.prototype.doClassValidation_ = function(opt_newValue) { - if (opt_newValue === null) { - return null; - } - const newId = /** @type {string} */ (opt_newValue); - const variable = Variables.getVariable(this.sourceBlock_.workspace, newId); - if (!variable) { - console.warn( - 'Variable id doesn\'t point to a real variable! ' + - 'ID was ' + newId); - return null; - } - // Type Checks. - const type = variable.type; - if (!this.typeIsAllowed_(type)) { - console.warn('Variable type doesn\'t match this field! Type was ' + type); - return null; - } - return newId; -}; - -/** - * Update the value of this variable field, as well as its variable and text. - * - * The variable ID should be valid at this point, but if a variable field - * validator returns a bad ID, this could break. - * @param {*} newId The value to be saved. - * @protected - */ -FieldVariable.prototype.doValueUpdate_ = function(newId) { - this.variable_ = Variables.getVariable( - this.sourceBlock_.workspace, /** @type {string} */ (newId)); - FieldVariable.superClass_.doValueUpdate_.call(this, newId); -}; - -/** - * Check whether the given variable type is allowed on this field. - * @param {string} type The type to check. - * @return {boolean} True if the type is in the list of allowed types. - * @private - */ -FieldVariable.prototype.typeIsAllowed_ = function(type) { - const typeList = this.getVariableTypes_(); - if (!typeList) { - return true; // If it's null, all types are valid. - } - for (let i = 0; i < typeList.length; i++) { - if (type === typeList[i]) { - return true; + // This should never happen :) + if (variableType !== null && variableType !== variable.type) { + throw Error( + 'Serialized variable type with id \'' + variable.getId() + + '\' had type ' + variable.type + ', and ' + + 'does not match variable field that references it: ' + + Xml.domToText(fieldElement) + '.'); } - } - return false; -}; -/** - * Return a list of variable types to include in the dropdown. - * @return {!Array} Array of variable types. - * @throws {Error} if variableTypes is an empty array. - * @private - */ -FieldVariable.prototype.getVariableTypes_ = function() { - // TODO (#1513): Try to avoid calling this every time the field is edited. - let variableTypes = this.variableTypes; - if (variableTypes === null) { - // If variableTypes is null, return all variable types. - if (this.sourceBlock_ && this.sourceBlock_.workspace) { - return this.sourceBlock_.workspace.getVariableTypes(); + this.setValue(variable.getId()); + } + + /** + * Serialize this field to XML. + * @param {!Element} fieldElement The element to populate with info about the + * field's state. + * @return {!Element} The element containing info about the field's state. + */ + toXml(fieldElement) { + // Make sure the variable is initialized. + this.initModel(); + + fieldElement.id = this.variable_.getId(); + fieldElement.textContent = this.variable_.name; + if (this.variable_.type) { + fieldElement.setAttribute('variabletype', this.variable_.type); } + return fieldElement; } - variableTypes = variableTypes || ['']; - if (variableTypes.length === 0) { - // Throw an error if variableTypes is an empty list. - const name = this.getText(); - throw Error( - '\'variableTypes\' of field variable ' + name + ' was an empty list'); - } - return variableTypes; -}; -/** - * Parse the optional arguments representing the allowed variable types and the - * default variable type. - * @param {Array=} opt_variableTypes A list of the types of variables - * to include in the dropdown. If null or undefined, variables of all types - * will be displayed in the dropdown. - * @param {string=} opt_defaultType The type of the variable to create if this - * field's value is not explicitly set. Defaults to ''. - * @private - */ -FieldVariable.prototype.setTypes_ = function( - opt_variableTypes, opt_defaultType) { - // If you expected that the default type would be the same as the only entry - // in the variable types array, tell the Blockly team by commenting on #1499. - const defaultType = opt_defaultType || ''; - let variableTypes; - // Set the allowable variable types. Null means all types on the workspace. - if (opt_variableTypes === null || opt_variableTypes === undefined) { - variableTypes = null; - } else if (Array.isArray(opt_variableTypes)) { - variableTypes = opt_variableTypes; - // Make sure the default type is valid. - let isInArray = false; - for (let i = 0; i < variableTypes.length; i++) { - if (variableTypes[i] === defaultType) { - isInArray = true; + /** + * Saves this field's value. + * @param {boolean=} doFullSerialization If true, the variable field will + * serialize the full state of the field being referenced (ie ID, name, + * and type) rather than just a reference to it (ie ID). + * @return {*} The state of the variable field. + * @override + * @package + */ + saveState(doFullSerialization) { + const legacyState = this.saveLegacyState(FieldVariable); + if (legacyState !== null) { + return legacyState; + } + // Make sure the variable is initialized. + this.initModel(); + const state = {'id': this.variable_.getId()}; + if (doFullSerialization) { + state['name'] = this.variable_.name; + state['type'] = this.variable_.type; + } + return state; + } + + /** + * Sets the field's value based on the given state. + * @param {*} state The state of the variable to assign to this variable + * field. + * @override + * @package + */ + loadState(state) { + if (this.loadLegacyState(FieldVariable, state)) { + return; + } + // This is necessary so that blocks in the flyout can have custom var names. + const variable = Variables.getOrCreateVariablePackage( + this.sourceBlock_.workspace, state['id'] || null, state['name'], + state['type'] || ''); + this.setValue(variable.getId()); + } + + /** + * Attach this field to a block. + * @param {!Block} block The block containing this field. + */ + setSourceBlock(block) { + if (block.isShadow()) { + throw Error('Variable fields are not allowed to exist on shadow blocks.'); + } + super.setSourceBlock(block); + } + + /** + * Get the variable's ID. + * @return {?string} Current variable's ID. + */ + getValue() { + return this.variable_ ? this.variable_.getId() : null; + } + + /** + * Get the text from this field, which is the selected variable's name. + * @return {string} The selected variable's name, or the empty string if no + * variable is selected. + */ + getText() { + return this.variable_ ? this.variable_.name : ''; + } + + /** + * Get the variable model for the selected variable. + * Not guaranteed to be in the variable map on the workspace (e.g. if accessed + * after the variable has been deleted). + * @return {?VariableModel} The selected variable, or null if none was + * selected. + * @package + */ + getVariable() { + return this.variable_; + } + + /** + * Gets the validation function for this field, or null if not set. + * Returns null if the variable is not set, because validators should not + * run on the initial setValue call, because the field won't be attached to + * a block and workspace at that point. + * @return {?Function} Validation function, or null. + */ + getValidator() { + // Validators shouldn't operate on the initial setValue call. + // Normally this is achieved by calling setValidator after setValue, but + // this is not a possibility with variable fields. + if (this.variable_) { + return this.validator_; + } + return null; + } + + /** + * Ensure that the ID belongs to a valid variable of an allowed type. + * @param {*=} opt_newValue The ID of the new variable to set. + * @return {?string} The validated ID, or null if invalid. + * @protected + */ + doClassValidation_(opt_newValue) { + if (opt_newValue === null) { + return null; + } + const newId = /** @type {string} */ (opt_newValue); + const variable = Variables.getVariable(this.sourceBlock_.workspace, newId); + if (!variable) { + console.warn( + 'Variable id doesn\'t point to a real variable! ' + + 'ID was ' + newId); + return null; + } + // Type Checks. + const type = variable.type; + if (!this.typeIsAllowed_(type)) { + console.warn( + 'Variable type doesn\'t match this field! Type was ' + type); + return null; + } + return newId; + } + + /** + * Update the value of this variable field, as well as its variable and text. + * + * The variable ID should be valid at this point, but if a variable field + * validator returns a bad ID, this could break. + * @param {*} newId The value to be saved. + * @protected + */ + doValueUpdate_(newId) { + this.variable_ = Variables.getVariable( + this.sourceBlock_.workspace, /** @type {string} */ (newId)); + super.doValueUpdate_(newId); + } + + /** + * Check whether the given variable type is allowed on this field. + * @param {string} type The type to check. + * @return {boolean} True if the type is in the list of allowed types. + * @private + */ + typeIsAllowed_(type) { + const typeList = this.getVariableTypes_(); + if (!typeList) { + return true; // If it's null, all types are valid. + } + for (let i = 0; i < typeList.length; i++) { + if (type === typeList[i]) { + return true; } } - if (!isInArray) { + return false; + } + + /** + * Return a list of variable types to include in the dropdown. + * @return {!Array} Array of variable types. + * @throws {Error} if variableTypes is an empty array. + * @private + */ + getVariableTypes_() { + // TODO (#1513): Try to avoid calling this every time the field is edited. + let variableTypes = this.variableTypes; + if (variableTypes === null) { + // If variableTypes is null, return all variable types. + if (this.sourceBlock_ && this.sourceBlock_.workspace) { + return this.sourceBlock_.workspace.getVariableTypes(); + } + } + variableTypes = variableTypes || ['']; + if (variableTypes.length === 0) { + // Throw an error if variableTypes is an empty list. + const name = this.getText(); throw Error( - 'Invalid default type \'' + defaultType + '\' in ' + - 'the definition of a FieldVariable'); + '\'variableTypes\' of field variable ' + name + ' was an empty list'); } - } else { - throw Error( - '\'variableTypes\' was not an array in the definition of ' + - 'a FieldVariable'); + return variableTypes; } - // Only update the field once all checks pass. - this.defaultType_ = defaultType; - this.variableTypes = variableTypes; -}; -/** - * Refreshes the name of the variable by grabbing the name of the model. - * Used when a variable gets renamed, but the ID stays the same. Should only - * be called by the block. - * @package - */ -FieldVariable.prototype.refreshVariableName = function() { - this.forceRerender(); -}; - -/** - * Return a sorted list of variable names for variable dropdown menus. - * Include a special option at the end for creating a new variable name. - * @return {!Array} Array of variable names/id tuples. - * @this {FieldVariable} - */ -FieldVariable.dropdownCreate = function() { - if (!this.variable_) { - throw Error( - 'Tried to call dropdownCreate on a variable field with no' + - ' variable selected.'); - } - const name = this.getText(); - let variableModelList = []; - if (this.sourceBlock_ && this.sourceBlock_.workspace) { - const variableTypes = this.getVariableTypes_(); - // Get a copy of the list, so that adding rename and new variable options - // doesn't modify the workspace's list. - for (let i = 0; i < variableTypes.length; i++) { - const variableType = variableTypes[i]; - const variables = - this.sourceBlock_.workspace.getVariablesOfType(variableType); - variableModelList = variableModelList.concat(variables); + /** + * Parse the optional arguments representing the allowed variable types and + * the default variable type. + * @param {Array=} opt_variableTypes A list of the types of variables + * to include in the dropdown. If null or undefined, variables of all + * types will be displayed in the dropdown. + * @param {string=} opt_defaultType The type of the variable to create if this + * field's value is not explicitly set. Defaults to ''. + * @private + */ + setTypes_(opt_variableTypes, opt_defaultType) { + // If you expected that the default type would be the same as the only entry + // in the variable types array, tell the Blockly team by commenting on + // #1499. + const defaultType = opt_defaultType || ''; + let variableTypes; + // Set the allowable variable types. Null means all types on the workspace. + if (opt_variableTypes === null || opt_variableTypes === undefined) { + variableTypes = null; + } else if (Array.isArray(opt_variableTypes)) { + variableTypes = opt_variableTypes; + // Make sure the default type is valid. + let isInArray = false; + for (let i = 0; i < variableTypes.length; i++) { + if (variableTypes[i] === defaultType) { + isInArray = true; + } + } + if (!isInArray) { + throw Error( + 'Invalid default type \'' + defaultType + '\' in ' + + 'the definition of a FieldVariable'); + } + } else { + throw Error( + '\'variableTypes\' was not an array in the definition of ' + + 'a FieldVariable'); } - } - variableModelList.sort(VariableModel.compareByName); - - const options = []; - for (let i = 0; i < variableModelList.length; i++) { - // Set the UUID as the internal representation of the variable. - options[i] = [variableModelList[i].name, variableModelList[i].getId()]; - } - options.push([Msg['RENAME_VARIABLE'], internalConstants.RENAME_VARIABLE_ID]); - if (Msg['DELETE_VARIABLE']) { - options.push([ - Msg['DELETE_VARIABLE'].replace('%1', name), - internalConstants.DELETE_VARIABLE_ID, - ]); + // Only update the field once all checks pass. + this.defaultType_ = defaultType; + this.variableTypes = variableTypes; } - return options; -}; + /** + * Refreshes the name of the variable by grabbing the name of the model. + * Used when a variable gets renamed, but the ID stays the same. Should only + * be called by the block. + * @override + * @package + */ + refreshVariableName() { + this.forceRerender(); + } -/** - * Handle the selection of an item in the variable dropdown menu. - * Special case the 'Rename variable...' and 'Delete variable...' options. - * In the rename case, prompt the user for a new name. - * @param {!Menu} menu The Menu component clicked. - * @param {!MenuItem} menuItem The MenuItem selected within menu. - * @protected - */ -FieldVariable.prototype.onItemSelected_ = function(menu, menuItem) { - const id = menuItem.getValue(); - // Handle special cases. - if (this.sourceBlock_ && this.sourceBlock_.workspace) { - if (id === internalConstants.RENAME_VARIABLE_ID) { - // Rename variable. - Variables.renameVariable(this.sourceBlock_.workspace, this.variable_); - return; - } else if (id === internalConstants.DELETE_VARIABLE_ID) { - // Delete variable. - this.sourceBlock_.workspace.deleteVariableById(this.variable_.getId()); - return; + /** + * Handle the selection of an item in the variable dropdown menu. + * Special case the 'Rename variable...' and 'Delete variable...' options. + * In the rename case, prompt the user for a new name. + * @param {!Menu} menu The Menu component clicked. + * @param {!MenuItem} menuItem The MenuItem selected within menu. + * @protected + */ + onItemSelected_(menu, menuItem) { + const id = menuItem.getValue(); + // Handle special cases. + if (this.sourceBlock_ && this.sourceBlock_.workspace) { + if (id === internalConstants.RENAME_VARIABLE_ID) { + // Rename variable. + Variables.renameVariable( + this.sourceBlock_.workspace, + /** @type {!VariableModel} */ (this.variable_)); + return; + } else if (id === internalConstants.DELETE_VARIABLE_ID) { + // Delete variable. + this.sourceBlock_.workspace.deleteVariableById(this.variable_.getId()); + return; + } } + // Handle unspecial case. + this.setValue(id); } - // Handle unspecial case. - this.setValue(id); -}; -/** - * Overrides referencesVariables(), indicating this field refers to a variable. - * @return {boolean} True. - * @package - * @override - */ -FieldVariable.prototype.referencesVariables = function() { - return true; -}; + /** + * Overrides referencesVariables(), indicating this field refers to a + * variable. + * @return {boolean} True. + * @package + * @override + */ + referencesVariables() { + return true; + } + + /** + * Construct a FieldVariable from a JSON arg object, + * dereferencing any string table references. + * @param {!Object} options A JSON object with options (variable, + * variableTypes, and defaultType). + * @return {!FieldVariable} The new field instance. + * @package + * @nocollapse + * @override + */ + static fromJson(options) { + const varName = parsing.replaceMessageReferences(options['variable']); + // `this` might be a subclass of FieldVariable if that class doesn't + // override the static fromJson method. + return new this(varName, undefined, undefined, undefined, options); + } + + /** + * Return a sorted list of variable names for variable dropdown menus. + * Include a special option at the end for creating a new variable name. + * @return {!Array} Array of variable names/id tuples. + * @this {FieldVariable} + */ + static dropdownCreate() { + if (!this.variable_) { + throw Error( + 'Tried to call dropdownCreate on a variable field with no' + + ' variable selected.'); + } + const name = this.getText(); + let variableModelList = []; + if (this.sourceBlock_ && this.sourceBlock_.workspace) { + const variableTypes = this.getVariableTypes_(); + // Get a copy of the list, so that adding rename and new variable options + // doesn't modify the workspace's list. + for (let i = 0; i < variableTypes.length; i++) { + const variableType = variableTypes[i]; + const variables = + this.sourceBlock_.workspace.getVariablesOfType(variableType); + variableModelList = variableModelList.concat(variables); + } + } + variableModelList.sort(VariableModel.compareByName); + + const options = []; + for (let i = 0; i < variableModelList.length; i++) { + // Set the UUID as the internal representation of the variable. + options[i] = [variableModelList[i].name, variableModelList[i].getId()]; + } + options.push( + [Msg['RENAME_VARIABLE'], internalConstants.RENAME_VARIABLE_ID]); + if (Msg['DELETE_VARIABLE']) { + options.push([ + Msg['DELETE_VARIABLE'].replace('%1', name), + internalConstants.DELETE_VARIABLE_ID, + ]); + } + + return options; + } +} fieldRegistry.register('field_variable', FieldVariable); diff --git a/core/utils/sentinel.js b/core/utils/sentinel.js new file mode 100644 index 000000000..66fa48e68 --- /dev/null +++ b/core/utils/sentinel.js @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A type used to create flag values (e.g. SKIP_SETUP). + */ +'use strict'; + +/** + * A type used to create flag values. + * @class + */ +goog.module('Blockly.utils.Sentinel'); + + +/** + * A type used to create flag values. + */ +class Sentinel {} + +exports.Sentinel = Sentinel; diff --git a/scripts/gulpfiles/chunks.json b/scripts/gulpfiles/chunks.json index 79331823a..72c0d27c8 100644 --- a/scripts/gulpfiles/chunks.json +++ b/scripts/gulpfiles/chunks.json @@ -1,6 +1,6 @@ { "chunk": [ - "blockly:259", + "blockly:260", "blocks:10:blockly", "all:11:blockly", "all1:11:blockly", @@ -21,7 +21,6 @@ "./core/field_number.js", "./core/field_multilineinput.js", "./core/field_label_serializable.js", - "./core/field_dropdown.js", "./core/field_colour.js", "./core/field_checkbox.js", "./core/field_angle.js", @@ -57,8 +56,6 @@ "./core/contextmenu_items.js", "./core/widgetdiv.js", "./core/clipboard.js", - "./core/menuitem.js", - "./core/menu.js", "./core/contextmenu.js", "./core/utils/global.js", "./core/utils/useragent.js", @@ -80,16 +77,23 @@ "./core/utils/math.js", "./core/utils/array.js", "./core/workspace.js", - "./core/keyboard_nav/basic_cursor.js", - "./core/keyboard_nav/tab_navigate_cursor.js", - "./core/warning.js", - "./core/comment.js", + "./core/menu.js", + "./core/menuitem.js", + "./core/interfaces/i_registrable_field.js", + "./core/shortcut_registry.js", + "./core/interfaces/i_keyboard_accessible.js", "./core/events/events_block_drag.js", - "./core/events/events_block_move.js", "./core/bump_objects.js", "./core/block_dragger.js", "./core/workspace_dragger.js", "./core/interfaces/i_block_dragger.js", + "./core/bubble_dragger.js", + "./core/keyboard_nav/basic_cursor.js", + "./core/keyboard_nav/tab_navigate_cursor.js", + "./core/mutator.js", + "./core/warning.js", + "./core/comment.js", + "./core/events/events_block_move.js", "./core/events/events_viewport.js", "./core/events/events_theme_change.js", "./core/events/events_block_create.js", @@ -119,10 +123,12 @@ "./core/theme_manager.js", "./core/scrollbar_pair.js", "./core/options.js", + "./core/marker_manager.js", "./core/interfaces/i_bounded_element.js", "./core/grid.js", "./core/css.js", "./core/flyout_button.js", + "./core/keyboard_nav/cursor.js", "./core/contextmenu_registry.js", "./core/theme/classic.js", "./core/blockly_options.js", @@ -134,7 +140,8 @@ "./core/renderers/zelos/path_object.js", "./core/renderers/zelos/drawer.js", "./core/renderers/zelos/renderer.js", - "./core/utils/aria.js", + "./core/utils/keycodes.js", + "./core/dropdowndiv.js", "./core/field_textinput.js", "./core/field_image.js", "./core/renderers/zelos/constants.js", @@ -145,6 +152,13 @@ "./core/renderers/measurables/spacer_row.js", "./core/renderers/measurables/round_corner.js", "./core/renderers/common/path_object.js", + "./core/events/events_marker_move.js", + "./core/keyboard_nav/marker.js", + "./core/interfaces/i_ast_node_location_svg.js", + "./core/interfaces/i_ast_node_location.js", + "./core/interfaces/i_ast_node_location_with_block.js", + "./core/keyboard_nav/ast_node.js", + "./core/renderers/common/marker_svg.js", "./core/interfaces/i_positionable.js", "./core/interfaces/i_drag_target.js", "./core/interfaces/i_delete_area.js", @@ -166,6 +180,7 @@ "./core/interfaces/i_toolbox.js", "./core/utils/metrics.js", "./core/interfaces/i_metrics_manager.js", + "./core/interfaces/i_registrable.js", "./core/interfaces/i_flyout.js", "./core/metrics_manager.js", "./core/interfaces/i_deletable.js", @@ -181,6 +196,10 @@ "./core/renderers/common/info.js", "./core/renderers/measurables/field.js", "./core/renderers/common/debugger.js", + "./core/utils/sentinel.js", + "./core/field_label.js", + "./core/input_types.js", + "./core/input.js", "./core/renderers/measurables/input_connection.js", "./core/renderers/measurables/in_row_spacer.js", "./core/renderers/measurables/row.js", @@ -188,42 +207,23 @@ "./core/renderers/measurables/base.js", "./core/renderers/measurables/connection.js", "./core/renderers/measurables/next_connection.js", - "./core/renderers/measurables/bottom_row.js", - "./core/renderers/common/debug.js", - "./core/renderers/common/block_rendering.js", - "./core/variables_dynamic.js", - "./core/events/events_var_rename.js", - "./core/events/events_var_delete.js", - "./core/variable_map.js", - "./core/names.js", - "./core/events/events_marker_move.js", - "./core/renderers/common/marker_svg.js", - "./core/keyboard_nav/marker.js", - "./core/keyboard_nav/ast_node.js", - "./core/keyboard_nav/cursor.js", - "./core/marker_manager.js", - "./core/field_label.js", - "./core/input_types.js", - "./core/interfaces/i_registrable_field.js", - "./core/field_registry.js", - "./core/input.js", - "./core/interfaces/i_registrable.js", - "./core/utils/keycodes.js", - "./core/shortcut_registry.js", - "./core/interfaces/i_keyboard_accessible.js", - "./core/interfaces/i_ast_node_location_with_block.js", - "./core/interfaces/i_ast_node_location.js", - "./core/interfaces/i_ast_node_location_svg.js", - "./core/dropdowndiv.js", "./core/theme.js", - "./core/constants.js", "./core/interfaces/i_connection_checker.js", "./core/connection_db.js", "./core/config.js", "./core/rendered_connection.js", "./core/utils/svg_paths.js", "./core/renderers/common/constants.js", - "./core/field.js", + "./core/renderers/measurables/bottom_row.js", + "./core/renderers/common/debug.js", + "./core/renderers/common/block_rendering.js", + "./core/variables_dynamic.js", + "./core/events/events_block_base.js", + "./core/events/events_block_change.js", + "./core/events/events_var_rename.js", + "./core/events/events_var_delete.js", + "./core/variable_map.js", + "./core/names.js", "./core/events/events_ui_base.js", "./core/events/events_bubble_open.js", "./core/procedures.js", @@ -234,31 +234,32 @@ "./core/utils/style.js", "./core/utils/deprecation.js", "./core/utils/svg_math.js", - "./core/bubble_dragger.js", "./core/connection_type.js", "./core/internal_constants.js", + "./core/constants.js", + "./core/block_svg.js", "./core/block_animations.js", "./core/gesture.js", "./core/touch.js", "./core/browser_events.js", "./core/tooltip.js", - "./core/block_svg.js", - "./core/events/events_block_base.js", - "./core/events/events_block_change.js", - "./core/utils/xml.js", - "./core/mutator.js", + "./core/field.js", + "./core/field_registry.js", + "./core/utils/aria.js", + "./core/field_dropdown.js", "./core/msg.js", "./core/utils/colour.js", "./core/utils/parsing.js", "./core/extensions.js", "./core/block.js", "./core/utils/string.js", + "./core/utils/object.js", "./core/dialog.js", + "./core/utils/xml.js", "./core/events/events_var_base.js", "./core/events/events_var_create.js", "./core/variable_model.js", "./core/variables.js", - "./core/utils/object.js", "./core/events/events_abstract.js", "./core/registry.js", "./core/events/utils.js", diff --git a/tests/deps.js b/tests/deps.js index 96699e744..3d6cad703 100644 --- a/tests/deps.js +++ b/tests/deps.js @@ -67,20 +67,20 @@ goog.addDependency('../../core/events/events_var_rename.js', ['Blockly.Events.Va goog.addDependency('../../core/events/events_viewport.js', ['Blockly.Events.ViewportChange'], ['Blockly.Events.UiBase', 'Blockly.Events.utils', 'Blockly.registry'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/events/utils.js', ['Blockly.Events.utils'], ['Blockly.registry', 'Blockly.utils.idGenerator'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/events/workspace_events.js', ['Blockly.Events.FinishedLoading'], ['Blockly.Events.Abstract', 'Blockly.Events.utils', 'Blockly.registry'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/extensions.js', ['Blockly.Extensions'], ['Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field.js', ['Blockly.Field'], ['Blockly.DropDownDiv', 'Blockly.Events.BlockChange', 'Blockly.Events.utils', 'Blockly.Gesture', 'Blockly.IASTNodeLocationSvg', 'Blockly.IASTNodeLocationWithBlock', 'Blockly.IKeyboardAccessible', 'Blockly.IRegistrable', 'Blockly.MarkerManager', 'Blockly.Tooltip', 'Blockly.WidgetDiv', 'Blockly.Xml', 'Blockly.browserEvents', 'Blockly.utils.Rect', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.parsing', 'Blockly.utils.style', 'Blockly.utils.userAgent', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_angle.js', ['Blockly.FieldAngle'], ['Blockly.Css', 'Blockly.DropDownDiv', 'Blockly.FieldTextInput', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.fieldRegistry', 'Blockly.utils.KeyCodes', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.math', 'Blockly.utils.object', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_checkbox.js', ['Blockly.FieldCheckbox'], ['Blockly.Events.BlockChange', 'Blockly.Field', 'Blockly.fieldRegistry', 'Blockly.utils.dom', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_colour.js', ['Blockly.FieldColour'], ['Blockly.Css', 'Blockly.DropDownDiv', 'Blockly.Events.BlockChange', 'Blockly.Field', 'Blockly.browserEvents', 'Blockly.fieldRegistry', 'Blockly.utils.KeyCodes', 'Blockly.utils.Size', 'Blockly.utils.aria', 'Blockly.utils.colour', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_dropdown.js', ['Blockly.FieldDropdown'], ['Blockly.DropDownDiv', 'Blockly.Field', 'Blockly.Menu', 'Blockly.MenuItem', 'Blockly.fieldRegistry', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.parsing', 'Blockly.utils.string', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_image.js', ['Blockly.FieldImage'], ['Blockly.Field', 'Blockly.fieldRegistry', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_label.js', ['Blockly.FieldLabel'], ['Blockly.Field', 'Blockly.fieldRegistry', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_label_serializable.js', ['Blockly.FieldLabelSerializable'], ['Blockly.FieldLabel', 'Blockly.fieldRegistry', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/extensions.js', ['Blockly.Extensions'], ['Blockly.FieldDropdown', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field.js', ['Blockly.Field'], ['Blockly.DropDownDiv', 'Blockly.Events.BlockChange', 'Blockly.Events.utils', 'Blockly.Gesture', 'Blockly.IASTNodeLocationSvg', 'Blockly.IASTNodeLocationWithBlock', 'Blockly.IKeyboardAccessible', 'Blockly.IRegistrable', 'Blockly.MarkerManager', 'Blockly.Tooltip', 'Blockly.WidgetDiv', 'Blockly.Xml', 'Blockly.browserEvents', 'Blockly.utils.Rect', 'Blockly.utils.Sentinel', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.parsing', 'Blockly.utils.style', 'Blockly.utils.userAgent', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_angle.js', ['Blockly.FieldAngle'], ['Blockly.Css', 'Blockly.DropDownDiv', 'Blockly.Field', 'Blockly.FieldTextInput', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.fieldRegistry', 'Blockly.utils.KeyCodes', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.math', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_checkbox.js', ['Blockly.FieldCheckbox'], ['Blockly.Events.BlockChange', 'Blockly.Field', 'Blockly.fieldRegistry', 'Blockly.utils.dom'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_colour.js', ['Blockly.FieldColour'], ['Blockly.Css', 'Blockly.DropDownDiv', 'Blockly.Events.BlockChange', 'Blockly.Field', 'Blockly.browserEvents', 'Blockly.fieldRegistry', 'Blockly.utils.KeyCodes', 'Blockly.utils.Size', 'Blockly.utils.aria', 'Blockly.utils.colour', 'Blockly.utils.dom', 'Blockly.utils.idGenerator'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_dropdown.js', ['Blockly.FieldDropdown'], ['Blockly.DropDownDiv', 'Blockly.Field', 'Blockly.Menu', 'Blockly.MenuItem', 'Blockly.fieldRegistry', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.parsing', 'Blockly.utils.string', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_image.js', ['Blockly.FieldImage'], ['Blockly.Field', 'Blockly.fieldRegistry', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_label.js', ['Blockly.FieldLabel'], ['Blockly.Field', 'Blockly.fieldRegistry', 'Blockly.utils.dom', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_label_serializable.js', ['Blockly.FieldLabelSerializable'], ['Blockly.FieldLabel', 'Blockly.fieldRegistry', 'Blockly.utils.parsing'], {'lang': 'es_2020', 'module': 'goog'}); goog.addDependency('../../core/field_multilineinput.js', ['Blockly.FieldMultilineInput'], ['Blockly.Css', 'Blockly.Field', 'Blockly.FieldTextInput', 'Blockly.WidgetDiv', 'Blockly.fieldRegistry', 'Blockly.utils.KeyCodes', 'Blockly.utils.Svg', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.parsing', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_number.js', ['Blockly.FieldNumber'], ['Blockly.FieldTextInput', 'Blockly.fieldRegistry', 'Blockly.utils.aria', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_number.js', ['Blockly.FieldNumber'], ['Blockly.Field', 'Blockly.FieldTextInput', 'Blockly.fieldRegistry', 'Blockly.utils.aria'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/field_registry.js', ['Blockly.fieldRegistry'], ['Blockly.registry'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_textinput.js', ['Blockly.FieldTextInput'], ['Blockly.DropDownDiv', 'Blockly.Events.BlockChange', 'Blockly.Events.utils', 'Blockly.Field', 'Blockly.Msg', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.dialog', 'Blockly.fieldRegistry', 'Blockly.utils.Coordinate', 'Blockly.utils.KeyCodes', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.parsing', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/field_variable.js', ['Blockly.FieldVariable'], ['Blockly.Events.BlockChange', 'Blockly.FieldDropdown', 'Blockly.Msg', 'Blockly.VariableModel', 'Blockly.Variables', 'Blockly.Xml', 'Blockly.fieldRegistry', 'Blockly.internalConstants', 'Blockly.utils.Size', 'Blockly.utils.object', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_textinput.js', ['Blockly.FieldTextInput'], ['Blockly.DropDownDiv', 'Blockly.Events.BlockChange', 'Blockly.Events.utils', 'Blockly.Field', 'Blockly.Msg', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.dialog', 'Blockly.fieldRegistry', 'Blockly.utils.Coordinate', 'Blockly.utils.KeyCodes', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.parsing', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/field_variable.js', ['Blockly.FieldVariable'], ['Blockly.Events.BlockChange', 'Blockly.Field', 'Blockly.FieldDropdown', 'Blockly.Msg', 'Blockly.VariableModel', 'Blockly.Variables', 'Blockly.Xml', 'Blockly.fieldRegistry', 'Blockly.internalConstants', 'Blockly.utils.Size', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/flyout_base.js', ['Blockly.Flyout'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events.BlockCreate', 'Blockly.Events.VarCreate', 'Blockly.Events.utils', 'Blockly.FlyoutMetricsManager', 'Blockly.Gesture', 'Blockly.IFlyout', 'Blockly.ScrollbarPair', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Variables', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.blockRendering', 'Blockly.browserEvents', 'Blockly.common', 'Blockly.serialization.blocks', 'Blockly.utils.Coordinate', 'Blockly.utils.Rect', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/flyout_button.js', ['Blockly.FlyoutButton'], ['Blockly.Css', 'Blockly.browserEvents', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.parsing', 'Blockly.utils.style'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/flyout_horizontal.js', ['Blockly.HorizontalFlyout'], ['Blockly.DropDownDiv', 'Blockly.Flyout', 'Blockly.Scrollbar', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.registry', 'Blockly.utils.Rect', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); @@ -242,6 +242,7 @@ goog.addDependency('../../core/utils/metrics.js', ['Blockly.utils.Metrics'], [], goog.addDependency('../../core/utils/object.js', ['Blockly.utils.object'], [], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils/parsing.js', ['Blockly.utils.parsing'], ['Blockly.Msg', 'Blockly.utils.colour', 'Blockly.utils.string'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils/rect.js', ['Blockly.utils.Rect'], [], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/utils/sentinel.js', ['Blockly.utils.Sentinel'], [], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils/size.js', ['Blockly.utils.Size'], [], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils/string.js', ['Blockly.utils.string'], [], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils/style.js', ['Blockly.utils.style'], ['Blockly.utils.Coordinate', 'Blockly.utils.Size'], {'lang': 'es6', 'module': 'goog'}); diff --git a/tests/mocha/field_registry_test.js b/tests/mocha/field_registry_test.js index 0e49b5620..0df2f6bd0 100644 --- a/tests/mocha/field_registry_test.js +++ b/tests/mocha/field_registry_test.js @@ -10,13 +10,15 @@ const {createDeprecationWarningStub, sharedTestSetup, sharedTestTeardown} = goog suite('Field Registry', function() { - function CustomFieldType(value) { - CustomFieldType.superClass_.constructor.call(this, value); + class CustomFieldType extends Blockly.Field { + constructor(value) { + super(value); + } + + static fromJson(options) { + return new CustomFieldType(options['value']); + } } - Blockly.utils.object.inherits(CustomFieldType, Blockly.Field); - CustomFieldType.fromJson = function(options) { - return new CustomFieldType(options['value']); - }; setup(function() { sharedTestSetup.call(this); diff --git a/tests/mocha/field_test.js b/tests/mocha/field_test.js index 6ef1cbf24..0ba6a9e98 100644 --- a/tests/mocha/field_test.js +++ b/tests/mocha/field_test.js @@ -20,29 +20,40 @@ suite('Abstract Fields', function() { suite('Is Serializable', function() { // Both EDITABLE and SERIALIZABLE are default. - function FieldDefault() { - this.name = 'NAME'; + class FieldDefault extends Blockly.Field { + constructor() { + super(); + this.name = 'NAME'; + } } - FieldDefault.prototype = Object.create(Blockly.Field.prototype); + // EDITABLE is false and SERIALIZABLE is default. - function FieldFalseDefault() { - this.name = 'NAME'; + class FieldFalseDefault extends Blockly.Field { + constructor() { + super(); + this.name = 'NAME'; + this.EDITABLE = false; + } } - FieldFalseDefault.prototype = Object.create(Blockly.Field.prototype); - FieldFalseDefault.prototype.EDITABLE = false; + // EDITABLE is default and SERIALIZABLE is true. - function FieldDefaultTrue() { - this.name = 'NAME'; + class FieldDefaultTrue extends Blockly.Field { + constructor() { + super(); + this.name = 'NAME'; + this.SERIALIZABLE = true; + } } - FieldDefaultTrue.prototype = Object.create(Blockly.Field.prototype); - FieldDefaultTrue.prototype.SERIALIZABLE = true; + // EDITABLE is false and SERIALIZABLE is true. - function FieldFalseTrue() { - this.name = 'NAME'; + class FieldFalseTrue extends Blockly.Field { + constructor() { + super(); + this.name = 'NAME'; + this.EDITABLE = false; + this.SERIALIZABLE = true; + } } - FieldFalseTrue.prototype = Object.create(Blockly.Field.prototype); - FieldFalseTrue.prototype.EDITABLE = false; - FieldFalseTrue.prototype.SERIALIZABLE = true; /* Test Backwards Compatibility */ test('Editable Default(true), Serializable Default(false)', function() { @@ -585,14 +596,15 @@ suite('Abstract Fields', function() { suite('Customization', function() { // All this field does is wrap the abstract field. - function CustomField(opt_config) { - CustomField.superClass_.constructor.call( - this, 'value', null, opt_config); + class CustomField extends Blockly.Field { + constructor(opt_config) { + super('value', null, opt_config); + } + + static fromJson(options) { + return new CustomField(options); + } } - Blockly.utils.object.inherits(CustomField, Blockly.Field); - CustomField.fromJson = function(options) { - return new CustomField(options); - }; suite('Tooltip', function() { test('JS Constructor', function() {