/** * @copyright (C) 2019 Open Source Matters, Inc. * @license GNU General Public License version 2 or later; see LICENSE.txt */ /* eslint class-methods-use-this: ["error", { "exceptMethods": ["rgbToHex", "hslToRgb"] }] */ (document => { /** * Regex for hex values e.g. #FF3929 * @type {RegExp} */ const hexRegex = /^#([a-z0-9]{1,2})([a-z0-9]{1,2})([a-z0-9]{1,2})$/i; /** * Regex for rgb values e.g. rgba(255, 0, 24, 0.5); * @type {RegExp} */ const rgbRegex = /^rgba?\(([0-9]+)[\D]+([0-9]+)[\D]+([0-9]+)(?:[\D]+([0-9](?:.\d+)?))?\)$/i; /** * Regex for hsl values e.g. hsl(255,0,24); * @type {RegExp} */ const hslRegex = /^hsla?\(([0-9]+)[\D]+([0-9]+)[\D]+([0-9]+)[\D]+([0-9](?:.\d+)?)?\)$/i; /** * Regex for saturation and lightness of hsl - only accepts 1 or 0 or 0.4 or 40 * @type {RegExp} */ const hslNumberRegex = /^(([0-1])|(0\\.[0-9]+)|([0-9]{1,2})|(100))$/; /** * Regex for hue values - one to three numbers * @type {RegExp} */ const hueRegex = /^[0-9]{1,3}$/; /** * Creates a slider for the color values hue, saturation and light. * * @since 4.0.0 */ class JoomlaFieldColorSlider { /** * @param {HTMLElement} element */ constructor(element) { // Elements this.messageSpan = element.querySelector('.form-control-feedback'); this.mainInput = element.querySelector('.color-input'); this.input = element.querySelector('#slider-input'); this.sliders = element.querySelectorAll('.color-slider'); this.hueSlider = element.querySelector('#hue-slider'); this.saturationSlider = element.querySelector('#saturation-slider'); this.lightSlider = element.querySelector('#light-slider'); this.alphaSlider = element.querySelector('#alpha-slider'); // Attributes this.color = element.dataset.color || ''; this.default = element.dataset.default || ''; this.format = this.input.dataset.format || 'hex'; this.saveFormat = this.mainInput.dataset.format || 'hex'; this.preview = element.dataset.preview === 'true'; this.setAlpha = this.format === 'hsla' || this.format === 'rgba'; this.hue = 360; this.saturation = 1; this.light = 1; this.alpha = 1; this.defaultHsl = [this.hue, this.saturation, this.light, this.alpha]; this.setInitValue(); this.setBackground(); // Hide preview field, when selected value should not be visible if (!this.preview) { this.input.classList.add('hidden'); } else { this.setInputPattern(); } // Always hide main input field (value saved in database) this.mainInput.classList.add('hidden'); Array.prototype.forEach.call(this.sliders, slider => { slider.addEventListener('change', () => this.updateValue(slider)); }); this.input.addEventListener('change', () => this.changeInput(this.input)); } /** * Set selected value into input field and set it as its background-color. */ updateValue(slider) { this.showError(''); const hsl = this.getSliderValueAsHsl(slider.value, slider.dataset.type); const rgb = this.hslToRgb(hsl); [this.hue, this.saturation, this.light, this.alpha] = hsl; this.input.style.border = `2px solid ${this.getRgbString(rgb)}`; this.setSliderValues(hsl, slider.dataset.type); this.setInputValue(hsl); this.setBackground(slider); } /** * React on user changing input value * * @param {HTMLElement} inputField */ changeInput(inputField) { let hsl = [this.hue, this.saturation, this.light, this.alpha]; if (!inputField.value) { this.mainInput.value = ''; this.showError(''); return; } if (!this.checkValue(inputField.value)) { this.showError('JFIELD_COLOR_ERROR_WRONG_FORMAT'); this.setInputValue(this.defaultHsl); } else { this.showError(''); switch (this.format) { case 'hue': hsl[0] = inputField.value; this.hue = inputField.value; break; case 'saturation': hsl[1] = inputField.value; this.saturation = inputField.value; break; case 'light': hsl[2] = inputField.value; this.light = inputField.value; break; case 'alpha': hsl[3] = inputField.value; this.alpha = inputField.value; break; default: hsl = this.getHsl(inputField.value); } this.setSliderValues(hsl); this.setInputValue(hsl, true); } } /** * Check validity of value * * @param {number|string} value to check * @param {string=false} format for which the value gets tested * @returns {boolean} */ checkValue(value, format) { const test = format || this.format; switch (test) { case 'hue': return value <= 360 && hueRegex.test(value); case 'saturation': case 'light': case 'alpha': return hslNumberRegex.test(value); case 'hsl': case 'hsla': return hslRegex.test(value); case 'hex': return hexRegex.test(value); case 'rgb': case 'rgba': return rgbRegex.test(value); default: return false; } } /** * Set validation pattern on input field */ setInputPattern() { let pattern; // RegExp has '/' at start and end switch (this.format) { case 'hue': pattern = hueRegex.source.slice(1, -1); break; case 'saturation': case 'light': case 'alpha': pattern = hslNumberRegex.source.slice(1, -1); break; case 'hsl': case 'hsla': pattern = hslRegex.source.slice(1, -1); break; case 'rgb': pattern = rgbRegex.source.slice(1, -1); break; case 'hex': default: pattern = hexRegex.source.slice(1, -1); } this.input.setAttribute('pattern', pattern); } /** * Set linear gradient for slider background * @param {HTMLInputElement} [exceptSlider] */ setBackground(exceptSlider) { Array.prototype.forEach.call(this.sliders, slider => { // Jump over changed slider if (exceptSlider === slider) { return; } let colors = []; let endValue = 100; // Longer start color so slider selection matches displayed colors colors.push(this.getSliderValueAsRgb(0, slider.dataset.type)); if (slider.dataset.type === 'hue') { const steps = Math.floor(360 / 20); endValue = 360; for (let i = 0; i <= 360; i += steps) { colors.push(this.getSliderValueAsRgb(i, slider.dataset.type)); } } else { for (let i = 0; i <= 100; i += 10) { colors.push(this.getSliderValueAsRgb(i, slider.dataset.type)); } } // Longer end color so slider selection matches displayed colors colors.push(this.getSliderValueAsRgb(endValue, slider.dataset.type)); colors = colors.map(value => this.getRgbString(value)); slider.style.background = `linear-gradient(90deg, ${colors.join(',')})`; slider.style.webkitAppearance = 'none'; }); } /** * Convert given color into hue, saturation and light */ setInitValue() { // The initial value can be also a color defined in css const cssValue = window.getComputedStyle(this.input).getPropertyValue(this.default); this.default = cssValue || this.default; if (this.color === '' || typeof this.color === 'undefined') { // Unable to get hsl with empty value this.input.value = ''; this.mainInput.value = ''; return; } const value = this.checkValue(this.color, this.saveFormat) ? this.color : this.default; if (!value) { this.showError('JFIELD_COLOR_ERROR_NO_COLOUR'); return; } let hsl = []; // When given value is a number, use it as defined format and get rest from default value if (/^[0-9]+$/.test(value)) { hsl = this.default && this.getHsl(this.default); if (this.format === 'hue') { hsl[0] = value; } if (this.format === 'saturation') { hsl[1] = value > 1 ? value / 100 : value; } if (this.format === 'light') { hsl[2] = value > 1 ? value / 100 : value; } if (this.format === 'alpha') { hsl[3] = value > 1 ? value / 100 : value; } } else { hsl = this.getHsl(value); } [this.hue, this.saturation, this.light] = hsl; this.alpha = hsl[4] || this.alpha; this.defaultHsl = this.default ? this.getHsl(this.default) : hsl; this.setSliderValues(hsl); this.setInputValue(hsl); this.input.style.border = `2px solid ${this.getRgbString(this.hslToRgb(hsl))}`; } /** * Insert message into error message span * Message gets handled with Joomla.Text or as empty string * * @param {string} msg */ showError(msg) { this.messageSpan.innerText = msg ? Joomla.Text._(msg) : ''; } /** * Convert value into HSLa e.g. #003E7C => [210, 100, 24] * @param {array|number|string} value * @returns {array} */ getHsl(value) { let hsl = []; if (Array.isArray(value)) { hsl = value; } else if (hexRegex.test(value)) { hsl = this.hexToHsl(value); } else if (rgbRegex.test(value)) { hsl = this.rgbToHsl(value); } else if (hslRegex.test(value)) { const matches = value.match(hslRegex); hsl = [matches[1], matches[2], matches[3], matches[4]]; } else { this.showError('JFIELD_COLOR_ERROR_CONVERT_HSL'); return this.defaultHsl; } // Convert saturation etc. values from e.g. 40 to 0.4 let i; for (i = 1; i < hsl.length; i += 1) { hsl[i] = hsl[i] > 1 ? hsl[i] / 100 : hsl[i]; } return hsl; } /** * Returns HSL value from color slider value * @params {int} value convert this value * @params {string} type type of value: hue, saturation, light or alpha * @returns array */ getSliderValueAsHsl(value, type) { let h = this.hue; let s = this.saturation; let l = this.light; let a = this.alpha; switch (type) { case 'alpha': a = value; break; case 'saturation': s = value; break; case 'light': l = value; break; case 'hue': default: h = value; } // Percentage light and saturation if (l > 1) { l /= 100; } if (s > 1) { s /= 100; } if (a > 1) { a /= 100; } return [h, s, l, a]; } /** * Calculates RGB value from color slider value * @params {int} value convert this value * @params {string} type type of value: hue, saturation, light or alpha * @returns array */ getSliderValueAsRgb(value, type) { return this.hslToRgb(this.getSliderValueAsHsl(value, type)); } /** * Set value in all sliders * @param {array} [hsla] * @param {string} [except] */ setSliderValues([h, s, l, a], except) { if (this.hueSlider && except !== 'hue') { this.hueSlider.value = Math.round(h); } if (this.saturationSlider && except !== 'saturation') { this.saturationSlider.value = Math.round(s * 100); } if (this.lightSlider && except !== 'light') { this.lightSlider.value = Math.round(l * 100); } if (a && this.alphaSlider && except !== 'alpha') { this.alphaSlider.value = Math.round(a * 100); } } /** * Set value in text input fields depending on their format * @param {array} hsl * @param {boolean=false} onlyMain indicates to change mainInput only */ setInputValue(hsl, onlyMain) { const inputs = [this.mainInput]; if (!onlyMain) { inputs.push(this.input); } inputs.forEach(input => { let value; switch (input.dataset.format) { case 'hsl': value = this.getHslString(hsl); break; case 'hsla': value = this.getHslString(hsl, true); break; case 'rgb': value = this.getRgbString(this.hslToRgb(hsl)); break; case 'rgba': value = this.getRgbString(this.hslToRgb(hsl), true); break; case 'hex': value = this.rgbToHex(this.hslToRgb(hsl)); break; case 'alpha': value = Math.round(hsl[3] * 100); break; case 'saturation': value = Math.round(hsl[1] * 100); break; case 'light': value = Math.round(hsl[2] * 100); break; case 'hue': default: value = Math.round(hsl[0]); break; } input.value = value; }); } /** * Put RGB values into a string like 'rgb(, , )' * @params {array} rgba * @params {boolean=false} withAlpha * @return {string} */ getRgbString([r, g, b, a], withAlpha) { if (withAlpha || this.setAlpha) { const alpha = typeof a === 'undefined' ? this.alpha : a; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } return `rgb(${r}, ${g}, ${b})`; } /** * Put HSL values into a string like 'hsl(, %, %, )' * @params {array} values * @params {boolean=false} withAlpha * @return {string} */ getHslString(values, withAlpha) { let [h, s, l, a] = values; s *= 100; l *= 100; [h, s, l] = [h, s, l].map(value => Math.round(value)); if (withAlpha || this.setAlpha) { a = a || this.alpha; return `hsla(${h}, ${s}%, ${l}%, ${a})`; } return `hsl(${h}, ${s}%, ${l}%)`; } /** * Returns hsl values out of hex * @param {array} rgb * @return {string} */ rgbToHex(rgb) { let r = rgb[0].toString(16).toUpperCase(); let g = rgb[1].toString(16).toUpperCase(); let b = rgb[2].toString(16).toUpperCase(); // Double value for hex with '#' and 6 chars r = r.length === 1 ? `${r}${r}` : r; g = g.length === 1 ? `${g}${g}` : g; b = b.length === 1 ? `${b}${b}` : b; return `#${r}${g}${b}`; } /** * Returns hsl values out of rgb * @param {string|array} values * @return {array} */ rgbToHsl(values) { let rgb = values; if (typeof values === 'string') { const parts = values.match(rgbRegex); rgb = [parts[1], parts[2], parts[3], parts[4]]; } const [r, g, b] = rgb.map(value => value > 1 ? value / 255 : value); const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; const d = max - min; let h = 0; let s = 0; let a = rgb[3] || values[3] || this.alpha; if (max !== min) { if (max === 0) { s = max; } else if (min === 1) { s = min; } else { s = (max - l) / Math.min(l, 1 - l); } switch (max) { case r: h = 60 * (g - b) / d; break; case g: h = 60 * (2 + (b - r) / d); break; case b: default: h = 60 * (4 + (r - g) / d); break; } } h = h < 0 ? h + 360 : h; a = a > 1 ? a / 100 : a; return [h, s, l, a]; } /** * Returns hsl values out of hex * @param {string} hex * @return {array} */ hexToHsl(hex) { const parts = hex.match(hexRegex); const r = parts[1]; const g = parts[2]; const b = parts[3]; const rgb = [parseInt(r, 16), parseInt(g, 16), parseInt(b, 16)]; return this.rgbToHsl(rgb); } /** * Convert HSLa values into RGBa * @param {array} hsla * @returns {number[]} */ hslToRgb([h, sat, light, alpha]) { let r = 1; let g = 1; let b = 1; // Saturation and light were calculated as 0.24 instead of 24% const s = sat > 1 ? sat / 100 : sat; const l = light > 1 ? light / 100 : light; const a = alpha > 1 ? alpha / 100 : alpha; if (h < 0 || h > 360 || s < 0 || s > 1 || l < 0 || l > 1) { this.showError('JFIELD_COLOR_ERROR_CONVERT_HSL'); return this.hslToRgb(this.defaultHsl); } const c = (1 - Math.abs(2 * l - 1)) * s; const hi = h / 60; const x = c * (1 - Math.abs(hi % 2 - 1)); const m = l - c / 2; if (h >= 0 && h < 60) { [r, g, b] = [c, x, 0]; } else if (h >= 60 && h < 120) { [r, g, b] = [x, c, 0]; } else if (h >= 120 && h < 180) { [r, g, b] = [0, c, x]; } else if (h >= 180 && h < 240) { [r, g, b] = [0, x, c]; } else if (h >= 240 && h < 300) { [r, g, b] = [x, 0, c]; } else if (h >= 300 && h <= 360) { [r, g, b] = [c, 0, x]; } else { this.showError('JFIELD_COLOR_ERROR_CONVERT_HUE'); return this.hslToRgb(this.defaultHsl); } const rgb = [r, g, b].map(value => Math.round((value + m) * 255)); rgb.push(a); return rgb; } } document.addEventListener('DOMContentLoaded', () => { const fields = document.querySelectorAll('.color-slider-wrapper'); if (fields) { Array.prototype.forEach.call(fields, slider => { // eslint-disable-next-line no-new new JoomlaFieldColorSlider(slider); }); } }); })(document);