tweb/src/components/colorPicker.ts

293 lines
10 KiB
TypeScript

import {ColorHsla, ColorRgba, hexaToHsla, hslaToRgba, rgbaToHexa as rgbaToHexa, rgbaToHsla} from '../helpers/color';
import attachGrabListeners from '../helpers/dom/attachGrabListeners';
import clamp from '../helpers/number/clamp';
import InputField, {InputState} from './inputField';
export type ColorPickerColor = {
hsl: string;
rgb: string;
hex: string;
hsla: string;
rgba: string;
hexa: string;
rgbaArray: ColorRgba;
};
export default class ColorPicker {
private static BASE_CLASS = 'color-picker';
public container: HTMLElement;
private boxRect: DOMRect;
// private boxDraggerRect: DOMRect;
private hueRect: DOMRect;
// private hueDraggerRect: DOMRect;
private hue = 0;
private saturation = 100;
private lightness = 50;
private alpha = 1;
private elements: {
box: SVGSVGElement,
boxDragger: SVGSVGElement,
sliders: HTMLElement,
hue: SVGSVGElement,
hueDragger: SVGSVGElement,
saturation: SVGLinearGradientElement,
} = {} as any;
private hexInputField: InputField;
private rgbInputField: InputField;
public onChange: (color: ReturnType<ColorPicker['getCurrentColor']>) => void;
constructor() {
this.container = document.createElement('div');
this.container.classList.add(ColorPicker.BASE_CLASS);
const html = `
<svg class="${ColorPicker.BASE_CLASS + '-box'}" viewBox="0 0 380 198">
<defs>
<linearGradient id="color-picker-saturation" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#fff"></stop>
<stop offset="100%" stop-color="hsl(0,100%,50%)"></stop>
</linearGradient>
<linearGradient id="color-picker-brightness" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgba(0,0,0,0)"></stop>
<stop offset="100%" stop-color="#000"></stop>
</linearGradient>
<pattern id="color-picker-pattern" width="100%" height="100%">
<rect x="0" y="0" width="100%" height="100%" fill="url(#color-picker-saturation)"></rect>
<rect x="0" y="0" width="100%" height="100%" fill="url(#color-picker-brightness)"></rect>
</pattern>
</defs>
<rect rx="10" ry="10" x="0" y="0" width="380" height="198" fill="url(#color-picker-pattern)"></rect>
<svg class="${ColorPicker.BASE_CLASS + '-dragger'} ${ColorPicker.BASE_CLASS + '-box-dragger'}" x="0" y="0">
<circle r="11" fill="inherit" stroke="#fff" stroke-width="2"></circle>
</svg>
</svg>
<div class="${ColorPicker.BASE_CLASS + '-sliders'}">
<svg class="${ColorPicker.BASE_CLASS + '-color-slider'}" viewBox="0 0 380 24">
<defs>
<linearGradient id="hue" x1="100%" y1="0%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#f00"></stop>
<stop offset="16.666%" stop-color="#f0f"></stop>
<stop offset="33.333%" stop-color="#00f"></stop>
<stop offset="50%" stop-color="#0ff"></stop>
<stop offset="66.666%" stop-color="#0f0"></stop>
<stop offset="83.333%" stop-color="#ff0"></stop>
<stop offset="100%" stop-color="#f00"></stop>
</linearGradient>
</defs>
<rect rx="4" ry="4" x="0" y="9" width="380" height="8" fill="url(#hue)"></rect>
<svg class="${ColorPicker.BASE_CLASS + '-dragger'} ${ColorPicker.BASE_CLASS + '-color-slider-dragger'}" x="0" y="13">
<circle r="11" fill="inherit" stroke="#fff" stroke-width="2"></circle>
</svg>
</svg>
</div>
`;
this.container.innerHTML = html;
this.elements.box = this.container.firstElementChild as any;
this.elements.boxDragger = this.elements.box.lastElementChild as any;
this.elements.saturation = this.elements.box.firstElementChild.firstElementChild as any;
this.elements.sliders = this.elements.box.nextElementSibling as any;
this.elements.hue = this.elements.sliders.firstElementChild as any;
this.elements.hueDragger = this.elements.hue.lastElementChild as any;
this.hexInputField = new InputField({plainText: true, label: 'Appearance.Color.Hex'});
this.rgbInputField = new InputField({plainText: true, label: 'Appearance.Color.RGB'});
const inputs = document.createElement('div');
inputs.className = ColorPicker.BASE_CLASS + '-inputs';
inputs.append(this.hexInputField.container, this.rgbInputField.container);
this.container.append(inputs);
this.hexInputField.input.addEventListener('input', () => {
let value = this.hexInputField.value.replace(/#/g, '').slice(0, 6);
const match = value.match(/([a-fA-F\d]+)/);
const valid = match && match[0].length === value.length && [/* 3, 4, */6].includes(value.length);
this.hexInputField.setState(valid ? InputState.Neutral : InputState.Error);
value = '#' + value;
this.hexInputField.setValueSilently(value);
if(valid) {
this.setColor(value, false, true);
}
});
// patched https://stackoverflow.com/a/34029238/6758968
const rgbRegExp = /^(?:rgb)?\(?([01]?\d\d?|2[0-4]\d|25[0-5])(?:\W+)([01]?\d\d?|2[0-4]\d|25[0-5])\W+(?:([01]?\d\d?|2[0-4]\d|25[0-5])\)?)$/;
this.rgbInputField.input.addEventListener('input', () => {
const match = this.rgbInputField.value.match(rgbRegExp);
this.rgbInputField.setState(match ? InputState.Neutral : InputState.Error);
if(match) {
this.setColor(rgbaToHsla(+match[1], +match[2], +match[3]), true, false);
}
});
this.attachBoxListeners();
this.attachHueListeners();
}
private onGrabStart = () => {
document.documentElement.style.cursor = this.elements.boxDragger.style.cursor = 'grabbing';
};
private onGrabEnd = () => {
document.documentElement.style.cursor = this.elements.boxDragger.style.cursor = '';
};
private attachBoxListeners() {
attachGrabListeners(this.elements.box as any, () => {
this.onGrabStart();
this.boxRect = this.elements.box.getBoundingClientRect();
// this.boxDraggerRect = this.elements.boxDragger.getBoundingClientRect();
}, (pos) => {
this.saturationHandler(pos.x, pos.y);
}, () => {
this.onGrabEnd();
});
}
private attachHueListeners() {
attachGrabListeners(this.elements.hue as any, () => {
this.onGrabStart();
this.hueRect = this.elements.hue.getBoundingClientRect();
// this.hueDraggerRect = this.elements.hueDragger.getBoundingClientRect();
}, (pos) => {
this.hueHandler(pos.x);
}, () => {
this.onGrabEnd();
});
}
public setColor(color: ColorHsla | string, updateHexInput = true, updateRgbInput = true) {
if(color === undefined) { // * set to red
color = {
h: 0,
s: 100,
l: 50,
a: 1
};
} else if(typeof(color) === 'string') {
if(color[0] === '#') {
color = hexaToHsla(color);
} else {
const rgb = color.match(/[.?\d]+/g);
color = rgbaToHsla(+rgb[0], +rgb[1], +rgb[2], rgb[3] === undefined ? 1 : +rgb[3]);
}
}
// Set box
this.boxRect = this.elements.box.getBoundingClientRect();
const boxX = this.boxRect.width / 100 * color.s;
const percentY = 100 - (color.l / (100 - color.s / 2)) * 100;
const boxY = this.boxRect.height / 100 * percentY;
this.saturationHandler(this.boxRect.left + boxX, this.boxRect.top + boxY, false);
// Set hue
this.hueRect = this.elements.hue.getBoundingClientRect();
const percentHue = color.h / 360;
const hueX = this.hueRect.left + this.hueRect.width * percentHue;
this.hueHandler(hueX, false);
// Set values
this.hue = color.h;
this.saturation = color.s;
this.lightness = color.l;
this.alpha = color.a;
this.updatePicker(updateHexInput, updateRgbInput);
};
public getCurrentColor(): ColorPickerColor {
const rgbaArray = hslaToRgba(this.hue, this.saturation, this.lightness, this.alpha);
const hexa = rgbaToHexa(rgbaArray);
const hex = hexa.slice(0, -2);
return {
hsl: `hsl(${this.hue}, ${this.saturation}%, ${this.lightness}%)`,
rgb: `rgb(${rgbaArray[0]}, ${rgbaArray[1]}, ${rgbaArray[2]})`,
hex: hex,
hsla: `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha})`,
rgba: `rgba(${rgbaArray[0]}, ${rgbaArray[1]}, ${rgbaArray[2]}, ${rgbaArray[3]})`,
hexa: hexa,
rgbaArray: rgbaArray
};
}
public updatePicker(updateHexInput = true, updateRgbInput = true) {
const color = this.getCurrentColor();
this.elements.boxDragger.setAttributeNS(null, 'fill', color.hex);
if(updateHexInput) {
this.hexInputField.setValueSilently(color.hex);
this.hexInputField.setState(InputState.Neutral);
}
if(updateRgbInput) {
this.rgbInputField.setValueSilently(color.rgbaArray.slice(0, -1).join(', '));
this.rgbInputField.setState(InputState.Neutral);
}
if(this.onChange) {
this.onChange(color);
}
}
private hueHandler(pageX: number, update = true) {
const eventX = clamp(pageX - this.hueRect.left, 0, this.hueRect.width);
const percents = eventX / this.hueRect.width;
this.hue = Math.round(360 * percents);
const hsla = `hsla(${this.hue}, 100%, 50%, ${this.alpha})`;
this.elements.hueDragger.setAttributeNS(null, 'x', (percents * 100) + '%');
this.elements.hueDragger.setAttributeNS(null, 'fill', hsla);
this.elements.saturation.lastElementChild.setAttributeNS(null, 'stop-color', hsla);
if(update) {
this.updatePicker();
}
}
private saturationHandler(pageX: number, pageY: number, update = true) {
const maxX = this.boxRect.width;
const maxY = this.boxRect.height;
const eventX = clamp(pageX - this.boxRect.left, 0, maxX);
const eventY = clamp(pageY - this.boxRect.top, 0, maxY);
const posX = eventX / maxX * 100;
const posY = eventY / maxY * 100;
const boxDragger = this.elements.boxDragger;
boxDragger.setAttributeNS(null, 'x', posX + '%');
boxDragger.setAttributeNS(null, 'y', posY + '%');
const saturation = clamp(posX, 0, 100);
const lightnessX = 100 - saturation / 2;
const lightnessY = 100 - clamp(posY, 0, 100);
const lightness = clamp(lightnessY / 100 * lightnessX, 0, 100);
this.saturation = saturation;
this.lightness = lightness;
if(update) {
this.updatePicker();
}
};
}