mirror of
https://github.com/google/blockly.git
synced 2026-01-09 18:10:08 +01:00
* fix(build): Restore erroneously-deleted filter function This was deleted in PR #7406 as it was mainly being used to filter core/ vs. test/mocha/ deps into separate deps files - but it turns out also to be used for filtering error messages too. Oops. * refactor(tests): Migrate advanced compilation test to ES Modules * refactor(build): Migrate main.js to TypeScript This turns out to be pretty straight forward, even if it would cause crashing if one actually tried to import this module instead of just feeding it to Closure Compiler. * chore(build): Remove goog.declareModuleId calls Replace goog.declareModuleId calls with a comment recording the former module ID for posterity (or at least until we decide how to reformat the renamings file. * chore(tests): Delete closure/goog/* For the moment we still need something to serve as base.js for the benefit of closure-make-deps, so we keep a vestigial base.js around, containing only the @provideGoog declaration. * refactor(build): Remove vestigial base.js By changing slightly the command line arguments to closure-make-deps and closure-calculate-chunks the need to have any base.js is eliminated. * chore: Typo fix for PR #7415
613 lines
16 KiB
TypeScript
613 lines
16 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2013 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Angle input field.
|
|
*
|
|
* @class
|
|
*/
|
|
// Former goog.module ID: 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 * as eventUtils from './events/utils.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.
|
|
*/
|
|
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_) {
|
|
// Intermediate value changes from user input are not confirmed until the
|
|
// user closes the editor, and may be numerous. Inhibit reporting these as
|
|
// normal block change events, and instead report them as special
|
|
// intermediate changes that do not get recorded in undo history.
|
|
const oldValue = this.value_;
|
|
this.setEditorValue_(angle, false);
|
|
if (
|
|
this.sourceBlock_ &&
|
|
eventUtils.isEnabled() &&
|
|
this.value_ !== oldValue
|
|
) {
|
|
eventUtils.fire(
|
|
new (eventUtils.get(eventUtils.BLOCK_FIELD_INTERMEDIATE_CHANGE))(
|
|
this.sourceBlock_,
|
|
this.name || null,
|
|
oldValue,
|
|
this.value_,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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>;
|