/** * @license * Copyright 2012 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Colour input field. */ 'use strict'; /** * Colour input field. * @class */ goog.module('Blockly.FieldColour'); const Css = goog.require('Blockly.Css'); const DropDownDiv = goog.require('Blockly.DropDownDiv'); const Field = goog.require('Blockly.Field'); const KeyCodes = goog.require('Blockly.utils.KeyCodes'); const Size = goog.require('Blockly.utils.Size'); const aria = goog.require('Blockly.utils.aria'); const browserEvents = goog.require('Blockly.browserEvents'); const colour = goog.require('Blockly.utils.colour'); const dom = goog.require('Blockly.utils.dom'); const fieldRegistry = goog.require('Blockly.fieldRegistry'); const idGenerator = goog.require('Blockly.utils.idGenerator'); const object = goog.require('Blockly.utils.object'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.BlockChange'); /** * Class for a colour input field. * @param {string=} opt_value The initial value of the field. Should be in * '#rrggbb' format. Defaults to the first value in the default colour array. * @param {Function=} opt_validator A function that is called to validate * changes to the field's value. Takes in a colour string & returns a * validated colour string ('#rrggbb' format), or null to abort the * change.Blockly. * @param {Object=} 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/colour} * for a list of properties this parameter supports. * @extends {Field} * @constructor * @alias Blockly.FieldColour */ const FieldColour = function(opt_value, opt_validator, opt_config) { FieldColour.superClass_.constructor.call( this, opt_value, opt_validator, opt_config); /** * The field's colour picker element. * @type {?Element} * @private */ this.picker_ = null; /** * Index of the currently highlighted element. * @type {?number} * @private */ this.highlightedIndex_ = null; /** * Mouse click event data. * @type {?browserEvents.Data} * @private */ this.onClickWrapper_ = null; /** * Mouse move event data. * @type {?browserEvents.Data} * @private */ this.onMouseMoveWrapper_ = null; /** * Mouse enter event data. * @type {?browserEvents.Data} * @private */ this.onMouseEnterWrapper_ = null; /** * Mouse leave event data. * @type {?browserEvents.Data} * @private */ this.onMouseLeaveWrapper_ = null; /** * Key down event data. * @type {?browserEvents.Data} * @private */ this.onKeyDownWrapper_ = null; }; object.inherits(FieldColour, Field); /** * Construct a FieldColour from a JSON arg object. * @param {!Object} options A JSON object with options (colour). * @return {!FieldColour} The new field instance. * @package * @nocollapse */ FieldColour.fromJson = function(options) { // `this` might be a subclass of FieldColour if that class doesn't override // the static fromJson method. return new this(options['colour'], undefined, options); }; /** * Serializable fields are saved by the XML renderer, non-serializable fields * are not. Editable fields should also be serializable. * @type {boolean} */ FieldColour.prototype.SERIALIZABLE = true; /** * Mouse cursor style when over the hotspot that initiates the editor. */ FieldColour.prototype.CURSOR = 'default'; /** * Used to tell if the field needs to be rendered the next time the block is * rendered. Colour fields are statically sized, and only need to be * rendered at initialization. * @type {boolean} * @protected */ FieldColour.prototype.isDirty_ = false; /** * Array of colours used by this field. If null, use the global list. * @type {Array} * @private */ FieldColour.prototype.colours_ = null; /** * Array of colour tooltips used by this field. If null, use the global list. * @type {Array} * @private */ FieldColour.prototype.titles_ = null; /** * Number of colour columns used by this field. If 0, use the global setting. * By default use the global constants for columns. * @type {number} * @private */ FieldColour.prototype.columns_ = 0; /** * Configure the field based on the given map of options. * @param {!Object} config A map of options to configure the field based on. * @protected * @override */ FieldColour.prototype.configure_ = function(config) { FieldColour.superClass_.configure_.call(this, config); if (config['colourOptions']) { this.colours_ = config['colourOptions']; this.titles_ = config['colourTitles']; } if (config['columns']) { this.columns_ = config['columns']; } }; /** * Create the block UI for this colour field. * @package */ FieldColour.prototype.initView = function() { this.size_ = new Size( this.getConstants().FIELD_COLOUR_DEFAULT_WIDTH, this.getConstants().FIELD_COLOUR_DEFAULT_HEIGHT); if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) { this.createBorderRect_(); this.borderRect_.style['fillOpacity'] = '1'; } else { this.clickTarget_ = this.sourceBlock_.getSvgRoot(); } }; /** * @override */ FieldColour.prototype.applyColour = function() { if (!this.getConstants().FIELD_COLOUR_FULL_BLOCK) { if (this.borderRect_) { this.borderRect_.style.fill = /** @type {string} */ (this.getValue()); } } else { this.sourceBlock_.pathObject.svgPath.setAttribute('fill', this.getValue()); this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff'); } }; /** * Ensure that the input value is a valid colour. * @param {*=} opt_newValue The input value. * @return {?string} A valid colour, or null if invalid. * @protected */ FieldColour.prototype.doClassValidation_ = function(opt_newValue) { if (typeof opt_newValue !== 'string') { return null; } return colour.parse(opt_newValue); }; /** * Update the value of this colour field, and update the displayed colour. * @param {*} newValue The value to be saved. The default validator guarantees * that this is a colour in '#rrggbb' format. * @protected */ FieldColour.prototype.doValueUpdate_ = function(newValue) { this.value_ = newValue; if (this.borderRect_) { this.borderRect_.style.fill = /** @type {string} */ (newValue); } else if (this.sourceBlock_ && this.sourceBlock_.rendered) { this.sourceBlock_.pathObject.svgPath.setAttribute('fill', newValue); this.sourceBlock_.pathObject.svgPath.setAttribute('stroke', '#fff'); } }; /** * Get the text for this field. Used when the block is collapsed. * @return {string} Text representing the value of this field. */ FieldColour.prototype.getText = function() { let colour = /** @type {string} */ (this.value_); // Try to use #rgb format if possible, rather than #rrggbb. if (/^#(.)\1(.)\2(.)\3$/.test(colour)) { colour = '#' + colour[1] + colour[3] + colour[5]; } return colour; }; /** * An array of colour strings for the palette. * Copied from goog.ui.ColorPicker.SIMPLE_GRID_COLORS * All colour pickers use this unless overridden with setColours. * @type {!Array} */ FieldColour.COLOURS = [ // grays '#ffffff', '#cccccc', '#c0c0c0', '#999999', '#666666', '#333333', '#000000', // reds '#ffcccc', '#ff6666', '#ff0000', '#cc0000', '#990000', '#660000', '#330000', // oranges '#ffcc99', '#ff9966', '#ff9900', '#ff6600', '#cc6600', '#993300', '#663300', // yellows '#ffff99', '#ffff66', '#ffcc66', '#ffcc33', '#cc9933', '#996633', '#663333', // olives '#ffffcc', '#ffff33', '#ffff00', '#ffcc00', '#999900', '#666600', '#333300', // greens '#99ff99', '#66ff99', '#33ff33', '#33cc00', '#009900', '#006600', '#003300', // turquoises '#99ffff', '#33ffff', '#66cccc', '#00cccc', '#339999', '#336666', '#003333', // blues '#ccffff', '#66ffff', '#33ccff', '#3366ff', '#3333ff', '#000099', '#000066', // purples '#ccccff', '#9999ff', '#6666cc', '#6633ff', '#6600cc', '#333399', '#330099', // violets '#ffccff', '#ff99ff', '#cc66cc', '#cc33cc', '#993399', '#663366', '#330033' ]; /** * The default value for this field. * @type {*} * @protected */ FieldColour.prototype.DEFAULT_VALUE = FieldColour.COLOURS[0]; /** * An array of tooltip strings for the palette. If not the same length as * COLOURS, the colour's hex code will be used for any missing titles. * All colour pickers use this unless overridden with setColours. * @type {!Array} */ FieldColour.TITLES = []; /** * Number of columns in the palette. * All colour pickers use this unless overridden with setColumns. */ FieldColour.COLUMNS = 7; /** * Set a custom colour grid for this field. * @param {Array} colours Array of colours for this block, * or null to use default (FieldColour.COLOURS). * @param {Array=} opt_titles Optional array of colour tooltips, * or null to use default (FieldColour.TITLES). * @return {!FieldColour} Returns itself (for method chaining). */ FieldColour.prototype.setColours = function(colours, opt_titles) { this.colours_ = colours; if (opt_titles) { this.titles_ = opt_titles; } return this; }; /** * Set a custom grid size for this field. * @param {number} columns Number of columns for this block, * or 0 to use default (FieldColour.COLUMNS). * @return {!FieldColour} Returns itself (for method chaining). */ FieldColour.prototype.setColumns = function(columns) { this.columns_ = columns; return this; }; /** * Create and show the colour field's editor. * @protected */ FieldColour.prototype.showEditor_ = function() { this.dropdownCreate_(); DropDownDiv.getContentDiv().appendChild(this.picker_); DropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this)); // Focus so we can start receiving keyboard events. this.picker_.focus({preventScroll: true}); }; /** * Handle a click on a colour cell. * @param {!MouseEvent} e Mouse event. * @private */ FieldColour.prototype.onClick_ = function(e) { const cell = /** @type {!Element} */ (e.target); const colour = cell && cell.label; if (colour !== null) { this.setValue(colour); DropDownDiv.hideIfOwner(this); } }; /** * Handle a key down event. Navigate around the grid with the * arrow keys. Enter selects the highlighted colour. * @param {!KeyboardEvent} e Keyboard event. * @private */ FieldColour.prototype.onKeyDown_ = function(e) { let handled = false; if (e.keyCode === KeyCodes.UP) { this.moveHighlightBy_(0, -1); handled = true; } else if (e.keyCode === KeyCodes.DOWN) { this.moveHighlightBy_(0, 1); handled = true; } else if (e.keyCode === KeyCodes.LEFT) { this.moveHighlightBy_(-1, 0); handled = true; } else if (e.keyCode === KeyCodes.RIGHT) { this.moveHighlightBy_(1, 0); handled = true; } else if (e.keyCode === KeyCodes.ENTER) { // Select the highlighted colour. const highlighted = this.getHighlighted_(); if (highlighted) { const colour = highlighted && highlighted.label; if (colour !== null) { this.setValue(colour); } } DropDownDiv.hideWithoutAnimation(); handled = true; } if (handled) { e.stopPropagation(); } }; /** * Move the currently highlighted position by dx and dy. * @param {number} dx Change of x * @param {number} dy Change of y * @private */ FieldColour.prototype.moveHighlightBy_ = function(dx, dy) { const colours = this.colours_ || FieldColour.COLOURS; const columns = this.columns_ || FieldColour.COLUMNS; // Get the current x and y coordinates let x = this.highlightedIndex_ % columns; let y = Math.floor(this.highlightedIndex_ / columns); // Add the offset x += dx; y += dy; if (dx < 0) { // Move left one grid cell, even in RTL. // Loop back to the end of the previous row if we have room. if (x < 0 && y > 0) { x = columns - 1; y--; } else if (x < 0) { x = 0; } } else if (dx > 0) { // Move right one grid cell, even in RTL. // Loop to the start of the next row, if there's room. if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) { x = 0; y++; } else if (x > columns - 1) { x--; } } else if (dy < 0) { // Move up one grid cell, stop at the top. if (y < 0) { y = 0; } } else if (dy > 0) { // Move down one grid cell, stop at the bottom. if (y > Math.floor(colours.length / columns) - 1) { y = Math.floor(colours.length / columns) - 1; } } // Move the highlight to the new coordinates. const cell = /** @type {!Element} */ (this.picker_.childNodes[y].childNodes[x]); const index = (y * columns) + x; this.setHighlightedCell_(cell, index); }; /** * Handle a mouse move event. Highlight the hovered colour. * @param {!MouseEvent} e Mouse event. * @private */ FieldColour.prototype.onMouseMove_ = function(e) { const cell = /** @type {!Element} */ (e.target); const index = cell && Number(cell.getAttribute('data-index')); if (index !== null && index !== this.highlightedIndex_) { this.setHighlightedCell_(cell, index); } }; /** * Handle a mouse enter event. Focus the picker. * @private */ FieldColour.prototype.onMouseEnter_ = function() { this.picker_.focus({preventScroll: true}); }; /** * Handle a mouse leave event. Blur the picker and unhighlight * the currently highlighted colour. * @private */ FieldColour.prototype.onMouseLeave_ = function() { this.picker_.blur(); const highlighted = this.getHighlighted_(); if (highlighted) { dom.removeClass(highlighted, 'blocklyColourHighlighted'); } }; /** * Returns the currently highlighted item (if any). * @return {?HTMLElement} Highlighted item (null if none). * @private */ FieldColour.prototype.getHighlighted_ = function() { const columns = this.columns_ || FieldColour.COLUMNS; const x = this.highlightedIndex_ % columns; const y = Math.floor(this.highlightedIndex_ / columns); const row = this.picker_.childNodes[y]; if (!row) { return null; } const col = /** @type {HTMLElement} */ (row.childNodes[x]); return col; }; /** * Update the currently highlighted cell. * @param {!Element} cell the new cell to highlight * @param {number} index the index of the new cell * @private */ FieldColour.prototype.setHighlightedCell_ = function(cell, index) { // Unhighlight the current item. const highlighted = this.getHighlighted_(); if (highlighted) { dom.removeClass(highlighted, 'blocklyColourHighlighted'); } // Highlight new item. dom.addClass(cell, 'blocklyColourHighlighted'); // Set new highlighted index. this.highlightedIndex_ = index; // Update accessibility roles. aria.setState( /** @type {!Element} */ (this.picker_), aria.State.ACTIVEDESCENDANT, cell.getAttribute('id')); }; /** * Create a colour picker dropdown editor. * @private */ FieldColour.prototype.dropdownCreate_ = function() { const columns = this.columns_ || FieldColour.COLUMNS; const colours = this.colours_ || FieldColour.COLOURS; const titles = this.titles_ || FieldColour.TITLES; const selectedColour = this.getValue(); // Create the palette. const table = document.createElement('table'); table.className = 'blocklyColourTable'; table.tabIndex = 0; table.dir = 'ltr'; aria.setRole(table, aria.Role.GRID); aria.setState(table, aria.State.EXPANDED, true); aria.setState( table, aria.State.ROWCOUNT, Math.floor(colours.length / columns)); aria.setState(table, aria.State.COLCOUNT, columns); let row; for (let i = 0; i < colours.length; i++) { if (i % columns === 0) { row = document.createElement('tr'); aria.setRole(row, aria.Role.ROW); table.appendChild(row); } const cell = document.createElement('td'); row.appendChild(cell); cell.label = colours[i]; // This becomes the value, if clicked. cell.title = titles[i] || colours[i]; cell.id = idGenerator.getNextUniqueId(); cell.setAttribute('data-index', i); aria.setRole(cell, aria.Role.GRIDCELL); aria.setState(cell, aria.State.LABEL, colours[i]); aria.setState(cell, aria.State.SELECTED, colours[i] === selectedColour); cell.style.backgroundColor = colours[i]; if (colours[i] === selectedColour) { cell.className = 'blocklyColourSelected'; this.highlightedIndex_ = i; } } // Configure event handler on the table to listen for any event in a cell. this.onClickWrapper_ = browserEvents.conditionalBind(table, 'click', this, this.onClick_, true); this.onMouseMoveWrapper_ = browserEvents.conditionalBind( table, 'mousemove', this, this.onMouseMove_, true); this.onMouseEnterWrapper_ = browserEvents.conditionalBind( table, 'mouseenter', this, this.onMouseEnter_, true); this.onMouseLeaveWrapper_ = browserEvents.conditionalBind( table, 'mouseleave', this, this.onMouseLeave_, true); this.onKeyDownWrapper_ = browserEvents.conditionalBind(table, 'keydown', this, this.onKeyDown_); this.picker_ = table; }; /** * Disposes of events and DOM-references belonging to the colour editor. * @private */ FieldColour.prototype.dropdownDispose_ = function() { if (this.onClickWrapper_) { browserEvents.unbind(this.onClickWrapper_); this.onClickWrapper_ = null; } if (this.onMouseMoveWrapper_) { browserEvents.unbind(this.onMouseMoveWrapper_); this.onMouseMoveWrapper_ = null; } if (this.onMouseEnterWrapper_) { browserEvents.unbind(this.onMouseEnterWrapper_); this.onMouseEnterWrapper_ = null; } if (this.onMouseLeaveWrapper_) { browserEvents.unbind(this.onMouseLeaveWrapper_); this.onMouseLeaveWrapper_ = null; } if (this.onKeyDownWrapper_) { browserEvents.unbind(this.onKeyDownWrapper_); this.onKeyDownWrapper_ = null; } this.picker_ = null; this.highlightedIndex_ = null; }; /** * CSS for colour picker. See css.js for use. */ Css.register(` .blocklyColourTable { border-collapse: collapse; display: block; outline: none; padding: 1px; } .blocklyColourTable>tr>td { border: .5px solid #888; box-sizing: border-box; cursor: pointer; display: inline-block; height: 20px; padding: 0; width: 20px; } .blocklyColourTable>tr>td.blocklyColourHighlighted { border-color: #eee; box-shadow: 2px 2px 7px 2px rgba(0,0,0,.3); position: relative; } .blocklyColourSelected, .blocklyColourSelected:hover { border-color: #eee !important; outline: 1px solid #333; position: relative; } `); fieldRegistry.register('field_colour', FieldColour); exports = FieldColour;