chore: add validator and field value types (#6603)

* chore: add validator and field value types

* chore/clean up unused default and unnecessary field type setting

* chore: removed IRegisterableField and updated various Field instances

* fix: remove unused field image validator function

* chore: added pass-through constructor to field_textinput
This commit is contained in:
Blake Thomas Williams
2022-11-11 10:54:57 -06:00
committed by GitHub
parent 0532b5d1c0
commit 79f620647f
19 changed files with 714 additions and 676 deletions

View File

@@ -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']) {

View File

@@ -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};

View File

@@ -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<T>(
field: Field<T>, 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<T>(
field: Field<T>, 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<T>(
newOwner: Field<T>, 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<T>(
divOwner: Field<T>, opt_withoutAnimation?: boolean): boolean {
if (owner === divOwner) {
if (opt_withoutAnimation) {
hideWithoutAnimation();

View File

@@ -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<T = unknown> = (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<T = unknown> 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<T>|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<T>|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<T>) {
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);
}
}

View File

@@ -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<number>;
/**
* Class for an editable angle field.
*
* @alias Blockly.FieldAngle
*/
export class FieldAngle extends FieldTextInput {
export class FieldAngle extends FieldInput<number> {
/** 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;

View File

@@ -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<boolean>;
/**
* Class for a checkbox field.
*
* @alias Blockly.FieldCheckbox
*/
export class FieldCheckbox extends Field {
export class FieldCheckbox extends Field<boolean> {
/** 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);

View File

@@ -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<string>;
/**
* Class for a colour input field.
*
* @alias Blockly.FieldColour
*/
export class FieldColour extends Field {
export class FieldColour extends Field<string> {
/**
* 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);

View File

@@ -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<string>;
/**
* Class for an editable dropdown field.
*
* @alias Blockly.FieldDropdown
*/
export class FieldDropdown extends Field {
export class FieldDropdown extends Field<string> {
/** 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);

View File

@@ -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<string> {
/**
* Vertical padding below the image, which is included in the reported height
* of the field.

600
core/field_input.ts Normal file
View File

@@ -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<T extends InputTypes> = FieldValidator<T>;
/**
* Class for an editable text field.
*
* @alias Blockly.FieldInput
*/
export abstract class FieldInput<T extends InputTypes> extends Field<T> {
/**
* 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<T>|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;
}

View File

@@ -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<string> {
/** The html class name to use for this field. */
private class_: string|null = null;

View File

@@ -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.
*

View File

@@ -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);

View File

@@ -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<number>;
/**
* Class for an editable number field.
*
* @alias Blockly.FieldNumber
*/
export class FieldNumber extends FieldTextInput {
export class FieldNumber extends FieldInput<number> {
/** 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;

View File

@@ -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<T>(options: RegistryOptions): Field<T>|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<T>(options: RegistryOptions): Field<T>|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<T>;
return (fieldObject as unknown as {fromJson: fromJson}).fromJson(options);
}
}

View File

@@ -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<string>;
/**
* 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<string> {
/**
* @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};

View File

@@ -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);

View File

@@ -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<T>(field: string|Field<T>, 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<T>(index: number, field: string|Field<T>, 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.

View File

@@ -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;
}