From 0ca52b7346d9b6b7b63fce622779442118f79966 Mon Sep 17 00:00:00 2001 From: Jannih Date: Sun, 23 Nov 2025 19:10:14 +0000 Subject: [PATCH] support colors support colors --- @types/culori/index.d.ts | 5 + packages/css-data/package.json | 1 + packages/css-data/src/color.ts | 127 +++++++++ packages/css-data/src/culori.d.ts | 6 + packages/css-data/src/index.ts | 1 + packages/css-data/src/parse-css-value.test.ts | 9 + packages/css-data/src/parse-css-value.ts | 41 ++- .../src/property-parsers/gradient-utils.ts | 18 +- packages/css-engine/src/core/to-value.test.ts | 12 + packages/css-engine/src/core/to-value.ts | 3 + packages/css-engine/src/schema.ts | 4 + .../src/components/color-picker.tsx | 245 ++++++++++++++---- .../src/components/gradient-picker.tsx | 14 +- pnpm-lock.yaml | 9 + 14 files changed, 398 insertions(+), 97 deletions(-) create mode 100644 @types/culori/index.d.ts create mode 100644 packages/css-data/src/color.ts create mode 100644 packages/css-data/src/culori.d.ts diff --git a/@types/culori/index.d.ts b/@types/culori/index.d.ts new file mode 100644 index 000000000000..fa27e4431c02 --- /dev/null +++ b/@types/culori/index.d.ts @@ -0,0 +1,5 @@ +declare module "culori" { + export function converter(mode: string): (color: unknown) => unknown; + export function parse(input: string): unknown; + export function formatCss(color: unknown): string | undefined; +} diff --git a/packages/css-data/package.json b/packages/css-data/package.json index f6f91b2d08ab..15ef00fb3d28 100644 --- a/packages/css-data/package.json +++ b/packages/css-data/package.json @@ -36,6 +36,7 @@ "change-case": "^5.4.4", "colord": "^2.9.3", "css-tree": "^3.1.0", + "culori": "^4.0.2", "openai": "^3.2.1", "p-retry": "^6.2.1", "warn-once": "^0.1.1" diff --git a/packages/css-data/src/color.ts b/packages/css-data/src/color.ts new file mode 100644 index 000000000000..f3aac63db8eb --- /dev/null +++ b/packages/css-data/src/color.ts @@ -0,0 +1,127 @@ +import { converter, formatCss, parse } from "culori"; +import type { RgbValue } from "@webstudio-is/css-engine"; + +const toRgb = converter("rgb"); + +export type ParsedColor = RgbValue & { + colorSpace?: string; + original?: string; +}; + +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); + +const toByte = (value: number) => Math.round(clamp01(value) * 255); + +/** + * Parse arbitrary CSS color strings (CSS Color 4/5) into an RGB representation. + * Keeps the source color space and a normalized string so we can round-trip. + */ +export const parseCssColor = (input: string): ParsedColor | undefined => { + const normalizedInput = input.trim(); + if (normalizedInput.length === 0) { + return; + } + + let parsed: unknown; + try { + parsed = parse(normalizedInput); + } catch { + return; + } + + if (parsed === undefined || parsed === null) { + return; + } + + const rgbResult = toRgb(parsed); + if ( + rgbResult === undefined || + rgbResult === null || + typeof rgbResult !== "object" + ) { + return; + } + + const { + r, + g, + b, + alpha: alphaValue, + } = rgbResult as { + r?: number; + g?: number; + b?: number; + alpha?: number; + }; + + if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") { + return; + } + + const alpha = Number(clamp01(alphaValue ?? 1).toFixed(2)); + const formatted = typeof parsed === "object" ? formatCss(parsed) : undefined; + + const color: ParsedColor = { + type: "rgb", + r: toByte(r), + g: toByte(g), + b: toByte(b), + alpha, + }; + + const mode = + typeof parsed === "object" && parsed !== null && "mode" in parsed + ? (parsed as { mode?: string }).mode + : undefined; + + if (mode && mode !== "rgb") { + color.colorSpace = mode; + } + + if (mode && mode !== "rgb") { + color.original = formatted ?? normalizedInput; + } + + return color; +}; + +/** + * Serialize an RGB color, optionally preserving the source color space when provided. + */ +export const formatRgbColor = ( + color: ParsedColor, + preferredColorSpace?: string +): string => { + const targetSpace = preferredColorSpace ?? color.colorSpace; + if (color.original && preferredColorSpace === color.colorSpace) { + return color.original; + } + + const rgbColor = { + mode: "rgb", + r: clamp01(color.r / 255), + g: clamp01(color.g / 255), + b: clamp01(color.b / 255), + alpha: clamp01(color.alpha), + }; + + if (targetSpace) { + try { + const toPreferred = converter(targetSpace); + const converted = toPreferred(rgbColor); + const formattedPreferred = formatCss(converted); + if (formattedPreferred) { + return formattedPreferred; + } + } catch { + // If the color space is not supported, fall back to RGBA below. + } + } + + const formatted = formatCss(rgbColor); + if (formatted) { + return formatted; + } + + return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.alpha})`; +}; diff --git a/packages/css-data/src/culori.d.ts b/packages/css-data/src/culori.d.ts new file mode 100644 index 000000000000..f400e7448f12 --- /dev/null +++ b/packages/css-data/src/culori.d.ts @@ -0,0 +1,6 @@ +declare module "culori" { + // Minimal surface we use; culori ships JS only. + export function converter(mode: string): (color: unknown) => unknown; + export function parse(input: string): unknown; + export function formatCss(color: unknown): string | undefined; +} diff --git a/packages/css-data/src/index.ts b/packages/css-data/src/index.ts index b11d0d27ce97..6f09fd13a860 100644 --- a/packages/css-data/src/index.ts +++ b/packages/css-data/src/index.ts @@ -17,3 +17,4 @@ export * from "./shorthands"; export { shorthandProperties } from "./__generated__/shorthand-properties"; export { properties as propertiesData } from "./__generated__/properties"; +export * from "./color"; diff --git a/packages/css-data/src/parse-css-value.test.ts b/packages/css-data/src/parse-css-value.test.ts index da1f36f3f66a..c1725cb73d93 100644 --- a/packages/css-data/src/parse-css-value.test.ts +++ b/packages/css-data/src/parse-css-value.test.ts @@ -132,6 +132,15 @@ describe("Parse CSS value", () => { value: "red", }); }); + + test("Supports OKLCH values", () => { + expect(parseCssValue("color", "oklch(0.7 0.12 45 / 0.8)")).toMatchObject({ + type: "rgb", + alpha: 0.8, + colorSpace: "oklch", + original: "oklch(0.7 0.12 45 / 0.8)", + }); + }); }); }); diff --git a/packages/css-data/src/parse-css-value.ts b/packages/css-data/src/parse-css-value.ts index f80ad5e49fd9..e7ec0664a5e7 100644 --- a/packages/css-data/src/parse-css-value.ts +++ b/packages/css-data/src/parse-css-value.ts @@ -1,5 +1,3 @@ -import { colord, extend } from "colord"; -import namesPlugin from "colord/plugins/names"; import { type CssNode, type FunctionNode, @@ -29,9 +27,12 @@ import { } from "@webstudio-is/css-engine"; import { keywordValues } from "./__generated__/keyword-values"; import { units } from "./__generated__/units"; +import { parseCssColor } from "./color"; -// To support color names -extend([namesPlugin]); +const isColorProperty = (property: CssProperty): boolean => { + const name = property as string; + return name.endsWith("color") || name === "fill" || name === "stroke"; +}; export const cssTryParseValue = (input: string): undefined | CssNode => { try { @@ -64,6 +65,10 @@ export const isValidDeclaration = ( return true; } + if (isColorProperty(property) && parseCssColor(value)) { + return true; + } + // these properties have poor support natively and in csstree // though rendered styles are merged as shorthand // so validate artifically @@ -159,16 +164,9 @@ const tupleProps = new Set([ const availableUnits = new Set(Object.values(units).flat()); const parseColor = (colorString: string): undefined | RgbValue => { - const color = colord(colorString); - if (color.isValid()) { - const rgb = color.toRgb(); - return { - type: "rgb", - alpha: rgb.a, - r: rgb.r, - g: rgb.g, - b: rgb.b, - }; + const parsed = parseCssColor(colorString); + if (parsed) { + return parsed; } }; @@ -294,18 +292,11 @@ const parseLiteral = ( } } if (node?.type === "Function") { - // - if ( - node.name === "hsl" || - node.name === "hsla" || - node.name === "rgb" || - node.name === "rgba" - ) { - const color = parseColor(generate(node)); - if (color) { - return color; - } + const color = parseColor(generate(node)); + if (color) { + return color; } + if (node.name === "var") { return parseCssVar(node); } diff --git a/packages/css-data/src/property-parsers/gradient-utils.ts b/packages/css-data/src/property-parsers/gradient-utils.ts index 198160b034f9..ca627945e53e 100644 --- a/packages/css-data/src/property-parsers/gradient-utils.ts +++ b/packages/css-data/src/property-parsers/gradient-utils.ts @@ -6,11 +6,8 @@ import { type VarValue, toValue, } from "@webstudio-is/css-engine"; -import { colord, extend } from "colord"; -import namesPlugin from "colord/plugins/names"; import type { GradientColorValue, GradientStop } from "./types"; - -extend([namesPlugin]); +import { parseCssColor } from "../color"; export const angleUnitIdentifiers = ["deg", "grad", "rad", "turn"] as const; const angleUnitSet = new Set(angleUnitIdentifiers); @@ -119,16 +116,9 @@ export const getColor = ( return parsed; } if (parsed.type === "keyword") { - const color = colord(parsed.value); - if (color.isValid()) { - const { r, g, b, a } = color.toRgb(); - return { - type: "rgb", - r, - g, - b, - alpha: a, - } satisfies GradientColorValue; + const color = parseCssColor(parsed.value); + if (color) { + return color as GradientColorValue; } } }; diff --git a/packages/css-engine/src/core/to-value.test.ts b/packages/css-engine/src/core/to-value.test.ts index 3e61ee011edb..7dd72e1dbfc7 100644 --- a/packages/css-engine/src/core/to-value.test.ts +++ b/packages/css-engine/src/core/to-value.test.ts @@ -22,6 +22,18 @@ describe("Convert WS CSS Values to native CSS strings", () => { expect(value).toBe(""); }); + test("rgb preserves original string when provided", () => { + const value = toValue({ + type: "rgb", + r: 10, + g: 20, + b: 30, + alpha: 0.5, + original: "oklch(0.5 0.05 90 / 0.5)", + }); + expect(value).toBe("oklch(0.5 0.05 90 / 0.5)"); + }); + test("var", () => { const value = toValue({ type: "var", value: "namespace" }); expect(value).toBe("var(--namespace)"); diff --git a/packages/css-engine/src/core/to-value.ts b/packages/css-engine/src/core/to-value.ts index efaf8f5dc928..6700733b8e97 100644 --- a/packages/css-engine/src/core/to-value.ts +++ b/packages/css-engine/src/core/to-value.ts @@ -84,6 +84,9 @@ export const toValue = ( } if (value.type === "rgb") { + if (value.original) { + return value.original; + } return `rgba(${value.r}, ${value.g}, ${value.b}, ${value.alpha})`; } diff --git a/packages/css-engine/src/schema.ts b/packages/css-engine/src/schema.ts index 9aa2bc5e12ce..d8422149c899 100644 --- a/packages/css-engine/src/schema.ts +++ b/packages/css-engine/src/schema.ts @@ -60,6 +60,10 @@ const RgbValue = z.object({ g: z.number(), b: z.number(), alpha: z.number(), + // Optional color space metadata used to preserve user intent when parsing CSS Color 4/5 formats. + colorSpace: z.string().optional(), + // Original CSS color string, used to round-trip non-rgb formats without losing fidelity. + original: z.string().optional(), hidden: z.boolean().optional(), }); export type RgbValue = z.infer; diff --git a/packages/design-system/src/components/color-picker.tsx b/packages/design-system/src/components/color-picker.tsx index ca1e5e6a8710..a5e1ccbdcb51 100644 --- a/packages/design-system/src/components/color-picker.tsx +++ b/packages/design-system/src/components/color-picker.tsx @@ -5,26 +5,26 @@ import { useEffect, useState, } from "react"; -import { colord, extend, type RgbaColor } from "colord"; -import namesPlugin from "colord/plugins/names"; +import { colord, type RgbaColor } from "colord"; import { clamp } from "@react-aria/utils"; import { useDebouncedCallback } from "use-debounce"; import { RgbaColorPicker } from "react-colorful"; -import { EyedropperIcon } from "@webstudio-is/icons"; +import { EyedropperIcon, RefreshIcon } from "@webstudio-is/icons"; import { toValue, type StyleValue, type Unit, type RgbValue, } from "@webstudio-is/css-engine"; +import { formatRgbColor, parseCssColor } from "@webstudio-is/css-data"; import { css, rawTheme, theme, type CSS } from "../stitches.config"; import { useDisableCanvasPointerEvents } from "../utilities"; +import { Flex } from "./flex"; import { Grid } from "./grid"; import { IconButton } from "./icon-button"; import { InputField } from "./input-field"; import { Popover, PopoverContent, PopoverTrigger } from "./popover"; - -extend([namesPlugin]); +import { Text } from "./text"; const colorfulStyles = css({ ".react-colorful__pointer": { @@ -118,35 +118,81 @@ export const ColorThumb = forwardRef, ColorThumbProps>( ColorThumb.displayName = "ColorThumb"; -const colorResultToRgbValue = (rgb: RgbaColor): RgbValue => ({ +const colorResultToRgbValue = ( + rgb: RgbaColor, + colorSpace?: string +): RgbValue => ({ type: "rgb", r: rgb.r, g: rgb.g, b: rgb.b, alpha: rgb.a ?? 1, + colorSpace, }); -const normalizeHex = (value: string) => { - const trimmed = value.trim(); - const hex = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; - return hex; -}; - -export const styleValueToRgbaColor = ( - value: StyleValue | IntermediateColorValue -): RgbaColor => { - const color = colord( - value.type === "intermediate" ? value.value : toValue(value) - ).toRgb(); +const convertToFormat = (color: RgbValue, target: string): RgbValue => { + if (target === "hex") { + const formatted = colord(rgbaFromRgbValue(color)).toHex(); + return { ...color, colorSpace: undefined, original: formatted }; + } + const formatted = formatRgbColor(color, target); return { - r: color.r, - g: color.g, - b: color.b, - a: color.a, + ...color, + colorSpace: target, + original: formatted, }; }; +type IntermediateColorValue = { + type: "intermediate"; + value: string; + unit?: Unit; +}; + +type ColorPickerValue = StyleValue | IntermediateColorValue; + +const rgbaFromRgbValue = (rgb?: RgbValue): RgbaColor => + rgb + ? { + r: rgb.r, + g: rgb.g, + b: rgb.b, + a: rgb.alpha ?? 1, + } + : transparentColor; + +const parseStyleValueToRgb = ( + value: ColorPickerValue +): RgbValue | undefined => { + if (value.type === "rgb") { + return value; + } + const cssValue = value.type === "intermediate" ? value.value : toValue(value); + return parseCssColor(cssValue); +}; + +const toInputString = (parsed: RgbValue): string => { + if (parsed.original) { + return parsed.original; + } + if (parsed.colorSpace && parsed.colorSpace !== "rgb") { + return formatRgbColor(parsed); + } + return colord(rgbaFromRgbValue(parsed)).toHex(); +}; + +const colorToDisplayString = (value: ColorPickerValue, parsed?: RgbValue) => { + if (parsed) { + return toInputString(parsed); + } + return value.type === "intermediate" ? value.value : toValue(value); +}; + +export const styleValueToRgbaColor = ( + value: StyleValue | IntermediateColorValue +): RgbaColor => rgbaFromRgbValue(parseStyleValueToRgb(value)); + const getEyeDropper = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const Constructor = (window as any).EyeDropper; @@ -161,14 +207,6 @@ const getEyeDropper = () => { }; }; -type IntermediateColorValue = { - type: "intermediate"; - value: string; - unit?: Unit; -}; - -type ColorPickerValue = StyleValue | IntermediateColorValue; - type ColorPickerProps = { value: ColorPickerValue; onChange: (value: StyleValue | undefined) => void; @@ -213,49 +251,160 @@ export const ColorPicker = ({ onChange, onChangeComplete, }: ColorPickerProps) => { - const [hex, setHex] = useState(() => - colord(styleValueToRgbaColor(value)).toHex() + const initialParsed = parseStyleValueToRgb(value); + const [parsedColor, setParsedColor] = useState( + initialParsed ); - const normalizedHex = normalizeHex(hex); + const [inputValue, setInputValue] = useState(() => + colorToDisplayString(value, initialParsed) + ); + const [hasInvalidInput, setHasInvalidInput] = useState(false); const handleCompleteDebounced = useDebouncedCallback( (newValue: RgbValue) => onChangeComplete(newValue), 500 ); + useEffect(() => { + const nextParsed = parseStyleValueToRgb(value); + setParsedColor(nextParsed); + setInputValue(colorToDisplayString(value, nextParsed)); + setHasInvalidInput(false); + }, [value]); + + const commitColor = (nextValue: RgbValue) => { + const formatted = toInputString(nextValue); + setParsedColor({ ...nextValue, original: formatted }); + setInputValue(formatted); + setHasInvalidInput(false); + onChange(nextValue); + handleCompleteDebounced(nextValue); + }; + return ( <> { const fixedRgb = fixColor(value, newRgb); - setHex(colord(fixedRgb).toHex()); - const newValue = colorResultToRgbValue(fixedRgb); - onChange(newValue); - handleCompleteDebounced(newValue); + commitColor(colorResultToRgbValue(fixedRgb, parsedColor?.colorSpace)); }} /> { - setHex(newHex); - const newValue = colorResultToRgbValue(colord(newHex).toRgb()); - onChangeComplete(newValue); + const parsed = parseCssColor(newHex); + if (parsed) { + const formatted = toInputString(parsed); + const nextValue = { ...parsed, original: formatted }; + setParsedColor(nextValue); + setInputValue(formatted); + setHasInvalidInput(false); + onChangeComplete(nextValue); + } }} /> { - setHex(event.target.value); - const color = colord(normalizeHex(event.target.value)); - if (color.isValid()) { - const newValue = colorResultToRgbValue(color.toRgb()); - onChange(newValue); - handleCompleteDebounced(newValue); + const nextInput = event.target.value; + setInputValue(nextInput); + const parsed = parseCssColor(nextInput); + if (parsed) { + commitColor(parsed); + } else if (nextInput.trim() !== "") { + setHasInvalidInput(true); + } else { + setHasInvalidInput(false); } }} + placeholder="hex, rgb, hsl, lab, oklch..." + aria-label="Color value input" + aria-invalid={hasInvalidInput} + css={{ + ...(hasInvalidInput && { + borderColor: theme.colors.borderDestructiveMain, + "&:focus": { + borderColor: theme.colors.borderDestructiveMain, + }, + }), + }} /> + {hasInvalidInput && ( + + Invalid color format + + )} + + + Format + + + + {parsedColor?.colorSpace?.toUpperCase() || "HEX"} + + { + if (parsedColor) { + const formats = ["hex", "rgb", "hsl", "oklch"]; + const currentFormat = parsedColor.colorSpace || "hex"; + const currentIndex = formats.indexOf( + currentFormat.toLowerCase() + ); + const nextIndex = (currentIndex + 1) % formats.length; + const nextFormat = formats[nextIndex]; + const converted = convertToFormat(parsedColor, nextFormat); + commitColor(converted); + } + }} + aria-label="Switch color format" + css={{ + width: theme.sizes.controlHeight, + height: theme.sizes.controlHeight, + minWidth: theme.sizes.controlHeight, + padding: 0, + color: theme.colors.foregroundSubtle, + "&:hover": { + color: theme.colors.foregroundMain, + backgroundColor: theme.colors.backgroundHover, + }, + }} + > + + + + ); }; diff --git a/packages/design-system/src/components/gradient-picker.tsx b/packages/design-system/src/components/gradient-picker.tsx index 40b552473ce6..4e2d0016f646 100644 --- a/packages/design-system/src/components/gradient-picker.tsx +++ b/packages/design-system/src/components/gradient-picker.tsx @@ -18,6 +18,7 @@ import { type GradientStop, type ParsedGradient, } from "@webstudio-is/css-data"; +import { parseCssColor } from "@webstudio-is/css-data"; import { colord, extend } from "colord"; import mixPlugin from "colord/plugins/mix"; import { ChevronFilledUpIcon } from "@webstudio-is/icons"; @@ -66,16 +67,9 @@ const toRgbColor = ( return color; } - const parsed = colord(toValue(color)); - if (parsed.isValid()) { - const { r, g, b, a } = parsed.toRgb(); - return { - type: "rgb", - r, - g, - b, - alpha: a, - }; + const parsed = parseCssColor(toValue(color)); + if (parsed) { + return parsed; } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 138f96251ba9..6e25686c3f6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1234,6 +1234,9 @@ importers: css-tree: specifier: ^3.1.0 version: 3.1.0 + culori: + specifier: ^4.0.2 + version: 4.0.2 openai: specifier: ^3.2.1 version: 3.2.1 @@ -6119,6 +6122,10 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + culori@4.0.2: + resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} @@ -13552,6 +13559,8 @@ snapshots: csstype@3.1.3: {} + culori@4.0.2: {} + data-uri-to-buffer@2.0.2: {} data-uri-to-buffer@3.0.1: {}