scratch-blocks/core/field_number.js
Paul Kaplan d2f03647e9
Merge pull request from paulkaplan/touch-numpad
Use quiet inputs/numpad for touch interaction instead of based on user agent
2019-08-02 14:12:50 -04:00

366 lines
13 KiB
JavaScript

/**
* @license
* Visual Blocks Editor
*
* Copyright 2016 Massachusetts Institute of Technology
* All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Field for numbers. Includes validator and numpad on touch.
* @author tmickel@mit.edu (Tim Mickel)
*/
'use strict';
goog.provide('Blockly.FieldNumber');
goog.require('Blockly.FieldTextInput');
goog.require('Blockly.Touch');
goog.require('goog.math');
goog.require('goog.userAgent');
/**
* Class for an editable number field.
* In scratch-blocks, the min/max/precision properties are only used
* to construct a restrictor on typable characters, and to inform the pop-up
* numpad on touch devices.
* These properties are included here (i.e. instead of just accepting a
* decimalAllowed, negativeAllowed) to maintain API compatibility with Blockly
* and Blockly for Android.
* @param {(string|number)=} opt_value The initial content of the field. The value
* should cast to a number, and if it does not, '0' will be used.
* @param {(string|number)=} opt_min Minimum value.
* @param {(string|number)=} opt_max Maximum value.
* @param {(string|number)=} opt_precision Precision for value.
* @param {Function=} 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 the accepted text or null to abort
* the change.
* @extends {Blockly.FieldTextInput}
* @constructor
*/
Blockly.FieldNumber = function(opt_value, opt_min, opt_max, opt_precision,
opt_validator) {
var numRestrictor = this.getNumRestrictor(opt_min, opt_max, opt_precision);
opt_value = (opt_value && !isNaN(opt_value)) ? String(opt_value) : '0';
Blockly.FieldNumber.superClass_.constructor.call(
this, opt_value, opt_validator, numRestrictor);
this.addArgType('number');
};
goog.inherits(Blockly.FieldNumber, Blockly.FieldTextInput);
/**
* Construct a FieldNumber from a JSON arg object.
* @param {!Object} options A JSON object with options (value, min, max, and
* precision).
* @returns {!Blockly.FieldNumber} The new field instance.
* @package
* @nocollapse
*/
Blockly.FieldNumber.fromJson = function(options) {
return new Blockly.FieldNumber(options['value'],
options['min'], options['max'], options['precision']);
};
/**
* Fixed width of the num-pad drop-down, in px.
* @type {number}
* @const
*/
Blockly.FieldNumber.DROPDOWN_WIDTH = 168;
/**
* Buttons for the num-pad, in order from the top left.
* Values are strings of the number or symbol will be added to the field text
* when the button is pressed.
* @type {Array.<string>}
* @const
*/
// Calculator order
Blockly.FieldNumber.NUMPAD_BUTTONS =
['7', '8', '9', '4', '5', '6', '1', '2', '3', '.', '0', '-', ' '];
/**
* Src for the delete icon to be shown on the num-pad.
* @type {string}
* @const
*/
Blockly.FieldNumber.NUMPAD_DELETE_ICON = 'data:image/svg+xml;utf8,' +
'<svg ' +
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">' +
'<path d="M28.89,11.45H16.79a2.86,2.86,0,0,0-2,.84L9.09,1' +
'8a2.85,2.85,0,0,0,0,4l5.69,5.69a2.86,2.86,0,0,0,2,.84h12' +
'.1a2.86,2.86,0,0,0,2.86-2.86V14.31A2.86,2.86,0,0,0,28.89' +
',11.45ZM27.15,22.73a1,1,0,0,1,0,1.41,1,1,0,0,1-.71.3,1,1' +
',0,0,1-.71-0.3L23,21.41l-2.73,2.73a1,1,0,0,1-1.41,0,1,1,' +
'0,0,1,0-1.41L21.59,20l-2.73-2.73a1,1,0,0,1,0-1.41,1,1,0,' +
'0,1,1.41,0L23,18.59l2.73-2.73a1,1,0,1,1,1.42,1.41L24.42,20Z" fill="' +
Blockly.Colours.numPadText + '"/></svg>';
/**
* Currently active field during an edit.
* Used to give a reference to the num-pad button callbacks.
* @type {?FieldNumber}
* @private
*/
Blockly.FieldNumber.activeField_ = null;
/**
* Return an appropriate restrictor, depending on whether this FieldNumber
* allows decimal or negative numbers.
* @param {number|string|undefined} opt_min Minimum value.
* @param {number|string|undefined} opt_max Maximum value.
* @param {number|string|undefined} opt_precision Precision for value.
* @return {!RegExp} Regular expression for this FieldNumber's restrictor.
*/
Blockly.FieldNumber.prototype.getNumRestrictor = function(opt_min, opt_max,
opt_precision) {
this.setConstraints_(opt_min, opt_max, opt_precision);
var pattern = "[\\d]"; // Always allow digits.
if (this.decimalAllowed_) {
pattern += "|[\\.]";
}
if (this.negativeAllowed_) {
pattern += "|[-]";
}
if (this.exponentialAllowed_) {
pattern += "|[eE]";
}
return new RegExp(pattern);
};
/**
* Set the constraints for this field.
* @param {number=} opt_min Minimum number allowed.
* @param {number=} opt_max Maximum number allowed.
* @param {number=} opt_precision Step allowed between numbers
*/
Blockly.FieldNumber.prototype.setConstraints_ = function(opt_min, opt_max,
opt_precision) {
this.decimalAllowed_ = (typeof opt_precision == 'undefined') ||
isNaN(opt_precision) || (opt_precision == 0) ||
(Math.floor(opt_precision) != opt_precision);
this.negativeAllowed_ = (typeof opt_min == 'undefined') || isNaN(opt_min) ||
opt_min < 0;
this.exponentialAllowed_ = this.decimalAllowed_;
};
/**
* Show the inline free-text editor on top of the text and the num-pad if
* appropriate.
* @private
*/
Blockly.FieldNumber.prototype.showEditor_ = function() {
Blockly.FieldNumber.activeField_ = this;
// Do not focus on mobile devices so we can show the num-pad
var showNumPad = this.useTouchInteraction_;
Blockly.FieldNumber.superClass_.showEditor_.call(this, false, showNumPad);
// Show a numeric keypad in the drop-down on touch
if (showNumPad) {
this.showNumPad_();
}
};
/**
* Show the number pad.
* @private
*/
Blockly.FieldNumber.prototype.showNumPad_ = function() {
// If there is an existing drop-down someone else owns, hide it immediately
// and clear it.
Blockly.DropDownDiv.hideWithoutAnimation();
Blockly.DropDownDiv.clearContent();
var contentDiv = Blockly.DropDownDiv.getContentDiv();
// Accessibility properties
contentDiv.setAttribute('role', 'menu');
contentDiv.setAttribute('aria-haspopup', 'true');
this.addButtons_(contentDiv);
// Set colour and size of drop-down
Blockly.DropDownDiv.setColour(this.sourceBlock_.parentBlock_.getColour(),
this.sourceBlock_.getColourTertiary());
contentDiv.style.width = Blockly.FieldNumber.DROPDOWN_WIDTH + 'px';
this.position_();
};
/**
* Figure out where to place the drop-down, and move it there.
* @private
*/
Blockly.FieldNumber.prototype.position_ = function() {
// Calculate positioning for the drop-down
// sourceBlock_ is the rendered shadow field input box
var scale = this.sourceBlock_.workspace.scale;
var bBox = this.sourceBlock_.getHeightWidth();
bBox.width *= scale;
bBox.height *= scale;
var position = this.getAbsoluteXY_();
// If we can fit it, render below the shadow block
var primaryX = position.x + bBox.width / 2;
var primaryY = position.y + bBox.height;
// If we can't fit it, render above the entire parent block
var secondaryX = primaryX;
var secondaryY = position.y;
Blockly.DropDownDiv.setBoundsElement(
this.sourceBlock_.workspace.getParentSvg().parentNode);
Blockly.DropDownDiv.show(this, primaryX, primaryY, secondaryX, secondaryY,
this.onHide_.bind(this));
};
/**
* Add number, punctuation, and erase buttons to the numeric keypad's content
* div.
* @param {Element} contentDiv The div for the numeric keypad.
* @private
*/
Blockly.FieldNumber.prototype.addButtons_ = function(contentDiv) {
var buttonColour = this.sourceBlock_.parentBlock_.getColour();
var buttonBorderColour = this.sourceBlock_.parentBlock_.getColourTertiary();
// Add numeric keypad buttons
var buttons = Blockly.FieldNumber.NUMPAD_BUTTONS;
for (var i = 0, buttonText; buttonText = buttons[i]; i++) {
var button = document.createElement('button');
button.setAttribute('role', 'menuitem');
button.setAttribute('class', 'blocklyNumPadButton');
button.setAttribute('style',
'background:' + buttonColour + ';' +
'border: 1px solid ' + buttonBorderColour + ';');
button.title = buttonText;
button.innerHTML = buttonText;
Blockly.bindEvent_(button, 'mousedown', button,
Blockly.FieldNumber.numPadButtonTouch);
if (buttonText == '.' && !this.decimalAllowed_) {
// Don't show the decimal point for inputs that must be round numbers
button.setAttribute('style', 'visibility: hidden');
} else if (buttonText == '-' && !this.negativeAllowed_) {
continue;
} else if (buttonText == ' ' && !this.negativeAllowed_) {
continue;
} else if (buttonText == ' ' && this.negativeAllowed_) {
button.setAttribute('style', 'visibility: hidden');
}
contentDiv.appendChild(button);
}
// Add erase button to the end
var eraseButton = document.createElement('button');
eraseButton.setAttribute('role', 'menuitem');
eraseButton.setAttribute('class', 'blocklyNumPadButton');
eraseButton.setAttribute('style',
'background:' + buttonColour + ';' +
'border: 1px solid ' + buttonBorderColour + ';');
eraseButton.title = 'Delete';
var eraseImage = document.createElement('img');
eraseImage.src = Blockly.FieldNumber.NUMPAD_DELETE_ICON;
eraseButton.appendChild(eraseImage);
Blockly.bindEvent_(eraseButton, 'mousedown', null,
Blockly.FieldNumber.numPadEraseButtonTouch);
contentDiv.appendChild(eraseButton);
};
/**
* Call for when a num-pad number or punctuation button is touched.
* Determine what the user is inputting and update the text field appropriately.
* @param {Event} e DOM event triggering the touch.
*/
Blockly.FieldNumber.numPadButtonTouch = function(e) {
// String of the button (e.g., '7')
var spliceValue = this.innerHTML;
// Old value of the text field
var oldValue = Blockly.FieldTextInput.htmlInput_.value;
// Determine the selected portion of the text field
var selectionStart = Blockly.FieldTextInput.htmlInput_.selectionStart;
var selectionEnd = Blockly.FieldTextInput.htmlInput_.selectionEnd;
// Splice in the new value
var newValue = oldValue.slice(0, selectionStart) + spliceValue +
oldValue.slice(selectionEnd);
// Set new value and advance the cursor
Blockly.FieldNumber.updateDisplay_(newValue, selectionStart + spliceValue.length);
// This is just a click.
Blockly.Touch.clearTouchIdentifier();
// Prevent default to not lose input focus
e.preventDefault();
};
/**
* Call for when the num-pad erase button is touched.
* Determine what the user is asking to erase, and erase it.
* @param {Event} e DOM event triggering the touch.
*/
Blockly.FieldNumber.numPadEraseButtonTouch = function(e) {
// Old value of the text field
var oldValue = Blockly.FieldTextInput.htmlInput_.value;
// Determine what is selected to erase (if anything)
var selectionStart = Blockly.FieldTextInput.htmlInput_.selectionStart;
var selectionEnd = Blockly.FieldTextInput.htmlInput_.selectionEnd;
// If selection is zero-length, shift start to the left 1 character
if (selectionStart == selectionEnd) {
selectionStart = Math.max(0, selectionStart - 1);
}
// Cut out selected range
var newValue = oldValue.slice(0, selectionStart) +
oldValue.slice(selectionEnd);
Blockly.FieldNumber.updateDisplay_(newValue, selectionStart);
// This is just a click.
Blockly.Touch.clearTouchIdentifier();
// Prevent default to not lose input focus which resets cursors in Chrome
e.preventDefault();
};
/**
* Update the displayed value and resize/scroll the text field as needed.
* @param {string} newValue The new text to display.
* @param {string} newSelection The new index to put the cursor
* @private.
*/
Blockly.FieldNumber.updateDisplay_ = function(newValue, newSelection) {
var htmlInput = Blockly.FieldTextInput.htmlInput_;
// Updates the display. The actual setValue occurs when editing ends.
htmlInput.value = newValue;
// Resize and scroll the text field appropriately
Blockly.FieldNumber.superClass_.resizeEditor_.call(
Blockly.FieldNumber.activeField_);
htmlInput.setSelectionRange(newSelection, newSelection);
htmlInput.scrollLeft = htmlInput.scrollWidth;
Blockly.FieldNumber.activeField_.validate_();
};
/**
* Callback for when the drop-down is hidden.
*/
Blockly.FieldNumber.prototype.onHide_ = function() {
// Clear accessibility properties
Blockly.DropDownDiv.content_.removeAttribute('role');
Blockly.DropDownDiv.content_.removeAttribute('aria-haspopup');
};
Blockly.Field.register('field_number', Blockly.FieldNumber);