diff --git a/apps/builder/app/builder/features/style-panel/controls/color/color-control.tsx b/apps/builder/app/builder/features/style-panel/controls/color/color-control.tsx index 3498d42371..6c8a06b269 100644 --- a/apps/builder/app/builder/features/style-panel/controls/color/color-control.tsx +++ b/apps/builder/app/builder/features/style-panel/controls/color/color-control.tsx @@ -1,27 +1,36 @@ import { Flex } from "@webstudio-is/design-system"; import { toValue } from "@webstudio-is/css-engine"; import type { ControlProps } from "../../style-sections"; -import { ColorPicker } from "../../shared/color-picker"; +import { + ColorPicker, + CssColorPickerValueInput, +} from "../../shared/color-picker"; import { colord } from "colord"; +import { getStyleSource } from "../../shared/style-info"; +import { styleConfigByName } from "../../shared/configs"; +import { useState } from "react"; export const ColorControl = ({ property, + items, currentStyle, setProperty, + deleteProperty, }: ControlProps) => { + const [intermediateValue, setIntermediateValue] = + useState(); + + const { items: defaultItems } = styleConfigByName[property]; + const styleInfo = currentStyle[property]; + let value = currentStyle[property]?.value ?? { - // provide default value to avoid control hiding - // when value is recomputed - type: "rgb" as const, - r: 0, - g: 0, - b: 0, - alpha: 0, + type: "keyword" as const, + value: "black", }; const setValue = setProperty(property); - if (value.type !== "rgb") { + if (value.type !== "rgb" && value.type !== "keyword") { // Support previously set colors const colordValue = colord(toValue(value)); @@ -50,12 +59,40 @@ export const ColorControl = ({ return ( { - setValue(value, { isEphemeral: true }); + styleSource={getStyleSource(styleInfo)} + keywords={(items ?? defaultItems).map((item) => ({ + type: "keyword", + value: item.name, + }))} + intermediateValue={intermediateValue} + onChange={(styleValue) => { + setIntermediateValue(styleValue); + + if (styleValue === undefined) { + deleteProperty(property, { isEphemeral: true }); + return; + } + + if (styleValue.type !== "intermediate") { + setValue(styleValue, { isEphemeral: true }); + } + }} + onHighlight={(styleValue) => { + if (styleValue !== undefined) { + setValue(styleValue, { isEphemeral: true }); + } else { + deleteProperty(property, { isEphemeral: true }); + } + }} + onChangeComplete={({ value }) => { + setValue(value); + setIntermediateValue(undefined); + }} + onAbort={() => { + deleteProperty(property, { isEphemeral: true }); }} - onChangeComplete={setValue} /> ); diff --git a/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx b/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx index 10bb994f24..fd8f94bd73 100644 --- a/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx @@ -1,19 +1,30 @@ import { useState } from "react"; -import { colord, type RgbaColor } from "colord"; +import { colord, extend, type RgbaColor } from "colord"; +import namesPlugin from "colord/plugins/names"; import { ColorResult, RGBColor, SketchPicker } from "react-color"; -import type { RgbValue } from "@webstudio-is/css-data"; - +import type { + InvalidValue, + KeywordValue, + RgbValue, + StyleProperty, + StyleValue, +} from "@webstudio-is/css-data"; import { Box, - DeprecatedPopover, - DeprecatedPopoverTrigger, - DeprecatedPopoverContent, - DeprecatedPopoverPortal, - TextField, + Popover, + PopoverTrigger, + PopoverContent, css, + rawTheme, } from "@webstudio-is/design-system"; import { toValue } from "@webstudio-is/css-engine"; import { theme } from "@webstudio-is/design-system"; +import type { StyleSource } from "./style-info"; +import { CssValueInput } from "./css-value-input"; +import type { IntermediateStyleValue } from "./css-value-input/css-value-input"; + +// To support color names +extend([namesPlugin]); const pickerStyle = css({ padding: theme.spacing[5], @@ -33,11 +44,23 @@ const defaultPickerStyles = { }, }; +export type CssColorPickerValueInput = + | RgbValue + | KeywordValue + | IntermediateStyleValue; + type ColorPickerProps = { - onChange: (value: RgbValue) => void; - onChangeComplete: (value: RgbValue) => void; - value: RgbValue; - id: string; + onChange: (value: CssColorPickerValueInput | undefined) => void; + onChangeComplete: (event: { + value: RgbValue | KeywordValue | InvalidValue; + }) => void; + onHighlight: (value: StyleValue | undefined) => void; + onAbort: () => void; + intermediateValue: CssColorPickerValueInput | undefined; + value: RgbValue | KeywordValue; + styleSource: StyleSource; + keywords?: Array; + property: StyleProperty; }; const colorResultToRgbValue = (rgb: RgbaColor | RGBColor): RgbValue => { @@ -50,83 +73,108 @@ const colorResultToRgbValue = (rgb: RgbaColor | RGBColor): RgbValue => { }; }; -const rgbValueToRgbColor = (rgb: RgbValue): RGBColor => { +const styleValueToRgbaColor = (value: CssColorPickerValueInput): RgbaColor => { + const color = colord( + value.type === "intermediate" ? value.value : toValue(value) + ).toRgb(); + return { - r: rgb.r, - g: rgb.g, - b: rgb.b, - a: rgb.alpha, + r: color.r, + g: color.g, + b: color.b, + a: color.a, }; }; +const whiteColor = { r: 255, g: 255, b: 255, a: 1 }; +const borderColorSwatch = colord(rawTheme.colors.borderColorSwatch).toRgb(); +const transparent = colord("transparent").toRgb(); + +const distance = (a: RgbaColor, b: RgbaColor) => { + // Use Euclidian distance, if will not give good results use https://zschuessler.github.io/DeltaE/ + return Math.sqrt( + Math.pow(a.r / 255 - b.r / 255, 2) + + Math.pow(a.g / 255 - b.g / 255, 2) + + Math.pow(a.b / 255 - b.b / 255, 2) + + Math.pow(a.a - b.a, 2) + ); +}; + +const lerp = (a: number, b: number, t: number) => { + return a * (1 - t) + b * t; +}; + +const lerpColor = (a: RgbaColor, b: RgbaColor, t: number) => { + return { + r: lerp(a.r, b.r, t), + g: lerp(a.g, b.g, t), + b: lerp(a.b, b.b, t), + a: lerp(a.a, b.a, t), + }; +}; + +const clamp = (value: number, min: number, max: number) => { + return Math.min(Math.max(value, min), max); +}; + export const ColorPicker = ({ value, + intermediateValue, onChange, onChangeComplete, - id, + onHighlight, + onAbort, + styleSource, + keywords, + property, }: ColorPickerProps) => { const [displayColorPicker, setDisplayColorPicker] = useState(false); - const [intermediateValue, setIntermediateValue] = useState< - | { - /** - * TextField value - */ - stringValue: string; - /** - * Color picker value - */ - rgbValue: RGBColor; - state: "invalid" | undefined; - } - | undefined - >(undefined); - const onInputChangeComplete = (source: "enter" | "blur") => { - if (intermediateValue === undefined) { - return; - } + const currentValue = intermediateValue ?? value; - const colordValue = colord(intermediateValue.stringValue); - if (colordValue.isValid()) { - const rgb = colordValue.toRgb(); - onChangeComplete({ - type: "rgb", - r: rgb.r, - g: rgb.g, - b: rgb.b, - alpha: rgb.a, - }); - setIntermediateValue(undefined); - return; - } + const rgbValue = styleValueToRgbaColor(currentValue); - // We don't store invalid values in the CSS data, below is the only way we can show errors. - // See for details: https://github.com/webstudio-is/webstudio-builder/issues/564 - // Anyway same behavior has Webflow - shows an error state on "Enter", and resets on Blur + // Change prefix color in sync with color picker, don't change during input changed + const prefixColor = + currentValue.type === "keyword" || currentValue.type === "rgb" + ? currentValue + : value; - // In case of "Enter" click show that the value is invalid - if (source === "enter") { - setIntermediateValue({ - ...intermediateValue, - state: "invalid", - }); - return; - } + const prefixColorRgba = styleValueToRgbaColor(prefixColor); - // In case of blur, just reset to external value - setIntermediateValue(undefined); - }; + // @todo transparent icon can be better + const background = + prefixColorRgba.a < 1 + ? // chessboard 5x5 + `repeating-conic-gradient(rgba(0,0,0,0.22) 0% 25%, transparent 0% 50%) 0% 33.33% / 40% 40%, ${toValue( + prefixColor + )}` + : toValue(prefixColor); - const stringValue = intermediateValue?.stringValue ?? toValue(value); - const rgbValue = intermediateValue?.rgbValue ?? rgbValueToRgbColor(value); + const distanceToStartDrawBorder = 0.15; + + // White color is invisible on white background, so we need to draw border + // the more color is white the more border is visible + const borderColor = colord( + lerpColor( + transparent, + borderColorSwatch, + clamp( + (distanceToStartDrawBorder - distance(whiteColor, prefixColorRgba)) / + distanceToStartDrawBorder, + 0, + 1 + ) + ) + ).toRgbString(); const prefix = ( - - setDisplayColorPicker((shown) => !shown)} @@ -136,61 +184,77 @@ export const ColorPicker = ({ margin: theme.spacing[3], width: theme.spacing[10], height: theme.spacing[10], + backgroundBlendMode: "difference", borderRadius: 2, - background: toValue(value), + background, + borderColor, + borderStyle: "solid", }} /> - - - - { - setIntermediateValue({ - stringValue: toValue(colorResultToRgbValue(color.rgb)), - rgbValue: color.rgb, - state: undefined, - }); - onChange(colorResultToRgbValue(color.rgb)); - }} - onChangeComplete={(color: ColorResult) => { - setIntermediateValue(undefined); - onChangeComplete(colorResultToRgbValue(color.rgb)); - }} - // @todo to remove both when we have preset colors - presetColors={[]} - className={pickerStyle()} - styles={defaultPickerStyles} - /> - - - + + + + { + onChange(colorResultToRgbValue(color.rgb)); + }} + onChangeComplete={(color: ColorResult) => { + onChangeComplete({ + value: colorResultToRgbValue(color.rgb), + }); + }} + // @todo to remove both when we have preset colors + presetColors={[]} + className={pickerStyle()} + styles={defaultPickerStyles} + /> + + ); return ( - { - setIntermediateValue({ - stringValue: event.target.value, - rgbValue: rgbValue, - state: undefined, - }); - }} - onBlur={() => { - if (displayColorPicker) { + { + if ( + styleValue?.type === "rgb" || + styleValue?.type === "keyword" || + styleValue?.type === "intermediate" || + styleValue === undefined + ) { + onChange(styleValue); return; } - onInputChangeComplete("blur"); + + onChange({ + type: "intermediate", + value: toValue(styleValue), + }); }} - value={stringValue} - state={intermediateValue?.state} - id={id} - prefix={prefix} - onKeyDown={(event) => { - if (event.key === "Enter") { - onInputChangeComplete("enter"); + onHighlight={onHighlight} + onChangeComplete={({ value }) => { + if ( + value.type === "rgb" || + value.type === "keyword" || + value.type === "invalid" + ) { + onChangeComplete({ value }); } + + onChangeComplete({ + value: { + type: "invalid", + value: toValue(value), + }, + }); }} + onAbort={onAbort} /> ); }; diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx index 3886825326..4b405ffb5f 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx @@ -200,9 +200,9 @@ export type IntermediateStyleValue = { unit?: Unit; }; -type CssValueInputValue = StyleValue | IntermediateStyleValue; +export type CssValueInputValue = StyleValue | IntermediateStyleValue; -type ChangeReason = +export type ChangeReason = | "enter" | "blur" | "unit-select" diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts b/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts index f9848e984d..f3d0c1272f 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts @@ -322,3 +322,36 @@ describe("Value ending with `-` should be considered unitless", () => { }); }); }); + +describe("Colors", () => { + test("color with value rgba(0,0,0,0)", () => { + const result = parseIntermediateOrInvalidValue("color", { + type: "intermediate", + value: "rgba(10,20,30,0.5)", + }); + + expect(result).toEqual({ + type: "rgb", + alpha: 0.5, + r: 10, + g: 20, + b: 30, + }); + }); + + test("color with value rgba(0,0,0,0)", () => { + const result = parseIntermediateOrInvalidValue("color", { + type: "intermediate", + value: "rgba(10,20,30,0.5)", + unit: "px", + }); + + expect(result).toEqual({ + type: "rgb", + alpha: 0.5, + r: 10, + g: 20, + b: 30, + }); + }); +}); diff --git a/apps/builder/app/builder/features/style-panel/shared/parse-css-value.test.ts b/apps/builder/app/builder/features/style-panel/shared/parse-css-value.test.ts index 45f847495b..edecb1c288 100644 --- a/apps/builder/app/builder/features/style-panel/shared/parse-css-value.test.ts +++ b/apps/builder/app/builder/features/style-panel/shared/parse-css-value.test.ts @@ -104,4 +104,33 @@ describe("Parse CSS value", () => { }); }); }); + + describe("Colors", () => { + test("Color rgba values", () => { + expect(parseCssValue("backgroundColor", "rgba(0,0,0,0)")).toEqual({ + type: "rgb", + alpha: 0, + b: 0, + g: 0, + r: 0, + }); + }); + + test("Color rgba values", () => { + expect(parseCssValue("backgroundColor", "#00220011")).toEqual({ + type: "rgb", + alpha: 0.07, + b: 0, + g: 34, + r: 0, + }); + }); + + test("Color rgba values", () => { + expect(parseCssValue("color", "red")).toEqual({ + type: "keyword", + value: "red", + }); + }); + }); }); diff --git a/apps/builder/app/builder/features/style-panel/shared/parse-css-value.ts b/apps/builder/app/builder/features/style-panel/shared/parse-css-value.ts index ad2be3b288..13a2d9b35d 100644 --- a/apps/builder/app/builder/features/style-panel/shared/parse-css-value.ts +++ b/apps/builder/app/builder/features/style-panel/shared/parse-css-value.ts @@ -3,6 +3,7 @@ import hyphenate from "hyphenate-style-name"; import type { StyleProperty, StyleValue, Unit } from "@webstudio-is/css-data"; import { units, keywordValues } from "@webstudio-is/css-data"; import warnOnce from "warn-once"; +import { colord } from "colord"; const cssTryParseValue = (input: string) => { try { @@ -122,6 +123,21 @@ export const parseCssValue = ( } } + // Probably a color (we can use csstree.lexer.matchProperty(cssPropertyName, ast) to extract the type but this looks much simpler) + if (property.toLocaleLowerCase().includes("color")) { + const mayBeColor = colord(input); + if (mayBeColor.isValid()) { + const rgb = mayBeColor.toRgb(); + return { + type: "rgb", + alpha: rgb.a, + r: rgb.r, + g: rgb.g, + b: rgb.b, + }; + } + } + return { type: "unparsed", value: input, diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index 0e070aadb0..7ef259e684 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -52,6 +52,7 @@ module.exports = { "@lexical/react/*", "__generated__/*", "react-hot-toast/headless", + "colord/plugins/*", ], }, ], diff --git a/packages/react-sdk/src/css/get-browser-style.ts b/packages/react-sdk/src/css/get-browser-style.ts index a803ab9d41..cefaf4f5bb 100644 --- a/packages/react-sdk/src/css/get-browser-style.ts +++ b/packages/react-sdk/src/css/get-browser-style.ts @@ -1,5 +1,5 @@ import { detectFont } from "detect-font"; -import type { Style, StyleValue, Unit } from "@webstudio-is/css-data"; +import { keywordValues, Style, StyleValue, Unit } from "@webstudio-is/css-data"; import { properties, units } from "@webstudio-is/css-data"; const unitsList = Object.values(units).flat(); @@ -16,9 +16,20 @@ const parseValue = ( value = "transparent"; } if (Number.isNaN(number)) { + const values = keywordValues[ + property as keyof typeof keywordValues + ] as ReadonlyArray; + + if (values?.includes(value)) { + return { + type: "keyword", + value: value, + }; + } + return { - type: "keyword", - value, + type: "unparsed", + value: value, }; }