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'; export default class Pickr { // Expose pickr utils static utils = _; // Assign version and export static version = VERSION; // 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.setProperty('--pcr-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.setProperty('--pcr-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( `