mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
Fix color input does not accept CSS color names (#1089)
## Description 1. What is this PR about (link the issue and add a short description) Found few bugs. - [x] - White color have border - [x] - Fix transparent - all is parsed well its `react-color` feature <img width="237" alt="image" src="https://user-images.githubusercontent.com/5077042/220187124-fe8ddc9a-1cc0-4ad3-9979-4037e4a69b5a.png"> - [x] - Synchronize picker color and color shown in input closes #1081 `+` Added transparent color (need be in design, looks better for most inputs if anything is shown) <img width="212" alt="image" src="https://user-images.githubusercontent.com/5077042/220186642-8cc0bcad-ab35-4444-b773-da652cafb734.png"> <img width="217" alt="image" src="https://user-images.githubusercontent.com/5077042/220186773-071e6176-066a-4d37-807d-cd6a2db70642.png"> Accessibility is not done fully for the color picker https://github.com/webstudio-is/webstudio-builder/issues/1092 ## Steps for reproduction 1. You are now able to select colors by name <img width="243" alt="image" src="https://user-images.githubusercontent.com/5077042/220174790-7e33219d-e955-4718-bad1-0c1ac79ebaf3.png"> 2. Colors with non-1 alpha will be shown on chessboard <img width="225" alt="image" src="https://user-images.githubusercontent.com/5077042/220174949-2c0c4a45-d330-4a3c-a769-f30bd33b2154.png"> ## Code Review - [ ] hi @kof, I need you to do - test it on preview - [ ] hi @TrySound - conceptual review (architecture, feature-correctness) - detailed review (read every line) ## Before requesting a review - [x] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [x] tested locally and on preview environment (preview dev login: 5de6) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio-builder/blob/main/apps/builder/docs/test-cases.md) document - [x] added tests - [ ] if any new env variables are added, added them to `.env.example` and the `builder/env-check.js` if mandatory
This commit is contained in:
1 parent
c916c2f7ca
commit
1076d6efd0
8 files changed
+320
-129
No files matched your search
@@ -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<CssColorPickerValueInput>();
|
||||
|
||||
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 (
|
||||
<Flex align="center" css={{ gridColumn: "2/4" }} gap="1">
|
||||
<ColorPicker
|
||||
id={property}
|
||||
property={property}
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
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}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -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<KeywordValue>;
|
||||
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 = (
|
||||
<DeprecatedPopover
|
||||
<Popover
|
||||
modal
|
||||
open={displayColorPicker}
|
||||
onOpenChange={setDisplayColorPicker}
|
||||
>
|
||||
<DeprecatedPopoverTrigger
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
aria-label="Open color picker"
|
||||
onClick={() => 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",
|
||||
}}
|
||||
/>
|
||||
</DeprecatedPopoverTrigger>
|
||||
<DeprecatedPopoverPortal>
|
||||
<DeprecatedPopoverContent>
|
||||
<SketchPicker
|
||||
color={rgbValue}
|
||||
onChange={(color: ColorResult) => {
|
||||
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}
|
||||
/>
|
||||
</DeprecatedPopoverContent>
|
||||
</DeprecatedPopoverPortal>
|
||||
</DeprecatedPopover>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent>
|
||||
<SketchPicker
|
||||
color={rgbValue}
|
||||
onChange={(color: ColorResult) => {
|
||||
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}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
onChange={(event) => {
|
||||
setIntermediateValue({
|
||||
stringValue: event.target.value,
|
||||
rgbValue: rgbValue,
|
||||
state: undefined,
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (displayColorPicker) {
|
||||
<CssValueInput
|
||||
styleSource={styleSource}
|
||||
icon={prefix}
|
||||
property={property}
|
||||
value={value}
|
||||
intermediateValue={intermediateValue}
|
||||
keywords={keywords}
|
||||
onChange={(styleValue) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+33
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -52,6 +52,7 @@ module.exports = {
|
||||
"@lexical/react/*",
|
||||
"__generated__/*",
|
||||
"react-hot-toast/headless",
|
||||
"colord/plugins/*",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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<string>;
|
||||
|
||||
if (values?.includes(value)) {
|
||||
return {
|
||||
type: "keyword",
|
||||
value: value,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "keyword",
|
||||
value,
|
||||
type: "unparsed",
|
||||
value: value,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user