blob: e076b6332e81f77f78ccf9732c756c250826e2ff [file] [log] [blame]
// Copyright (C) 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Color picker used by <input type='color' />
*
* This can be debugged with manual_tests/forms/color-suggestion-picker.html
*/
function initializeColorPicker() {
if (global.params.selectedColor === undefined) {
global.params.selectedColor = DefaultColor;
}
const colorPicker = new ColorPicker(new Color(global.params.selectedColor));
main.append(colorPicker);
const width = colorPicker.offsetWidth;
const height = colorPicker.offsetHeight;
resizeWindow(width, height);
}
/**
* @param {!Object} args
* @return {?string} An error message, or null if the argument has no errors.
*/
function validateColorPickerArguments(args) {
if (args.shouldShowColorSuggestionPicker)
return 'Should be showing the color suggestion picker.';
if (!args.selectedColor)
return 'No selectedColor.';
return null;
}
/**
* Supported movement directions.
* @enum {number}
*/
const Direction = {
UNDEFINED: 0,
LEFT: 1,
RIGHT: 2,
UP: 3,
DOWN: 4,
}
/**
* Supported color channels.
* @enum {number}
*/
const ColorChannel = {
HEX: 0,
R: 1,
G: 2,
B: 3,
H: 4,
S: 5,
L: 6,
};
/**
* Supported color formats.
* @enum {number}
*/
const ColorFormat = {
HEX: 0,
RGB: 1,
HSL: 2,
};
/**
* Color: Helper class to get color values in different color formats.
*/
class Color {
/**
* @param {string|!ColorFormat} colorStringOrFormat
* @param {...number} colorValues ignored if colorStringOrFormat is a string
*/
constructor(colorStringOrFormat, ...colorValues) {
if (typeof colorStringOrFormat === 'string') {
colorStringOrFormat = colorStringOrFormat.toLowerCase();
if (colorStringOrFormat.startsWith('#')) {
this.hexValue_ = colorStringOrFormat.substr(1);
} else if (colorStringOrFormat.startsWith('rgb')) {
// Ex. 'rgb(255, 255, 255)' => [255,255,255]
colorStringOrFormat = colorStringOrFormat.replace(/\s+/g, '');
[this.rValue_, this.gValue_, this.bValue_] =
colorStringOrFormat.substring(4, colorStringOrFormat.length - 1)
.split(',')
.map(Number);
} else if (colorStringOrFormat.startsWith('hsl')) {
colorStringOrFormat = colorStringOrFormat.replace(/%|\s+/g, '');
[this.hValue_, this.sValue_, this.lValue_] =
colorStringOrFormat.substring(4, colorStringOrFormat.length - 1)
.split(',')
.map(Number);
}
} else {
switch (colorStringOrFormat) {
case ColorFormat.HEX:
this.hexValue_ = colorValues[0].toLowerCase();
break;
case ColorFormat.RGB:
[this.rValue_, this.gValue_, this.bValue_] = colorValues.map(Number);
break;
case ColorFormat.HSL:
[this.hValue_, this.sValue_, this.lValue_] = colorValues.map(Number);
break;
}
}
}
/**
* @param {!Color} other
*/
equals(other) {
return (this.hexValue === other.hexValue);
}
/**
* @returns {string}
*/
get hexValue() {
this.computeHexValue_();
return this.hexValue_;
}
computeHexValue_() {
if (this.hexValue_ !== undefined) {
// Already computed.
} else if (this.rValue_ !== undefined) {
this.hexValue_ = Color.rgbToHex(this.rValue_, this.gValue_, this.bValue_);
} else if (this.hValue_ !== undefined) {
this.hexValue_ = Color.hslToHex(this.hValue_, this.sValue_, this.lValue_);
}
}
asHex() {
return '#' + this.hexValue;
}
/**
* @returns {number} between 0 and 255
*/
get rValue() {
this.computeRGBValues_();
return this.rValue_;
}
/**
* @returns {number} between 0 and 255
*/
get gValue() {
this.computeRGBValues_();
return this.gValue_;
}
/**
* @returns {number} between 0 and 255
*/
get bValue() {
this.computeRGBValues_();
return this.bValue_;
}
computeRGBValues_() {
if (this.rValue_ !== undefined) {
// Already computed.
} else if (this.hexValue_ !== undefined) {
[this.rValue_, this.gValue_, this.bValue_] =
Color.hexToRGB(this.hexValue_);
} else if (this.hValue_ !== undefined) {
[this.rValue_, this.gValue_, this.bValue_] =
Color.hslToRGB(this.hValue_, this.sValue_, this.lValue_);
}
}
rgbValues() {
return [this.rValue, this.gValue, this.bValue];
}
asRGB() {
return 'rgb(' + this.rgbValues().join() + ')';
}
/**
* @returns {number} between 0 and 359
*/
get hValue() {
this.computeHSLValues_();
return this.hValue_;
}
/**
* @returns {number} between 0 and 100
*/
get sValue() {
this.computeHSLValues_();
return this.sValue_;
}
/**
* @returns {number} between 0 and 100
*/
get lValue() {
this.computeHSLValues_();
return this.lValue_;
}
computeHSLValues_() {
if (this.hValue_ !== undefined) {
// Already computed.
} else if (this.rValue_ !== undefined) {
[this.hValue_, this.sValue_, this.lValue_] =
Color.rgbToHSL(this.rValue_, this.gValue_, this.bValue_);
} else if (this.hexValue_ !== undefined) {
[this.hValue_, this.sValue_, this.lValue_] =
Color.hexToHSL(this.hexValue_);
}
}
hslValues() {
return [this.hValue, this.sValue, this.lValue];
}
asHSL() {
return 'hsl(' + this.hValue + ',' + this.sValue + '%,' + this.lValue + '%)';
}
/**
* @param {string} hexValue
* @returns {number[]}
*/
static hexToRGB(hexValue) {
// Ex. 'ffffff' => '[255,255,255]'
const colorValue = parseInt(hexValue, 16);
return [
(colorValue >> 16) & 255, (colorValue >> 8) & 255, colorValue & 255
];
}
/**
* @param {...number} rgbValues
* @returns {string}
*/
static rgbToHex(...rgbValues) {
// Ex. '[255,255,255]' => 'ffffff'
return rgbValues.reduce((cumulativeHexValue, rgbValue) => {
let hexValue = Number(rgbValue).toString(16);
if (hexValue.length == 1) {
hexValue = '0' + hexValue;
}
return (cumulativeHexValue + hexValue);
}, '');
}
/**
* The algorithm has been written based on the mathematical formula found at:
* https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB.
* @param {...number} hslValues
* @returns {number[]}
*/
static hslToRGB(...hslValues) {
let [hValue, sValue, lValue] = hslValues;
hValue /= 60;
sValue /= 100;
lValue /= 100;
let rValue = lValue;
let gValue = lValue;
let bValue = lValue;
let match = 0;
if (sValue !== 0) {
const chroma = (1 - Math.abs(2 * lValue - 1)) * sValue;
const x = chroma * (1 - Math.abs(hValue % 2 - 1));
match = lValue - chroma / 2;
if ((0 <= hValue) && (hValue <= 1)) {
rValue = chroma;
gValue = x;
bValue = 0;
} else if ((1 < hValue) && (hValue <= 2)) {
rValue = x;
gValue = chroma;
bValue = 0;
} else if ((2 < hValue) && (hValue <= 3)) {
rValue = 0;
gValue = chroma;
bValue = x;
} else if ((3 < hValue) && (hValue <= 4)) {
rValue = 0;
gValue = x;
bValue = chroma;
} else if ((4 < hValue) && (hValue <= 5)) {
rValue = x;
gValue = 0;
bValue = chroma;
} else {
// (5 < hValue) && (hValue < 6)
rValue = chroma;
gValue = 0;
bValue = x;
}
}
rValue = Math.round((rValue + match) * 255);
gValue = Math.round((gValue + match) * 255);
bValue = Math.round((bValue + match) * 255);
return [rValue, gValue, bValue];
}
/**
* The algorithm has been written based on the mathematical formula found at:
* https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB.
* @param {...number} rgbValues
* @returns {number[]}
*/
static rgbToHSL(...rgbValues) {
const [rValue, gValue, bValue] = rgbValues.map((value) => value / 255);
const max = Math.max(rValue, gValue, bValue);
const min = Math.min(rValue, gValue, bValue);
let hValue = 0;
let sValue = 0;
let lValue = (max + min) / 2;
if (max !== min) {
const diff = max - min;
if (max === rValue) {
hValue = ((gValue - bValue) / diff);
} else if (max === gValue) {
hValue = ((bValue - rValue) / diff) + 2;
} else {
// max === bValue
hValue = ((rValue - gValue) / diff) + 4;
}
hValue = Math.round(hValue * 60);
if (hValue < 0) {
hValue += 360;
}
sValue = Math.round((diff / (1 - Math.abs(2 * lValue - 1))) * 100);
}
lValue = Math.round(lValue * 100);
return [hValue, sValue, lValue];
}
/**
* @param {...number} rgbValues
* @returns {string}
*/
static hslToHex(...hslValues) {
return Color.rgbToHex(...Color.hslToRGB(...hslValues));
}
/**
* @param {string} hexValue
* @returns {...number}
*/
static hexToHSL(hexValue) {
return Color.rgbToHSL(...Color.hexToRGB(hexValue));
}
/**
* @param {number[]} colorTripleA RGB or HSL values
* @param {number[]} colorTripleB RGB or HSL values
* Both color triples must be of the same color format.
*/
static distance(colorTripleA, colorTripleB) {
return Math.sqrt(
Math.pow(colorTripleA[0] - colorTripleB[0], 2) +
Math.pow(colorTripleA[1] - colorTripleB[1], 2) +
Math.pow(colorTripleA[2] - colorTripleB[2], 2));
}
}
/**
* Point: Helper class to get/set coordinates for two dimensional points.
*/
class Point {
/**
* @param {number} x
* @param {number} y
*/
constructor(x, y) {
this.set(x, y);
}
/**
* @param {number} x
* @param {number} y
*/
set(x, y) {
this.x = x;
this.y = y;
}
get x() {
return this.x_;
}
/**
* @param {number} x
*/
set x(x) {
this.x_ = x;
}
get y() {
return this.y_;
}
/**
* @param {number} y
*/
set y(y) {
this.y_ = y;
}
}
/**
* ColorPicker: Custom element providing a color picker implementation.
* A color picker is comprised of three main parts: a visual color
* picker to allow visual selection of colors, a manual color
* picker to allow numeric selection of colors, and submission
* controls to save/discard new color selections.
*/
class ColorPicker extends HTMLElement {
/**
* @param {!Color} initialColor
*/
constructor(initialColor) {
super();
if (global.params.isBorderTransparent) {
this.style.borderColor = 'transparent';
}
this.selectedColor_ = initialColor;
this.colorWhenOpened_ = initialColor;
this.visualColorPicker_ = new VisualColorPicker(initialColor);
this.manualColorPicker_ = new ManualColorPicker(initialColor);
this.colorValueAXAnnouncer_ = new ColorValueAXAnnouncer();
this.append(
this.visualColorPicker_, this.manualColorPicker_,
this.colorValueAXAnnouncer_);
this.visualColorPicker_.addEventListener(
'visual-color-picker-initialized', this.initializeListeners_);
window.addEventListener('resize', this.onWindowResize_, {once: true});
}
initializeListeners_ = () => {
this.manualColorPicker_
.addEventListener('manual-color-change', this.onManualColorChange_);
this.addEventListener('visual-color-change', this.onVisualColorChange_);
this.addEventListener('format-change', this.onFormatChange_);
this.addEventListener('focusin', this.onFocusin_);
window.addEventListener('message', this.onMessageReceived_);
document.documentElement.addEventListener('keydown', this.onKeyDown_);
}
get selectedColor() {
return this.selectedColor_;
}
/**
* @param {!Color} newColor
*/
set selectedColor(newColor) {
this.selectedColor_ = newColor;
}
/**
* @param {!Event} event
*/
onManualColorChange_ = (event) => {
const newColor = event.detail.color;
if (!this.selectedColor.equals(newColor)) {
this.selectedColor = newColor;
this.updateVisualColorPicker(newColor);
const selectedValue = newColor.asHex();
window.pagePopupController.setValue(selectedValue);
}
};
/**
* @param {!Event} event
*/
onVisualColorChange_ = (event) => {
const newColor = event.detail.color;
if (!this.selectedColor.equals(newColor)) {
if (!this.processingManualColorChange_) {
this.selectedColor = newColor;
this.manualColorPicker_.color = newColor;
this.colorValueAXAnnouncer_.announceColor(newColor);
const selectedValue = newColor.asHex();
window.pagePopupController.setValue(selectedValue);
} else {
// We are making a visual color change in response to a manual color
// change. So we do not overwrite the manually specified values and do
// not change the selected color.
}
}
};
/**
* @param {!Color} newColor
*/
updateVisualColorPicker(newColor) {
// There may not be an exact match for newColor in the HueSlider or
// ColorWell, in which case we will display the closest match. When this
// happens though, we want the manually chosen values to remain the
// selected values (as they were explicitly specified by the user).
// Therefore, we need to prevent them from getting overwritten when
// onVisualColorChange_ runs. We do this by setting the
// processingManualColorChange_ flag here and checking for it inside
// onVisualColorChange_. If the flag is set, the manual color values
// will not be updated with the color shown in the visual color picker.
this.processingManualColorChange_ = true;
this.visualColorPicker_.color = newColor;
this.processingManualColorChange_ = false;
}
/**
* @param {!Event} event
*/
onKeyDown_ = (event) => {
switch (event.key) {
case 'Enter':
window.pagePopupController.closePopup();
break;
case 'Escape':
if (this.selectedColor.equals(this.colorWhenOpened_)) {
window.pagePopupController.closePopup();
} else {
this.manualColorPicker_.dispatchEvent(new CustomEvent(
'manual-color-change',
{bubbles: true, detail: {color: this.colorWhenOpened_}}));
}
break;
case 'Tab':
event.preventDefault();
if (this.focusableElements_ === undefined) {
this.updateFocusableElements_();
}
const length = this.focusableElements_.length;
if (length > 0) {
const currentFocusIndex =
this.focusableElements_.indexOf(document.activeElement);
let nextFocusIndex;
if (event.shiftKey) {
nextFocusIndex =
(currentFocusIndex > 0) ? currentFocusIndex - 1 : length - 1;
} else {
nextFocusIndex = (currentFocusIndex + 1) % length;
}
this.focusableElements_[nextFocusIndex].focus({preventScroll: true});
}
break;
}
};
onFormatChange_ = (event) => {
this.updateFocusableElements_();
this.colorValueAXAnnouncer_.updateColorFormat(event.detail.colorFormat);
};
onFocusin_ = (event) => {
if (event.target instanceof ColorSelectionRing) {
// Announce the current color when the user focuses the ColorWell or the
// HueSlider.
this.colorValueAXAnnouncer_.announceColor(this.selectedColor);
} else if (event.target instanceof FormatToggler) {
// Announce the current color format when the user focuses the
// FormatToggler.
this.colorValueAXAnnouncer_.announceColorFormat();
}
};
updateFocusableElements_ = () => {
this.focusableElements_ = Array.from(this.querySelectorAll(
'color-value-container:not(.hidden-color-value-container) > input,' +
'[tabindex]:not([tabindex=\'-1\'])'));
};
onWindowResize_ = () => {
// Set focus on the first focusable element.
if (this.focusableElements_ === undefined) {
this.updateFocusableElements_();
}
this.focusableElements_[0].focus({preventScroll: true});
};
onMessageReceived_ = (event) => {
eval(event.data);
if (window.updateData && window.updateData.success) {
// Update the popup with the color selected using the eye dropper.
const selectedValue = new Color(window.updateData.color);
this.selectedColor = selectedValue;
this.manualColorPicker_.color = selectedValue;
this.updateVisualColorPicker(selectedValue);
const hexValue = selectedValue.asHex();
window.pagePopupController.setValue(hexValue);
}
this.visualColorPicker_.eyeDropper.finished();
delete window.updateData;
}
}
window.customElements.define('color-picker', ColorPicker);
/**
* VisualColorPicker: Provides functionality to see the selected color and
* select a different color visually.
*/
class VisualColorPicker extends HTMLElement {
/**
* @param {!Color} initialColor
*/
constructor(initialColor) {
super();
let visualColorPickerStrip = document.createElement('span');
visualColorPickerStrip.setAttribute('id', 'visual-color-picker-strip');
this.eyeDropper_ = new EyeDropper();
this.colorViewer_ = new ColorViewer(initialColor);
this.hueSlider_ = new HueSlider(initialColor);
visualColorPickerStrip.append(
this.eyeDropper_, this.colorViewer_, this.hueSlider_);
this.append(visualColorPickerStrip);
this.colorWell_ = new ColorWell(initialColor);
this.prepend(this.colorWell_);
this.colorWell_.addEventListener('color-well-initialized', () => {
this.initializeListeners_();
});
this.hueSlider_.addEventListener('hue-slider-initialized', () => {
this.initializeListeners_();
});
}
initializeListeners_ = () => {
if (this.colorWell_.initialized && this.hueSlider_.initialized) {
this.addEventListener('hue-slider-update', this.onHueSliderUpdate_);
this.addEventListener('visual-color-change', this.onVisualColorChange_);
this.colorWell_
.addEventListener('mousedown', this.onColorWellMouseDown_);
this.hueSlider_
.addEventListener('mousedown', this.onHueSliderMouseDown_);
document.documentElement
.addEventListener('mousedown', this.onMouseDown_);
document.documentElement
.addEventListener('mousemove', this.onMouseMove_);
document.documentElement.addEventListener('mouseup', this.onMouseUp_);
this.colorWell_
.addEventListener('touchstart', this.onColorWellTouchStart_);
this.hueSlider_
.addEventListener('touchstart', this.onHueSliderTouchStart_);
document.documentElement
.addEventListener('touchstart', this.onTouchStart_);
document.documentElement
.addEventListener('touchmove', this.onTouchMove_);
document.documentElement.addEventListener('touchend', this.onTouchEnd_);
document.documentElement.addEventListener('keydown', this.onKeyDown_);
this.dispatchEvent(new CustomEvent('visual-color-picker-initialized'));
}
}
onHueSliderUpdate_ = () => {
this.colorWell_.fillColor = this.hueSlider_.color;
}
/**
* @param {!Event} event
*/
onVisualColorChange_ = (event) => {
this.colorViewer_.color = event.detail.color;
}
/**
* @param {!Event} event
*/
onColorWellMouseDown_ = (event) => {
event.preventDefault();
event.stopPropagation();
this.hueSlider_.focused = false;
this.colorWell_.pointerDown(new Point(event.clientX, event.clientY));
}
/**
* @param {!Event} event
*/
onHueSliderMouseDown_ = (event) => {
event.preventDefault();
event.stopPropagation();
this.colorWell_.focused = false;
this.hueSlider_.pointerDown(new Point(event.clientX, event.clientY));
}
onMouseDown_ = () => {
this.colorWell_.focused = false;
this.hueSlider_.focused = false;
}
/**
* @param {!Event} event
*/
onMouseMove_ = (event) => {
var point = new Point(event.clientX, event.clientY);
this.colorWell_.pointerMove(point);
this.hueSlider_.pointerMove(point);
}
onMouseUp_ = () => {
this.colorWell_.pointerUp();
this.hueSlider_.pointerUp();
}
/**
* @param {!Event} event
*/
onColorWellTouchStart_ = (event) => {
event.preventDefault();
event.stopPropagation();
this.hueSlider_.focused = false;
this.colorWell_.pointerDown(new Point(Math.round(event.touches[0].clientX), Math.round(event.touches[0].clientY)));
}
/**
* @param {!Event} event
*/
onHueSliderTouchStart_ = (event) => {
event.preventDefault();
event.stopPropagation();
this.colorWell_.focused = false;
this.hueSlider_.pointerDown(new Point(Math.round(event.touches[0].clientX), Math.round(event.touches[0].clientY)));
}
onTouchStart_ = () => {
this.colorWell_.focused = false;
this.hueSlider_.focused = false;
}
/**
* @param {!Event} event
*/
onTouchMove_ = (event) => {
var point = new Point(Math.round(event.touches[0].clientX), Math.round(event.touches[0].clientY));
this.colorWell_.pointerMove(point);
this.hueSlider_.pointerMove(point);
}
onTouchEnd_ = () => {
this.colorWell_.pointerUp();
this.hueSlider_.pointerUp();
}
/**
* @param {!Event} event
*/
onKeyDown_ = (event) => {
let moveDirection = Direction.UNDEFINED;
switch(event.key) {
case 'ArrowUp':
moveDirection = Direction.UP;
break;
case 'ArrowDown':
moveDirection = Direction.DOWN;
break;
case 'ArrowLeft':
moveDirection = Direction.LEFT;
break;
case 'ArrowRight':
moveDirection = Direction.RIGHT;
break;
}
if (moveDirection !== Direction.UNDEFINED) {
const acceleratedMove = event.ctrlKey;
this.hueSlider_.move(moveDirection, acceleratedMove);
this.colorWell_.move(moveDirection, acceleratedMove);
}
}
/**
* @param {!Color} newColor
*/
set color(newColor) {
this.hueSlider_.color = newColor;
this.colorWell_.selectedColor = newColor;
}
get eyeDropper() {
return this.eyeDropper_;
}
}
window.customElements.define('visual-color-picker', VisualColorPicker);
/**
* EyeDropper: Allows color selection from content outside the color picker.
* (This is currently just a placeholder for a future
* implementation.)
* TODO(http://crbug.com/992297): Implement eye dropper
*/
class EyeDropper extends HTMLElement {
constructor() {
super();
if (!global.params.isEyeDropperEnabled) {
this.classList.add('hidden');
return;
}
this.setAttribute('tabIndex', 0);
this.setAttribute('role', 'button');
this.setAttribute('aria-label', global.params.axEyedropperLabel);
this.addEventListener('click', this.onClick_);
this.addEventListener('keydown', this.onKeyDown_);
}
onClick_ = () => {
event.preventDefault();
event.stopPropagation();
this.classList.add('selected');
window.pagePopupController.openEyeDropper();
};
/**
* @param {!Event} event
*/
onKeyDown_ = (event) => {
switch (event.key) {
case 'Enter':
this.onClick_();
break;
}
};
finished = () => {
this.classList.remove('selected');
}
}
window.customElements.define('eye-dropper', EyeDropper);
/**
* ColorViewer: Provides a view of the selected color.
*/
class ColorViewer extends HTMLElement {
/**
* @param {!Color} initialColor
*/
constructor(initialColor) {
super();
this.color = initialColor;
// Leave the ColorViewer out of the accessibility tree; it's redundant
// with the updates from ColorValueAXAnnouncer.
this.setAttribute('aria-hidden', 'true');
}
get color() {
return this.color_;
}
/**
* @param {!Color} color
*/
set color(color) {
if (this.color_ === undefined || !this.color_.equals(color)) {
this.color_ = color;
this.style.backgroundColor = color.asRGB();
}
}
}
window.customElements.define('color-viewer', ColorViewer);
/**
* ColorSelectionArea: Base class for ColorWell and HueSlider that encapsulates
* a ColorPalette and a ColorSelectionRing.
*/
class ColorSelectionArea extends HTMLElement {
constructor() {
super();
this.colorPalette_ = new ColorPalette();
this.colorSelectionRing_ = new ColorSelectionRing(this.colorPalette_);
this.append(this.colorPalette_, this.colorSelectionRing_);
this.initialized_ = false;
this.colorSelectionRing_.addEventListener(
'focus', this.onColorSelectionRingFocus_);
this.colorSelectionRing_.addEventListener(
'blur', this.onColorSelectionRingBlur_);
}
get initialized() {
return this.initialized_;
}
onColorSelectionRingFocus_ = () => {
this.focused_ = true;
};
onColorSelectionRingBlur_ = () => {
this.focused_ = false;
};
/**
* @param {!Point} point
*/
pointerDown(point) {
this.colorSelectionRing_.focus({preventScroll: true});
this.colorSelectionRing_.drag = true;
this.moveColorSelectionRingTo_(point);
}
/**
* @param {!Point} point
*/
pointerMove(point) {
if (this.colorSelectionRing_.drag) {
this.moveColorSelectionRingTo_(point);
}
}
pointerUp() {
this.colorSelectionRing_.drag = false;
}
/**
* @param {!Direction} direction
* @param {bool} accelerated
*/
move(direction, accelerated) {
if (this.focused) {
this.colorSelectionRing_.move(direction, accelerated);
}
}
get focused() {
return this.focused_;
}
/**
* @param {bool} focused
*/
set focused(focused) {
this.focused_ = focused;
}
}
window.customElements.define('color-selection-area', ColorSelectionArea);
/**
* ColorPalette: Displays a range of colors.
*/
class ColorPalette extends HTMLCanvasElement {
constructor() {
super();
this.gradients_ = [];
}
/**
* @param {...CanvasGradient} gradients
*/
initialize(...gradients) {
this.width = this.offsetWidth;
this.height = this.offsetHeight;
this.renderingContext.rect(0, 0, this.width, this.height);
this.gradients_.push(...gradients);
this.fillColor = new Color('hsl(0, 100%, 50%)');
}
get hslImageData() {
if (this.pendingColorChange_) {
const rgbaImageData =
this.renderingContext.getImageData(0, 0, this.width, this.height)
.data;
this.hslImageData_ =
rgbaImageData.reduce((hslArray, {}, currentIndex, rgbaArray) => {
if ((currentIndex % 4) === 0) {
hslArray.push(...Color.rgbToHSL(
rgbaArray[currentIndex], rgbaArray[currentIndex + 1],
rgbaArray[currentIndex + 2]));
}
return hslArray;
}, []);
this.pendingColorChange_ = false;
}
if (this.pendingHueChange_) {
const hValueToSet = this.fillColor.hValue;
this.hslImageData_.forEach(({}, currentIndex, hslArray) => {
if ((currentIndex % 3) === 0) {
hslArray[currentIndex] = hValueToSet;
}
});
this.pendingHueChange_ = false;
}
return this.hslImageData_;
}
/**
* @param {!Point} point
*/
colorAtPoint(point) {
const hslImageDataAtPoint =
this.hslImageDataAtPoint_(point.x - this.left, point.y - this.top);
return new Color(
ColorFormat.HSL, hslImageDataAtPoint[0], hslImageDataAtPoint[1],
hslImageDataAtPoint[2]);
}
/**
* @param {number} x
* @param {number} y
*/
hslImageDataAtPoint_(x, y) {
let offset = (y * this.width + x) * 3;
// It is possible that the computed offset is larger than the hslImageData
// array's length. This can happen at certain zoom levels (ex. 150%), where
// the height of the color well is not a round number. The getImageData API
// only works with integer values and will truncate decimal values. As
// such, if the color well's selection ring is placed at the bottom of the
// color well at such a zoom level, a valid data point for the ring's
// position will not be found in the hslImageData array. When this happens,
// we just report the color at the end of the hslImageData array. This will
// be the same color that is seen at the bottom of the color well (black).
offset = Math.min(offset, this.hslImageData.length - 3);
return this.hslImageData.slice(offset, offset + 3);
}
get renderingContext() {
return this.getContext('2d');
}
get fillColor() {
return this.fillColor_;
}
/**
* @param {!Color} color
*/
set fillColor(color) {
this.fillColor_ = color;
this.fillColorAndGradients_();
this.pendingColorChange_ = true;
}
/**
* @param {!Color} color
*/
fillHue(color) {
this.fillColor_ = new Color(
ColorFormat.HSL, color.hValue, this.fillColor_.sValue,
this.fillColor_.lValue);
this.fillColorAndGradients_();
this.pendingHueChange_ = true;
}
fillColorAndGradients_() {
this.fillWithStyle_(this.fillColor_.asRGB());
this.gradients_.forEach((gradient) => this.fillWithStyle_(gradient));
}
/**
* @param {string|!CanvasGradient} fillStyle
*/
fillWithStyle_(fillStyle) {
let colorPaletteCtx = this.renderingContext;
colorPaletteCtx.fillStyle = fillStyle;
colorPaletteCtx.fill();
}
/**
* @param {!Point} point
*/
nearestPointOnColorPalette(point) {
if (!this.isXCoordinateOnColorPalette_(point)) {
if (point.x > this.right) {
point.x = this.right;
} else if (point.x < this.left) {
point.x = this.left;
}
}
if (!this.isYCoordinateOnColorPalette_(point)) {
if (point.y > this.bottom) {
point.y = this.bottom;
} else if (point.y < this.top) {
point.y = this.top;
}
}
return point;
}
/**
* @param {!Point} point
*/
isXCoordinateOnColorPalette_(point) {
return (point.x >= this.left) && (point.x <= this.right);
}
/**
* @param {!Point} point
*/
isYCoordinateOnColorPalette_(point) {
return (point.y >= this.top) && (point.y <= this.bottom);
}
get left() {
return Math.ceil(this.getBoundingClientRect().left);
}
get right() {
return Math.ceil(this.getBoundingClientRect().right - 1);
}
get top() {
return Math.ceil(this.getBoundingClientRect().top);
}
get bottom() {
return Math.ceil(this.getBoundingClientRect().bottom - 1);
}
}
window.customElements.define(
'color-palette', ColorPalette, {extends: 'canvas'});
/**
* ColorSelectionRing: Provides movement and color selection functionality to
* pick colors from a given ColorPalette.
*/
class ColorSelectionRing extends HTMLElement {
/**
* @param {!ColorPalette} backingColorPalette
*/
constructor(backingColorPalette) {
super();
this.setAttribute('tabIndex', 0);
this.backingColorPalette_ = backingColorPalette;
this.position_ = new Point(0, 0);
this.drag_ = false;
}
static get ACCELERATED_MOVE_DISTANCE() {
return 20;
}
static get MOVE_DISTANCE() {
return 1;
}
initialize() {
this.set(this.backingColorPalette_.left, this.backingColorPalette_.top);
this.onPositionChange_();
}
/**
* @param {!Point} newPosition
*/
moveTo(newPosition) {
this.set(newPosition.x, newPosition.y);
}
/**
* @param {number} x
* @param {number} y
*/
set(x, y) {
if ((x !== this.position_.x) || (y !== this.position_.y)) {
this.position_.x = x;
this.position_.y = y;
this.onPositionChange_();
}
}
/**
* @param {number} x
*/
setX(x) {
if (x !== this.position_.x) {
this.position_.x = x;
this.onPositionChange_();
}
}
/**
* @param {number} x
*/
setY(y) {
if (y !== this.position_.y) {
this.position_.y = y;
this.onPositionChange_();
}
}
/**
* @param {number} shiftFactor
*/
shiftX(shiftFactor) {
this.setX(this.position_.x + shiftFactor);
}
onPositionChange_() {
this.setElementPosition_();
this.updatePositionForAria_();
this.updateColor();
}
initializeAria(isForColorWell) {
this.setAttribute('role', 'slider');
this.isForColorWell = isForColorWell;
this.setAttribute('aria-valuemin', 0);
if (isForColorWell) {
this.setAttribute('aria-label', global.params.axColorWellLabel);
this.setAttribute(
'aria-roledescription', global.params.axColorWellRoleDescription);
this.setAttribute(
'aria-valuemax',
this.backingColorPalette_.offsetHeight *
this.backingColorPalette_.offsetWidth);
} else {
this.setAttribute('aria-label', global.params.axHueSliderLabel);
this.setAttribute(
'aria-valuemax',
this.backingColorPalette_.right - this.backingColorPalette_.left);
}
this.updatePositionForAria_();
}
updatePositionForAria_() {
if (this.isForColorWell) {
let positionX = (this.position_.x - this.backingColorPalette_.left);
let positionY = (this.position_.y - this.backingColorPalette_.top);
let colorWellWidth =
(this.backingColorPalette_.right - this.backingColorPalette_.left);
// aria-valuenow only takes a single numeric value, so we use this
// scheme to collapse the 2-D coordinates into a 1-D slider value.
this.setAttribute(
'aria-valuenow', (positionY * colorWellWidth) + positionX);
this.setAttribute('aria-valuetext', `X: ${positionX}, Y: ${positionY}`);
} else {
this.setAttribute(
'aria-valuenow', this.position_.x - this.backingColorPalette_.left);
}
}
setElementPosition_() {
if (this.height > this.backingColorPalette_.height) {
this.style.top = this.top -
(this.height - this.backingColorPalette_.height) / 2 -
this.backingColorPalette_.top + 'px';
} else {
this.style.top =
this.top - this.radius - this.backingColorPalette_.top + 'px';
}
if (this.width > this.backingColorPalette_.width) {
this.style.left = this.left -
(this.width - this.backingColorPalette_.width) / 2 -
this.backingColorPalette_.left + 'px';
} else {
this.style.left =
this.left - this.radius - this.backingColorPalette_.left + 'px';
}
}
updateColor() {
this.color = this.backingColorPalette_.colorAtPoint(this.position_);
this.dispatchEvent(new CustomEvent('color-selection-ring-update'));
}
get color() {
return this.color_;
}
/**
* @param {!Color} color
*/
set color(color) {
if (this.color_ === undefined || !this.color_.equals(color)) {
this.color_ = color;
this.style.backgroundColor = color.asRGB();
}
}
get canMoveHorizontally_() {
return this.width < this.backingColorPalette_.width;
}
get canMoveVertically_() {
return this.height < this.backingColorPalette_.height;
}
/**
* @param {!Direction} direction
* @param {bool} accelerated
*/
move(direction, accelerated) {
let shiftFactor = accelerated ?
ColorSelectionRing.ACCELERATED_MOVE_DISTANCE :
ColorSelectionRing.MOVE_DISTANCE;
if ((direction === Direction.UP) || (direction === Direction.LEFT)) {
shiftFactor *= -1;
}
if (this.canMoveHorizontally_ &&
((direction === Direction.LEFT) || (direction === Direction.RIGHT))) {
let newX = this.position_.x + shiftFactor;
if (direction === Direction.LEFT) {
if (this.position_.x + shiftFactor < this.backingColorPalette_.left) {
newX = this.backingColorPalette_.left;
}
} else {
// direction === Direction.RIGHT
if (this.position_.x + shiftFactor > this.backingColorPalette_.right) {
newX = this.backingColorPalette_.right;
}
}
this.setX(newX);
} else if (
this.canMoveVertically_ &&
((direction === Direction.UP) || (direction === Direction.DOWN))) {
let newY = this.position_.y + shiftFactor;
if (direction === Direction.UP) {
if (this.position_.y + shiftFactor < this.backingColorPalette_.top) {
newY = this.backingColorPalette_.top;
}
} else {
// direction === Direction.DOWN
if (this.position_.y + shiftFactor > this.backingColorPalette_.bottom) {
newY = this.backingColorPalette_.bottom;
}
}
this.setY(newY);
}
}
get drag() {
return this.drag_;
}
/**
* @param {boolean} drag
*/
set drag(drag) {
this.drag_ = drag;
}
get radius() {
return this.width / 2;
}
get width() {
return Math.floor(this.getBoundingClientRect().width);
}
get height() {
return Math.floor(this.getBoundingClientRect().height);
}
get left() {
return this.position_.x;
}
get top() {
return this.position_.y;
}
}
window.customElements.define('color-selection-ring', ColorSelectionRing);
/**
* ColorWell: Allows selection from a range of colors, between black and white,
* that have the same hue value.
*/
class ColorWell extends ColorSelectionArea {
/**
* @param {!Color} initialColor
*/
constructor(initialColor) {
super();
this.fillColor_ = new Color(ColorFormat.HSL, initialColor.hValue, 100, 50);
this.selectedColor_ = initialColor;
this.resizeObserver_ = new ResizeObserver(() => {
let whiteGradient =
this.colorPalette_.renderingContext.createLinearGradient(
0, 0, this.colorPalette_.offsetWidth, 0);
whiteGradient.addColorStop(0.01, 'hsla(0, 0%, 100%, 1)');
whiteGradient.addColorStop(0.99, 'hsla(0, 0%, 100%, 0)');
let blackGradient =
this.colorPalette_.renderingContext.createLinearGradient(
0, this.colorPalette_.offsetHeight, 0, 0);
blackGradient.addColorStop(0.01, 'hsla(0, 0%, 0%, 1)');
blackGradient.addColorStop(0.99, 'hsla(0, 0%, 0%, 0)');
this.colorPalette_.initialize(whiteGradient, blackGradient);
this.colorPalette_.fillHue(this.fillColor_);
this.colorSelectionRing_.initialize();
this.colorSelectionRing_.addEventListener(
'color-selection-ring-update', this.onColorSelectionRingUpdate_);
this.moveColorSelectionRingTo_(this.selectedColor_);
this.colorSelectionRing_.initializeAria(/*isForColorWell*/ true);
this.resizeObserver_.disconnect();
this.resizeObserver_ = null;
this.initialized_ = true;
this.dispatchEvent(new CustomEvent('color-well-initialized'));
});
this.resizeObserver_.observe(this);
}
/**
* @param {!Point|!Color} newPositionOrColor
*/
moveColorSelectionRingTo_(newPositionOrColor) {
if (newPositionOrColor instanceof Point) {
const point =
this.colorPalette_.nearestPointOnColorPalette(newPositionOrColor);
this.colorSelectionRing_.moveTo(point);
} else {
const closestHSLValueIndex = this.colorPalette_.hslImageData.reduce(
(closestSoFar, {}, index, array) => {
if ((index % 3) === 0) {
const currentHSLValueDistance = Color.distance(
[array[index], array[index + 1], array[index + 2]],
newPositionOrColor.hslValues());
const closestHSLValueDistance = Color.distance(
[
array[closestSoFar], array[closestSoFar + 1],
array[closestSoFar + 2]
],
newPositionOrColor.hslValues());
if (currentHSLValueDistance < closestHSLValueDistance) {
return index;
}
}
return closestSoFar;
},
0);
const offsetX = (closestHSLValueIndex / 3) % this.colorPalette_.width;
const offsetY =
Math.floor((closestHSLValueIndex / 3) / this.colorPalette_.width);
this.colorSelectionRing_.set(
this.colorPalette_.left + offsetX, this.colorPalette_.top + offsetY);
}
}
get selectedColor() {
return this.selectedColor_;
}
/**
* @param {!Color} newColor
*/
set selectedColor(newColor) {
if (!this.selectedColor_.equals(newColor)) {
this.selectedColor_ = newColor;
this.moveColorSelectionRingTo_(newColor);
}
}
get fillColor() {
return this.fillColor_;
}
/**
* @param {!Color} color
*/
set fillColor(color) {
if (!this.fillColor_.equals(color)) {
this.fillColor_ = color;
this.colorPalette_.fillHue(color);
this.colorSelectionRing_.updateColor();
}
}
onColorSelectionRingUpdate_ = () => {
this.selectedColor_ = this.colorSelectionRing_.color;
this.dispatchEvent(new CustomEvent(
'visual-color-change',
{bubbles: true, detail: {color: this.selectedColor}}));
}
}
window.customElements.define('color-well', ColorWell);
/**
* HueSlider: Allows selection from a range of colors with distinct hue values.
*/
class HueSlider extends ColorSelectionArea {
/**
* @param {!Color} initialColor
*/
constructor(initialColor) {
super();
this.color_ = new Color(ColorFormat.HSL, initialColor.hValue, 100, 50);
this.resizeObserver_ = new ResizeObserver(() => {
let hueSliderPaletteGradient =
this.colorPalette_.renderingContext.createLinearGradient(
0, 0, this.colorPalette_.offsetWidth, 0);
hueSliderPaletteGradient.addColorStop(0.01, 'hsl(0, 100%, 50%)');
hueSliderPaletteGradient.addColorStop(0.17, 'hsl(300, 100%, 50%)');
hueSliderPaletteGradient.addColorStop(0.33, 'hsl(240, 100%, 50%)');
hueSliderPaletteGradient.addColorStop(0.5, 'hsl(180, 100%, 50%)');
hueSliderPaletteGradient.addColorStop(0.67, 'hsl(120, 100%, 50%)');
hueSliderPaletteGradient.addColorStop(0.83, 'hsl(60, 100%, 50%)');
hueSliderPaletteGradient.addColorStop(0.99, 'hsl(0, 100%, 50%)');
this.colorPalette_.initialize(hueSliderPaletteGradient);
this.colorSelectionRing_.initialize();
this.colorSelectionRing_.addEventListener(
'color-selection-ring-update', this.onColorSelectionRingUpdate_);
this.moveColorSelectionRingTo_(this.color_);
this.colorSelectionRing_.initializeAria(/*isForColorWell*/ false);
this.resizeObserver_.disconnect();
this.resizeObserver_ = null;
this.initialized_ = true;
this.dispatchEvent(new CustomEvent('hue-slider-initialized'));
});
this.resizeObserver_.observe(this);
}
/**
* @param {!Point|!Color} newPositionOrColor
*/
moveColorSelectionRingTo_(newPositionOrColor) {
if (newPositionOrColor instanceof Point) {
const point =
this.colorPalette_.nearestPointOnColorPalette(newPositionOrColor);
this.colorSelectionRing_.shiftX(point.x - this.colorSelectionRing_.left);
} else {
const targetHValue = newPositionOrColor.hValue;
if (targetHValue !== this.colorSelectionRing_.color.hValue) {
const closestHValueIndex = this.colorPalette_.hslImageData.reduce(
(closestHValueIndexSoFar, currentHValue, index, array) => {
if ((index % 3 === 0) &&
(Math.abs(currentHValue - targetHValue) <
Math.abs(array[closestHValueIndexSoFar] - targetHValue))) {
return index;
}
return closestHValueIndexSoFar;
},
0);
const offsetX = (closestHValueIndex / 3) % this.colorPalette_.width;
this.colorSelectionRing_.setX(this.colorPalette_.left + offsetX);
}
}
}
get color() {
return this.color_;
}
/**
* @param {!Color} newColor
*/
set color(newColor) {
if (this.color_.hValue !== newColor.hValue) {
this.color_ = new Color(ColorFormat.HSL, newColor.hValue, 100, 50);
this.moveColorSelectionRingTo_(this.color_);
}
}
onColorSelectionRingUpdate_ = () => {
this.color_ = this.colorSelectionRing_.color;
this.dispatchEvent(new CustomEvent('hue-slider-update', {bubbles: true}));
}
}
window.customElements.define('hue-slider', HueSlider);
/**
* ManualColorPicker: Provides functionality to change the selected color by
* manipulating its numeric values.
*/
class ManualColorPicker extends HTMLElement {
/**
* @param {!Color} initialColor
*/
constructor(initialColor) {
super();
this.hexValueContainer_ =
new ColorValueContainer(ColorChannel.HEX, initialColor);
this.rgbValueContainer_ =
new ColorValueContainer(ColorFormat.RGB, initialColor);
this.hslValueContainer_ =
new ColorValueContainer(ColorFormat.HSL, initialColor);
this.colorValueContainers_ = [
this.hexValueContainer_,
this.rgbValueContainer_,
this.hslValueContainer_,
];
this.currentColorFormat_ = ColorFormat.RGB;
this.adjustValueContainerVisibility_();
this.formatToggler_ = new FormatToggler(this.currentColorFormat_);
this.append(...this.colorValueContainers_, this.formatToggler_);
this.formatToggler_.addEventListener('format-change', this.onFormatChange_);
this.addEventListener('manual-color-change', this.onManualColorChange_);
}
adjustValueContainerVisibility_() {
this.colorValueContainers_.forEach((colorValueContainer) => {
if (colorValueContainer.colorFormat === this.currentColorFormat_) {
colorValueContainer.show();
} else {
colorValueContainer.hide();
}
});
}
/**
* @param {!Event} event
*/
onFormatChange_ = (event) => {
this.currentColorFormat_ = event.detail.colorFormat;
this.adjustValueContainerVisibility_();
}
/**
* @param {!Event} event
*/
onManualColorChange_ = (event) => {
this.color = event.detail.color;
}
/**
* @param {!Color} newColor
*/
set color(newColor) {
this.colorValueContainers_.forEach(
(colorValueContainer) => colorValueContainer.color = newColor);
}
}
window.customElements.define('manual-color-picker', ManualColorPicker);
/**
* ColorValueContainer: Maintains a set of channel values that make up a given
* color format, and tracks value changes.
*/
class ColorValueContainer extends HTMLElement {
/**
* @param {!ColorFormat} colorFormat
* @param {!Color} initialColor
*/
constructor(colorFormat, initialColor) {
super();
this.colorFormat_ = colorFormat;
this.channelValueContainers_ = [];
if (this.colorFormat_ === ColorFormat.HEX) {
const hexValueContainer =
new ChannelValueContainer(ColorChannel.HEX, initialColor);
this.channelValueContainers_.push(hexValueContainer);
} else if (this.colorFormat_ === ColorFormat.RGB) {
const rValueContainer =
new ChannelValueContainer(ColorChannel.R, initialColor);
const gValueContainer =
new ChannelValueContainer(ColorChannel.G, initialColor);
const bValueContainer =
new ChannelValueContainer(ColorChannel.B, initialColor);
this.channelValueContainers_.push(
rValueContainer, gValueContainer, bValueContainer);
} else if (this.colorFormat_ === ColorFormat.HSL) {
const hValueContainer =
new ChannelValueContainer(ColorChannel.H, initialColor);
const sValueContainer =
new ChannelValueContainer(ColorChannel.S, initialColor);
const lValueContainer =
new ChannelValueContainer(ColorChannel.L, initialColor);
this.channelValueContainers_.push(
hValueContainer, sValueContainer, lValueContainer);
}
this.append(...this.channelValueContainers_);
this.channelValueContainers_.forEach(
(channelValueContainer) => channelValueContainer.addEventListener(
'input', this.onChannelValueChange_));
}
get colorFormat() {
return this.colorFormat_;
}
get color() {
return new Color(
this.colorFormat_,
...this.channelValueContainers_.map(
(channelValueContainer) => channelValueContainer.channelValue));
}
/**
* @param {!Color} color
*/
set color(color) {
this.channelValueContainers_.forEach(
(channelValueContainer) => channelValueContainer.setValue(color));
}
show() {
return this.classList.remove('hidden-color-value-container');
}
hide() {
return this.classList.add('hidden-color-value-container');
}
onChannelValueChange_ = () => {
this.dispatchEvent(new CustomEvent(
'manual-color-change', {bubbles: true, detail: {color: this.color}}));
}
}
window.customElements.define('color-value-container', ColorValueContainer);
/**
* ChannelValueContainer: Maintains and displays the numeric value
* for a given color channel.
*/
class ChannelValueContainer extends HTMLInputElement {
/**
* @param {!ColorChannel} colorChannel
* @param {!Color} initialColor
*/
constructor(colorChannel, initialColor) {
super();
this.setAttribute('type', 'text');
this.colorChannel_ = colorChannel;
switch (colorChannel) {
case ColorChannel.HEX:
this.setAttribute('id', 'hexValueContainer');
this.setAttribute('maxlength', '7');
this.setAttribute('aria-label', global.params.axHexadecimalEditLabel);
break;
case ColorChannel.R:
this.setAttribute('id', 'rValueContainer');
this.setAttribute('maxlength', '3');
this.setAttribute('aria-label', global.params.axRedEditLabel);
break;
case ColorChannel.G:
this.setAttribute('id', 'gValueContainer');
this.setAttribute('maxlength', '3');
this.setAttribute('aria-label', global.params.axGreenEditLabel);
break;
case ColorChannel.B:
this.setAttribute('id', 'bValueContainer');
this.setAttribute('maxlength', '3');
this.setAttribute('aria-label', global.params.axBlueEditLabel);
break;
case ColorChannel.H:
this.setAttribute('id', 'hValueContainer');
this.setAttribute('maxlength', '3');
this.setAttribute('aria-label', global.params.axHueEditLabel);
break;
case ColorChannel.S:
// up to 3 digits plus '%'
this.setAttribute('id', 'sValueContainer');
this.setAttribute('maxlength', '4');
this.setAttribute('aria-label', global.params.axSaturationEditLabel);
break;
case ColorChannel.L:
// up to 3 digits plus '%'
this.setAttribute('id', 'lValueContainer');
this.setAttribute('maxlength', '4');
this.setAttribute('aria-label', global.params.axLightnessEditLabel);
break;
}
this.setValue(initialColor);
this.addEventListener('input', this.onValueChange_);
this.addEventListener('blur', this.onBlur_);
this.addEventListener('focus', this.onFocus_);
}
get channelValue() {
return this.channelValue_;
}
/**
* @param {!Color} color
*/
setValue(color) {
switch (this.colorChannel_) {
case ColorChannel.HEX:
if (this.channelValue_ !== color.hexValue) {
this.channelValue_ = color.hexValue;
this.value = '#' + this.channelValue_;
}
break;
case ColorChannel.R:
if (this.channelValue_ !== color.rValue) {
this.channelValue_ = color.rValue;
this.value = this.channelValue_;
}
break;
case ColorChannel.G:
if (this.channelValue_ !== color.gValue) {
this.channelValue_ = color.gValue;
this.value = this.channelValue_;
}
break;
case ColorChannel.B:
if (this.channelValue_ !== color.bValue) {
this.channelValue_ = color.bValue;
this.value = this.channelValue_;
}
break;
case ColorChannel.H:
if (this.channelValue_ !== color.hValue) {
this.channelValue_ = color.hValue;
this.value = this.channelValue_;
}
break;
case ColorChannel.S:
if (this.channelValue_ !== color.sValue) {
this.channelValue_ = color.sValue;
this.value = this.channelValue_ + '%';
}
break;
case ColorChannel.L:
if (this.channelValue_ !== color.lValue) {
this.channelValue_ = color.lValue;
this.value = this.channelValue_ + '%';
}
break;
}
}
onValueChange_ = () => {
// Set this.channelValue_ based on the element's new value.
let value = this.value;
if (value) {
switch (this.colorChannel_) {
case ColorChannel.HEX:
if (value.startsWith('#'))
value = value.substr(1).toLowerCase();
if (value.match(/^[0-9a-f]+$/)) {
// Ex. 'ffffff' => this.channelValue_ == 'ffffff'
// Ex. 'ff' => this.channelValue_ == '0000ff'
this.channelValue_ = ('000000' + value).slice(-6);
}
break;
case ColorChannel.R:
case ColorChannel.G:
case ColorChannel.B:
if (value.match(/^\d+$/) && (0 <= value) && (value <= 255)) {
this.channelValue_ = Number(value);
}
break;
case ColorChannel.H:
if (value.match(/^\d+$/) && (0 <= value) && (value < 360)) {
this.channelValue_ = Number(value);
}
break;
case ColorChannel.S:
case ColorChannel.L:
if (value.endsWith('%'))
value = value.substring(0, value.length - 1);
if (value.match(/^\d+$/) && (0 <= value) && (value <= 100)) {
this.channelValue_ = Number(value);
}
break;
}
}
}
onBlur_ = () => {
switch (this.colorChannel_) {
case ColorChannel.HEX:
if (this.channelValue_ !== Number(this.value.substr(1))) {
this.value = '#' + this.channelValue_;
}
break;
case ColorChannel.R:
case ColorChannel.G:
case ColorChannel.B:
case ColorChannel.H:
if (this.channelValue_ !== Number(this.value)) {
this.value = this.channelValue_;
}
break;
case ColorChannel.S:
case ColorChannel.L:
if (this.channelValue_ !==
Number(this.value.substring(0, this.value.length - 1))) {
this.value = this.channelValue_ + '%';
}
break;
}
}
onFocus_ = () => {
this.select();
}
}
window.customElements.define(
'channel-value-container', ChannelValueContainer, {extends: 'input'});
/**
* FormatToggler: Button that powers switching between different color formats.
*/
class FormatToggler extends HTMLElement {
/**
* @param {!ColorFormat} initialColorFormat
*/
constructor(initialColorFormat) {
super();
this.setAttribute('tabIndex', 0);
this.setAttribute('role', 'spinbutton');
this.setAttribute('aria-label', global.params.axFormatTogglerLabel);
this.setAttribute('aria-valuenow', '1');
this.setAttribute('aria-valuemin', '1');
this.setAttribute('aria-valuemax', '3');
this.currentColorFormat_ = initialColorFormat;
this.rgbFormatLabel_ = new FormatLabel(ColorFormat.RGB);
this.hexFormatLabel_ = new FormatLabel(ColorFormat.HEX);
this.hslFormatLabel_ = new FormatLabel(ColorFormat.HSL);
this.colorFormatLabels_ = [
this.rgbFormatLabel_,
this.hexFormatLabel_,
this.hslFormatLabel_,
];
this.adjustFormatLabelVisibility_();
this.upDownIcon_ = document.createElement('span');
this.upDownIcon_.setAttribute('id', 'up-down-icon');
this.upDownIcon_.innerHTML =
'<svg class="up-down-icon" width="6" height="8" viewBox="0 0 6 8" fill="none" ' +
'xmlns="http://www.w3.org/2000/svg"><path d="M1.18359 ' +
'3.18359L0.617188 2.61719L3 0.234375L5.38281 2.61719L4.81641 ' +
'3.18359L3 1.36719L1.18359 3.18359ZM4.81641 4.81641L5.38281 ' +
'5.38281L3 7.76562L0.617188 5.38281L1.18359 4.81641L3 ' +
'6.63281L4.81641 4.81641Z" fill="WindowText"/></svg>';
this.append(...this.colorFormatLabels_, this.upDownIcon_);
this.addEventListener('click', this.onClick_);
this.addEventListener('keydown', this.onKeyDown_);
this.addEventListener('mousedown', (event) => event.preventDefault());
}
/**
* @param {bool} choosePreviousFormat if true, choose previous format
* instead of next
*/
updateColorFormat_(choosePreviousFormat) {
const numFormats = Object.keys(ColorFormat).length;
const newValue = choosePreviousFormat ? this.currentColorFormat_ - 1 :
this.currentColorFormat_ + 1;
const newColorFormatKey = Object.keys(ColorFormat).filter((key) => {
return ColorFormat[key] ===
(((newValue % numFormats) + numFormats) % numFormats);
});
this.currentColorFormat_ = ColorFormat[newColorFormatKey];
if (this.currentColorFormat_ === ColorFormat.RGB) {
this.setAttribute('aria-valuenow', '1');
} else if (this.currentColorFormat_ === ColorFormat.HSL) {
this.setAttribute('aria-valuenow', '2');
} else if (this.currentColorFormat_ === ColorFormat.HEX) {
this.setAttribute('aria-valuenow', '3');
}
this.adjustFormatLabelVisibility_();
this.dispatchEvent(new CustomEvent(
'format-change',
{bubbles: true, detail: {colorFormat: this.currentColorFormat_}}));
}
adjustFormatLabelVisibility_() {
this.colorFormatLabels_.forEach((colorFormatLabel) => {
if (colorFormatLabel.colorFormat === this.currentColorFormat_) {
colorFormatLabel.show();
} else {
colorFormatLabel.hide();
}
});
}
onClick_ = () => {
this.focus();
this.updateColorFormat_(false);
};
/**
* @param {!Event} event
*/
onKeyDown_ = (event) => {
switch (event.key) {
case 'ArrowUp':
this.updateColorFormat_(true);
break;
case 'ArrowDown':
case ' ':
this.updateColorFormat_(false);
break;
}
}
}
window.customElements.define('format-toggler', FormatToggler);
/**
* FormatLabel: Label for a given color format.
*/
class FormatLabel extends HTMLElement {
/**
* @param {!ColorFormat} colorFormat
*/
constructor(colorFormat) {
super();
this.colorFormat_ = colorFormat;
if (colorFormat === ColorFormat.HEX) {
this.hexChannelLabel_ = new ChannelLabel(ColorChannel.HEX);
this.append(this.hexChannelLabel_);
} else if (colorFormat === ColorFormat.RGB) {
this.rChannelLabel_ = new ChannelLabel(ColorChannel.R);
this.gChannelLabel_ = new ChannelLabel(ColorChannel.G);
this.bChannelLabel_ = new ChannelLabel(ColorChannel.B);
this.append(
this.rChannelLabel_, this.gChannelLabel_, this.bChannelLabel_);
} else if (colorFormat === ColorFormat.HSL) {
this.hChannelLabel_ = new ChannelLabel(ColorChannel.H);
this.sChannelLabel_ = new ChannelLabel(ColorChannel.S);
this.lChannelLabel_ = new ChannelLabel(ColorChannel.L);
this.append(
this.hChannelLabel_, this.sChannelLabel_, this.lChannelLabel_);
}
}
get colorFormat() {
return this.colorFormat_;
}
show() {
return this.classList.remove('hidden-format-label');
}
hide() {
return this.classList.add('hidden-format-label');
}
}
window.customElements.define('format-label', FormatLabel);
/**
* ChannelLabel: Label for a color channel, to be used within a FormatLabel.
*/
class ChannelLabel extends HTMLElement {
/**
* @param {!ColorChannel} colorChannel
*/
constructor(colorChannel) {
super();
if (colorChannel === ColorChannel.HEX) {
this.textContent = 'HEX';
} else if (colorChannel === ColorChannel.R) {
this.textContent = 'R';
} else if (colorChannel === ColorChannel.G) {
this.textContent = 'G';
} else if (colorChannel === ColorChannel.B) {
this.textContent = 'B';
} else if (colorChannel === ColorChannel.H) {
this.textContent = 'H';
} else if (colorChannel === ColorChannel.S) {
this.textContent = 'S';
} else if (colorChannel === ColorChannel.L) {
this.textContent = 'L';
}
}
}
window.customElements.define('channel-label', ChannelLabel);
/**
* ColorValueAXAnnouncer: Make announcements to be read out by accessibility tools
* when the color value is changed by the ColorWell or HueSlider.
* Ideally it would be sufficient to just set the right ARIA attributes on the elements
* themselves, but the color control does not fit neatly into existing ARIA roles.
* ColorValueAXAnnouncer fills this gap by reading out color value changes using an
* ARIA live region.
*/
class ColorValueAXAnnouncer extends HTMLElement {
constructor() {
super();
this.setAttribute('aria-live', 'polite');
this.colorFormat_ = ColorFormat.RGB;
// We don't want this element to be visible so hide it off the edge of the popup.
this.style.position = 'absolute';
this.style.left = '-99999ch';
this.addEventListener('format-change', this.onFormatChange_);
}
announceColor(newColor) {
let announcementString = null;
if (this.colorFormat_ === ColorFormat.HEX) {
announcementString =
`${global.params.axHexadecimalEditLabel} ${newColor.hexValue}`;
} else if (this.colorFormat_ === ColorFormat.RGB) {
announcementString =
`${global.params.axRedEditLabel} ${newColor.rValue}, ${
global.params.axGreenEditLabel} ${newColor.gValue}, ${
global.params.axBlueEditLabel} ${newColor.bValue}`;
} else if (this.colorFormat_ === ColorFormat.HSL) {
announcementString =
`${global.params.axHueEditLabel} ${newColor.hValue}, ${
global.params.axSaturationEditLabel} ${newColor.sValue}, ${
global.params.axLightnessEditLabel} ${newColor.lValue}`;
}
this.announce_(announcementString)
}
// Announce format changes via the live region in order to work around an
// issue where Windows Narrator does not support aria-valuetext for
// spinbutton. The behavior that this achieves is similar to updating the
// FormatToggler spinbutton's aria-valuetext whenever the format changes,
// but it dodges the Narrator bug.
// TODO(crbug.com/1073188): Remove this workaround and use aria-valuetext
// instead once the Narrator bug has been fixed.
announceColorFormat() {
// These are deliberately non-localized so that they match the
// abbreviations of the text on the FormatToggler ChannelLabels,
// which are also not localized.
let announcementString = null;
if (this.colorFormat_ === ColorFormat.HEX) {
announcementString = 'Hex';
} else if (this.colorFormat_ === ColorFormat.RGB) {
announcementString = 'RGB';
} else if (this.colorFormat_ === ColorFormat.HSL) {
announcementString = 'HSL';
}
this.announce_(announcementString)
}
updateColorFormat(newColorFormat) {
this.colorFormat_ = newColorFormat;
this.announceColorFormat();
}
announce_(announcementString) {
// Only cue one announcement at a time so that user isn't spammed with a backlog
// of announcements after holding down an arrow key.
// Announce after a delay so that the control announces its raw position before
// the full announcement starts.
window.clearTimeout(this.pendingAnnouncement_);
this.pendingAnnouncement_ = window.setTimeout(() => {
if (this.textContent === announcementString) {
// The AT will only do an announcement if the live-region content has
// changed, so make a no-op change to fool it into announcing every
// time. Normal whitespace is ignored by Narrator for this purpose,
// so use a non-breaking space.
this.textContent += String.fromCharCode(160);
} else {
this.textContent = announcementString;
}
}, ColorValueAXAnnouncer.announcementDelayMS);
}
static announcementDelayMS = 500;
}
window.customElements.define('color-value-ax-announcer', ColorValueAXAnnouncer);