import React from "react"; import ReactDOM from "react-dom"; import { observer } from "mobx-react"; import { action, observable, makeObservable, runInAction } from "mobx"; import { closest } from "eez-studio-shared/dom"; import { isDark, isLight, parseColorString, rgbToHexString, hsvToRgb, rgbToHsv, rgbToHsl, hslToRgb, HTML_COLOR_NAMES } from "eez-studio-shared/color"; function colorStringToHex(color: string): string { const parsed = parseColorString(color); if (!parsed) return "#000000"; return rgbToHexString(parsed.r, parsed.g, parsed.b); } import { settingsController } from "home/settings"; import { getProperty } from "project-editor/core/object"; import { getEezStudioDataFromDragEvent } from "project-editor/store"; import { ProjectContext } from "project-editor/project/context"; import { getThemedColor } from "project-editor/features/style/theme"; import { ColorFormat, ColorFormatType } from "project-editor/features/style/color-format"; //////////////////////////////////////////////////////////////////////////////// type ColorInputMode = "hex" | "rgb" | "hsl" | "name" | "theme"; type AdjustMode = "none" | "lighten" | "darken"; type AdjustFormat = "percent" | "decimal"; const COLOR_PICKER_WIDTH = 230; const SV_PICKER_HEIGHT = 150; const HUE_BAR_HEIGHT = 12; const PICKER_PADDING = 10; const ColorPicker = observer( class ColorPicker extends React.Component<{ colorFormat: ColorFormat; onChange: (colorFormat: ColorFormat) => void; themeColors: Array<{name: string, colorValue: string}>; }> { svCanvasRef = React.createRef(); hueCanvasRef = React.createRef(); draggingSV = false; draggingHue = false; _inputMode: ColorInputMode | undefined; // Internal HSV state hue = 0; sat = 1; val = 1; hexInputValue = ""; hexInputFocused = false; // Lighten/Darken state adjustMode: AdjustMode = "none"; adjustLevel: number = 25; adjustFormat: AdjustFormat = "percent"; constructor(props: any) { super(props); makeObservable(this, { _inputMode: observable, hue: observable, sat: observable, val: observable, hexInputValue: observable, hexInputFocused: observable, adjustMode: observable, adjustLevel: observable, adjustFormat: observable }); this.setFromColorFormat(this.props.colorFormat); } get inputMode() { if (this._inputMode == undefined) { let formatType = this.props.colorFormat.innerColor ? this.props.colorFormat.innerColor.formatType : this.props.colorFormat.formatType; let inputMode: ColorInputMode; if ( formatType == ColorFormatType.HEX_0X || formatType == ColorFormatType.HEX_HASH ) { inputMode = "hex"; } else if (formatType == ColorFormatType.HTML_NAME) { inputMode = "name"; } else if (formatType == ColorFormatType.THEME_NAME) { inputMode = "theme"; } else { inputMode = "hex"; } return inputMode; } return this._inputMode; } set inputMode(value: ColorInputMode) { this._inputMode = value; } componentDidMount() { this.drawSV(); this.drawHue(); } componentDidUpdate() { this.drawSV(); this.drawHue(); } setFromColorFormat(cf: ColorFormat) { if ( cf.formatType === ColorFormatType.DARKEN || cf.formatType === ColorFormatType.LIGHTEN ) { this.adjustMode = cf.formatType === ColorFormatType.DARKEN ? "darken" : "lighten"; this.adjustLevel = cf.level; this.adjustFormat = cf.levelFormat === "percent" ? "percent" : "decimal"; // Set HSV from the inner (unwrapped) color if (cf.innerColor) { const hsv = cf.innerColor.getHsv(); this.hue = hsv.h; this.sat = hsv.s; this.val = hsv.v; } } else { this.adjustMode = "none"; const hsv = cf.getHsv(); this.hue = hsv.h; this.sat = hsv.s; this.val = hsv.v; } } // Returns the base (inner) color hex, ignoring lighten/darken getBaseHexColor(): string { const rgb = hsvToRgb(this.hue, this.sat, this.val); return rgbToHexString(rgb.r, rgb.g, rgb.b); } // Returns the final color hex, with lighten/darken applied getHexColor(): string { const rgb = hsvToRgb(this.hue, this.sat, this.val); if (this.adjustMode !== "none") { const fraction = this.adjustFormat === "percent" ? Math.max(0, Math.min(1, this.adjustLevel / 100)) : Math.max(0, Math.min(1, this.adjustLevel / 255)); let r: number, g: number, b: number; if (this.adjustMode === "darken") { r = Math.round(rgb.r * (1 - fraction)); g = Math.round(rgb.g * (1 - fraction)); b = Math.round(rgb.b * (1 - fraction)); } else { r = Math.round(rgb.r + (255 - rgb.r) * fraction); g = Math.round(rgb.g + (255 - rgb.g) * fraction); b = Math.round(rgb.b + (255 - rgb.b) * fraction); } return rgbToHexString(r, g, b); } return rgbToHexString(rgb.r, rgb.g, rgb.b); } changeColorFormatType(cf: ColorFormat) { if (this.inputMode == "hex" || this.inputMode == "rgb" || this.inputMode == "hsl") { if (cf.formatType != ColorFormatType.HEX_0X && cf.formatType != ColorFormatType.HEX_HASH) { cf.formatType = ColorFormatType.HEX_HASH; } } } emitChange = () => { if (this.adjustMode !== "none") { // Build the inner color format from the current HSV let innerCf: ColorFormat; const origFormat = this.props.colorFormat; if ( (origFormat.formatType === ColorFormatType.DARKEN || origFormat.formatType === ColorFormatType.LIGHTEN) && origFormat.innerColor ) { innerCf = origFormat.innerColor.clone(); } else { innerCf = origFormat.clone(); } innerCf.setFromHsv(this.hue, this.sat, this.val); this.changeColorFormatType(innerCf); // Create wrapper format const cf = new ColorFormat(); cf.formatType = this.adjustMode === "darken" ? ColorFormatType.DARKEN : ColorFormatType.LIGHTEN; cf.innerColor = innerCf; cf.level = this.adjustLevel; cf.levelFormat = this.adjustFormat === "percent" ? "percent" : "decimal"; cf.darkenLightenSeparator = ", "; // Compute resolved RGB const fraction = this.adjustFormat === "percent" ? Math.max(0, Math.min(1, this.adjustLevel / 100)) : Math.max(0, Math.min(1, this.adjustLevel / 255)); const innerRgb = innerCf.getRgb(); if (this.adjustMode === "darken") { cf.r = Math.round(innerRgb.r * (1 - fraction)); cf.g = Math.round(innerRgb.g * (1 - fraction)); cf.b = Math.round(innerRgb.b * (1 - fraction)); } else { cf.r = Math.round( innerRgb.r + (255 - innerRgb.r) * fraction ); cf.g = Math.round( innerRgb.g + (255 - innerRgb.g) * fraction ); cf.b = Math.round( innerRgb.b + (255 - innerRgb.b) * fraction ); } this.props.onChange(cf); } else { const cf = this.props.colorFormat.clone(); // If the original was darken/lighten but user turned it off, // unwrap to a plain color if ( cf.formatType === ColorFormatType.DARKEN || cf.formatType === ColorFormatType.LIGHTEN ) { if (cf.innerColor) { const unwrapped = cf.innerColor.clone(); unwrapped.setFromHsv(this.hue, this.sat, this.val); this.changeColorFormatType(unwrapped); this.props.onChange(unwrapped); return; } } cf.setFromHsv(this.hue, this.sat, this.val); this.changeColorFormatType(cf); this.props.onChange(cf); } }; drawSV() { const canvas = this.svCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d")!; const w = canvas.width; const h = canvas.height; ctx.fillStyle = `hsl(${this.hue}, 100%, 50%)`; ctx.fillRect(0, 0, w, h); const whiteGrad = ctx.createLinearGradient(0, 0, w, 0); whiteGrad.addColorStop(0, "rgba(255,255,255,1)"); whiteGrad.addColorStop(1, "rgba(255,255,255,0)"); ctx.fillStyle = whiteGrad; ctx.fillRect(0, 0, w, h); const blackGrad = ctx.createLinearGradient(0, 0, 0, h); blackGrad.addColorStop(0, "rgba(0,0,0,0)"); blackGrad.addColorStop(1, "rgba(0,0,0,1)"); ctx.fillStyle = blackGrad; ctx.fillRect(0, 0, w, h); } drawHue() { const canvas = this.hueCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d")!; const w = canvas.width; const h = canvas.height; const grad = ctx.createLinearGradient(0, 0, w, 0); grad.addColorStop(0, "#ff0000"); grad.addColorStop(1 / 6, "#ffff00"); grad.addColorStop(2 / 6, "#00ff00"); grad.addColorStop(3 / 6, "#00ffff"); grad.addColorStop(4 / 6, "#0000ff"); grad.addColorStop(5 / 6, "#ff00ff"); grad.addColorStop(1, "#ff0000"); ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); } // SV picking onSVPointerDown = (e: React.PointerEvent) => { this.draggingSV = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); this.updateSV(e); }; onSVPointerMove = (e: React.PointerEvent) => { if (!this.draggingSV) return; this.updateSV(e); }; onSVPointerUp = (_e: React.PointerEvent) => { this.draggingSV = false; }; updateSV = action((e: React.PointerEvent) => { const canvas = this.svCanvasRef.current!; const rect = canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); this.sat = x / rect.width; this.val = 1 - y / rect.height; if (this.inputMode == "name" || this.inputMode == "theme") { this.inputMode = "hex"; } this.emitChange(); }); // Hue picking onHuePointerDown = (e: React.PointerEvent) => { this.draggingHue = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); this.updateHue(e); }; onHuePointerMove = (e: React.PointerEvent) => { if (!this.draggingHue) return; this.updateHue(e); }; onHuePointerUp = (_e: React.PointerEvent) => { this.draggingHue = false; }; updateHue = action((e: React.PointerEvent) => { const canvas = this.hueCanvasRef.current!; const rect = canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); this.hue = (x / rect.width) * 360; if (this.inputMode == "name" || this.inputMode == "theme") { this.inputMode = "hex"; } this.emitChange(); }); onEyeDropper = async () => { try { const eyeDropper = new (window as any).EyeDropper(); const result = await eyeDropper.open(); if (result && result.sRGBHex) { runInAction(() => { const parsed = parseColorString(result.sRGBHex); if (parsed) { const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b); this.hue = hsv.h; this.sat = hsv.s; this.val = hsv.v; if (this.inputMode == "name" || this.inputMode == "theme") { this.inputMode = "hex"; } this.emitChange(); } }); } } catch { // User cancelled or API not available } }; onHexChange = action((e: React.ChangeEvent) => { this.hexInputValue = e.target.value; const parsed = parseColorString(this.hexInputValue); if (parsed) { const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b); this.hue = hsv.h; this.sat = hsv.s; this.val = hsv.v; this.emitChange(); } }); onHexFocus = action(() => { this.hexInputValue = this.getBaseHexColor(); this.hexInputFocused = true; }); onHexBlur = action(() => { this.hexInputFocused = false; }); onRGBChange = action((channel: "r" | "g" | "b", value: string) => { const rgb = hsvToRgb(this.hue, this.sat, this.val); const num = parseInt(value, 10); if (isNaN(num)) return; rgb[channel] = Math.max(0, Math.min(255, num)); const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); this.hue = hsv.h; this.sat = hsv.s; this.val = hsv.v; this.emitChange(); }); onHSLChange = action((channel: "h" | "s" | "l", value: string) => { const rgb = hsvToRgb(this.hue, this.sat, this.val); const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); const num = parseFloat(value); if (isNaN(num)) return; if (channel === "h") { hsl.h = Math.max(0, Math.min(360, num)); } else if (channel === "s") { hsl.s = Math.max(0, Math.min(100, num)) / 100; } else { hsl.l = Math.max(0, Math.min(100, num)) / 100; } const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); const hsv = rgbToHsv(newRgb.r, newRgb.g, newRgb.b); this.hue = hsv.h; this.sat = hsv.s; this.val = hsv.v; this.emitChange(); }); cycleMode = action(() => { const hasThemeColors = this.props.themeColors.length > 0; if (this.inputMode === "hex") this.inputMode = "rgb"; else if (this.inputMode === "rgb") this.inputMode = "hsl"; else if (this.inputMode === "hsl") this.inputMode = "name"; else if (this.inputMode === "name") { this.inputMode = hasThemeColors ? "theme" : "hex"; } else { this.inputMode = "hex"; } }); setAdjustMode = action((mode: AdjustMode) => { this.adjustMode = mode; this.emitChange(); }); onAdjustLevelChange = action((value: string) => { const num = parseFloat(value); if (isNaN(num)) return; const max = this.adjustFormat === "percent" ? 100 : 255; this.adjustLevel = Math.max(0, Math.min(max, num)); this.emitChange(); }); toggleAdjustFormat = action(() => { if (this.adjustFormat === "percent") { // Convert percentage to 0-255 this.adjustFormat = "decimal"; this.adjustLevel = Math.round((this.adjustLevel / 100) * 255); } else { // Convert 0-255 to percentage this.adjustFormat = "percent"; this.adjustLevel = Math.round((this.adjustLevel / 255) * 100); } this.emitChange(); }); selectColorName = action((name: string, type: "html" | "theme") => { const innerCf = new ColorFormat(); if (type === "html") { innerCf.formatType = ColorFormatType.HTML_NAME; innerCf.name = name; const htmlRgb = HTML_COLOR_NAMES[name.toLowerCase()]; if (htmlRgb) { innerCf.r = htmlRgb[0]; innerCf.g = htmlRgb[1]; innerCf.b = htmlRgb[2]; } } else { innerCf.formatType = ColorFormatType.THEME_NAME; innerCf.name = name; const tc = this.props.themeColors.find(c => c.name === name); if (tc) { const parsed = parseColorString(tc.colorValue); if (parsed) { innerCf.r = parsed.r; innerCf.g = parsed.g; innerCf.b = parsed.b; } } } const hsv = innerCf.getHsv(); this.hue = hsv.h; this.sat = hsv.s; this.val = hsv.v; if (this.adjustMode !== "none") { // Wrap the selected name in lighten/darken const cf = new ColorFormat(); cf.formatType = this.adjustMode === "darken" ? ColorFormatType.DARKEN : ColorFormatType.LIGHTEN; cf.innerColor = innerCf; cf.level = this.adjustLevel; cf.levelFormat = this.adjustFormat === "percent" ? "percent" : "decimal"; cf.darkenLightenSeparator = ", "; const fraction = this.adjustFormat === "percent" ? Math.max(0, Math.min(1, this.adjustLevel / 100)) : Math.max(0, Math.min(1, this.adjustLevel / 255)); const innerRgb = innerCf.getRgb(); if (this.adjustMode === "darken") { cf.r = Math.round(innerRgb.r * (1 - fraction)); cf.g = Math.round(innerRgb.g * (1 - fraction)); cf.b = Math.round(innerRgb.b * (1 - fraction)); } else { cf.r = Math.round( innerRgb.r + (255 - innerRgb.r) * fraction ); cf.g = Math.round( innerRgb.g + (255 - innerRgb.g) * fraction ); cf.b = Math.round( innerRgb.b + (255 - innerRgb.b) * fraction ); } this.props.onChange(cf); } else { this.props.onChange(innerCf); } }); renderInputFields() { const hex = this.getBaseHexColor(); const rgb = hsvToRgb(this.hue, this.sat, this.val); const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); const inputStyle: React.CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid #ddd", borderRadius: 3, textAlign: "center", fontSize: 11, padding: "3px 4px", outline: "none", color: "#333", backgroundColor: "#fff" }; const labelStyle: React.CSSProperties = { fontSize: 11, color: "#999", textAlign: "center", marginTop: 2, userSelect: "none" }; const fieldContainerStyle: React.CSSProperties = { display: "flex", flexDirection: "column", alignItems: "center", flex: 1, minWidth: 0 }; if (this.inputMode === "hex") { return (
HEX
); } if (this.inputMode === "rgb") { return (
{(["r", "g", "b"] as const).map(ch => (
this.onRGBChange(ch, e.target.value) } />
{ch.toUpperCase()}
))}
); } if (this.inputMode === "hsl") { return (
this.onHSLChange("h", e.target.value) } />
H
this.onHSLChange("s", e.target.value) } />
S
this.onHSLChange("l", e.target.value) } />
L
); } // Name or Theme mode const isThemeMode = this.inputMode === "theme"; const colorNames = isThemeMode ? this.props.themeColors.map(c => c.name) : Object.keys(HTML_COLOR_NAMES); // Determine currently selected name from the color format const cf = this.props.colorFormat; const innerCf = (cf.formatType === ColorFormatType.DARKEN || cf.formatType === ColorFormatType.LIGHTEN) && cf.innerColor ? cf.innerColor : cf; const selectedName = (isThemeMode && innerCf.formatType === ColorFormatType.THEME_NAME) || (!isThemeMode && innerCf.formatType === ColorFormatType.HTML_NAME) ? innerCf.name : ""; return (
{isThemeMode ? "THEME COLOR" : "COLOR BY NAME"}
); } render() { const svWidth = COLOR_PICKER_WIDTH; const hueWidth = COLOR_PICKER_WIDTH - PICKER_PADDING * 2; const hex = this.getHexColor(); // SV cursor position const svCursorX = this.sat * svWidth; const svCursorY = (1 - this.val) * SV_PICKER_HEIGHT; const hasEyeDropper = typeof (window as any).EyeDropper === "function"; return (
{/* Saturation/Value area */}
{/* SV cursor */}
{/* Color preview + hue bar row */}
{/* Eyedropper button */} {hasEyeDropper && ( )} {/* Color preview circle */}
{/* Hue bar */}
{/* Hue cursor */}
{/* Input fields + mode toggle */}
{this.renderInputFields()}
{/* Lighten / Darken controls */}
{this.adjustMode !== "none" && (
this.onAdjustLevelChange( e.target.value ) } style={{ flex: 1, height: 4, cursor: "pointer" }} /> this.onAdjustLevelChange( e.target.value ) } style={{ width: 42, border: "1px solid #ddd", borderRadius: 3, textAlign: "center", fontSize: 11, padding: "2px 2px", outline: "none", color: "#333", backgroundColor: "#fff" }} />
)}
); } } ); //////////////////////////////////////////////////////////////////////////////// export const ThemedColorInput = observer( class ThemedColorInput extends React.Component<{ inputRef?: (ref: any) => void; value: any; onChange: (newValue: any) => void; readOnly: boolean; onClick?: (event: React.MouseEvent) => void; }> { static contextType = ProjectContext; declare context: React.ContextType; buttonRef = React.createRef(); dropDownRef = React.createRef(); dropDownOpen: boolean | undefined = undefined; dropDownLeft = 0; dropDownTop = 0; // Parsed format of the current value, used to preserve format on picker changes currentColorFormat: ColorFormat | null = null; constructor(props: any) { super(props); makeObservable(this, { dropDownOpen: observable, dropDownLeft: observable, dropDownTop: observable, setDropDownOpen: action }); } onDragOver = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); var data = getEezStudioDataFromDragEvent(this.context, event); if (data && data.objectClassName === "Color" && data.object) { event.dataTransfer.dropEffect = "copy"; } }; onDrop = (event: React.DragEvent) => { event.stopPropagation(); event.preventDefault(); if (this.props.onClick) { this.props.onClick(event as any); } var data = getEezStudioDataFromDragEvent(this.context, event); if (data && data.objectClassName === "Color" && data.object) { let value = getProperty(data.object, "name"); setTimeout(() => { this.props.onChange(value); }); } }; onChange = (event: React.ChangeEvent) => { const color = event.target.value; this.props.onChange(color); }; onPickerChange = (colorFormat: ColorFormat) => { this.currentColorFormat = colorFormat; this.props.onChange(colorFormat.toString()); }; updateDropDownPosition = action(() => { const buttonEl = this.buttonRef.current; if (!buttonEl) return; const rectButton = buttonEl.getBoundingClientRect(); const DROP_DOWN_WIDTH = COLOR_PICKER_WIDTH; const DROP_DOWN_HEIGHT = SV_PICKER_HEIGHT + 280; this.dropDownLeft = rectButton.right - DROP_DOWN_WIDTH; this.dropDownTop = rectButton.bottom - 9; if (this.dropDownTop + DROP_DOWN_HEIGHT > window.innerHeight) { this.dropDownTop = window.innerHeight - DROP_DOWN_HEIGHT; } }); onScroll = () => { if (this.dropDownOpen) { this.updateDropDownPosition(); } }; setDropDownOpen(open: boolean) { if (this.dropDownOpen === false) { document.removeEventListener( "pointerdown", this.onDocumentPointerDown, true ); document.removeEventListener("keydown", this.onKeyDown, true); document.removeEventListener("scroll", this.onScroll, true); } this.dropDownOpen = open; if (this.dropDownOpen) { document.addEventListener( "pointerdown", this.onDocumentPointerDown, true ); document.addEventListener("keydown", this.onKeyDown, true); document.addEventListener("scroll", this.onScroll, true); } } openDropdown = action(() => { const buttonEl = this.buttonRef.current; if (!buttonEl) { return; } const dropDownEl = this.dropDownRef.current; if (!dropDownEl) { return; } this.setDropDownOpen(!this.dropDownOpen); if (this.dropDownOpen) { this.context.undoManager.setCombineCommands(true); // Parse the current value to establish the format this.currentColorFormat = ColorFormat.parse( this.props.value, this.context.project ); } else { this.context.undoManager.setCombineCommands(false); } if (this.dropDownOpen) { this.updateDropDownPosition(); } }); onDocumentPointerDown = action((event: MouseEvent) => { if (this.dropDownOpen) { if ( !closest( event.target, el => this.buttonRef.current == el || this.dropDownRef.current == el ) ) { event.preventDefault(); event.stopPropagation(); this.setDropDownOpen(false); } } }); onKeyDown = action((event: KeyboardEvent) => { if (event.key === "Escape") { if (this.context.undoManager.commands.length > 0) { this.context.undoManager.undo(); } this.setDropDownOpen(false); } else if (event.key == "Enter") { this.setDropDownOpen(false); } }); render() { const { value, readOnly } = this.props; let colorValue: string; if (!value) { colorValue = "#00000000"; } else { colorValue = value == "transparent" ? settingsController.isDarkTheme ? "0x00000000" : "0xffffffff" : value; } const colorFormat = this.currentColorFormat ? ColorFormat.parse(this.currentColorFormat.toString(), this.context.project) : ColorFormat.parse(colorValue, this.context.project); let portal; if (!readOnly) { portal = ReactDOM.createPortal(
{this.dropDownOpen && ( ({ name: c.name as string, colorValue: getThemedColor(this.context, c.name).colorValue }) )} /> )}
, document.body ); } let color; if (colorFormat.isValid()) { color = colorFormat.getHexString(); } else { color = settingsController.isDarkTheme ? "0x00000000" : "0xffffffff"; } const inputColor = settingsController.isDarkTheme ? isLight(colorStringToHex(color)) ? "#000" : undefined : isDark(colorStringToHex(color)) ? "#fff" : undefined; const inputBackgroundColor = !value ? "transparent" : colorStringToHex(color); return (
{!readOnly && ( <>
); } } );