/** * @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 { /** 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', }); svg.style.touchAction = '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;