/** @jsxImportSource react */ import { Widget, VDOM } from "../../ui/Widget"; import { Field, FieldConfig, FieldInstance } from "./Field"; import { captureMouseOrTouch, getCursorPos } from "../overlay/captureMouse"; import { hslToRgb } from "../../util/color/hslToRgb"; import { rgbToHsl } from "../../util/color/rgbToHsl"; import { rgbToHex } from "../../util/color/rgbToHex"; import { parseColor } from "../../util/color/parseColor"; import { getVendorPrefix } from "../../util/getVendorPrefix"; import { stopPropagation } from "../../util/eventCallbacks"; import { isString } from "../../util/isString"; import { getTopLevelBoundingClientRect } from "../../util/getTopLevelBoundingClientRect"; import PixelPickerIcon from "../icons/pixel-picker"; import type { RenderingContext } from "../../ui/RenderingContext"; import type { Instance } from "../../ui/Instance"; import { StringProp } from "../../ui/Prop"; // Type declaration for EyeDropper API declare global { class EyeDropper { open(): Promise<{ sRGBHex: string }>; } interface Window { EyeDropper: typeof EyeDropper; } } //TODO: Increase HSL precision in calculations, round only RGB values //TODO: Resolve alpha input problems interface ColorState { r: number; g: number; b: number; h: number; s: number; l: number; a: number; } export interface ColorPickerConfig extends FieldConfig { /** Either `rgba`, `hsla` or `hex` value of the selected color. */ value?: StringProp; /** Format of the color representation. Either `rgba`, `hsla` or `hex`. */ format?: "rgba" | "hsla" | "hex"; /** * A string containing the list of all events that cause that selected value is written to the store. * Default value is `blur change` which means that changes are propagated immediately. */ reportOn?: string; /** Callback function invoked when the color preview is clicked. */ onColorClick?: (e: React.MouseEvent, instance: Instance) => void; } export class ColorPicker extends Field< ColorPickerConfig, FieldInstance > { declare format: string; declare reportOn: string; declare onColorClick?: ( e: React.MouseEvent, instance: FieldInstance ) => void; constructor(config?: ColorPickerConfig) { super(config); } declareData(...args: Record[]): void { super.declareData( { value: this.emptyValue, format: undefined, }, ...args ); } renderInput( context: RenderingContext, instance: FieldInstance, key: string ): React.ReactNode { return ; } handleEvent( eventType: string, instance: FieldInstance, color: ColorState ): void { let { data } = instance; if (this.reportOn.indexOf(eventType) != -1) { let value; switch (data.format) { default: case "rgba": value = `rgba(${color.r.toFixed(0)},${color.g.toFixed(0)},${color.b.toFixed(0)},${ Math.round(color.a * 100) / 100 })`; break; case "hsla": value = `hsla(${color.h.toFixed(0)},${color.s.toFixed(0)}%,${color.l.toFixed(0)}%,${ Math.round(color.a * 100) / 100 })`; break; case "hex": value = rgbToHex(color.r, color.g, color.b); break; } instance.set("value", value); } } } ColorPicker.prototype.baseClass = "colorpicker"; ColorPicker.prototype.reportOn = "blur change"; ColorPicker.prototype.format = "rgba"; Widget.alias("color-picker", ColorPicker); interface ColorPickerComponentProps { instance: FieldInstance; } class ColorPickerComponent extends VDOM.Component< ColorPickerComponentProps, ColorState > { data: Record; constructor(props: ColorPickerComponentProps) { super(props); this.data = props.instance.data; try { this.state = this.parse(props.instance.data.value); } catch (e) { //if web colors are used (e.g. red), fallback to the default color this.state = this.parse(null); } } UNSAFE_componentWillReceiveProps(props: ColorPickerComponentProps): void { let { data } = props.instance; let color; try { color = this.parse(data.value); } catch { color = this.parse(null); } if ( color.r != this.state.r || color.g != this.state.g || color.b != this.state.b || color.a != this.state.a ) this.setState(color); } parse(color: string | null): ColorState { let c = parseColor(color); if (c == null) { c = { type: "rgba", r: 128, g: 128, b: 128, a: 0, }; } c.a = Math.round(c.a * 100) / 100; if (c.type == "rgba") { let [h, s, l] = rgbToHsl(c.r, c.g, c.b); return { r: c.r, g: c.g, b: c.b, h, s, l, a: c.a }; } if (c.type == "hsla") { let [r, g, b] = hslToRgb(c.h, c.s, c.l); r = this.fix255(r); g = this.fix255(g); b = this.fix255(b); return { r: r, g, b, h: c.h, s: c.s, l: c.l, a: c.a }; } throw new Error(`Color ${color} parsing failed.`); } render(): React.ReactNode { let { h, s, l, a, r, g, b } = this.state; let { instance } = this.props; let { widget, data } = instance; let { CSS, baseClass } = widget; let hcolor = `hsl(${h},100%,50%)`; let hsla = `hsla(${h.toFixed(0)},${s.toFixed(0)}%,${l.toFixed(0)}%,${a})`; let rgba = `rgba(${r.toFixed(0)},${g.toFixed(0)},${b.toFixed(0)},${a})`; let hex = rgbToHex(r, g, b); let pixelPicker; let alphaGradient = `${getVendorPrefix( "css" )}linear-gradient(left, hsla(${h},${s}%,${l}%,0) 0%, hsla(${h},${s}%,${l}%,1) 100%)`; if ( typeof window !== "undefined" && "EyeDropper" in window && window.EyeDropper ) { pixelPicker = (
{ const eyeDropper = new EyeDropper(); eyeDropper .open() .then((result: { sRGBHex: string }) => { instance.set("value", result.sRGBHex); }) .catch((e: any) => {}); }} >
); } return (
{ this.onWheel(e, "h", 10); }} >
{ this.onWheel(e, "a", 0.1); }} >
{ this.onColorClick(e); }} >
{pixelPicker}
); } onColorClick(e: React.MouseEvent): void { let { instance } = this.props; let { widget } = instance; if ((widget as ColorPicker).onColorClick) (widget as ColorPicker).onColorClick!(e, instance); } onHueSelect(e: React.MouseEvent | React.TouchEvent): void { e.preventDefault(); e.stopPropagation(); let el = e.currentTarget; let bounds = el.getBoundingClientRect(); let move = (e: MouseEvent) => { let pos = getCursorPos(e); let x = Math.max( 0, Math.min( 1, (pos.clientX + 1 - bounds.left) / (el as HTMLElement).offsetWidth ) ); this.setColorProp({ h: x * 360, }); }; captureMouseOrTouch(e, move); move(e as any); } onAlphaSelect(e: React.MouseEvent | React.TouchEvent): void { e.preventDefault(); e.stopPropagation(); let el = e.currentTarget; let bounds = getTopLevelBoundingClientRect(el); let move = (e: MouseEvent | React.MouseEvent) => { let pos = getCursorPos(e); let x = Math.max( 0, Math.min( 1, (pos.clientX + 1 - bounds.left) / (el as HTMLElement).offsetWidth ) ); this.setColorProp({ a: x, }); }; captureMouseOrTouch(e, move); move(e as any); } onSLSelect(e: React.MouseEvent | React.TouchEvent): void { e.preventDefault(); e.stopPropagation(); let el = e.currentTarget; let bounds = getTopLevelBoundingClientRect(el); let move = (e: MouseEvent) => { let pos = getCursorPos(e); let x = Math.max( 0, Math.min( 1, (pos.clientX + 1 - bounds.left) / (el as HTMLElement).offsetWidth ) ); let y = Math.max( 0, Math.min( 1, (pos.clientY + 1 - bounds.top) / (el as HTMLElement).offsetWidth ) ); let s = x; let l = 1 - y; this.setColorProp({ s: s * 100, l: l * 100, }); }; captureMouseOrTouch(e, move); move(e as any); } fix255(v: number): number { return Math.max(0, Math.min(255, Math.round(v))); } setColorProp(props: string | Partial, value?: number): void { let propsObj: Partial; if (isString(props)) { propsObj = { [props]: value, }; } else { propsObj = props; } let state = { ...this.state }; let fixAlpha = false; for (let prop in propsObj) { let propValue = propsObj[prop as keyof ColorState]; if (propValue === undefined) continue; switch (prop) { case "h": state.h = Math.min(360, Math.max(0, propValue)); [state.r, state.g, state.b] = hslToRgb(state.h, state.s, state.l); fixAlpha = true; break; case "s": state.s = Math.min(100, Math.max(0, propValue)); [state.r, state.g, state.b] = hslToRgb(state.h, state.s, state.l); fixAlpha = true; break; case "l": state.l = Math.min(100, Math.max(0, propValue)); [state.r, state.g, state.b] = hslToRgb(state.h, state.s, state.l); fixAlpha = true; break; case "r": case "g": case "b": state[prop] = Math.round(Math.min(255, Math.max(0, propValue))); let [h, s, l] = rgbToHsl(state.r, state.g, state.b); state.h = h; state.s = s; state.l = l; fixAlpha = true; break; case "a": state.a = Math.round(100 * Math.min(1, Math.max(0, propValue))) / 100; break; } } state.r = this.fix255(state.r); state.g = this.fix255(state.g); state.b = this.fix255(state.b); if (fixAlpha && state.a === 0) state.a = 1; this.setState(state, () => { (this.props.instance.widget as ColorPicker).handleEvent( "change", this.props.instance, this.state ); }); } onNumberChange( e: React.ChangeEvent, prop: keyof ColorState ): void { e.preventDefault(); e.stopPropagation(); let number = parseFloat(e.target.value || "0"); this.setColorProp(prop, number); } onWheel(e: React.WheelEvent, prop: keyof ColorState, delta: number): void { e.preventDefault(); e.stopPropagation(); let factor = e.deltaY < 0 ? 1 : -1; this.setColorProp(prop, this.state[prop] + delta * factor); } onBlur(): void { (this.props.instance.widget as ColorPicker).handleEvent( "blur", this.props.instance, this.state ); } }