diff options
Diffstat (limited to 'devtools/client/shared/widgets/Spectrum.js')
-rw-r--r-- | devtools/client/shared/widgets/Spectrum.js | 783 |
1 files changed, 783 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/Spectrum.js b/devtools/client/shared/widgets/Spectrum.js new file mode 100644 index 0000000000..cdf5f2df6b --- /dev/null +++ b/devtools/client/shared/widgets/Spectrum.js @@ -0,0 +1,783 @@ +/* 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; |