UNPKG

@fe6/water-pro

Version:

An enterprise-class UI design language and Vue-based implementation

924 lines (757 loc) 24.4 kB
import * as _ from './utils/utils'; import { parseToHSVA } from './utils/color'; import { HSVaColor } from './utils/hsvacolor'; import Moveable from './libs/moveable'; import Selectable from './libs/selectable'; import buildPickr from './template'; import { createPopper } from 'nanopop'; class Pickr { // Expose pickr utils static utils = _; // Assign version and export static version = '1.0.0'; // Default strings static I18N_DEFAULTS = { // Strings visible in the UI 'ui:dialog': 'color picker dialog', 'btn:toggle': 'toggle color picker dialog', 'btn:swatch': 'color swatch', 'btn:last-color': 'use previous color', 'btn:save': 'Save', 'btn:cancel': 'Cancel', 'btn:clear': 'Clear', // Strings used for aria-labels 'aria:btn:save': 'save and close', 'aria:btn:cancel': 'cancel and close', 'aria:btn:clear': 'clear and close', 'aria:input': 'color input field', 'aria:palette': 'color selection area', 'aria:hue': 'hue selection slider', 'aria:opacity': 'selection slider', }; // Default options static DEFAULT_OPTIONS = { appClass: null, theme: 'classic', useAsButton: false, padding: 8, disabled: false, comparison: true, closeOnScroll: false, outputPrecision: 0, lockOpacity: false, autoReposition: true, container: 'body', components: { interaction: {}, }, i18n: {}, swatches: null, inline: false, sliders: null, default: '#42445a', defaultRepresentation: null, position: 'bottom-middle', adjustableNumbers: true, showAlways: false, closeWithKey: 'Escape', }; // Will be used to prevent specific actions during initilization _initializingActive = true; // If the current color value should be recalculated _recalc = true; // Positioning engine and DOM-Tree _nanopop = null; _root = null; // Current and last color for comparison _color = HSVaColor(); _lastColor = HSVaColor(); _swatchColors = []; // Animation frame used for setup. // Will be cancelled in case of destruction. _setupAnimationFrame = null; // Evenlistener name: [callbacks] _eventListener = { init: [], save: [], hide: [], show: [], clear: [], change: [], changestop: [], cancel: [], swatchselect: [], }; constructor(opt) { // Assign default values this.options = opt = Object.assign({ ...Pickr.DEFAULT_OPTIONS }, opt); const { swatches, components, theme, sliders, lockOpacity, padding } = opt; if (['nano', 'monolith'].includes(theme) && !sliders) { opt.sliders = 'h'; } // Check interaction section if (!components.interaction) { components.interaction = {}; } // Overwrite palette if preview, opacity or hue are true const { preview, opacity, hue, palette } = components; components.opacity = !lockOpacity && opacity; components.palette = palette || preview || opacity || hue; // Initialize picker this._preBuild(); this._buildComponents(); this._bindEvents(); this._finalBuild(); // Append pre-defined swatch colors if (swatches && swatches.length) { swatches.forEach((color) => this.addSwatch(color)); } // Initialize positioning engine const { button, app } = this._root; this._nanopop = createPopper(button, app, { margin: padding, }); // Initialize accessibility button.setAttribute('role', 'button'); button.setAttribute('aria-label', this._t('btn:toggle')); // Initilization is finish, pickr is visible and ready for usage const that = this; this._setupAnimationFrame = requestAnimationFrame(function cb() { // TODO: Performance issue due to high call-rate? if (!app.offsetWidth) { return requestAnimationFrame(cb); } // Apply default color that.setColor(opt.default); that._rePositioningPicker(); // Initialize color representation if (opt.defaultRepresentation) { that._representation = opt.defaultRepresentation; that.setColorRepresentation(that._representation); } // Show pickr if locked if (opt.showAlways) { that.show(); } // Initialization is done - pickr is usable, fire init event that._initializingActive = false; that._emit('init'); }); } // Create instance via method static create = (options) => new Pickr(options); // Does only the absolutly basic thing to initialize the components _preBuild() { const { options } = this; // Resolve elements for (const type of ['el', 'container']) { options[type] = _.resolveElement(options[type]); } // Create element and append it to body to // Prevent initialization errors this._root = buildPickr(this); // Check if a custom button is used if (options.useAsButton) { this._root.button = options.el; // Replace button with customized button } options.container.appendChild(this._root.root); } _finalBuild() { const opt = this.options; const root = this._root; // Remove from body opt.container.removeChild(root.root); if (opt.inline) { const parent = opt.el.parentElement; if (opt.el.nextSibling) { parent.insertBefore(root.app, opt.el.nextSibling); } else { parent.appendChild(root.app); } } else { opt.container.appendChild(root.app); } // Don't replace the the element if a custom button is used if (!opt.useAsButton) { // Replace element with actual color-picker opt.el.parentNode.replaceChild(root.root, opt.el); } else if (opt.inline) { opt.el.remove(); } // Check if it should be immediatly disabled if (opt.disabled) { this.disable(); } // Check if color comparison is disabled, if yes - remove transitions so everything keeps smoothly if (!opt.comparison) { root.button.style.transition = 'none'; if (!opt.useAsButton) { root.preview.lastColor.style.transition = 'none'; } } this.hide(); } _buildComponents() { // Instance reference const inst = this; const cs = this.options.components; const sliders = (inst.options.sliders || 'v').repeat(2); const [so, sh] = sliders.match(/^[vh]+$/g) ? sliders : []; // Re-assign if null const getColor = () => this._color || (this._color = this._lastColor.clone()); const components = { palette: Moveable({ element: inst._root.palette.picker, wrapper: inst._root.palette.palette, onstop: () => inst._emit('changestop', 'slider', inst), onchange(x, y) { if (!cs.palette) { return; } const color = getColor(); const { _root, options } = inst; const { lastColor, currentColor } = _root.preview; // Update the input field only if the user is currently not typing if (inst._recalc) { // Calculate saturation based on the position color.s = x * 100; // Calculate the value color.v = 100 - y * 100; // Prevent falling under zero color.v < 0 ? (color.v = 0) : 0; inst._updateOutput('slider'); } // Set picker and gradient color const cssRGBaString = color.toRGBA().toString(0); this.element.style.background = cssRGBaString; this.wrapper.style.background = ` linear-gradient(to top, rgba(0, 0, 0, ${color.a}), transparent), linear-gradient(to left, hsla(${color.h}, 100%, 50%, ${color.a}), rgba(255, 255, 255, ${color.a})) `; // Check if color is locked if (!options.comparison) { _root.button.style.color = cssRGBaString; // If the user changes the color, remove the cleared icon _root.button.classList.remove('clear'); } else if (!options.useAsButton && !inst._lastColor) { // Apply color to both the last and current color since the current state is cleared lastColor.style.setProperty('--pcr-color', cssRGBaString); } // Check if there's a swatch which color matches the current one const hexa = color.toHEXA().toString(); for (const { el, color } of inst._swatchColors) { el.classList[hexa === color.toHEXA().toString() ? 'add' : 'remove']('pcr-active'); } // Change current color currentColor.style.setProperty('--pcr-color', cssRGBaString); }, }), hue: Moveable({ lock: sh === 'v' ? 'h' : 'v', element: inst._root.hue.picker, wrapper: inst._root.hue.slider, onstop: () => inst._emit('changestop', 'slider', inst), onchange(v) { if (!cs.hue || !cs.palette) { return; } const color = getColor(); // Calculate hue if (inst._recalc) { color.h = v * 360; } // Update color this.element.style.backgroundColor = `hsl(${color.h}, 100%, 50%)`; components.palette.trigger(); }, }), opacity: Moveable({ lock: so === 'v' ? 'h' : 'v', element: inst._root.opacity.picker, wrapper: inst._root.opacity.slider, onstop: () => inst._emit('changestop', 'slider', inst), onchange(v) { if (!cs.opacity || !cs.palette) { return; } const color = getColor(); // Calculate opacity if (inst._recalc) { color.a = Math.round(v * 1e2) / 100; } // Update color this.element.style.background = `rgba(0, 0, 0, ${color.a})`; components.palette.trigger(); }, }), selectable: Selectable({ elements: inst._root.interaction.options, className: 'active', onchange(e) { inst._representation = e.target.getAttribute('data-type').toUpperCase(); inst._recalc && inst._updateOutput('swatch'); }, }), }; this._components = components; } _bindEvents() { const { _root, options } = this; const eventBindings = [ // Clear color _.on(_root.interaction.clear, 'click', () => this._clearColor()), // Select last color on click _.on([_root.interaction.cancel, _root.preview.lastColor], 'click', () => { this.setHSVA(...(this._lastColor || this._color).toHSVA(), true); this._emit('cancel'); }), // Save color _.on(_root.interaction.save, 'click', () => { !this.applyColor() && !options.showAlways && this.hide(); }), // User input _.on(_root.interaction.result, ['keyup', 'input'], (e) => { // Fire listener if initialization is finish and changed color was valid if (this.setColor(e.target.value, true) && !this._initializingActive) { this._emit('change', this._color, 'input', this); this._emit('changestop', 'input', this); } e.stopImmediatePropagation(); }), // Detect user input and disable auto-recalculation _.on(_root.interaction.result, ['focus', 'blur'], (e) => { this._recalc = e.type === 'blur'; this._recalc && this._updateOutput(null); }), // Cancel input detection on color change _.on( [ _root.palette.palette, _root.palette.picker, _root.hue.slider, _root.hue.picker, _root.opacity.slider, _root.opacity.picker, ], ['mousedown', 'touchstart'], () => (this._recalc = true), { passive: true }, ), ]; // Provide hiding / showing abilities only if showAlways is false if (!options.showAlways) { const ck = options.closeWithKey; eventBindings.push( // Save and hide / show picker _.on(_root.button, 'click', () => (this.isOpen() ? this.hide() : this.show())), // Close with escape key _.on( document, 'keyup', (e) => this.isOpen() && (e.key === ck || e.code === ck) && this.hide(), ), // Cancel selecting if the user taps behind the color picker _.on( document, ['touchstart', 'mousedown'], (e) => { if ( this.isOpen() && !_.eventPath(e).some((el) => el === _root.app || el === _root.button) ) { this.hide(); } }, { capture: true }, ), ); } // Make input adjustable if enabled if (options.adjustableNumbers) { const ranges = { rgba: [255, 255, 255, 1], hsva: [360, 100, 100, 1], hsla: [360, 100, 100, 1], cmyk: [100, 100, 100, 100], }; _.adjustableInputNumbers(_root.interaction.result, (o, step, index) => { const range = ranges[this.getColorRepresentation().toLowerCase()]; if (range) { const max = range[index]; // Calculate next reasonable number const nv = o + (max >= 100 ? step * 1000 : step); // Apply range of zero up to max, fix floating-point issues return nv <= 0 ? 0 : Number((nv < max ? nv : max).toPrecision(3)); } return o; }); } if (options.autoReposition && !options.inline) { let timeout = null; const that = this; // Re-calc position on window resize, scroll and wheel eventBindings.push( _.on( window, ['scroll', 'resize'], () => { if (that.isOpen()) { if (options.closeOnScroll) { that.hide(); } if (timeout === null) { timeout = setTimeout(() => (timeout = null), 100); // Update position on every frame requestAnimationFrame(function rs() { that._rePositioningPicker(); timeout !== null && requestAnimationFrame(rs); }); } else { clearTimeout(timeout); timeout = setTimeout(() => (timeout = null), 100); } } }, { capture: true }, ), ); } // Save bindings this._eventBindings = eventBindings; } _rePositioningPicker() { const { options } = this; // No repositioning needed if inline if (!options.inline) { const success = this._nanopop.update({ container: document.body.getBoundingClientRect(), position: options.position, }); if (!success) { const el = this._root.app; const eb = el.getBoundingClientRect(); el.style.top = `${(window.innerHeight - eb.height) / 2}px`; el.style.left = `${(window.innerWidth - eb.width) / 2}px`; } } } _updateOutput(eventSource) { const { _root, _color, options } = this; // Check if component is present if (_root.interaction.type()) { // Construct function name and call if present const method = `to${_root.interaction.type().getAttribute('data-type')}`; _root.interaction.result.value = typeof _color[method] === 'function' ? _color[method]().toString(options.outputPrecision) : ''; } // Fire listener if initialization is finish if (!this._initializingActive && this._recalc) { this._emit('change', _color, eventSource, this); } } _clearColor(silent = false) { const { _root, options } = this; // Change only the button color if it isn't customized if (!options.useAsButton) { _root.button.style.color = 'rgba(0, 0, 0, 0.15)'; } _root.button.classList.add('clear'); if (!options.showAlways) { this.hide(); } this._lastColor = null; if (!this._initializingActive && !silent) { // Fire listener this._emit('save', null); this._emit('clear'); } } _parseLocalColor(str) { const { values, type, a } = parseToHSVA(str); const { lockOpacity } = this.options; const alphaMakesAChange = a !== undefined && a !== 1; // If no opacity is applied, add undefined at the very end which gets // Set to 1 in setHSVA if (values && values.length === 3) { values[3] = undefined; } return { values: !values || (lockOpacity && alphaMakesAChange) ? null : values, type, }; } _t(key) { return this.options.i18n[key] || Pickr.I18N_DEFAULTS[key]; } _emit(event, ...args) { this._eventListener[event].forEach((cb) => cb(...args, this)); } on(event, cb) { this._eventListener[event].push(cb); return this; } off(event, cb) { const callBacks = this._eventListener[event] || []; const index = callBacks.indexOf(cb); if (~index) { callBacks.splice(index, 1); } return this; } /** * Appends a color to the swatch palette * @param color * @returns {boolean} */ addSwatch(color) { const { values } = this._parseLocalColor(color); if (values) { const { _swatchColors, _root } = this; const color = HSVaColor(...values); // Create new swatch HTMLElement const el = _.createElementFromString( `<button type="button" style="--pcr-color: ${color .toRGBA() .toString(0)}" aria-label="${this._t('btn:swatch')}"/>`, ); // Append element and save swatch data _root.swatches.appendChild(el); _swatchColors.push({ el, color }); // Bind event this._eventBindings.push( _.on(el, 'click', () => { this.setHSVA(...color.toHSVA(), true); this._emit('swatchselect', color); this._emit('change', color, 'swatch', this); }), ); return true; } return false; } /** * Removes a swatch color by it's index * @param index * @returns {boolean} */ removeSwatch(index) { const swatchColor = this._swatchColors[index]; // Check swatch data if (swatchColor) { const { el } = swatchColor; // Remove HTML child and swatch data this._root.swatches.removeChild(el); this._swatchColors.splice(index, 1); return true; } return false; } applyColor(silent = false) { const { preview, button } = this._root; // Change preview and current color const cssRGBaString = this._color.toRGBA().toString(0); preview.lastColor.style.setProperty('--pcr-color', cssRGBaString); // Change only the button color if it isn't customized if (!this.options.useAsButton) { button.style.setProperty('--pcr-color', cssRGBaString); } // User changed the color so remove the clear clas button.classList.remove('clear'); // Save last color this._lastColor = this._color.clone(); // Fire listener if (!this._initializingActive && !silent) { this._emit('save', this._color); } return this; } /** * Destroy's all functionalitys */ destroy() { // Cancel setup-frame if set cancelAnimationFrame(this._setupAnimationFrame); // Unbind events this._eventBindings.forEach((args) => _.off(...args)); // Destroy sub-components Object.keys(this._components).forEach((key) => this._components[key].destroy()); } /** * Destroy's all functionalitys and removes * the pickr element. */ destroyAndRemove() { this.destroy(); const { root, app } = this._root; // Remove element if (root.parentElement) { root.parentElement.removeChild(root); } // Remove .pcr-app app.parentElement.removeChild(app); // There are references to various DOM elements stored in the pickr instance // This cleans all of them to avoid detached DOMs Object.keys(this).forEach((key) => (this[key] = null)); } /** * Hides the color-picker ui. */ hide() { if (this.isOpen()) { this._root.app.classList.remove('visible'); this._emit('hide'); return true; } return false; } /** * Shows the color-picker ui. */ show() { if (!this.options.disabled && !this.isOpen()) { this._root.app.classList.add('visible'); this._rePositioningPicker(); this._emit('show', this._color); return this; } return false; } /** * @return {boolean} If the color picker is currently open */ isOpen() { return this._root.app.classList.contains('visible'); } /** * Set a specific color. * @param h Hue * @param s Saturation * @param v Value * @param a Alpha channel (0 - 1) * @param silent If the button should not change the color * @return boolean if the color has been accepted */ setHSVA(h = 360, s = 0, v = 0, a = 1, silent = false) { // Deactivate color calculation const recalc = this._recalc; // Save state this._recalc = false; // Validate input if (h < 0 || h > 360 || s < 0 || s > 100 || v < 0 || v > 100 || a < 0 || a > 1) { return false; } // Override current color and re-active color calculation this._color = HSVaColor(h, s, v, a); // Update slider and palette const { hue, opacity, palette } = this._components; hue.update(h / 360); opacity.update(a); palette.update(s / 100, 1 - v / 100); // Check if call is silent if (!silent) { this.applyColor(); } // Update output if recalculation is enabled if (recalc) { this._updateOutput(); } // Restore old state this._recalc = recalc; return true; } /** * Tries to parse a string which represents a color. * Examples: #fff * rgb 10 10 200 * hsva 10 20 5 0.5 * @param string * @param silent */ setColor(string, silent = false) { // Check if null if (string === null) { this._clearColor(silent); return true; } const { values, type } = this._parseLocalColor(string); // Check if color is ok if (values) { // Change selected color format const utype = type.toUpperCase(); const { options } = this._root.interaction; const target = options.find((el) => el.getAttribute('data-type') === utype); // Auto select only if not hidden if (target && !target.hidden) { for (const el of options) { el.classList[el === target ? 'add' : 'remove']('active'); } } // Update color (fires 'save' event if silent is 'false') if (!this.setHSVA(...values, silent)) { return false; } // Update representation (fires 'change' event) return this.setColorRepresentation(utype); } return false; } /** * Changes the color _representation. * Allowed values are HEX, RGB, HSV, HSL and CMYK * @param type * @returns {boolean} if the selected type was valid. */ setColorRepresentation(type) { // Force uppercase to allow a case-sensitiv comparison type = type.toUpperCase(); // Find button with given type and trigger click event return !!this._root.interaction.options.find( (v) => v.getAttribute('data-type').startsWith(type) && !v.click(), ); } /** * Returns the current color representaion. See setColorRepresentation * @returns {*} */ getColorRepresentation() { return this._representation; } /** * @returns HSVaColor Current HSVaColor object. */ getColor() { return this._color; } /** * Returns the currently selected color. * @returns {{a, toHSVA, toHEXA, s, v, h, clone, toCMYK, toHSLA, toRGBA}} */ getSelectedColor() { return this._lastColor; } /** * @returns The root HTMLElement with all his components. */ getRoot() { return this._root; } /** * Disable pickr */ disable() { this.hide(); this.options.disabled = true; this._root.button.classList.add('disabled'); return this; } /** * Enable pickr */ enable() { this.options.disabled = false; this._root.button.classList.remove('disabled'); return this; } } export default Pickr;