/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const EventEmitter = require(
"resource://devtools/shared/event-emitter.js");
const {
MultiLocalizationHelper,
} = require(
"resource://devtools/shared/l10n.js");
loader.lazyRequireGetter(
this,
"colorUtils",
"resource://devtools/shared/css/color.js",
true
);
loader.lazyRequireGetter(
this,
"labColors",
"resource://devtools/shared/css/color-db.js",
true
);
loader.lazyRequireGetter(
this,
[
"getTextProperties",
"getContrastRatioAgainstBackground"],
"resource://devtools/shared/accessibility.js",
true
);
const L10N =
new MultiLocalizationHelper(
"devtools/client/locales/accessibility.properties",
"devtools/client/locales/inspector.properties"
);
const ARROW_KEYS = [
"ArrowUp",
"ArrowRight",
"ArrowDown",
"ArrowLeft"];
const [ArrowUp, ArrowRight, ArrowDown, ArrowLeft] = ARROW_KEYS;
const XHTML_NS =
"http://www.w3.org/1999/xhtml";
const SLIDER = {
hue: {
MIN:
"0",
MAX:
"128",
STEP:
"1",
},
alpha: {
MIN:
"0",
MAX:
"1",
STEP:
"0.01",
},
};
/**
* Spectrum creates a color picker widget in any container you give it.
*
* Simple usage example:
*
* const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
* let s = new Spectrum(containerElement, [255, 126, 255, 1]);
* s.on("changed", (rgba, color) => {
* console.log("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " +
* rgba[3] + ")");
* });
* s.show();
* s.destroy();
*
* Note that the color picker is hidden by default and you need to call show to
* make it appear. This 2 stages initialization helps in cases you are creating
* the color picker in a parent element that hasn't been appended anywhere yet
* or that is hidden. Calling show() when the parent element is appended and
* visible will allow spectrum to correctly initialize its various parts.
*
* Fires the following events:
* - changed : When the user changes the current color
*/
class Spectrum {
constructor(parentEl, rgb) {
EventEmitter.decorate(
this);
this.document = parentEl.ownerDocument;
this.element = parentEl.ownerDocument.createElementNS(XHTML_NS,
"div");
this.parentEl = parentEl;
this.element.className =
"spectrum-container";
// eslint-disable-next-line no-unsanitized/property
this.element.innerHTML = `
<section
class=
"spectrum-color-picker">
<div
class=
"spectrum-color spectrum-box"
tabindex=
"0"
role=
"slider"
title=
"${L10N.getStr("colorPickerTooltip.spectrumDraggerTitle
")}"
aria-describedby=
"spectrum-dragger">
<div
class=
"spectrum-sat">
<div
class=
"spectrum-val">
<div
class=
"spectrum-dragger" id=
"spectrum-dragger"></div>
</div>
</div>
</div>
</section>
<section
class=
"spectrum-controls">
<div
class=
"spectrum-color-preview"></div>
<div
class=
"spectrum-slider-container">
<div
class=
"spectrum-hue spectrum-box"></div>
<div
class=
"spectrum-alpha spectrum-checker spectrum-box"></div>
</div>
</section>
<section
class=
"spectrum-color-contrast accessibility-color-contrast">
<div
class=
"contrast-ratio-header-and-single-ratio">
<span
class=
"contrast-ratio-label" role=
"presentation"></span>
<span
class=
"contrast-value-and-swatch contrast-ratio-single" role=
"presentation">
<span
class=
"accessibility-contrast-value"></span>
</span>
</div>
<div
class=
"contrast-ratio-range">
<span
class=
"contrast-value-and-swatch contrast-ratio-min" role=
"presentation">
<span
class=
"accessibility-contrast-value"></span>
</span>
<span
class=
"accessibility-color-contrast-separator"></span>
<span
class=
"contrast-value-and-swatch contrast-ratio-max" role=
"presentation">
<span
class=
"accessibility-contrast-value"></span>
</span>
</div>
</section>
`;
this.onElementClick =
this.onElementClick.bind(
this);
this.element.addEventListener(
"click",
this.onElementClick);
this.parentEl.appendChild(
this.element);
// Color spectrum dragger.
this.dragger =
this.element.querySelector(
".spectrum-color");
this.dragHelper =
this.element.querySelector(
".spectrum-dragger");
draggable(
this.dragger,
this.dragHelper,
this.onDraggerMove.bind(
this));
// Here we define the components for the "controls" section of the color picker.
this.controls =
this.element.querySelector(
".spectrum-controls");
this.colorPreview =
this.element.querySelector(
".spectrum-color-preview");
// Create the eyedropper.
const eyedropper =
this.document.createElementNS(XHTML_NS,
"button");
eyedropper.id =
"eyedropper-button";
eyedropper.className =
"devtools-button";
eyedropper.style.pointerEvents =
"auto";
eyedropper.setAttribute(
"aria-label",
L10N.getStr(
"colorPickerTooltip.eyedropperTitle")
);
this.controls.insertBefore(eyedropper,
this.colorPreview);
// Hue slider and alpha slider
this.hueSlider =
this.createSlider(
"hue",
this.onHueSliderMove.bind(
this));
this.hueSlider.setAttribute(
"aria-describedby",
this.dragHelper.id);
this.alphaSlider =
this.createSlider(
"alpha",
this.onAlphaSliderMove.bind(
this)
);
// Color contrast
this.spectrumContrast =
this.element.querySelector(
".spectrum-color-contrast"
);
this.contrastLabel =
this.element.querySelector(
".contrast-ratio-label");
[
this.contrastValue,
this.contrastValueMin,
this.contrastValueMax] =
this.element.querySelectorAll(
".accessibility-contrast-value");
// Create the learn more info button
const learnMore =
this.document.createElementNS(XHTML_NS,
"button");
learnMore.id =
"learn-more-button";
learnMore.className =
"learn-more";
learnMore.title = L10N.getStr(
"accessibility.learnMore");
this.element
.querySelector(
".contrast-ratio-header-and-single-ratio")
.appendChild(learnMore);
if (rgb) {
this.rgb = rgb;
this.updateUI();
}
}
set textProps(style) {
this._textProps = style
? {
fontSize: style[
"font-size"].value,
fontWeight: style[
"font-weight"].value,
opacity: style.opacity.value,
}
:
null;
}
set rgb(color) {
this.hsv = rgbToHsv(color[0], color[1], color[2], color[3]);
}
set backgroundColorData(colorData) {
this._backgroundColorData = colorData;
}
get backgroundColorData() {
return this._backgroundColorData;
}
get textProps() {
return this._textProps;
}
get rgb() {
const rgb = hsvToRgb(
this.hsv[0],
this.hsv[1],
this.hsv[2],
this.hsv[3]);
return [
Math.round(rgb[0]),
Math.round(rgb[1]),
Math.round(rgb[2]),
Math.round(rgb[3] * 100) / 100,
];
}
/**
* Map current rgb to the closest color available in the database by
* calculating the delta-E between each available color and the current rgb
*
* @return {String}
* Color name or closest color name
*/
get colorName() {
const labColorEntries = Object.entries(labColors);
const deltaEs = labColorEntries.map(color =>
colorUtils.calculateDeltaE(color[1], colorUtils.rgbToLab(
this.rgb))
);
// Get the color name for the one that has the lowest delta-E
const minDeltaE = Math.min(...deltaEs);
const colorName = labColorEntries[deltaEs.indexOf(minDeltaE)][0];
return minDeltaE === 0
? colorName
: L10N.getFormatStr(
"colorPickerTooltip.colorNameTitle", colorName);
}
get rgbNoSatVal() {
const rgb = hsvToRgb(
this.hsv[0], 1, 1);
return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), rgb[3]];
}
get rgbCssString() {
const rgb =
this.rgb;
return (
"rgba(" + rgb[0] +
", " + rgb[1] +
", " + rgb[2] +
", " + rgb[3] +
")"
);
}
show() {
this.dragWidth =
this.dragger.offsetWidth;
this.dragHeight =
this.dragger.offsetHeight;
this.dragHelperHeight =
this.dragHelper.offsetHeight;
this.updateUI();
}
onElementClick(e) {
e.stopPropagation();
}
onHueSliderMove() {
this.hsv[0] =
this.hueSlider.value /
this.hueSlider.max;
this.updateUI();
this.onChange();
}
onDraggerMove(dragX, dragY) {
this.hsv[1] = dragX /
this.dragWidth;
this.hsv[2] = (
this.dragHeight - dragY) /
this.dragHeight;
this.updateUI();
this.onChange();
}
onAlphaSliderMove() {
this.hsv[3] =
this.alphaSlider.value /
this.alphaSlider.max;
this.updateUI();
this.onChange();
}
onChange() {
this.emit(
"changed",
this.rgb,
this.rgbCssString);
}
/**
* Creates and initializes a slider element, attaches it to its parent container
* based on the slider type and returns it
*
* @param {String} sliderType
* The type of the slider (i.e. alpha or hue)
* @param {Function} onSliderMove
* The function to tie the slider to on input
* @return {DOMNode}
* Newly created slider
*/
createSlider(sliderType, onSliderMove) {
const container =
this.element.querySelector(`.spectrum-${sliderType}`);
const slider =
this.document.createElementNS(XHTML_NS,
"input");
slider.className = `spectrum-${sliderType}-input`;
slider.type =
"range";
slider.min = SLIDER[sliderType].MIN;
slider.max = SLIDER[sliderType].MAX;
slider.step = SLIDER[sliderType].STEP;
slider.title = L10N.getStr(`colorPickerTooltip.${sliderType}SliderTitle`);
slider.addEventListener(
"input", onSliderMove);
container.appendChild(slider);
return slider;
}
/**
* Updates the contrast label with appropriate content (i.e. large text indicator
* if the contrast is calculated for large text, or a base label otherwise)
*
* @param {Boolean} isLargeText
* True if contrast is calculated for large text.
*/
updateContrastLabel(isLargeText) {
if (!isLargeText) {
this.contrastLabel.textContent = L10N.getStr(
"accessibility.contrast.ratio.label"
);
return;
}
// Clear previously appended children before appending any new children
while (
this.contrastLabel.firstChild) {
this.contrastLabel.firstChild.remove();
}
const largeTextStr = L10N.getStr(
"accessibility.contrast.large.text");
const contrastLabelStr = L10N.getFormatStr(
"colorPickerTooltip.contrast.large.title",
largeTextStr
);
// Build an array of children nodes for the contrast label element
const contents = contrastLabelStr
.split(
new RegExp(largeTextStr), 2)
.map(content =>
this.document.createTextNode(content));
const largeTextIndicator =
this.document.createElementNS(XHTML_NS,
"span");
largeTextIndicator.className =
"accessibility-color-contrast-large-text";
largeTextIndicator.textContent = largeTextStr;
largeTextIndicator.title = L10N.getStr(
"accessibility.contrast.large.title"
);
contents.splice(1, 0, largeTextIndicator);
// Append children to contrast label
for (
const content of contents) {
this.contrastLabel.appendChild(content);
}
}
/**
* Updates a contrast value element with the given score, value and swatches.
*
* @param {DOMNode} el
* Contrast value element to update.
* @param {String} score
* Contrast ratio score.
* @param {Number} value
* Contrast ratio value.
* @param {Array} backgroundColor
* RGBA color array for the background color to show in the swatch.
*/
updateContrastValueEl(el, score, value, backgroundColor) {
el.classList.toggle(score,
true);
el.textContent = value.toFixed(2);
el.title = L10N.getFormatStr(
`accessibility.contrast.annotation.${score}`,
L10N.getFormatStr(
"colorPickerTooltip.contrastAgainstBgTitle",
`rgba(${backgroundColor})`
)
);
el.parentElement.style.setProperty(
"--accessibility-contrast-color",
this.rgbCssString
);
el.parentElement.style.setProperty(
"--accessibility-contrast-bg",
`rgba(${backgroundColor})`
);
}
updateAlphaSlider() {
// Set alpha slider background
const rgb =
this.rgb;
const rgbNoAlpha =
"rgb(" + rgb[0] +
"," + rgb[1] +
"," + rgb[2] +
")";
const rgbAlpha0 =
"rgba(" + rgb[0] +
"," + rgb[1] +
"," + rgb[2] +
", 0)";
const alphaGradient =
"linear-gradient(to right, " + rgbAlpha0 +
", " + rgbNoAlpha +
")";
this.alphaSlider.style.background = alphaGradient;
}
updateColorPreview() {
// Overlay the rgba color over a checkered image background.
this.colorPreview.style.setProperty(
"--overlay-color",
this.rgbCssString);
// We should be able to distinguish the color preview on high luminance rgba values.
// Give the color preview a light grey border if the luminance of the current rgba
// tuple is great.
const colorLuminance = colorUtils.calculateLuminance(
this.rgb);
this.colorPreview.classList.toggle(
"high-luminance", colorLuminance > 0.85);
// Set title on color preview for better UX
this.colorPreview.title =
this.colorName;
}
updateDragger() {
// Set dragger background color
const flatColor =
"rgb(" +
this.rgbNoSatVal[0] +
", " +
this.rgbNoSatVal[1] +
", " +
this.rgbNoSatVal[2] +
")";
this.dragger.style.backgroundColor = flatColor;
// Set dragger aria attributes
this.dragger.setAttribute(
"aria-valuetext",
this.rgbCssString);
}
updateHueSlider() {
// Set hue slider aria attributes
this.hueSlider.setAttribute(
"aria-valuetext",
this.rgbCssString);
}
updateHelperLocations() {
const h =
this.hsv[0];
const s =
this.hsv[1];
const v =
this.hsv[2];
// Placing the color dragger
let dragX = s *
this.dragWidth;
let dragY =
this.dragHeight - v *
this.dragHeight;
const helperDim =
this.dragHelperHeight / 2;
dragX = Math.max(
-helperDim,
Math.min(
this.dragWidth - helperDim, dragX - helperDim)
);
dragY = Math.max(
-helperDim,
Math.min(
this.dragHeight - helperDim, dragY - helperDim)
);
this.dragHelper.style.top = dragY +
"px";
this.dragHelper.style.left = dragX +
"px";
// Placing the hue slider
this.hueSlider.value = h *
this.hueSlider.max;
// Placing the alpha slider
this.alphaSlider.value =
this.hsv[3] *
this.alphaSlider.max;
}
/* Calculates the contrast ratio for the currently selected
* color against a single or range of background colors and displays contrast ratio section
* components depending on the contrast ratio calculated.
*
* Contrast ratio components include:
* - contrastLargeTextIndicator: Hidden by default, shown when text has large font
* size if there is no error in calculation.
* - contrastValue(s): Set to calculated value(s), score(s) and text color on
* background swatches. Set to error text
* if there is an error in calculation.
*/
updateContrast() {
// Remove additional classes on spectrum contrast, leaving behind only base classes
this.spectrumContrast.classList.toggle(
"visible",
false);
this.spectrumContrast.classList.toggle(
"range",
false);
this.spectrumContrast.classList.toggle(
"error",
false);
// Assign only base class to all contrastValues, removing any score class
this.contrastValue.className =
this.contrastValueMin.className =
this.contrastValueMax.className =
"accessibility-contrast-value";
if (!
this.contrastEnabled) {
return;
}
const isRange =
this.backgroundColorData.min !== undefined;
this.spectrumContrast.classList.toggle(
"visible",
true);
this.spectrumContrast.classList.toggle(
"range", isRange);
const colorContrast = getContrastRatio(
{
...
this.textProps,
color:
this.rgbCssString,
},
this.backgroundColorData
);
const {
value,
min,
max,
score,
scoreMin,
scoreMax,
backgroundColor,
backgroundColorMin,
backgroundColorMax,
isLargeText,
error,
} = colorContrast;
if (error) {
this.updateContrastLabel(
false);
this.spectrumContrast.classList.toggle(
"error",
true);
// If current background color is a range, show the error text in the contrast range
// span. Otherwise, show it in the single contrast span.
const contrastValEl = isRange
?
this.contrastValueMin
:
this.contrastValue;
contrastValEl.textContent = L10N.getStr(
"accessibility.contrast.error");
contrastValEl.title = L10N.getStr(
"accessibility.contrast.annotation.transparent.error"
);
return;
}
this.updateContrastLabel(isLargeText);
if (!isRange) {
this.updateContrastValueEl(
this.contrastValue,
score,
value,
backgroundColor
);
return;
}
this.updateContrastValueEl(
this.contrastValueMin,
scoreMin,
min,
backgroundColorMin
);
this.updateContrastValueEl(
this.contrastValueMax,
scoreMax,
max,
backgroundColorMax
);
}
updateUI() {
this.updateHelperLocations();
this.updateColorPreview();
this.updateDragger();
this.updateHueSlider();
this.updateAlphaSlider();
this.updateContrast();
}
destroy() {
this.element.removeEventListener(
"click",
this.onElementClick);
this.hueSlider.removeEventListener(
"input",
this.onHueSliderMove);
this.alphaSlider.removeEventListener(
"input",
this.onAlphaSliderMove);
this.parentEl.removeChild(
this.element);
this.dragger =
this.dragHelper =
null;
this.alphaSlider =
null;
this.hueSlider =
null;
this.colorPreview =
null;
this.element =
null;
this.parentEl =
null;
this.spectrumContrast =
null;
this.contrastValue =
this.contrastValueMin =
this.contrastValueMax =
null;
this.contrastLabel =
null;
}
}
function hsvToRgb(h, s, v, a) {
let r, g, b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
r = v;
g = t;
b = p;
break;
case 1:
r = q;
g = v;
b = p;
break;
case 2:
r = p;
g = v;
b = t;
break;
case 3:
r = p;
g = q;
b = v;
break;
case 4:
r = t;
g = p;
b = v;
break;
case 5:
r = v;
g = p;
b = q;
break;
}
return [r * 255, g * 255, b * 255, a];
}
function rgbToHsv(r, g, b, a) {
r = r / 255;
g = g / 255;
b = b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const v = max;
const d = max - min;
const s = max == 0 ? 0 : d / max;
let h;
if (max == min) {
// achromatic
h = 0;
}
else {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return [h, s, v, a];
}
function draggable(element, dragHelper, onmove) {
onmove = onmove ||
function () {};
const doc = element.ownerDocument;
let dragging =
false;
let offset = {};
let maxHeight = 0;
let maxWidth = 0;
function setDraggerDimensionsAndOffset() {
maxHeight = element.offsetHeight;
maxWidth = element.offsetWidth;
offset = element.getBoundingClientRect();
}
function prevent(e) {
e.stopPropagation();
e.preventDefault();
}
function move(e) {
if (dragging) {
if (e.buttons === 0) {
// The button is no longer pressed but we did not get a mouseup event.
stop();
return;
}
const pageX = e.pageX;
const pageY = e.pageY;
const dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
const dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));
onmove.apply(element, [dragX, dragY]);
}
}
function start(e) {
const rightClick = e.which === 3;
if (!rightClick && !dragging) {
dragging =
true;
setDraggerDimensionsAndOffset();
move(e);
doc.addEventListener(
"selectstart", prevent);
doc.addEventListener(
"dragstart", prevent);
doc.addEventListener(
"mousemove", move);
doc.addEventListener(
"mouseup", stop);
prevent(e);
}
}
function stop() {
if (dragging) {
doc.removeEventListener(
"selectstart", prevent);
doc.removeEventListener(
"dragstart", prevent);
doc.removeEventListener(
"mousemove", move);
doc.removeEventListener(
"mouseup", stop);
}
dragging =
false;
}
function onKeydown(e) {
const { key } = e;
if (!ARROW_KEYS.includes(key)) {
return;
}
setDraggerDimensionsAndOffset();
const { offsetHeight, offsetTop, offsetLeft } = dragHelper;
let dragX = offsetLeft + offsetHeight / 2;
let dragY = offsetTop + offsetHeight / 2;
if (key === ArrowLeft && dragX > 0) {
dragX -= 1;
}
else if (key === ArrowRight && dragX < maxWidth) {
dragX += 1;
}
else if (key === ArrowUp && dragY > 0) {
dragY -= 1;
}
else if (key === ArrowDown && dragY < maxHeight) {
dragY += 1;
}
onmove.apply(element, [dragX, dragY]);
}
element.addEventListener(
"mousedown", start);
element.addEventListener(
"keydown", onKeydown);
}
/**
* Calculates the contrast ratio for a DOM node's computed style against
* a given background.
*
* @param {Object} computedStyle
* The computed style for which we want to calculate the contrast ratio.
* @param {Object} backgroundColor
* Object with one or more of the following properties: value, min, max
* @return {Object}
* An object that may contain one or more of the following fields: error,
* isLargeText, value, score for contrast.
*/
function getContrastRatio(computedStyle, backgroundColor) {
const props = getTextProperties(computedStyle);
if (!props) {
return {
error:
true,
};
}
return getContrastRatioAgainstBackground(backgroundColor, props);
}
module.exports = Spectrum;