Files
blockly/core/field_multilineinput.ts
Maribeth Bottorff 037eb59b89 chore: Lint TsDoc. (#6353)
* chore: add linting for tsdoc

* chore: don't require types on return

* chore: remove redundant fileoverview from ts

* chore: change return to returns and add some newlines

* chore: remove license tag

* chore: don't require params/return docs

* chore: remove spurious struct tags

* Revert "chore: change return to returns and add some newlines"

This reverts commit d6d8656a45.

* chore: don't auto-add param names

* chore: disable require-param bc it breaks on this

* return to returns and add line breaks

* chore: configure additional jsdoc rules

* chore: run format

* Revert "chore: remove license tag"

This reverts commit 173455588a.

* chore: allow license tag format

* chore: only require jsdoc on exported items

* chore: add missing jsdoc or silence where needed

* chore: run format

* chore: lint fixes
2022-08-23 14:27:22 -07:00

478 lines
16 KiB
TypeScript

/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Text Area field.
*
* @class
*/
import * as goog from '../closure/goog/goog.js';
goog.declareModuleId('Blockly.FieldMultilineInput');
import * as Css from './css.js';
import {Field} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {FieldTextInputConfig, FieldTextInput} from './field_textinput.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import {KeyCodes} from './utils/keycodes.js';
import * as parsing from './utils/parsing.js';
import type {Sentinel} from './utils/sentinel.js';
import {Svg} from './utils/svg.js';
import * as userAgent from './utils/useragent.js';
import * as WidgetDiv from './widgetdiv.js';
/**
* Class for an editable text area field.
*
* @alias Blockly.FieldMultilineInput
*/
export class FieldMultilineInput extends FieldTextInput {
/**
* The SVG group element that will contain a text element for each text row
* when initialized.
*/
// AnyDuringMigration because: Type 'null' is not assignable to type
// 'SVGGElement'.
textGroup_: SVGGElement = null as AnyDuringMigration;
/**
* Defines the maximum number of lines of field.
* If exceeded, scrolling functionality is enabled.
*/
protected maxLines_ = Infinity;
/** Whether Y overflow is currently occurring. */
protected isOverflowedY_ = false;
/**
* @param opt_value The initial content of the field. Should cast to a string.
* Defaults to an empty string if null or undefined. Also accepts
* Field.SKIP_SETUP if you wish to skip setup (only used by subclasses
* that want to handle configuration and setting the field value after
* their own constructors have run).
* @param opt_validator An optional function that is called to validate any
* constraints on what the user entered. Takes the new text as an
* argument and returns either the accepted text, a replacement text, or
* null to abort the change.
* @param opt_config A map of options used to configure the field.
* See the [field creation documentation]{@link
* https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation}
* for a list of properties this parameter supports.
*/
constructor(
opt_value?: string|Sentinel, opt_validator?: Function,
opt_config?: FieldMultilineInputConfig) {
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: FieldMultilineInputConfig) {
super.configure_(config);
if (config.maxLines) this.setMaxLines(config.maxLines);
}
/**
* Serializes this field's value to XML. Should only be called by Blockly.Xml.
*
* @param fieldElement The element to populate with info about the field's
* state.
* @returns The element containing info about the field's state.
* @internal
*/
override toXml(fieldElement: Element): Element {
// Replace '\n' characters with HTML-escaped equivalent '&#10'. This is
// needed so the plain-text representation of the XML produced by
// `Blockly.Xml.domToText` will appear on a single line (this is a
// limitation of the plain-text format).
fieldElement.textContent =
(this.getValue() as string).replace(/\n/g, '
');
return fieldElement;
}
/**
* Sets the field's value based on the given XML element. Should only be
* called by Blockly.Xml.
*
* @param fieldElement The element containing info about the field's state.
* @internal
*/
override fromXml(fieldElement: Element) {
this.setValue(fieldElement.textContent!.replace(/
/g, '\n'));
}
/**
* Saves this field's value.
*
* @returns The state of this field.
* @internal
*/
override saveState(): AnyDuringMigration {
const legacyState = this.saveLegacyState(FieldMultilineInput);
if (legacyState !== null) {
return legacyState;
}
return this.getValue();
}
/**
* Sets the field's value based on the given state.
*
* @param state The state of the variable to assign to this variable field.
* @internal
*/
override loadState(state: AnyDuringMigration) {
if (this.loadLegacyState(Field, state)) {
return;
}
this.setValue(state);
}
/**
* Create the block UI for this field.
*
* @internal
*/
override initView() {
this.createBorderRect_();
this.textGroup_ = dom.createSvgElement(
Svg.G, {
'class': 'blocklyEditableText',
},
this.fieldGroup_);
}
/**
* Get the text from this field as displayed on screen. May differ from
* getText due to ellipsis, and other formatting.
*
* @returns Currently displayed text.
*/
protected override getDisplayText_(): string {
let textLines = this.getText();
if (!textLines) {
// Prevent the field from disappearing if empty.
return Field.NBSP;
}
const lines = textLines.split('\n');
textLines = '';
const displayLinesNumber =
this.isOverflowedY_ ? this.maxLines_ : lines.length;
for (let i = 0; i < displayLinesNumber; i++) {
let text = lines[i];
if (text.length > this.maxDisplayLength) {
// Truncate displayed string and add an ellipsis ('...').
text = text.substring(0, this.maxDisplayLength - 4) + '...';
} else if (this.isOverflowedY_ && i === displayLinesNumber - 1) {
text = text.substring(0, text.length - 3) + '...';
}
// Replace whitespace with non-breaking spaces so the text doesn't
// collapse.
text = text.replace(/\s/g, Field.NBSP);
textLines += text;
if (i !== displayLinesNumber - 1) {
textLines += '\n';
}
}
if (this.sourceBlock_.RTL) {
// The SVG is LTR, force value to be RTL.
textLines += '\u200F';
}
return textLines;
}
/**
* 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_). Is being redefined here to update
* overflow state of the field.
*
* @param newValue The value to be saved. The default validator guarantees
* that this is a string.
*/
protected override doValueUpdate_(newValue: AnyDuringMigration) {
super.doValueUpdate_(newValue);
this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_;
}
/** Updates the text of the textElement. */
protected override render_() {
// Remove all text group children.
let currentChild;
while (currentChild = this.textGroup_.firstChild) {
this.textGroup_.removeChild(currentChild);
}
// Add in text elements into the group.
const lines = this.getDisplayText_().split('\n');
let y = 0;
for (let i = 0; i < lines.length; i++) {
const lineHeight = this.getConstants()!.FIELD_TEXT_HEIGHT +
this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING;
const span = dom.createSvgElement(
Svg.TEXT, {
'class': 'blocklyText blocklyMultilineText',
'x': this.getConstants()!.FIELD_BORDER_RECT_X_PADDING,
'y': y + this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING,
'dy': this.getConstants()!.FIELD_TEXT_BASELINE,
},
this.textGroup_);
span.appendChild(document.createTextNode(lines[i]));
y += lineHeight;
}
if (this.isBeingEdited_) {
const htmlInput = this.htmlInput_ as HTMLElement;
if (this.isOverflowedY_) {
dom.addClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
} else {
dom.removeClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY');
}
}
this.updateSize_();
if (this.isBeingEdited_) {
if (this.sourceBlock_.RTL) {
// in RTL, we need to let the browser reflow before resizing
// in order to get the correct bounding box of the borderRect
// avoiding issue #2777.
setTimeout(this.resizeEditor_.bind(this), 0);
} else {
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);
}
}
}
/** Updates the size of the field based on the text. */
protected override updateSize_() {
const nodes = this.textGroup_.childNodes;
let totalWidth = 0;
let totalHeight = 0;
for (let i = 0; i < nodes.length; i++) {
const tspan = nodes[i] as SVGTextElement;
const textWidth = dom.getTextWidth(tspan);
if (textWidth > totalWidth) {
totalWidth = textWidth;
}
totalHeight += this.getConstants()!.FIELD_TEXT_HEIGHT +
(i > 0 ? this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING : 0);
}
if (this.isBeingEdited_) {
// The default width is based on the longest line in the display text,
// but when it's being edited, width should be calculated based on the
// absolute longest line, even if it would be truncated after editing.
// Otherwise we would get wrong editor width when there are more
// lines than this.maxLines_.
const actualEditorLines = this.value_.split('\n');
const dummyTextElement = dom.createSvgElement(
Svg.TEXT, {'class': 'blocklyText blocklyMultilineText'});
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE;
const fontWeight = this.getConstants()!.FIELD_TEXT_FONTWEIGHT;
const fontFamily = this.getConstants()!.FIELD_TEXT_FONTFAMILY;
for (let i = 0; i < actualEditorLines.length; i++) {
if (actualEditorLines[i].length > this.maxDisplayLength) {
actualEditorLines[i] =
actualEditorLines[i].substring(0, this.maxDisplayLength);
}
dummyTextElement.textContent = actualEditorLines[i];
const lineWidth = dom.getFastTextWidth(
dummyTextElement, fontSize, fontWeight, fontFamily);
if (lineWidth > totalWidth) {
totalWidth = lineWidth;
}
}
const scrollbarWidth =
this.htmlInput_!.offsetWidth - this.htmlInput_!.clientWidth;
totalWidth += scrollbarWidth;
}
if (this.borderRect_) {
totalHeight += this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING * 2;
totalWidth += this.getConstants()!.FIELD_BORDER_RECT_X_PADDING * 2;
// AnyDuringMigration because: Argument of type 'number' is not
// assignable to parameter of type 'string'.
this.borderRect_.setAttribute('width', totalWidth as AnyDuringMigration);
// AnyDuringMigration because: Argument of type 'number' is not
// assignable to parameter of type 'string'.
this.borderRect_.setAttribute(
'height', totalHeight as AnyDuringMigration);
}
this.size_.width = totalWidth;
this.size_.height = totalHeight;
this.positionBorderRect_();
}
/**
* Show the inline free-text editor on top of the text.
* Overrides the default behaviour to force rerender in order to
* correct block size, based on editor 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.
*/
override showEditor_(_opt_e?: Event, opt_quietInput?: boolean) {
super.showEditor_(_opt_e, opt_quietInput);
this.forceRerender();
}
/**
* Create the text input editor widget.
*
* @returns The newly created text input editor.
*/
protected override widgetCreate_(): HTMLTextAreaElement {
const div = WidgetDiv.getDiv();
const scale = this.workspace_!.getScale();
const htmlInput = (document.createElement('textarea'));
htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput';
// AnyDuringMigration because: Argument of type 'boolean' is not assignable
// to parameter of type 'string'.
htmlInput.setAttribute(
'spellcheck', this.spellcheck_ as AnyDuringMigration);
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt';
div!.style.fontSize = fontSize;
htmlInput.style.fontSize = fontSize;
const borderRadius = FieldTextInput.BORDERRADIUS * scale + 'px';
htmlInput.style.borderRadius = borderRadius;
const paddingX = this.getConstants()!.FIELD_BORDER_RECT_X_PADDING * scale;
const paddingY =
this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING * scale / 2;
htmlInput.style.padding = paddingY + 'px ' + paddingX + 'px ' + paddingY +
'px ' + paddingX + 'px';
const lineHeight = this.getConstants()!.FIELD_TEXT_HEIGHT +
this.getConstants()!.FIELD_BORDER_RECT_Y_PADDING;
htmlInput.style.lineHeight = lineHeight * scale + 'px';
div!.appendChild(htmlInput);
htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
htmlInput.setAttribute('data-untyped-default-value', this.value_);
htmlInput.setAttribute('data-old-value', '');
if (userAgent.GECKO) {
// In FF, ensure the browser reflows before resizing to avoid issue #2777.
setTimeout(this.resizeEditor_.bind(this), 0);
} else {
this.resizeEditor_();
}
this.bindInputEvents_(htmlInput);
return htmlInput;
}
/**
* Sets the maxLines config for this field.
*
* @param maxLines Defines the maximum number of lines allowed, before
* scrolling functionality is enabled.
*/
setMaxLines(maxLines: number) {
if (typeof maxLines === 'number' && maxLines > 0 &&
maxLines !== this.maxLines_) {
this.maxLines_ = maxLines;
this.forceRerender();
}
}
/**
* Returns the maxLines config of this field.
*
* @returns The maxLines config value.
*/
getMaxLines(): number {
return this.maxLines_;
}
/**
* Handle key down to the editor. Override the text input definition of this
* so as to not close the editor when enter is typed in.
*
* @param e Keyboard event.
*/
protected override onHtmlInputKeyDown_(e: Event) {
// AnyDuringMigration because: Property 'keyCode' does not exist on type
// 'Event'.
if ((e as AnyDuringMigration).keyCode !== KeyCodes.ENTER) {
super.onHtmlInputKeyDown_(e);
}
}
/**
* Construct a FieldMultilineInput from a JSON arg object,
* dereferencing any string table references.
*
* @param options A JSON object with options (text, and spellcheck).
* @returns The new field instance.
* @nocollapse
* @internal
*/
static override fromJson(options: FieldMultilineInputFromJsonConfig):
FieldMultilineInput {
const text = parsing.replaceMessageReferences(options.text);
// `this` might be a subclass of FieldMultilineInput if that class doesn't
// override the static fromJson method.
return new this(text, undefined, options);
}
}
/** CSS for multiline field. See css.js for use. */
Css.register(`
.blocklyHtmlTextAreaInput {
font-family: monospace;
resize: none;
overflow: hidden;
height: 100%;
text-align: left;
}
.blocklyHtmlTextAreaInputOverflowedY {
overflow-y: scroll;
}
`);
fieldRegistry.register('field_multilinetext', FieldMultilineInput);
/**
* Config options for the multiline input field.
*/
export interface FieldMultilineInputConfig extends FieldTextInputConfig {
maxLines?: number;
}
/**
* fromJson config options for the multiline input field.
*/
export interface FieldMultilineInputFromJsonConfig extends
FieldMultilineInputConfig {
text?: string;
}