mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
refactor: improve color picker (#4029)
Color control can be a thin wrapper around color picker - fixed "inherit" keyword support - moved intermediate value into color picker - merge highlight and change low level events
This commit is contained in:
1 parent
34157d4f21
commit
bb1a5d52eb
4 files changed
+103
-167
No files matched your search
@@ -1,44 +1,13 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Flex } from "@webstudio-is/design-system";
|
||||
import { toValue } from "@webstudio-is/css-engine";
|
||||
import type { RgbValue, StyleValue } from "@webstudio-is/css-engine";
|
||||
import { colord } from "colord";
|
||||
import {
|
||||
type CssColorPickerValueInput,
|
||||
ColorPicker,
|
||||
} from "../../shared/color-picker";
|
||||
import { getStyleSource } from "../../shared/style-info";
|
||||
import { ColorPicker } from "../../shared/color-picker";
|
||||
import { styleConfigByName } from "../../shared/configs";
|
||||
import type { ControlProps } from "../types";
|
||||
import { AdvancedValueTooltip } from "../advanced-value-tooltip";
|
||||
import { createComputedStyleDeclStore } from "../../shared/model";
|
||||
|
||||
const parseColor = (color?: StyleValue): RgbValue => {
|
||||
const colordValue = colord(toValue(color));
|
||||
|
||||
if (colordValue.isValid()) {
|
||||
const rgb = colordValue.toRgb();
|
||||
return {
|
||||
type: "rgb",
|
||||
r: rgb.r,
|
||||
g: rgb.g,
|
||||
b: rgb.b,
|
||||
alpha: rgb.a ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
// @todo what to show as default?
|
||||
// Default to black
|
||||
return {
|
||||
type: "rgb",
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const ColorControl = ({
|
||||
property,
|
||||
items,
|
||||
@@ -53,20 +22,12 @@ export const ColorControl = ({
|
||||
);
|
||||
const computedStyleDecl = useStore($computedStyleDecl);
|
||||
|
||||
const [intermediateValue, setIntermediateValue] =
|
||||
useState<CssColorPickerValueInput>();
|
||||
|
||||
const { items: defaultItems } = styleConfigByName(property);
|
||||
const styleInfo = currentStyle[property];
|
||||
|
||||
const setValue = setProperty(property);
|
||||
|
||||
let value = computedStyleDecl.cascadedValue;
|
||||
if (value.type !== "rgb" && value.type !== "keyword") {
|
||||
// Support previously set colors
|
||||
value = parseColor(value);
|
||||
}
|
||||
const currentColor = parseColor(computedStyleDecl.usedValue);
|
||||
const value = computedStyleDecl.cascadedValue;
|
||||
const currentColor = computedStyleDecl.usedValue;
|
||||
|
||||
return (
|
||||
<Flex align="center" gap="1">
|
||||
@@ -82,38 +43,13 @@ export const ColorControl = ({
|
||||
currentColor={currentColor}
|
||||
property={property}
|
||||
value={value}
|
||||
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 });
|
||||
}}
|
||||
onChange={(styleValue) => setValue(styleValue, { isEphemeral: true })}
|
||||
onChangeComplete={setValue}
|
||||
onAbort={() => deleteProperty(property, { isEphemeral: true })}
|
||||
/>
|
||||
</AdvancedValueTooltip>
|
||||
</Flex>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { colord, extend, type RgbaColor } from "colord";
|
||||
import namesPlugin from "colord/plugins/names";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { RgbaColorPicker } from "react-colorful";
|
||||
import { EyedropperIcon } from "@webstudio-is/icons";
|
||||
import type {
|
||||
InvalidValue,
|
||||
KeywordValue,
|
||||
RgbValue,
|
||||
StyleProperty,
|
||||
StyleValue,
|
||||
KeywordValue,
|
||||
RgbValue,
|
||||
} from "@webstudio-is/css-engine";
|
||||
import {
|
||||
Popover,
|
||||
@@ -22,11 +22,9 @@ import {
|
||||
} 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";
|
||||
import { ColorThumb } from "./color-thumb";
|
||||
import { EyedropperIcon } from "@webstudio-is/icons";
|
||||
|
||||
// To support color names
|
||||
extend([namesPlugin]);
|
||||
@@ -48,27 +46,9 @@ const colorResultToRgbValue = (rgb: RgbaColor): RgbValue => {
|
||||
};
|
||||
};
|
||||
|
||||
const styleValueResolve = (
|
||||
value: RgbValue | KeywordValue,
|
||||
currentColor: RgbValue
|
||||
): RgbValue | KeywordValue => {
|
||||
if (
|
||||
value.type === "keyword" &&
|
||||
value.value.toLowerCase() === "currentcolor"
|
||||
) {
|
||||
return {
|
||||
type: "rgb",
|
||||
r: currentColor.r,
|
||||
g: currentColor.g,
|
||||
b: currentColor.b,
|
||||
alpha: currentColor.alpha,
|
||||
};
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const styleValueToRgbaColor = (value: CssColorPickerValueInput): RgbaColor => {
|
||||
const styleValueToRgbaColor = (
|
||||
value: StyleValue | IntermediateStyleValue
|
||||
): RgbaColor => {
|
||||
const color = colord(
|
||||
value.type === "intermediate" ? value.value : toValue(value)
|
||||
).toRgb();
|
||||
@@ -125,18 +105,16 @@ const ColorPickerPopoverContent = ({
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
}: {
|
||||
value: CssColorPickerValueInput;
|
||||
onChange: (value: CssColorPickerValueInput | undefined) => void;
|
||||
onChangeComplete: (event: {
|
||||
value: RgbValue | KeywordValue | InvalidValue;
|
||||
}) => void;
|
||||
value: StyleValue | IntermediateStyleValue;
|
||||
onChange: (value: StyleValue | undefined) => void;
|
||||
onChangeComplete: (value: StyleValue) => void;
|
||||
}) => {
|
||||
const [hex, setHex] = useState(() =>
|
||||
colord(styleValueToRgbaColor(value)).toHex()
|
||||
);
|
||||
const normalizedHex = normalizeHex(hex);
|
||||
const handleCompleteDebounced = useDebouncedCallback(
|
||||
(newValue: RgbValue) => onChangeComplete({ value: newValue }),
|
||||
(newValue: RgbValue) => onChangeComplete(newValue),
|
||||
500
|
||||
);
|
||||
/**
|
||||
@@ -167,7 +145,7 @@ const ColorPickerPopoverContent = ({
|
||||
onChange={(newHex) => {
|
||||
setHex(newHex);
|
||||
const newValue = colorResultToRgbValue(colord(newHex).toRgb());
|
||||
onChangeComplete({ value: newValue });
|
||||
onChangeComplete(newValue);
|
||||
}}
|
||||
/>
|
||||
<InputField
|
||||
@@ -187,23 +165,12 @@ const ColorPickerPopoverContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type CssColorPickerValueInput =
|
||||
| RgbValue
|
||||
| KeywordValue
|
||||
| IntermediateStyleValue
|
||||
| InvalidValue;
|
||||
|
||||
type ColorPickerProps = {
|
||||
onChange: (value: CssColorPickerValueInput | undefined) => void;
|
||||
onChangeComplete: (event: {
|
||||
value: RgbValue | KeywordValue | InvalidValue;
|
||||
}) => void;
|
||||
onHighlight: (value: StyleValue | undefined) => void;
|
||||
onChange: (value: StyleValue) => void;
|
||||
onChangeComplete: (value: StyleValue) => void;
|
||||
onAbort: () => void;
|
||||
intermediateValue: CssColorPickerValueInput | undefined;
|
||||
value: RgbValue | KeywordValue;
|
||||
currentColor: RgbValue;
|
||||
styleSource: StyleSource;
|
||||
value: StyleValue;
|
||||
currentColor: StyleValue;
|
||||
keywords?: Array<KeywordValue>;
|
||||
property: StyleProperty;
|
||||
disabled?: boolean;
|
||||
@@ -212,46 +179,21 @@ type ColorPickerProps = {
|
||||
export const ColorPicker = ({
|
||||
value,
|
||||
currentColor,
|
||||
intermediateValue,
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
onHighlight,
|
||||
onAbort,
|
||||
styleSource,
|
||||
keywords,
|
||||
property,
|
||||
disabled,
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
onAbort,
|
||||
}: ColorPickerProps) => {
|
||||
const [displayColorPicker, setDisplayColorPicker] = useState(false);
|
||||
const { enableCanvasPointerEvents, disableCanvasPointerEvents } =
|
||||
useDisableCanvasPointerEvents();
|
||||
|
||||
const currentValue =
|
||||
intermediateValue ?? styleValueResolve(value, currentColor);
|
||||
|
||||
const rgbValue = styleValueToRgbaColor(currentValue);
|
||||
|
||||
// Change prefix color in sync with color picker, don't change during input changed
|
||||
let prefixColor = styleValueResolve(
|
||||
currentValue.type === "keyword" || currentValue.type === "rgb"
|
||||
? currentValue
|
||||
: value,
|
||||
currentColor
|
||||
);
|
||||
// consider inherit on color same as currentColor
|
||||
if (
|
||||
property === "color" &&
|
||||
prefixColor.type === "keyword" &&
|
||||
prefixColor.value === "inherit"
|
||||
) {
|
||||
prefixColor = {
|
||||
type: "rgb",
|
||||
r: currentColor.r,
|
||||
g: currentColor.g,
|
||||
b: currentColor.b,
|
||||
alpha: currentColor.alpha,
|
||||
};
|
||||
}
|
||||
const [intermediateValue, setIntermediateValue] = useState<
|
||||
StyleValue | IntermediateStyleValue
|
||||
>();
|
||||
const currentValue = intermediateValue ?? value;
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setDisplayColorPicker(open);
|
||||
@@ -274,7 +216,10 @@ export const ColorPicker = ({
|
||||
aria-label="Open color picker"
|
||||
onClick={() => setDisplayColorPicker((shown) => !shown)}
|
||||
>
|
||||
<ColorThumb color={rgbValue} css={{ margin: theme.spacing[3] }} />
|
||||
<ColorThumb
|
||||
color={styleValueToRgbaColor(currentColor)}
|
||||
css={{ margin: theme.spacing[3] }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
css={{
|
||||
@@ -285,8 +230,18 @@ export const ColorPicker = ({
|
||||
>
|
||||
<ColorPickerPopoverContent
|
||||
value={currentValue}
|
||||
onChange={onChange}
|
||||
onChangeComplete={onChangeComplete}
|
||||
onChange={(styleValue) => {
|
||||
setIntermediateValue(styleValue);
|
||||
if (styleValue) {
|
||||
onChange(styleValue);
|
||||
} else {
|
||||
onAbort();
|
||||
}
|
||||
}}
|
||||
onChangeComplete={(value) => {
|
||||
setIntermediateValue(undefined);
|
||||
onChangeComplete(value);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -295,7 +250,7 @@ export const ColorPicker = ({
|
||||
return (
|
||||
<CssValueInput
|
||||
disabled={disabled}
|
||||
styleSource={styleSource}
|
||||
styleSource="default"
|
||||
prefix={prefix}
|
||||
showSuffix={false}
|
||||
property={property}
|
||||
@@ -303,33 +258,50 @@ export const ColorPicker = ({
|
||||
intermediateValue={intermediateValue}
|
||||
keywords={keywords}
|
||||
onChange={(styleValue) => {
|
||||
if (styleValue === undefined) {
|
||||
setIntermediateValue(styleValue);
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
if (styleValue.type === "intermediate") {
|
||||
setIntermediateValue(styleValue);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
styleValue?.type === "rgb" ||
|
||||
styleValue?.type === "keyword" ||
|
||||
styleValue?.type === "intermediate" ||
|
||||
styleValue?.type === "invalid" ||
|
||||
styleValue === undefined
|
||||
styleValue.type === "rgb" ||
|
||||
styleValue.type === "keyword" ||
|
||||
styleValue.type === "invalid"
|
||||
) {
|
||||
setIntermediateValue(styleValue);
|
||||
onChange(styleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
setIntermediateValue({
|
||||
type: "intermediate",
|
||||
value: toValue(styleValue),
|
||||
});
|
||||
}}
|
||||
onHighlight={onHighlight}
|
||||
onHighlight={(styleValue) => {
|
||||
if (styleValue) {
|
||||
onChange(styleValue);
|
||||
} else {
|
||||
onAbort();
|
||||
}
|
||||
}}
|
||||
onChangeComplete={({ value }) => {
|
||||
if (value.type === "rgb" || value.type === "keyword") {
|
||||
onChangeComplete({ value });
|
||||
setIntermediateValue(undefined);
|
||||
onChangeComplete(value);
|
||||
return;
|
||||
}
|
||||
// In case value is parsed to something wrong
|
||||
onChange({
|
||||
const invalidValue: StyleValue = {
|
||||
type: "invalid",
|
||||
value: toValue(value),
|
||||
});
|
||||
};
|
||||
setIntermediateValue(invalidValue);
|
||||
onChange(invalidValue);
|
||||
}}
|
||||
onAbort={onAbort}
|
||||
/>
|
||||
|
||||
@@ -1395,4 +1395,34 @@ describe("style value source", () => {
|
||||
styleSourceId: "boxLocal",
|
||||
});
|
||||
});
|
||||
|
||||
test("local with inherit keyword", () => {
|
||||
const model = createModel({
|
||||
css: `
|
||||
bodyLocal {
|
||||
color: red;
|
||||
}
|
||||
boxLocal {
|
||||
color: inherit;
|
||||
}
|
||||
`,
|
||||
jsx: (
|
||||
<$.Body ws:id="body" ws:tag="body" class="bodyLocal">
|
||||
<$.Box ws:id="box" ws:tag="div" class="boxLocal"></$.Box>
|
||||
</$.Body>
|
||||
),
|
||||
});
|
||||
expect(
|
||||
getComputedStyleDecl({
|
||||
model,
|
||||
instanceSelector: ["box", "body"],
|
||||
property: "color",
|
||||
}).source
|
||||
).toEqual({
|
||||
name: "local",
|
||||
instanceId: "box",
|
||||
breakpointId: "base",
|
||||
styleSourceId: "boxLocal",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -316,11 +316,9 @@ export const getComputedStyleDecl = ({
|
||||
(property === "color" && matchKeyword(cascadedValue, "currentcolor"))
|
||||
) {
|
||||
specifiedValue = inheritedValue;
|
||||
source = inheritedSource;
|
||||
} else if (matchKeyword(cascadedValue, "unset")) {
|
||||
if (inherited) {
|
||||
specifiedValue = inheritedValue;
|
||||
source = inheritedSource;
|
||||
} else {
|
||||
specifiedValue = initialValue;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user