diff --git a/core/blockly.ts b/core/blockly.ts index 71c19f77d..b236c2118 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -57,7 +57,7 @@ import {Field} from './field.js'; import {FieldAngle} from './field_angle.js'; import {FieldCheckbox} from './field_checkbox.js'; import {FieldColour} from './field_colour.js'; -import {FieldDropdown} from './field_dropdown.js'; +import {FieldDropdown, MenuGenerator, MenuGeneratorFunction, MenuOption} from './field_dropdown.js'; import {FieldImage} from './field_image.js'; import {FieldLabel} from './field_label.js'; import {FieldLabelSerializable} from './field_label_serializable.js'; @@ -651,7 +651,7 @@ export {Field}; export {FieldAngle}; export {FieldCheckbox}; export {FieldColour}; -export {FieldDropdown}; +export {FieldDropdown, MenuGenerator, MenuGeneratorFunction, MenuOption}; export {FieldImage}; export {FieldLabel}; export {FieldLabelSerializable}; diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 87580726f..4615db5ef 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -27,8 +27,6 @@ import * as parsing from './utils/parsing.js'; import type {Sentinel} from './utils/sentinel.js'; import * as utilsString from './utils/string.js'; import {Svg} from './utils/svg.js'; -import * as userAgent from './utils/useragent.js'; - /** * Class for an editable dropdown field. @@ -44,7 +42,8 @@ export class FieldDropdown extends Field { * height. */ static MAX_MENU_HEIGHT_VH = 0.45; - static ARROW_CHAR: AnyDuringMigration; + + static ARROW_CHAR = '▾'; /** A reference to the currently selected menu item. */ private selectedMenuItem_: MenuItem|null = null; @@ -71,14 +70,11 @@ export class FieldDropdown extends Field { /** Mouse cursor style when over the hotspot that initiates the editor. */ override CURSOR = 'default'; - // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. - protected menuGenerator_!: AnyDuringMigration[][]| - ((this: FieldDropdown) => AnyDuringMigration[][]); + + protected menuGenerator_?: MenuGenerator; /** A cache of the most recently generated options. */ - // AnyDuringMigration because: Type 'null' is not assignable to type - // 'string[][]'. - private generatedOptions_: string[][] = null as AnyDuringMigration; + private generatedOptions_: MenuOption[]|null = null; /** * The prefix field label, of common words set after options are trimmed. @@ -95,7 +91,7 @@ export class FieldDropdown extends Field { override suffixField: string|null = null; // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. private selectedOption_!: Array; - override clickTarget_: AnyDuringMigration; + override clickTarget_: SVGElement|null = null; /** * @param menuGenerator A non-empty array of options for a dropdown list, or a @@ -114,30 +110,31 @@ export class FieldDropdown extends Field { * @throws {TypeError} If `menuGenerator` options are incorrectly structured. */ constructor( - menuGenerator: AnyDuringMigration[][]|Function|Sentinel, - opt_validator?: Function, opt_config?: FieldConfig) { + menuGenerator: MenuGenerator, + opt_validator?: Function, + opt_config?: FieldConfig, + ); + constructor(menuGenerator: Sentinel); + constructor( + menuGenerator: MenuGenerator|Sentinel, + opt_validator?: Function, + opt_config?: FieldConfig, + ) { super(Field.SKIP_SETUP); // If we pass SKIP_SETUP, don't do *anything* with the menu generator. - if (menuGenerator === Field.SKIP_SETUP) { - return; - } + if (!isMenuGenerator(menuGenerator)) return; if (Array.isArray(menuGenerator)) { validateOptions(menuGenerator); - // Deep copy the option structure so it doesn't change. - menuGenerator = JSON.parse(JSON.stringify(menuGenerator)); + const trimmed = trimOptions(menuGenerator); + this.menuGenerator_ = trimmed.options; + this.prefixField = trimmed.prefix || null; + this.suffixField = trimmed.suffix || null; + } else { + this.menuGenerator_ = menuGenerator; } - /** - * An array of options for a dropdown list, - * or a function which generates these options. - */ - this.menuGenerator_ = menuGenerator as AnyDuringMigration[][] | - ((this: FieldDropdown) => AnyDuringMigration[][]); - - this.trimOptions_(); - /** * The currently selected option. The field is initialized with the * first option selected. @@ -227,14 +224,9 @@ export class FieldDropdown extends Field { this.getSourceBlock()?.RTL ? FieldDropdown.ARROW_CHAR + ' ' : ' ' + FieldDropdown.ARROW_CHAR)); if (this.getSourceBlock()?.RTL) { - // AnyDuringMigration because: Argument of type 'SVGTSpanElement | null' - // is not assignable to parameter of type 'Node'. - this.getTextElement().insertBefore( - this.arrow_ as AnyDuringMigration, this.textContent_); + this.getTextElement().insertBefore(this.arrow_, this.textContent_); } else { - // AnyDuringMigration because: Argument of type 'SVGTSpanElement | null' - // is not assignable to parameter of type 'Node'. - this.getTextElement().appendChild(this.arrow_ as AnyDuringMigration); + this.getTextElement().appendChild(this.arrow_); } } @@ -257,21 +249,14 @@ export class FieldDropdown extends Field { * @param opt_e Optional mouse event that triggered the field to open, or * undefined if triggered programmatically. */ - protected override showEditor_(opt_e?: Event) { + protected override showEditor_(opt_e?: MouseEvent) { const block = this.getSourceBlock(); if (!block) { throw new UnattachedFieldError(); } this.dropdownCreate_(); - // AnyDuringMigration because: Property 'clientX' does not exist on type - // 'Event'. - if (opt_e && typeof (opt_e as AnyDuringMigration).clientX === 'number') { - // AnyDuringMigration because: Property 'clientY' does not exist on type - // 'Event'. AnyDuringMigration because: Property 'clientX' does not exist - // on type 'Event'. - this.menu_!.openingCoords = new Coordinate( - (opt_e as AnyDuringMigration).clientX, - (opt_e as AnyDuringMigration).clientY); + if (opt_e && typeof opt_e.clientX === 'number') { + this.menu_!.openingCoords = new Coordinate(opt_e.clientX, opt_e.clientY); } else { this.menu_!.openingCoords = null; } @@ -318,15 +303,17 @@ export class FieldDropdown extends Field { 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 [label, value] = options[i]; + const content = (() => { + if (typeof label === 'object') { + // Convert ImageProperties to an HTMLImageElement. + const image = new Image(label['width'], label['height']); + image.src = label['src']; + image.alt = label['alt'] || ''; + return image; + } + return label; + })(); const menuItem = new MenuItem(content, value); menuItem.setRole(aria.Role.OPTION); menuItem.setRightToLeft(block.RTL); @@ -372,57 +359,6 @@ export class FieldDropdown extends Field { 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); - } - /** * @returns True if the option list is generated by a function. * Otherwise false. @@ -440,18 +376,18 @@ export class FieldDropdown extends Field { * (human-readable text or image, language-neutral name). * @throws {TypeError} If generated options are incorrectly structured. */ - getOptions(opt_useCache?: boolean): AnyDuringMigration[][] { - if (this.isOptionListDynamic()) { - if (!this.generatedOptions_ || !opt_useCache) { - // AnyDuringMigration because: Property 'call' does not exist on type - // 'any[][] | ((this: FieldDropdown) => any[][])'. - this.generatedOptions_ = - (this.menuGenerator_ as AnyDuringMigration).call(this); - validateOptions(this.generatedOptions_); - } - return this.generatedOptions_; + getOptions(opt_useCache?: boolean): MenuOption[] { + if (!this.menuGenerator_) { + // A subclass improperly skipped setup without defining the menu + // generator. + throw TypeError('A menu generator was never defined.'); } - return this.menuGenerator_ as string[][]; + if (Array.isArray(this.menuGenerator_)) return this.menuGenerator_; + if (opt_useCache && this.generatedOptions_) return this.generatedOptions_; + + this.generatedOptions_ = this.menuGenerator_(); + validateOptions(this.generatedOptions_); + return this.generatedOptions_; } /** @@ -460,17 +396,11 @@ export class FieldDropdown extends Field { * @param opt_newValue The input value. * @returns A valid language-neutral option, or null if invalid. */ - protected override doClassValidation_(opt_newValue?: AnyDuringMigration): - string|null { - let isValueValid = false; + protected override doClassValidation_(opt_newValue?: MenuOption[1]): string + |null { 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; - } - } + const isValueValid = options.some((option) => option[1] === opt_newValue); + if (!isValueValid) { if (this.sourceBlock_) { console.warn( @@ -489,7 +419,7 @@ export class FieldDropdown extends Field { * @param newValue The value to be saved. The default validator guarantees * that this is one of the valid dropdown options. */ - protected override doValueUpdate_(newValue: AnyDuringMigration) { + protected override doValueUpdate_(newValue: MenuOption[1]) { super.doValueUpdate_(newValue); const options = this.getOptions(true); for (let i = 0, option; option = options[i]; i++) { @@ -554,14 +484,8 @@ export class FieldDropdown extends Field { this.imageElement_!.style.display = ''; this.imageElement_!.setAttributeNS( dom.XLINK_NS, 'xlink:href', imageJson.src); - // AnyDuringMigration because: Argument of type 'number' is not assignable - // to parameter of type 'string'. - this.imageElement_!.setAttribute( - 'height', imageJson.height as AnyDuringMigration); - // AnyDuringMigration because: Argument of type 'number' is not assignable - // to parameter of type 'string'. - this.imageElement_!.setAttribute( - 'width', imageJson.width as AnyDuringMigration); + this.imageElement_!.setAttribute('height', `${imageJson.height}`); + this.imageElement_!.setAttribute('width', `${imageJson.width}`); const imageHeight = Number(imageJson.height); const imageWidth = Number(imageJson.width); @@ -697,30 +621,6 @@ export class FieldDropdown extends Field { // 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 options Array of option tuples: - * (human-readable text or image, language-neutral name). - * @param prefixLength The length of the common prefix. - * @param suffixLength The length of the common suffix - * @returns A new array with all of the option text trimmed. - */ - static applyTrim_( - options: AnyDuringMigration[][], prefixLength: number, - suffixLength: number): AnyDuringMigration[][] { - 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; - } } /** @@ -740,6 +640,18 @@ export interface ImageProperties { */ export type MenuOption = [string | ImageProperties, string]; +/** + * A function that generates an array of menu options for FieldDropdown + * or its descendants. + */ +export type MenuGeneratorFunction = (this: FieldDropdown) => MenuOption[]; + +/** + * Either an array of menu options or a function that generates an array of + * menu options for FieldDropdown or its descendants. + */ +export type MenuGenerator = MenuOption[]|MenuGeneratorFunction; + /** * fromJson config for the dropdown field. */ @@ -756,8 +668,81 @@ const IMAGE_Y_OFFSET = 5; /** The total vertical padding above and below an image. */ const IMAGE_Y_PADDING: number = IMAGE_Y_OFFSET * 2; -/** Android can't (in 2014) display "▾", so use "▼" instead. */ -FieldDropdown.ARROW_CHAR = userAgent.ANDROID ? '▼' : '▾'; +/** + * NOTE: Because Sentinel is an empty class, proving a value is Sentinel does + * not resolve in TS that it isn't a MenuGenerator. + */ +function isMenuGenerator(menuGenerator: MenuGenerator| + Sentinel): menuGenerator is MenuGenerator { + return menuGenerator !== Field.SKIP_SETUP; +} + +/** + * Factor out common words in statically defined options. + * Create prefix and/or suffix labels. + */ +function trimOptions(options: MenuOption[]): + {options: MenuOption[]; prefix?: string; suffix?: string;} { + let hasImages = false; + const trimmedOptions = options.map(([label, value]): MenuOption => { + if (typeof label === 'string') { + return [parsing.replaceMessageReferences(label), value]; + } + + hasImages = true; + // Copy the image properties so they're not influenced by the original. + // NOTE: No need to deep copy since image properties are only 1 level deep. + const imageLabel = label.alt !== null ? + {...label, alt: parsing.replaceMessageReferences(label.alt)} : + {...label}; + return [imageLabel, value]; + }); + + if (hasImages || options.length < 2) return {options: trimmedOptions}; + + const stringOptions = trimmedOptions as [string, string][]; + const stringLabels = stringOptions.map(([label]) => label); + + const shortest = utilsString.shortestStringLength(stringLabels); + const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest); + const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest); + + if ((!prefixLength && !suffixLength) || + (shortest <= prefixLength + suffixLength)) { + // One or more strings will entirely vanish if we proceed. Abort. + return {options: stringOptions}; + } + + const prefix = + prefixLength ? stringLabels[0].substring(0, prefixLength - 1) : undefined; + const suffix = + suffixLength ? stringLabels[0].substr(1 - suffixLength) : undefined; + return { + options: applyTrim(stringOptions, prefixLength, suffixLength), + prefix, + suffix, + }; +} + +/** + * Use the calculated prefix and suffix lengths to trim all of the options in + * the given array. + * + * @param options Array of option tuples: + * (human-readable text or image, language-neutral name). + * @param prefixLength The length of the common prefix. + * @param suffixLength The length of the common suffix + * @returns A new array with all of the option text trimmed. + */ +function applyTrim( + options: [string, string][], prefixLength: number, + suffixLength: number): MenuOption[] { + return options.map( + ([text, value]) => + [text.substring(prefixLength, text.length - suffixLength), + value, + ]); +} /** * Validates the data structure to be processed as an options list. @@ -765,7 +750,7 @@ FieldDropdown.ARROW_CHAR = userAgent.ANDROID ? '▼' : '▾'; * @param options The proposed dropdown options. * @throws {TypeError} If proposed options are incorrectly structured. */ -function validateOptions(options: AnyDuringMigration) { +function validateOptions(options: MenuOption[]) { if (!Array.isArray(options)) { throw TypeError('FieldDropdown options must be an array.'); } diff --git a/core/field_variable.ts b/core/field_variable.ts index 879eb1aa9..040a2f0ff 100644 --- a/core/field_variable.ts +++ b/core/field_variable.ts @@ -17,7 +17,7 @@ import './events/events_block_change.js'; import type {Block} from './block.js'; import {Field, FieldConfig, UnattachedFieldError} from './field.js'; -import {FieldDropdown} from './field_dropdown.js'; +import {FieldDropdown, MenuGenerator, MenuOption} from './field_dropdown.js'; import * as fieldRegistry from './field_registry.js'; import * as internalConstants from './internal_constants.js'; import type {Menu} from './menu.js'; @@ -37,8 +37,7 @@ import * as Xml from './xml.js'; * @alias Blockly.FieldVariable */ export class FieldVariable extends FieldDropdown { - protected override menuGenerator_: AnyDuringMigration[][]| - ((this: FieldDropdown) => AnyDuringMigration[][]); + protected override menuGenerator_: MenuGenerator|undefined; defaultVariableName: string; /** The type of the default variable for this field. */ @@ -89,9 +88,7 @@ export class FieldVariable extends FieldDropdown { * An array of options for a dropdown list, * or a function which generates these options. */ - // AnyDuringMigration because: Type '(this: FieldVariable) => any[][]' is - // not assignable to type 'any[][] | ((this: FieldDropdown) => any[][])'. - this.menuGenerator_ = FieldVariable.dropdownCreate as AnyDuringMigration; + this.menuGenerator_ = FieldVariable.dropdownCreate as MenuGenerator; /** * The initial variable name passed to this field's constructor, or an @@ -528,14 +525,14 @@ export class FieldVariable extends FieldDropdown { * * @returns Array of variable names/id tuples. */ - static dropdownCreate(this: FieldVariable): AnyDuringMigration[][] { + static dropdownCreate(this: FieldVariable): MenuOption[] { if (!this.variable_) { throw Error( 'Tried to call dropdownCreate on a variable field with no' + ' variable selected.'); } const name = this.getText(); - let variableModelList: AnyDuringMigration[] = []; + let variableModelList: VariableModel[] = []; if (this.sourceBlock_ && !this.sourceBlock_.isDeadOrDying()) { const variableTypes = this.getVariableTypes_(); // Get a copy of the list, so that adding rename and new variable options @@ -549,7 +546,7 @@ export class FieldVariable extends FieldDropdown { } variableModelList.sort(VariableModel.compareByName); - const options = []; + const options: [string, string][] = []; 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()];