Files
blockly/core/field_angle.ts
Christopher Allen faa95d022d fix(fields): Make SKIP_SETUP a Symbol; remove Sentinel class (#6919)
We introduced the SKIP_SETUP sentinel value when converting
Field and its subclasses to ES6 class syntax, because super
must be called before any other code in a subclass
constructor, breaking the previous mechanism where subclasses
would set some properties before calling their superclass
constructor.

SKIP_SETUP was a singleton value of class Sentinel.

Recently, in PR #6639 @btw17 introduced the isSentinel type
predicate to improve the typing of Field.  Unfortunately, there
were some aspects of this change that were not very elegant:

- isSentinel was declared as a static method on Field (rather
  than on Sentinel itself).
- It only checks against the specific value SKIP_SETUP,
  rather than checking if the argument is instanceof Sentinel
  (though perhaps this is for efficiency?)

Additionally - as a result of the migration from ES6 to TS, and
predating PR #6639 - The signature for the Field constructor's
first argument was typed T|Sentinel, with subclass constructors
generally being <some type(s)>|Sentinel.

This creates a small problem when attempting to port Fields from
core to plugins, because Sentinel is not reexported by
core/utils.ts (and therefore not from core/blockly.ts either).

The latter problem could be solved simply by reexporting Sentinel,
or by having plugin constructors not accept SKIP_SETUP (though
this potentially makes them more difficult to subclass), but
neither is particularly desirable.

Instead, this PR proposes that we:

- Make Field.SKIP_SETUP a (unique) Symbol.
- Change the value argument to the Field constructor to accept
  T|typeof Field.SKIP_SETUP - where typeof Field.SKIP_SETUP is
  (like a literal type) a type that accepts just the single
  value SKIP_SETUP.
- Remove the Sentinel class and core/utils/sentinel.ts.

Not treating this as a breaking change:

- Removes Field.isSentinel - though this addition has not yet
  been published, so it can only break our own as-yet-unreleased
  code in samples.

- Changes the type of Field.SKIP_SETUP and the first argument
  of the Field constructor from Sentinel to typeof SKIP_SETUP
  (a unique Symbol) - but given that Sentinel has never been
  exported this should not break any actual external code.
2023-03-23 20:30:48 +00:00

543 lines
15 KiB
TypeScript

/**
* @license
* Copyright 2013 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Angle input field.
*
* @class
*/
import * as goog from '../closure/goog/goog.js';
goog.declareModuleId('Blockly.FieldAngle');
import {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
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 {FieldInput, FieldInputConfig, FieldInputValidator} from './field_input.js';
import * as dom from './utils/dom.js';
import * as math from './utils/math.js';
import {Svg} from './utils/svg.js';
import * as userAgent from './utils/useragent.js';
import * as WidgetDiv from './widgetdiv.js';
/**
* Class for an editable angle field.
*/
export class FieldAngle extends FieldInput<number> {
/** Half the width of protractor image. */
static readonly HALF = 100 / 2;
/**
* Radius of protractor circle. Slightly smaller than protractor size since
* otherwise SVG crops off half the border at the edges.
*/
static readonly RADIUS: number = FieldAngle.HALF - 1;
/**
* Default property describing which direction makes an angle field's value
* increase. Angle increases clockwise (true) or counterclockwise (false).
*/
static readonly CLOCKWISE = false;
/**
* The default offset of 0 degrees (and all angles). Always offsets in the
* counterclockwise direction, regardless of the field's clockwise property.
* Usually either 0 (0 = right) or 90 (0 = up).
*/
static readonly OFFSET = 0;
/**
* The default maximum angle to allow before wrapping.
* Usually either 360 (for 0 to 359.9) or 180 (for -179.9 to 180).
*/
static readonly WRAP = 360;
/**
* The default amount to round angles to when using a mouse or keyboard nav
* input. Must be a positive integer to support keyboard navigation.
*/
static readonly ROUND = 15;
/**
* Whether the angle should increase as the angle picker is moved clockwise
* (true) or counterclockwise (false).
*/
private clockwise = FieldAngle.CLOCKWISE;
/**
* The offset of zero degrees (and all other angles).
*/
private offset = FieldAngle.OFFSET;
/**
* The maximum angle to allow before wrapping.
*/
private wrap = FieldAngle.WRAP;
/**
* The amount to round angles to when using a mouse or keyboard nav input.
*/
private round = FieldAngle.ROUND;
/**
* Array holding info needed to unbind events.
* Used for disposing.
* Ex: [[node, name, func], [node, name, func]].
*/
private boundEvents: browserEvents.Data[] = [];
/** Dynamic red line pointing at the value's angle. */
private line: SVGLineElement|null = null;
/** Dynamic pink area extending from 0 to the value's angle. */
private gauge: SVGPathElement|null = null;
/** The degree symbol for this field. */
protected symbol_: SVGTSpanElement|null = null;
/**
* @param value The initial value of the field. Should cast to a number.
* Defaults to 0. Also accepts Field.SKIP_SETUP if you wish to skip setup
* (only used by subclasses that want to handle configuration and setting
* the field value after their own constructors have run).
* @param validator A function that is called to validate changes to the
* field's value. Takes in a number & returns a validated number, or null
* to abort the change.
* @param config A map of options used to configure the field.
* See the [field creation documentation]{@link
* https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation}
* for a list of properties this parameter supports.
*/
constructor(
value?: string|number|typeof Field.SKIP_SETUP,
validator?: FieldAngleValidator, config?: FieldAngleConfig) {
super(Field.SKIP_SETUP);
if (value === Field.SKIP_SETUP) return;
if (config) {
this.configure_(config);
}
this.setValue(value);
if (validator) {
this.setValidator(validator);
}
}
/**
* Configure the field based on the given map of options.
*
* @param config A map of options to configure the field based on.
*/
protected override configure_(config: FieldAngleConfig) {
super.configure_(config);
switch (config.mode) {
case Mode.COMPASS:
this.clockwise = true;
this.offset = 90;
break;
case Mode.PROTRACTOR:
// This is the default mode, so we could do nothing. But just to
// future-proof, we'll set it anyway.
this.clockwise = false;
this.offset = 0;
break;
}
// Allow individual settings to override the mode setting.
if (config.clockwise) this.clockwise = config.clockwise;
if (config.offset) this.offset = config.offset;
if (config.wrap) this.wrap = config.wrap;
if (config.round) this.round = config.round;
}
/**
* Create the block UI for this field.
*
* @internal
*/
override initView() {
super.initView();
// Add the degree symbol to the left of the number,
// even in RTL (issue #2380).
this.symbol_ = dom.createSvgElement(Svg.TSPAN, {});
this.symbol_.appendChild(document.createTextNode('°'));
this.getTextElement().appendChild(this.symbol_);
}
/** Updates the angle when the field rerenders. */
protected override render_() {
super.render_();
this.updateGraph();
}
/**
* Create and show the angle field's editor.
*
* @param e Optional mouse event that triggered the field to open,
* or undefined if triggered programmatically.
*/
protected override showEditor_(e?: Event) {
// Mobile browsers have issues with in-line textareas (focus & keyboards).
const noFocus = userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD;
super.showEditor_(e, noFocus);
const editor = this.dropdownCreate();
dropDownDiv.getContentDiv().appendChild(editor);
if (this.sourceBlock_ instanceof BlockSvg) {
dropDownDiv.setColour(
this.sourceBlock_.style.colourPrimary,
this.sourceBlock_.style.colourTertiary);
}
dropDownDiv.showPositionedByField(this, this.dropdownDispose.bind(this));
this.updateGraph();
}
/**
* Creates the angle dropdown editor.
*
* @returns The newly created slider.
*/
private dropdownCreate(): SVGSVGElement {
const svg = dom.createSvgElement(Svg.SVG, {
'xmlns': dom.SVG_NS,
'xmlns:html': dom.HTML_NS,
'xmlns:xlink': dom.XLINK_NS,
'version': '1.1',
'height': FieldAngle.HALF * 2 + 'px',
'width': FieldAngle.HALF * 2 + 'px',
'style': 'touch-action: none',
});
const circle = dom.createSvgElement(
Svg.CIRCLE, {
'cx': FieldAngle.HALF,
'cy': FieldAngle.HALF,
'r': FieldAngle.RADIUS,
'class': 'blocklyAngleCircle',
},
svg);
this.gauge =
dom.createSvgElement(Svg.PATH, {'class': 'blocklyAngleGauge'}, svg);
this.line = dom.createSvgElement(
Svg.LINE, {
'x1': FieldAngle.HALF,
'y1': FieldAngle.HALF,
'class': 'blocklyAngleLine',
},
svg);
// Draw markers around the edge.
for (let angle = 0; angle < 360; angle += 15) {
dom.createSvgElement(
Svg.LINE, {
'x1': FieldAngle.HALF + FieldAngle.RADIUS,
'y1': FieldAngle.HALF,
'x2': FieldAngle.HALF + FieldAngle.RADIUS -
(angle % 45 === 0 ? 10 : 5),
'y2': FieldAngle.HALF,
'class': 'blocklyAngleMarks',
'transform': 'rotate(' + angle + ',' + FieldAngle.HALF + ',' +
FieldAngle.HALF + ')',
},
svg);
}
// The angle picker is different from other fields in that it updates on
// mousemove even if it's not in the middle of a drag. In future we may
// change this behaviour.
this.boundEvents.push(
browserEvents.conditionalBind(svg, 'click', this, this.hide));
// On touch devices, the picker's value is only updated with a drag. Add
// a click handler on the drag surface to update the value if the surface
// is clicked.
this.boundEvents.push(browserEvents.conditionalBind(
circle, 'pointerdown', this, this.onMouseMove_, true));
this.boundEvents.push(browserEvents.conditionalBind(
circle, 'pointermove', this, this.onMouseMove_, true));
return svg;
}
/** Disposes of events and DOM-references belonging to the angle editor. */
private dropdownDispose() {
for (const event of this.boundEvents) {
browserEvents.unbind(event);
}
this.boundEvents.length = 0;
this.gauge = null;
this.line = null;
}
/** Hide the editor. */
private hide() {
dropDownDiv.hideIfOwner(this);
WidgetDiv.hide();
}
/**
* Set the angle to match the mouse's position.
*
* @param e Mouse move event.
*/
protected onMouseMove_(e: PointerEvent) {
// Calculate angle.
const bBox = this.gauge!.ownerSVGElement!.getBoundingClientRect();
const dx = e.clientX - bBox.left - FieldAngle.HALF;
const dy = e.clientY - bBox.top - FieldAngle.HALF;
let angle = Math.atan(-dy / dx);
if (isNaN(angle)) {
// This shouldn't happen, but let's not let this error propagate further.
return;
}
angle = math.toDegrees(angle);
// 0: East, 90: North, 180: West, 270: South.
if (dx < 0) {
angle += 180;
} else if (dy > 0) {
angle += 360;
}
// Do offsetting.
if (this.clockwise) {
angle = this.offset + 360 - angle;
} else {
angle = 360 - (this.offset - angle);
}
this.displayMouseOrKeyboardValue(angle);
}
/**
* Handles and displays values that are input via mouse or arrow key input.
* These values need to be rounded and wrapped before being displayed so
* that the text input's value is appropriate.
*
* @param angle New angle.
*/
private displayMouseOrKeyboardValue(angle: number) {
if (this.round) {
angle = Math.round(angle / this.round) * this.round;
}
angle = this.wrapValue(angle);
if (angle !== this.value_) {
this.setEditorValue_(angle);
}
}
/** Redraw the graph with the current angle. */
private updateGraph() {
if (!this.gauge || !this.line) {
return;
}
// Always display the input (i.e. getText) even if it is invalid.
let angleDegrees = Number(this.getText()) + this.offset;
angleDegrees %= 360;
let angleRadians = math.toRadians(angleDegrees);
const path = ['M ', FieldAngle.HALF, ',', FieldAngle.HALF];
let x2 = FieldAngle.HALF;
let y2 = FieldAngle.HALF;
if (!isNaN(angleRadians)) {
const clockwiseFlag = Number(this.clockwise);
const angle1 = math.toRadians(this.offset);
const x1 = Math.cos(angle1) * FieldAngle.RADIUS;
const y1 = Math.sin(angle1) * -FieldAngle.RADIUS;
if (clockwiseFlag) {
angleRadians = 2 * angle1 - angleRadians;
}
x2 += Math.cos(angleRadians) * FieldAngle.RADIUS;
y2 -= Math.sin(angleRadians) * FieldAngle.RADIUS;
// Don't ask how the flag calculations work. They just do.
let largeFlag =
Math.abs(Math.floor((angleRadians - angle1) / Math.PI) % 2);
if (clockwiseFlag) {
largeFlag = 1 - largeFlag;
}
path.push(
' l ', x1, ',', y1, ' A ', FieldAngle.RADIUS, ',', FieldAngle.RADIUS,
' 0 ', largeFlag, ' ', clockwiseFlag, ' ', x2, ',', y2, ' z');
}
this.gauge.setAttribute('d', path.join(''));
this.line.setAttribute('x2', `${x2}`);
this.line.setAttribute('y2', `${y2}`);
}
/**
* Handle key down to the editor.
*
* @param e Keyboard event.
*/
protected override onHtmlInputKeyDown_(e: KeyboardEvent) {
super.onHtmlInputKeyDown_(e);
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
let multiplier = 0;
switch (e.key) {
case 'ArrowLeft':
// decrement (increment in RTL)
multiplier = block.RTL ? 1 : -1;
break;
case 'ArrowRight':
// increment (decrement in RTL)
multiplier = block.RTL ? -1 : 1;
break;
case 'ArrowDown':
// decrement
multiplier = -1;
break;
case 'ArrowUp':
// increment
multiplier = 1;
break;
}
if (multiplier) {
const value = this.getValue() as number;
this.displayMouseOrKeyboardValue(value + multiplier * this.round);
e.preventDefault();
e.stopPropagation();
}
}
/**
* Ensure that the input value is a valid angle.
*
* @param newValue The input value.
* @returns A valid angle, or null if invalid.
*/
protected override doClassValidation_(newValue?: any): number|null {
const value = Number(newValue);
if (isNaN(value) || !isFinite(value)) {
return null;
}
return this.wrapValue(value);
}
/**
* Wraps the value so that it is in the range (-360 + wrap, wrap).
*
* @param value The value to wrap.
* @returns The wrapped value.
*/
private wrapValue(value: number): number {
value %= 360;
if (value < 0) {
value += 360;
}
if (value > this.wrap) {
value -= 360;
}
return value;
}
/**
* Construct a FieldAngle from a JSON arg object.
*
* @param options A JSON object with options (angle).
* @returns The new field instance.
* @nocollapse
* @internal
*/
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);
}
}
fieldRegistry.register('field_angle', FieldAngle);
FieldAngle.prototype.DEFAULT_VALUE = 0;
/**
* CSS for angle field.
*/
Css.register(`
.blocklyAngleCircle {
stroke: #444;
stroke-width: 1;
fill: #ddd;
fill-opacity: 0.8;
}
.blocklyAngleMarks {
stroke: #444;
stroke-width: 1;
}
.blocklyAngleGauge {
fill: #f88;
fill-opacity: 0.8;
pointer-events: none;
}
.blocklyAngleLine {
stroke: #f00;
stroke-width: 2;
stroke-linecap: round;
pointer-events: none;
}
`);
/**
* The two main modes of the angle field.
* Compass specifies:
* - clockwise: true
* - offset: 90
* - wrap: 0
* - round: 15
*
* Protractor specifies:
* - clockwise: false
* - offset: 0
* - wrap: 0
* - round: 15
*/
export enum Mode {
COMPASS = 'compass',
PROTRACTOR = 'protractor',
}
/**
* Extra configuration options for the angle field.
*/
export interface FieldAngleConfig extends FieldInputConfig {
mode?: Mode;
clockwise?: boolean;
offset?: number;
wrap?: number;
round?: number;
}
/**
* fromJson configuration options for the angle field.
*/
export interface FieldAngleFromJsonConfig extends FieldAngleConfig {
angle?: number;
}
/**
* A function that is called to validate changes to the field's value before
* they are set.
*
* @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values}
* @param newValue The value to be validated.
* @returns One of three instructions for setting the new value: `T`, `null`,
* or `undefined`.
*
* - `T` to set this function's returned value instead of `newValue`.
*
* - `null` to invoke `doValueInvalid_` and not set a value.
*
* - `undefined` to set `newValue` as is.
*/
export type FieldAngleValidator = FieldInputValidator<number>;