diff --git a/core/block.ts b/core/block.ts index caff93df2..ee81f1cf8 100644 --- a/core/block.ts +++ b/core/block.ts @@ -1817,7 +1817,7 @@ export class Block implements IASTNodeLocation, IDeletable { * @param element The element to try to turn into a field. * @returns The field defined by the JSON, or null if one couldn't be created. */ - private fieldFromJson_(element: {alt?: string, type?: string, text?: string}): + private fieldFromJson_(element: {alt?: string, type: string, text?: string}): Field|null { const field = fieldRegistry.fromJson(element); if (!field && element['alt']) { diff --git a/core/blockly.ts b/core/blockly.ts index dcb1d8ba9..42c75ee79 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -53,19 +53,19 @@ import {DragTarget} from './drag_target.js'; import * as dropDownDiv from './dropdowndiv.js'; import * as Events from './events/events.js'; import * as Extensions from './extensions.js'; -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, MenuGenerator, MenuGeneratorFunction, MenuOption} from './field_dropdown.js'; +import {Field, FieldValidator} from './field.js'; +import {FieldAngle, FieldAngleValidator} from './field_angle.js'; +import {FieldCheckbox, FieldCheckboxValidator} from './field_checkbox.js'; +import {FieldColour, FieldColourValidator} from './field_colour.js'; +import {FieldDropdown, FieldDropdownValidator, 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'; -import {FieldMultilineInput} from './field_multilineinput.js'; -import {FieldNumber} from './field_number.js'; +import {FieldMultilineInput, FieldMultilineInputValidator} from './field_multilineinput.js'; +import {FieldNumber, FieldNumberValidator} from './field_number.js'; import * as fieldRegistry from './field_registry.js'; -import {FieldTextInput} from './field_textinput.js'; -import {FieldVariable} from './field_variable.js'; +import {FieldTextInput, FieldTextInputValidator} from './field_textinput.js'; +import {FieldVariable, FieldVariableValidator} from './field_variable.js'; import {Flyout} from './flyout_base.js'; import {FlyoutButton} from './flyout_button.js'; import {HorizontalFlyout} from './flyout_horizontal.js'; @@ -101,7 +101,6 @@ import {IMetricsManager} from './interfaces/i_metrics_manager.js'; import {IMovable} from './interfaces/i_movable.js'; import {IPositionable} from './interfaces/i_positionable.js'; import {IRegistrable} from './interfaces/i_registrable.js'; -import {IRegistrableField} from './interfaces/i_registrable_field.js'; import {ISelectable} from './interfaces/i_selectable.js'; import {ISelectableToolboxItem} from './interfaces/i_selectable_toolbox_item.js'; import {IStyleable} from './interfaces/i_styleable.js'; @@ -648,18 +647,24 @@ export {Cursor}; export {DeleteArea}; export {DragTarget}; export const DropDownDiv = dropDownDiv; -export {Field}; -export {FieldAngle}; -export {FieldCheckbox}; -export {FieldColour}; -export {FieldDropdown, MenuGenerator, MenuGeneratorFunction, MenuOption}; +export {Field, FieldValidator}; +export {FieldAngle, FieldAngleValidator}; +export {FieldCheckbox, FieldCheckboxValidator}; +export {FieldColour, FieldColourValidator}; +export { + FieldDropdown, + FieldDropdownValidator, + MenuGenerator, + MenuGeneratorFunction, + MenuOption, +}; export {FieldImage}; export {FieldLabel}; export {FieldLabelSerializable}; -export {FieldMultilineInput}; -export {FieldNumber}; -export {FieldTextInput}; -export {FieldVariable}; +export {FieldMultilineInput, FieldMultilineInputValidator}; +export {FieldNumber, FieldNumberValidator}; +export {FieldTextInput, FieldTextInputValidator}; +export {FieldVariable, FieldVariableValidator}; export {Flyout}; export {FlyoutButton}; export {FlyoutMetricsManager}; @@ -693,7 +698,6 @@ export {Input}; export {InsertionMarkerManager}; export {IPositionable}; export {IRegistrable}; -export {IRegistrableField}; export {ISelectable}; export {ISelectableToolboxItem}; export {IStyleable}; diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 27e1d160d..8a5cceabf 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -194,11 +194,12 @@ export function setColour(backgroundColour: string, borderColour: string) { * @param opt_secondaryYOffset Optional Y offset for above-block positioning. * @returns True if the menu rendered below block; false if above. */ -export function showPositionedByBlock( - field: Field, block: BlockSvg, opt_onHide?: Function, +export function showPositionedByBlock( + field: Field, block: BlockSvg, opt_onHide?: Function, opt_secondaryYOffset?: number): boolean { return showPositionedByRect( - getScaledBboxOfBlock(block), field, opt_onHide, opt_secondaryYOffset); + getScaledBboxOfBlock(block), field as Field, opt_onHide, + opt_secondaryYOffset); } /** @@ -212,12 +213,13 @@ export function showPositionedByBlock( * @param opt_secondaryYOffset Optional Y offset for above-block positioning. * @returns True if the menu rendered below block; false if above. */ -export function showPositionedByField( - field: Field, opt_onHide?: Function, +export function showPositionedByField( + field: Field, opt_onHide?: Function, opt_secondaryYOffset?: number): boolean { positionToField = true; return showPositionedByRect( - getScaledBboxOfField(field), field, opt_onHide, opt_secondaryYOffset); + getScaledBboxOfField(field as Field), field as Field, opt_onHide, + opt_secondaryYOffset); } /** * Get the scaled bounding box of a block. @@ -300,10 +302,10 @@ function showPositionedByRect( * @returns True if the menu rendered at the primary origin point. * @internal */ -export function show( - newOwner: Field, rtl: boolean, primaryX: number, primaryY: number, +export function show( + newOwner: Field, rtl: boolean, primaryX: number, primaryY: number, secondaryX: number, secondaryY: number, opt_onHide?: Function): boolean { - owner = newOwner; + owner = newOwner as Field; onHide = opt_onHide || null; // Set direction. div.style.direction = rtl ? 'rtl' : 'ltr'; @@ -540,8 +542,8 @@ export function isVisible(): boolean { * animating. * @returns True if hidden. */ -export function hideIfOwner( - divOwner: Field, opt_withoutAnimation?: boolean): boolean { +export function hideIfOwner( + divOwner: Field, opt_withoutAnimation?: boolean): boolean { if (owner === divOwner) { if (opt_withoutAnimation) { hideWithoutAnimation(); diff --git a/core/field.ts b/core/field.ts index dc9010633..e89fac784 100644 --- a/core/field.ts +++ b/core/field.ts @@ -45,15 +45,17 @@ import * as WidgetDiv from './widgetdiv.js'; import type {WorkspaceSvg} from './workspace_svg.js'; import * as Xml from './xml.js'; +export type FieldValidator = (value: T) => void; /** * Abstract class for an editable field. * * @alias Blockly.Field */ -export abstract class Field implements IASTNodeLocationSvg, - IASTNodeLocationWithBlock, - IKeyboardAccessible, IRegistrable { +export abstract class Field implements IASTNodeLocationSvg, + IASTNodeLocationWithBlock, + IKeyboardAccessible, + IRegistrable { /** Non-breaking space. */ static readonly NBSP = '\u00A0'; @@ -69,10 +71,10 @@ export abstract class Field implements IASTNodeLocationSvg, * Static labels are usually unnamed. */ name?: string = undefined; - protected value_: AnyDuringMigration; + protected value_: T|null; /** Validation function called when user edits an editable field. */ - protected validator_: Function|null = null; + protected validator_: FieldValidator|null = null; /** * Used to cache the field's tooltip value if setTooltip is called when the @@ -181,7 +183,7 @@ export abstract class Field implements IASTNodeLocationSvg, * this parameter supports. */ constructor( - value: AnyDuringMigration, opt_validator?: Function|null, + value: T|Sentinel, opt_validator?: FieldValidator|null, opt_config?: FieldConfig) { /** * A generic value possessed by the field. @@ -597,7 +599,7 @@ export abstract class Field implements IASTNodeLocationSvg, * @param handler The validator function or null to clear a previous * validator. */ - setValidator(handler: Function) { + setValidator(handler: FieldValidator) { this.validator_ = handler; } @@ -1073,7 +1075,7 @@ export abstract class Field implements IASTNodeLocationSvg, } const gesture = (this.sourceBlock_.workspace as WorkspaceSvg).getGesture(e); if (gesture) { - gesture.setStartField(this); + gesture.setStartField(this as Field); } } diff --git a/core/field_angle.ts b/core/field_angle.ts index 69a5df239..4d9012b36 100644 --- a/core/field_angle.ts +++ b/core/field_angle.ts @@ -18,7 +18,7 @@ import * as Css from './css.js'; import * as dropDownDiv from './dropdowndiv.js'; import {Field, UnattachedFieldError} from './field.js'; import * as fieldRegistry from './field_registry.js'; -import {FieldTextInputConfig, FieldTextInput} from './field_textinput.js'; +import {FieldInput, FieldInputConfig, FieldInputValidator} from './field_input.js'; import * as dom from './utils/dom.js'; import {KeyCodes} from './utils/keycodes.js'; import * as math from './utils/math.js'; @@ -27,13 +27,14 @@ import {Svg} from './utils/svg.js'; import * as userAgent from './utils/useragent.js'; import * as WidgetDiv from './widgetdiv.js'; +export type FieldAngleValidator = FieldInputValidator; /** * Class for an editable angle field. * * @alias Blockly.FieldAngle */ -export class FieldAngle extends FieldTextInput { +export class FieldAngle extends FieldInput { /** The default value for this field. */ // protected override DEFAULT_VALUE = 0; @@ -135,7 +136,7 @@ export class FieldAngle extends FieldTextInput { * for a list of properties this parameter supports. */ constructor( - opt_value?: string|number|Sentinel, opt_validator?: Function, + opt_value?: string|number|Sentinel, opt_validator?: FieldAngleValidator, opt_config?: FieldAngleConfig) { super(Field.SKIP_SETUP); @@ -480,7 +481,7 @@ export class FieldAngle extends FieldTextInput { * @nocollapse * @internal */ - static override fromJson(options: FieldAngleFromJsonConfig): FieldAngle { + static fromJson(options: FieldAngleFromJsonConfig): FieldAngle { // `this` might be a subclass of FieldAngle if that class doesn't override // the static fromJson method. return new this(options.angle, undefined, options); @@ -541,7 +542,7 @@ export enum Mode { /** * Extra configuration options for the angle field. */ -export interface FieldAngleConfig extends FieldTextInputConfig { +export interface FieldAngleConfig extends FieldInputConfig { mode?: Mode; clockwise?: boolean; offset?: number; diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index f8ac67aae..f42039855 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -16,17 +16,18 @@ goog.declareModuleId('Blockly.FieldCheckbox'); import './events/events_block_change.js'; import * as dom from './utils/dom.js'; -import {FieldConfig, Field} from './field.js'; +import {Field, FieldConfig, FieldValidator} from './field.js'; import * as fieldRegistry from './field_registry.js'; import type {Sentinel} from './utils/sentinel.js'; +export type FieldCheckboxValidator = FieldValidator; /** * Class for a checkbox field. * * @alias Blockly.FieldCheckbox */ -export class FieldCheckbox extends Field { +export class FieldCheckbox extends Field { /** Default character for the checkmark. */ static readonly CHECK_CHAR = '✓'; private checkChar_: string; @@ -58,7 +59,8 @@ export class FieldCheckbox extends Field { * for a list of properties this parameter supports. */ constructor( - opt_value?: string|boolean|Sentinel, opt_validator?: Function, + opt_value?: string|boolean|Sentinel, + opt_validator?: FieldCheckboxValidator, opt_config?: FieldCheckboxConfig) { super(Field.SKIP_SETUP); diff --git a/core/field_colour.ts b/core/field_colour.ts index aa1a5b9f0..3964a6650 100644 --- a/core/field_colour.ts +++ b/core/field_colour.ts @@ -20,7 +20,7 @@ import * as browserEvents from './browser_events.js'; import * as Css from './css.js'; import * as dom from './utils/dom.js'; import * as dropDownDiv from './dropdowndiv.js'; -import {FieldConfig, Field} from './field.js'; +import {Field, FieldConfig, FieldValidator} from './field.js'; import * as fieldRegistry from './field_registry.js'; import * as aria from './utils/aria.js'; import * as colour from './utils/colour.js'; @@ -29,13 +29,14 @@ import {KeyCodes} from './utils/keycodes.js'; import type {Sentinel} from './utils/sentinel.js'; import {Size} from './utils/size.js'; +export type FieldColourValidator = FieldValidator; /** * Class for a colour input field. * * @alias Blockly.FieldColour */ -export class FieldColour extends Field { +export class FieldColour extends Field { /** * An array of colour strings for the palette. * Copied from goog.ui.ColorPicker.SIMPLE_GRID_COLORS @@ -152,7 +153,7 @@ export class FieldColour extends Field { * for a list of properties this parameter supports. */ constructor( - opt_value?: string|Sentinel, opt_validator?: Function, + opt_value?: string|Sentinel, opt_validator?: FieldColourValidator, opt_config?: FieldColourConfig) { super(Field.SKIP_SETUP); diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 4615db5ef..eb14d766f 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -16,7 +16,7 @@ goog.declareModuleId('Blockly.FieldDropdown'); import type {BlockSvg} from './block_svg.js'; import * as dropDownDiv from './dropdowndiv.js'; -import {FieldConfig, Field, UnattachedFieldError} from './field.js'; +import {Field, FieldConfig, FieldValidator, UnattachedFieldError} from './field.js'; import * as fieldRegistry from './field_registry.js'; import {Menu} from './menu.js'; import {MenuItem} from './menuitem.js'; @@ -28,12 +28,14 @@ import type {Sentinel} from './utils/sentinel.js'; import * as utilsString from './utils/string.js'; import {Svg} from './utils/svg.js'; +export type FieldDropdownValidator = FieldValidator; + /** * Class for an editable dropdown field. * * @alias Blockly.FieldDropdown */ -export class FieldDropdown extends Field { +export class FieldDropdown extends Field { /** Horizontal distance that a checkmark overhangs the dropdown. */ static CHECKMARK_OVERHANG = 25; @@ -111,13 +113,13 @@ export class FieldDropdown extends Field { */ constructor( menuGenerator: MenuGenerator, - opt_validator?: Function, + opt_validator?: FieldDropdownValidator, opt_config?: FieldConfig, ); constructor(menuGenerator: Sentinel); constructor( menuGenerator: MenuGenerator|Sentinel, - opt_validator?: Function, + opt_validator?: FieldDropdownValidator, opt_config?: FieldConfig, ) { super(Field.SKIP_SETUP); diff --git a/core/field_image.ts b/core/field_image.ts index 9d0d527e6..183b2dd2e 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -12,7 +12,7 @@ import * as goog from '../closure/goog/goog.js'; goog.declareModuleId('Blockly.FieldImage'); -import {FieldConfig, Field} from './field.js'; +import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -20,13 +20,12 @@ import type {Sentinel} from './utils/sentinel.js'; import {Size} from './utils/size.js'; import {Svg} from './utils/svg.js'; - /** * Class for an image on a block. * * @alias Blockly.FieldImage */ -export class FieldImage extends Field { +export class FieldImage extends Field { /** * Vertical padding below the image, which is included in the reported height * of the field. diff --git a/core/field_input.ts b/core/field_input.ts new file mode 100644 index 000000000..fe58b91d3 --- /dev/null +++ b/core/field_input.ts @@ -0,0 +1,600 @@ +/** + * @license + * Copyright 2012 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Text input field. + * + * @class + */ +import * as goog from '../closure/goog/goog.js'; +goog.declareModuleId('Blockly.FieldInput'); + +// Unused import preserved for side-effects. Remove if unneeded. +import './events/events_block_change.js'; + +import type {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; +import * as dialog from './dialog.js'; +import * as dom from './utils/dom.js'; +import * as dropDownDiv from './dropdowndiv.js'; +import * as eventUtils from './events/utils.js'; +import {Field, FieldConfig, FieldValidator, UnattachedFieldError} from './field.js'; +import {Msg} from './msg.js'; +import * as aria from './utils/aria.js'; +import {Coordinate} from './utils/coordinate.js'; +import {KeyCodes} from './utils/keycodes.js'; +import type {Sentinel} from './utils/sentinel.js'; +import * as userAgent from './utils/useragent.js'; +import * as WidgetDiv from './widgetdiv.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +export type InputTypes = string|number; +export type FieldInputValidator = FieldValidator; + +/** + * Class for an editable text field. + * + * @alias Blockly.FieldInput + */ +export abstract class FieldInput extends Field { + /** + * Pixel size of input border radius. + * Should match blocklyText's border-radius in CSS. + */ + static BORDERRADIUS = 4; + + /** Allow browser to spellcheck this field. */ + protected spellcheck_ = true; + + /** The HTML input element. */ + protected htmlInput_: HTMLInputElement|null = null; + + /** True if the field's value is currently being edited via the UI. */ + protected isBeingEdited_ = false; + + /** + * True if the value currently displayed in the field's editory UI is valid. + */ + protected isTextValid_ = false; + + /** Key down event data. */ + private onKeyDownWrapper_: browserEvents.Data|null = null; + + /** Key input event data. */ + private onKeyInputWrapper_: browserEvents.Data|null = null; + + /** + * Whether the field should consider the whole parent block to be its click + * target. + */ + fullBlockClickTarget_: boolean|null = false; + + /** The workspace that this field belongs to. */ + protected workspace_: WorkspaceSvg|null = null; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + */ + override SERIALIZABLE = true; + + /** Mouse cursor style when over the hotspot that initiates the editor. */ + override CURSOR = 'text'; + override clickTarget_: AnyDuringMigration; + override value_: AnyDuringMigration; + override isDirty_: AnyDuringMigration; + + /** + * @param 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 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 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. + */ + constructor( + opt_value?: string|Sentinel, opt_validator?: FieldInputValidator|null, + opt_config?: FieldInputConfig) { + super(Field.SKIP_SETUP); + + 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); + } + } + + protected override configure_(config: FieldInputConfig) { + super.configure_(config); + if (config.spellcheck !== undefined) { + this.spellcheck_ = config.spellcheck; + } + } + + /** @internal */ + override initView() { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + 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 = block.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 && block.outputConnection && !nConnections; + } else { + this.fullBlockClickTarget_ = false; + } + + if (this.fullBlockClickTarget_) { + this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); + } else { + this.createBorderRect_(); + } + this.createTextElement_(); + } + + /** + * Ensure that the input value casts to a valid string. + * + * @param opt_newValue The input value. + * @returns A valid string, or null if invalid. + */ + protected override doClassValidation_(opt_newValue?: AnyDuringMigration): + AnyDuringMigration { + 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 override doValueInvalid_(_invalidValue: AnyDuringMigration) { + if (this.isBeingEdited_) { + this.isTextValid_ = false; + const oldValue = this.value_; + // Revert value when the text becomes invalid. + this.value_ = this.htmlInput_!.getAttribute('data-untyped-default-value'); + 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 override doValueUpdate_(newValue: AnyDuringMigration) { + 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. + * + * @internal + */ + override applyColour() { + if (!this.sourceBlock_ || !this.getConstants()!.FULL_BLOCK_FIELDS) return; + + const source = this.sourceBlock_ as BlockSvg; + + if (this.borderRect_) { + this.borderRect_.setAttribute('stroke', source.style.colourTertiary); + } else { + source.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 override 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 = this.htmlInput_ as HTMLElement; + 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 check True if checked. + */ + setSpellcheck(check: boolean) { + if (check === this.spellcheck_) { + return; + } + this.spellcheck_ = check; + if (this.htmlInput_) { + // AnyDuringMigration because: Argument of type 'boolean' is not + // assignable to parameter of type 'string'. + this.htmlInput_.setAttribute( + 'spellcheck', this.spellcheck_ as AnyDuringMigration); + } + } + + /** + * Show the inline free-text editor on top of the text. + * + * @param _opt_e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + * @param opt_quietInput True if editor should be created without focus. + * Defaults to false. + */ + protected override showEditor_(_opt_e?: Event, opt_quietInput?: boolean) { + this.workspace_ = (this.sourceBlock_ as BlockSvg).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(), (text: string|null) => { + // Text is null if user pressed cancel button. + if (text !== null) { + this.setValue(this.getValueFromEditorText_(text)); + } + }); + } + + /** + * Create and show a text input editor that sits directly over the text input. + * + * @param quietInput True if editor should be created without focus. + */ + private showInlineEditor_(quietInput: boolean) { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + WidgetDiv.show(this, block.RTL, this.widgetDispose_.bind(this)); + this.htmlInput_ = this.widgetCreate_() as HTMLInputElement; + this.isBeingEdited_ = true; + + if (!quietInput) { + (this.htmlInput_ as HTMLElement).focus({ + preventScroll: true, + }); + this.htmlInput_.select(); + } + } + + /** + * Create the text input editor widget. + * + * @returns The newly created text input editor. + */ + protected widgetCreate_(): HTMLElement { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + eventUtils.setGroup(true); + const div = WidgetDiv.getDiv(); + + const clickTarget = this.getClickTarget_(); + if (!clickTarget) throw new Error('A click target has not been set.'); + dom.addClass(clickTarget, 'editing'); + + const htmlInput = (document.createElement('input')); + htmlInput.className = 'blocklyHtmlInput'; + // AnyDuringMigration because: Argument of type 'boolean' is not assignable + // to parameter of type 'string'. + htmlInput.setAttribute( + 'spellcheck', this.spellcheck_ as AnyDuringMigration); + const scale = this.workspace_!.getScale(); + const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt'; + div!.style.fontSize = fontSize; + htmlInput.style.fontSize = fontSize; + let borderRadius = FieldInput.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 = block.getParent() ? + (block.getParent() as BlockSvg).style.colourTertiary : + (this.sourceBlock_ as BlockSvg).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.setAttribute('data-untyped-default-value', this.value_); + htmlInput.setAttribute('data-old-value', ''); + + 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; + + const clickTarget = this.getClickTarget_(); + if (!clickTarget) throw new Error('A click target has not been set.'); + dom.removeClass(clickTarget, '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: AnyDuringMigration) {} + // 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 htmlInput The htmlInput to which event handlers will be bound. + */ + protected bindInputEvents_(htmlInput: HTMLElement) { + // 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 e Keyboard event. + */ + protected onHtmlInputKeyDown_(e: Event) { + // AnyDuringMigration because: Property 'keyCode' does not exist on type + // 'Event'. + if ((e as AnyDuringMigration).keyCode === KeyCodes.ENTER) { + WidgetDiv.hide(); + dropDownDiv.hideWithoutAnimation(); + // AnyDuringMigration because: Property 'keyCode' does not exist on type + // 'Event'. + } else if ((e as AnyDuringMigration).keyCode === KeyCodes.ESC) { + this.setValue( + this.htmlInput_!.getAttribute('data-untyped-default-value')); + WidgetDiv.hide(); + dropDownDiv.hideWithoutAnimation(); + // AnyDuringMigration because: Property 'keyCode' does not exist on type + // 'Event'. + } else if ((e as AnyDuringMigration).keyCode === KeyCodes.TAB) { + WidgetDiv.hide(); + dropDownDiv.hideWithoutAnimation(); + // AnyDuringMigration because: Property 'shiftKey' does not exist on type + // 'Event'. AnyDuringMigration because: Argument of type 'this' is not + // assignable to parameter of type 'Field'. + (this.sourceBlock_ as BlockSvg) + .tab(this as AnyDuringMigration, !(e as AnyDuringMigration).shiftKey); + e.preventDefault(); + } + } + + /** + * Handle a change to the editor. + * + * @param _e Keyboard event. + */ + private onHtmlInputChange_(_e: Event) { + const text = this.htmlInput_!.value; + if (text !== this.htmlInput_!.getAttribute('data-old-value')) { + this.htmlInput_!.setAttribute('data-old-value', 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: AnyDuringMigration) { + 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 block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + 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 = block.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. + * + * @returns True if the field is tab navigable. + */ + override isTabNavigable(): boolean { + 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). + * + * @returns The HTML value if we're editing, otherwise null. + */ + protected override getText_(): string|null { + 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. + * @returns The text to show on the HTML input. + */ + protected getEditorText_(value: AnyDuringMigration): string { + 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 text Text received from the HTML input. + * @returns The value to store. + */ + protected getValueFromEditorText_(text: string): AnyDuringMigration { + return text; + } +} + +/** + * Config options for the input field. + */ +export interface FieldInputConfig extends FieldConfig { + spellcheck?: boolean; +} diff --git a/core/field_label.ts b/core/field_label.ts index c35e29157..34222afd0 100644 --- a/core/field_label.ts +++ b/core/field_label.ts @@ -14,18 +14,17 @@ import * as goog from '../closure/goog/goog.js'; goog.declareModuleId('Blockly.FieldLabel'); import * as dom from './utils/dom.js'; -import {FieldConfig, Field} from './field.js'; +import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; import * as parsing from './utils/parsing.js'; import type {Sentinel} from './utils/sentinel.js'; - /** * Class for a non-editable, non-serializable text field. * * @alias Blockly.FieldLabel */ -export class FieldLabel extends Field { +export class FieldLabel extends Field { /** The html class name to use for this field. */ private class_: string|null = null; diff --git a/core/field_label_serializable.ts b/core/field_label_serializable.ts index 63815e958..ad1a5a06e 100644 --- a/core/field_label_serializable.ts +++ b/core/field_label_serializable.ts @@ -14,11 +14,10 @@ import * as goog from '../closure/goog/goog.js'; goog.declareModuleId('Blockly.FieldLabelSerializable'); -import {FieldLabelConfig, FieldLabelFromJsonConfig, FieldLabel} from './field_label.js'; +import {FieldLabel, FieldLabelConfig, FieldLabelFromJsonConfig} from './field_label.js'; import * as fieldRegistry from './field_registry.js'; import * as parsing from './utils/parsing.js'; - /** * Class for a non-editable, serializable text field. * diff --git a/core/field_multilineinput.ts b/core/field_multilineinput.ts index 403f725cc..abd53193a 100644 --- a/core/field_multilineinput.ts +++ b/core/field_multilineinput.ts @@ -15,7 +15,7 @@ goog.declareModuleId('Blockly.FieldMultilineInput'); import * as Css from './css.js'; import {Field, UnattachedFieldError} from './field.js'; import * as fieldRegistry from './field_registry.js'; -import {FieldTextInputConfig, FieldTextInput} from './field_textinput.js'; +import {FieldTextInput, FieldTextInputConfig, FieldTextInputValidator} from './field_textinput.js'; import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {KeyCodes} from './utils/keycodes.js'; @@ -25,6 +25,7 @@ import {Svg} from './utils/svg.js'; import * as userAgent from './utils/useragent.js'; import * as WidgetDiv from './widgetdiv.js'; +export type FieldMultilineInputValidator = FieldTextInputValidator; /** * Class for an editable text area field. @@ -65,7 +66,7 @@ export class FieldMultilineInput extends FieldTextInput { * for a list of properties this parameter supports. */ constructor( - opt_value?: string|Sentinel, opt_validator?: Function, + opt_value?: string|Sentinel, opt_validator?: FieldMultilineInputValidator, opt_config?: FieldMultilineInputConfig) { super(Field.SKIP_SETUP); diff --git a/core/field_number.ts b/core/field_number.ts index 5725014ad..ea305f1e9 100644 --- a/core/field_number.ts +++ b/core/field_number.ts @@ -14,17 +14,18 @@ goog.declareModuleId('Blockly.FieldNumber'); import {Field} from './field.js'; import * as fieldRegistry from './field_registry.js'; -import {FieldTextInputConfig, FieldTextInput} from './field_textinput.js'; +import {FieldInput, FieldInputConfig, FieldInputValidator} from './field_input.js'; import * as aria from './utils/aria.js'; import type {Sentinel} from './utils/sentinel.js'; +export type FieldNumberValidator = FieldInputValidator; /** * Class for an editable number field. * * @alias Blockly.FieldNumber */ -export class FieldNumber extends FieldTextInput { +export class FieldNumber extends FieldInput { /** The minimum value this number field can contain. */ protected min_ = -Infinity; @@ -68,7 +69,8 @@ export class FieldNumber extends FieldTextInput { constructor( opt_value?: string|number|Sentinel, opt_min?: string|number|null, opt_max?: string|number|null, opt_precision?: string|number|null, - opt_validator?: Function|null, opt_config?: FieldNumberConfig) { + opt_validator?: FieldNumberValidator|null, + opt_config?: FieldNumberConfig) { // Pass SENTINEL so that we can define properties before value validation. super(Field.SKIP_SETUP); @@ -310,7 +312,7 @@ export class FieldNumber extends FieldTextInput { * @nocollapse * @internal */ - static override fromJson(options: FieldNumberFromJsonConfig): FieldNumber { + static fromJson(options: FieldNumberFromJsonConfig): FieldNumber { // `this` might be a subclass of FieldNumber if that class doesn't override // the static fromJson method. return new this( @@ -325,7 +327,7 @@ fieldRegistry.register('field_number', FieldNumber); /** * Config options for the number field. */ -export interface FieldNumberConfig extends FieldTextInputConfig { +export interface FieldNumberConfig extends FieldInputConfig { min?: number; max?: number; precision?: number; diff --git a/core/field_registry.ts b/core/field_registry.ts index 7880bf643..172e1d8ae 100644 --- a/core/field_registry.ts +++ b/core/field_registry.ts @@ -14,10 +14,13 @@ import * as goog from '../closure/goog/goog.js'; goog.declareModuleId('Blockly.fieldRegistry'); -import type {Field} from './field.js'; -import type {IRegistrableField} from './interfaces/i_registrable_field.js'; +import type {Field, FieldProto} from './field.js'; import * as registry from './registry.js'; +interface RegistryOptions { + type: string; + [key: string]: unknown; +} /** * Registers a field type. @@ -31,7 +34,7 @@ import * as registry from './registry.js'; * or the fieldClass is not an object containing a fromJson function. * @alias Blockly.fieldRegistry.register */ -export function register(type: string, fieldClass: IRegistrableField) { +export function register(type: string, fieldClass: FieldProto) { registry.register(registry.Type.FIELD, type, fieldClass); } @@ -57,7 +60,7 @@ export function unregister(type: string) { * @alias Blockly.fieldRegistry.fromJson * @internal */ -export function fromJson(options: AnyDuringMigration): Field|null { +export function fromJson(options: RegistryOptions): Field|null { return TEST_ONLY.fromJsonInternal(options); } @@ -66,8 +69,8 @@ export function fromJson(options: AnyDuringMigration): Field|null { * * @param options */ -function fromJsonInternal(options: AnyDuringMigration): Field|null { - const fieldObject = registry.getObject(registry.Type.FIELD, options['type']); +function fromJsonInternal(options: RegistryOptions): Field|null { + const fieldObject = registry.getObject(registry.Type.FIELD, options.type); if (!fieldObject) { console.warn( 'Blockly could not create a field of type ' + options['type'] + @@ -78,7 +81,8 @@ function fromJsonInternal(options: AnyDuringMigration): Field|null { } else if (typeof (fieldObject as any).fromJson !== 'function') { throw new TypeError('returned Field was not a IRegistrableField'); } else { - return (fieldObject as unknown as IRegistrableField).fromJson(options); + type fromJson = (options: {}) => Field; + return (fieldObject as unknown as {fromJson: fromJson}).fromJson(options); } } diff --git a/core/field_textinput.ts b/core/field_textinput.ts index ca4e6053c..0c2788a15 100644 --- a/core/field_textinput.ts +++ b/core/field_textinput.ts @@ -15,78 +15,14 @@ goog.declareModuleId('Blockly.FieldTextInput'); // Unused import preserved for side-effects. Remove if unneeded. import './events/events_block_change.js'; -import type {BlockSvg} from './block_svg.js'; -import * as browserEvents from './browser_events.js'; -import * as dialog from './dialog.js'; -import * as dom from './utils/dom.js'; -import * as dropDownDiv from './dropdowndiv.js'; -import * as eventUtils from './events/utils.js'; -import {FieldConfig, Field, UnattachedFieldError} from './field.js'; +import {FieldInput, FieldInputConfig, FieldInputValidator} from './field_input.js'; import * as fieldRegistry from './field_registry.js'; -import {Msg} from './msg.js'; -import * as aria from './utils/aria.js'; -import {Coordinate} from './utils/coordinate.js'; -import {KeyCodes} from './utils/keycodes.js'; import * as parsing from './utils/parsing.js'; import type {Sentinel} from './utils/sentinel.js'; -import * as userAgent from './utils/useragent.js'; -import * as WidgetDiv from './widgetdiv.js'; -import type {WorkspaceSvg} from './workspace_svg.js'; +export type FieldTextInputValidator = FieldInputValidator; -/** - * Class for an editable text field. - * - * @alias Blockly.FieldTextInput - */ -export class FieldTextInput extends Field { - /** - * Pixel size of input border radius. - * Should match blocklyText's border-radius in CSS. - */ - static BORDERRADIUS = 4; - - /** Allow browser to spellcheck this field. */ - protected spellcheck_ = true; - - /** The HTML input element. */ - protected htmlInput_: HTMLInputElement|null = null; - - /** True if the field's value is currently being edited via the UI. */ - protected isBeingEdited_ = false; - - /** - * True if the value currently displayed in the field's editory UI is valid. - */ - protected isTextValid_ = false; - - /** Key down event data. */ - private onKeyDownWrapper_: browserEvents.Data|null = null; - - /** Key input event data. */ - private onKeyInputWrapper_: browserEvents.Data|null = null; - - /** - * Whether the field should consider the whole parent block to be its click - * target. - */ - fullBlockClickTarget_: boolean|null = false; - - /** The workspace that this field belongs to. */ - protected workspace_: WorkspaceSvg|null = null; - - /** - * Serializable fields are saved by the serializer, non-serializable fields - * are not. Editable fields should also be serializable. - */ - override SERIALIZABLE = true; - - /** Mouse cursor style when over the hotspot that initiates the editor. */ - override CURSOR = 'text'; - override clickTarget_: AnyDuringMigration; - override value_: AnyDuringMigration; - override isDirty_: AnyDuringMigration; - +export class FieldTextInput extends FieldInput { /** * @param opt_value The initial value of the field. Should cast to a string. * Defaults to an empty string if null or undefined. Also accepts @@ -102,493 +38,9 @@ export class FieldTextInput extends Field { * for a list of properties this parameter supports. */ constructor( - opt_value?: string|Sentinel, opt_validator?: Function|null, - opt_config?: FieldTextInputConfig) { - super(Field.SKIP_SETUP); - - 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); - } - } - - protected override configure_(config: FieldTextInputConfig) { - super.configure_(config); - if (config.spellcheck !== undefined) { - this.spellcheck_ = config.spellcheck; - } - } - - /** @internal */ - override initView() { - const block = this.getSourceBlock(); - if (!block) { - throw new UnattachedFieldError(); - } - 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 = block.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 && block.outputConnection && !nConnections; - } else { - this.fullBlockClickTarget_ = false; - } - - if (this.fullBlockClickTarget_) { - this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); - } else { - this.createBorderRect_(); - } - this.createTextElement_(); - } - - /** - * Ensure that the input value casts to a valid string. - * - * @param opt_newValue The input value. - * @returns A valid string, or null if invalid. - */ - protected override doClassValidation_(opt_newValue?: AnyDuringMigration): - AnyDuringMigration { - 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 override doValueInvalid_(_invalidValue: AnyDuringMigration) { - if (this.isBeingEdited_) { - this.isTextValid_ = false; - const oldValue = this.value_; - // Revert value when the text becomes invalid. - this.value_ = this.htmlInput_!.getAttribute('data-untyped-default-value'); - 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 override doValueUpdate_(newValue: AnyDuringMigration) { - 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. - * - * @internal - */ - override applyColour() { - if (!this.sourceBlock_ || !this.getConstants()!.FULL_BLOCK_FIELDS) return; - - const source = this.sourceBlock_ as BlockSvg; - - if (this.borderRect_) { - this.borderRect_.setAttribute('stroke', source.style.colourTertiary); - } else { - source.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 override 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 = this.htmlInput_ as HTMLElement; - 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 check True if checked. - */ - setSpellcheck(check: boolean) { - if (check === this.spellcheck_) { - return; - } - this.spellcheck_ = check; - if (this.htmlInput_) { - // AnyDuringMigration because: Argument of type 'boolean' is not - // assignable to parameter of type 'string'. - this.htmlInput_.setAttribute( - 'spellcheck', this.spellcheck_ as AnyDuringMigration); - } - } - - /** - * Show the inline free-text editor on top of the text. - * - * @param _opt_e Optional mouse event that triggered the field to open, or - * undefined if triggered programmatically. - * @param opt_quietInput True if editor should be created without focus. - * Defaults to false. - */ - protected override showEditor_(_opt_e?: Event, opt_quietInput?: boolean) { - this.workspace_ = (this.sourceBlock_ as BlockSvg).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(), (text: string|null) => { - // Text is null if user pressed cancel button. - if (text !== null) { - this.setValue(this.getValueFromEditorText_(text)); - } - }); - } - - /** - * Create and show a text input editor that sits directly over the text input. - * - * @param quietInput True if editor should be created without focus. - */ - private showInlineEditor_(quietInput: boolean) { - const block = this.getSourceBlock(); - if (!block) { - throw new UnattachedFieldError(); - } - WidgetDiv.show(this, block.RTL, this.widgetDispose_.bind(this)); - this.htmlInput_ = this.widgetCreate_() as HTMLInputElement; - this.isBeingEdited_ = true; - - if (!quietInput) { - (this.htmlInput_ as HTMLElement).focus({ - preventScroll: true, - }); - this.htmlInput_.select(); - } - } - - /** - * Create the text input editor widget. - * - * @returns The newly created text input editor. - */ - protected widgetCreate_(): HTMLElement { - const block = this.getSourceBlock(); - if (!block) { - throw new UnattachedFieldError(); - } - eventUtils.setGroup(true); - const div = WidgetDiv.getDiv(); - - const clickTarget = this.getClickTarget_(); - if (!clickTarget) throw new Error('A click target has not been set.'); - dom.addClass(clickTarget, 'editing'); - - const htmlInput = (document.createElement('input')); - htmlInput.className = 'blocklyHtmlInput'; - // AnyDuringMigration because: Argument of type 'boolean' is not assignable - // to parameter of type 'string'. - htmlInput.setAttribute( - 'spellcheck', this.spellcheck_ as AnyDuringMigration); - 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 = block.getParent() ? - (block.getParent() as BlockSvg).style.colourTertiary : - (this.sourceBlock_ as BlockSvg).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.setAttribute('data-untyped-default-value', this.value_); - htmlInput.setAttribute('data-old-value', ''); - - 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; - - const clickTarget = this.getClickTarget_(); - if (!clickTarget) throw new Error('A click target has not been set.'); - dom.removeClass(clickTarget, '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: AnyDuringMigration) {} - // 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 htmlInput The htmlInput to which event handlers will be bound. - */ - protected bindInputEvents_(htmlInput: HTMLElement) { - // 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 e Keyboard event. - */ - protected onHtmlInputKeyDown_(e: Event) { - // AnyDuringMigration because: Property 'keyCode' does not exist on type - // 'Event'. - if ((e as AnyDuringMigration).keyCode === KeyCodes.ENTER) { - WidgetDiv.hide(); - dropDownDiv.hideWithoutAnimation(); - // AnyDuringMigration because: Property 'keyCode' does not exist on type - // 'Event'. - } else if ((e as AnyDuringMigration).keyCode === KeyCodes.ESC) { - this.setValue( - this.htmlInput_!.getAttribute('data-untyped-default-value')); - WidgetDiv.hide(); - dropDownDiv.hideWithoutAnimation(); - // AnyDuringMigration because: Property 'keyCode' does not exist on type - // 'Event'. - } else if ((e as AnyDuringMigration).keyCode === KeyCodes.TAB) { - WidgetDiv.hide(); - dropDownDiv.hideWithoutAnimation(); - // AnyDuringMigration because: Property 'shiftKey' does not exist on type - // 'Event'. AnyDuringMigration because: Argument of type 'this' is not - // assignable to parameter of type 'Field'. - (this.sourceBlock_ as BlockSvg) - .tab(this as AnyDuringMigration, !(e as AnyDuringMigration).shiftKey); - e.preventDefault(); - } - } - - /** - * Handle a change to the editor. - * - * @param _e Keyboard event. - */ - private onHtmlInputChange_(_e: Event) { - const text = this.htmlInput_!.value; - if (text !== this.htmlInput_!.getAttribute('data-old-value')) { - this.htmlInput_!.setAttribute('data-old-value', 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: AnyDuringMigration) { - 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 block = this.getSourceBlock(); - if (!block) { - throw new UnattachedFieldError(); - } - 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 = block.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. - * - * @returns True if the field is tab navigable. - */ - override isTabNavigable(): boolean { - 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). - * - * @returns The HTML value if we're editing, otherwise null. - */ - protected override getText_(): string|null { - 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. - * @returns The text to show on the HTML input. - */ - protected getEditorText_(value: AnyDuringMigration): string { - 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 text Text received from the HTML input. - * @returns The value to store. - */ - protected getValueFromEditorText_(text: string): AnyDuringMigration { - return text; + opt_value?: string|Sentinel, opt_validator?: FieldTextInputValidator|null, + opt_config?: FieldInputConfig) { + super(opt_value, opt_validator, opt_config); } /** @@ -612,16 +64,11 @@ fieldRegistry.register('field_input', FieldTextInput); (FieldTextInput.prototype as AnyDuringMigration).DEFAULT_VALUE = ''; -/** - * Config options for the text input field. - */ -export interface FieldTextInputConfig extends FieldConfig { - spellcheck?: boolean; -} - /** * fromJson config options for the text input field. */ -export interface FieldTextInputFromJsonConfig extends FieldTextInputConfig { +export interface FieldTextInputFromJsonConfig extends FieldInputConfig { text?: string; } + +export {FieldInputConfig as FieldTextInputConfig}; diff --git a/core/field_variable.ts b/core/field_variable.ts index 040a2f0ff..e3b98f4fe 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, MenuGenerator, MenuOption} from './field_dropdown.js'; +import {FieldDropdown, FieldDropdownValidator, 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'; @@ -30,6 +30,7 @@ import {VariableModel} from './variable_model.js'; import * as Variables from './variables.js'; import * as Xml from './xml.js'; +export type FieldVariableValidator = FieldDropdownValidator; /** * Class for a variable's dropdown field. @@ -79,7 +80,7 @@ export class FieldVariable extends FieldDropdown { * for a list of properties this parameter supports. */ constructor( - varName: string|null|Sentinel, opt_validator?: Function, + varName: string|null|Sentinel, opt_validator?: FieldVariableValidator, opt_variableTypes?: string[], opt_defaultType?: string, opt_config?: FieldVariableConfig) { super(Field.SKIP_SETUP); diff --git a/core/input.ts b/core/input.ts index ed321a6ad..3cba37e69 100644 --- a/core/input.ts +++ b/core/input.ts @@ -75,7 +75,7 @@ export class Input { * field again. Should be unique to the host block. * @returns The input being append to (to allow chaining). */ - appendField(field: string|Field, opt_name?: string): Input { + appendField(field: string|Field, opt_name?: string): Input { this.insertFieldAt(this.fieldRow.length, field, opt_name); return this; } @@ -90,7 +90,8 @@ export class Input { * field again. Should be unique to the host block. * @returns The index following the last inserted field. */ - insertFieldAt(index: number, field: string|Field, opt_name?: string): number { + insertFieldAt(index: number, field: string|Field, opt_name?: string): + number { if (index < 0 || index > this.fieldRow.length) { throw Error('index ' + index + ' out of bounds.'); } @@ -103,9 +104,9 @@ export class Input { // Generate a FieldLabel when given a plain text field. if (typeof field === 'string') { field = fieldRegistry.fromJson({ - 'type': 'field_label', - 'text': field, - }) as Field; + type: 'field_label', + text: field, + })!; } field.setSourceBlock(this.sourceBlock_); @@ -121,7 +122,7 @@ export class Input { index = this.insertFieldAt(index, field.prefixField); } // Add the field to the field row. - this.fieldRow.splice(index, 0, field); + this.fieldRow.splice(index, 0, field as Field); index++; if (field.suffixField) { // Add any suffix. diff --git a/core/interfaces/i_registrable_field.ts b/core/interfaces/i_registrable_field.ts deleted file mode 100644 index be2ad4eb0..000000000 --- a/core/interfaces/i_registrable_field.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The interface for a Blockly field that can be registered. - * - * @namespace Blockly.IRegistrableField - */ -import * as goog from '../../closure/goog/goog.js'; -goog.declareModuleId('Blockly.IRegistrableField'); - -import type {Field} from '../field.js'; - - -type fromJson = (p1: object) => Field; - -/** - * A registrable field. - * Note: We are not using an interface here as we are interested in defining the - * static methods of a field rather than the instance methods. - * - * @alias Blockly.IRegistrableField - */ -export interface IRegistrableField { - fromJson: fromJson; -}