experimental: show color picker in advanced panel (#4206)

Ref https://github.com/webstudio-is/webstudio/issues/3399

Now users can tinker colors in css variables

![Screenshot 2024-10-03 at 14 47
26](https://github.com/user-attachments/assets/fc48334a-fc72-41ee-922b-1ae31039fe7a)
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2024-10-03 19:47:39 +03:00
1 parent 5031292cca
commit 58df4bc3a9
4 files changed
+112 -60

No files matched your search

@@ -1,3 +1,4 @@
import { colord } from "colord";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { computed } from "nanostores";
import { matchSorter } from "match-sorter";
@@ -22,6 +23,7 @@ import {
import { useStore } from "@nanostores/react";
import {
hyphenateProperty,
toValue,
type StyleProperty,
} from "@webstudio-is/css-engine";
import {
@@ -39,6 +41,7 @@ import {
import { getDots } from "../../shared/style-section";
import { PropertyInfo } from "../../property-label";
import { sections } from "../sections";
import { ColorPopover } from "../../shared/color-picker";
// Only here to keep the same section module interface
export const properties = [];
@@ -229,6 +232,7 @@ const AdvancedPropertyValue = ({
inputRef.current?.focus();
}
}, [autoFocus]);
const isColor = colord(toValue(styleDecl.usedValue)).isValid();
return (
<CssValueInputContainer
inputRef={inputRef}
@@ -236,6 +240,25 @@ const AdvancedPropertyValue = ({
size="2"
text="mono"
fieldSizing="content"
prefix={
isColor && (
<ColorPopover
size={1}
value={styleDecl.usedValue}
onChange={(styleValue) => {
const options = { isEphemeral: true, listed: true };
if (styleValue) {
setProperty(property)(styleValue, options);
} else {
deleteProperty(property, options);
}
}}
onChangeComplete={(styleValue) => {
setProperty(property)(styleValue);
}}
/>
)
}
property={property}
styleSource={styleDecl.source.name}
keywords={[
@@ -176,25 +176,21 @@ type ColorPickerProps = {
disabled?: boolean;
};
export const ColorPicker = ({
export const ColorPopover = ({
size,
value,
currentColor,
keywords,
property,
disabled,
onChange,
onChangeComplete,
onAbort,
}: ColorPickerProps) => {
}: {
size?: 1 | 2;
value: StyleValue;
onChange: (value: undefined | StyleValue) => void;
onChangeComplete: (value: StyleValue) => void;
}) => {
const [displayColorPicker, setDisplayColorPicker] = useState(false);
const { enableCanvasPointerEvents, disableCanvasPointerEvents } =
useDisableCanvasPointerEvents();
const [intermediateValue, setIntermediateValue] = useState<
StyleValue | IntermediateStyleValue
>();
const currentValue = intermediateValue ?? value;
const handleOpenChange = (open: boolean) => {
setDisplayColorPicker(open);
if (open) {
@@ -209,7 +205,7 @@ export const ColorPicker = ({
enableCanvasPointerEvents();
};
const prefix = (
return (
<Popover modal open={displayColorPicker} onOpenChange={handleOpenChange}>
<PopoverTrigger
asChild
@@ -217,7 +213,8 @@ export const ColorPicker = ({
onClick={() => setDisplayColorPicker((shown) => !shown)}
>
<ColorThumb
color={styleValueToRgbaColor(currentColor)}
color={styleValueToRgbaColor(value)}
size={size}
css={{ margin: theme.spacing[2] }}
tabIndex={-1}
/>
@@ -230,7 +227,36 @@ export const ColorPicker = ({
}}
>
<ColorPickerPopoverContent
value={currentValue}
value={value}
onChange={onChange}
onChangeComplete={onChangeComplete}
/>
</PopoverContent>
</Popover>
);
};
export const ColorPicker = ({
value,
currentColor,
keywords,
property,
disabled,
onChange,
onChangeComplete,
onAbort,
}: ColorPickerProps) => {
const [intermediateValue, setIntermediateValue] = useState<
StyleValue | IntermediateStyleValue
>();
return (
<CssValueInput
aria-disabled={disabled}
styleSource="default"
prefix={
<ColorPopover
value={currentColor}
onChange={(styleValue) => {
setIntermediateValue(styleValue);
if (styleValue) {
@@ -244,15 +270,7 @@ export const ColorPicker = ({
onChangeComplete(value);
}}
/>
</PopoverContent>
</Popover>
);
return (
<CssValueInput
aria-disabled={disabled}
styleSource="default"
prefix={prefix}
}
showSuffix={false}
property={property}
value={value}
@@ -1,6 +1,11 @@
import { rawTheme, theme, css, type CSS } from "@webstudio-is/design-system";
import { colord, type RgbaColor } from "colord";
import { forwardRef, type ElementRef, type ComponentProps } from "react";
import {
forwardRef,
type ElementRef,
type ComponentProps,
type CSSProperties,
} from "react";
import { clamp } from "~/shared/math-utils";
const whiteColor: RgbaColor = { r: 255, g: 255, b: 255, a: 1 };
@@ -44,40 +49,48 @@ const lerpColor = (a: RgbaColor, b: RgbaColor, t: number) => {
};
};
const colorThumbSize = "--color-thumb-size";
const thumbStyle = css({
display: "block",
width: theme.spacing[10],
height: theme.spacing[10],
width: `var(${colorThumbSize}, 20px)`,
height: `var(${colorThumbSize}, 20px)`,
backgroundBlendMode: "difference",
borderRadius: theme.borderRadius[2],
borderWidth: 0,
borderStyle: "solid",
});
export const ColorThumb = forwardRef<
ElementRef<"button">,
Omit<ComponentProps<"button">, "color"> & { color?: RgbaColor; css?: CSS }
>(({ color = transparentColor, css, ...rest }, ref) => {
const background =
color === undefined || color.a < 1
? // Chessboard pattern 5x5
`repeating-conic-gradient(rgba(0,0,0,0.22) 0% 25%, transparent 0% 50%) 0% 33.33% / 40% 40%, ${colord(color).toRgbString()}`
: colord(color).toRgbString();
const borderColor = calcBorderColor(color);
type Props = Omit<ComponentProps<"button">, "color"> & {
color?: RgbaColor;
css?: CSS;
size?: 1 | 2;
};
return (
<button
{...rest}
ref={ref}
style={{
background,
borderColor: borderColor.toRgbString(),
// Border becomes visible when color is close to white so that the thumb is visible in the white input.
borderWidth: borderColor.alpha() === 0 ? 0 : 1,
}}
className={thumbStyle({ css })}
/>
);
});
export const ColorThumb = forwardRef<ElementRef<"button">, Props>(
({ color = transparentColor, size = 2, css, ...rest }, ref) => {
const background =
color === undefined || color.a < 1
? // Chessboard pattern 5x5
`repeating-conic-gradient(rgba(0,0,0,0.22) 0% 25%, transparent 0% 50%) 0% 33.33% / 40% 40%, ${colord(color).toRgbString()}`
: colord(color).toRgbString();
const borderColor = calcBorderColor(color);
return (
<button
{...rest}
ref={ref}
style={{
background,
borderColor: borderColor.toRgbString(),
// Border becomes visible when color is close to white so that the thumb is visible in the white input.
borderWidth: borderColor.alpha() === 0 ? 0 : 1,
[colorThumbSize as keyof CSSProperties]: size === 1 ? "16px" : "20px",
}}
className={thumbStyle({ css })}
/>
);
}
);
ColorThumb.displayName = "ColorThumb";
@@ -51,12 +51,11 @@ import { convertUnits } from "./convert-units";
import { mergeRefs } from "@react-aria/utils";
// We need to enable scrub on properties that can have numeric value.
const canBeNumber = (property: StyleProperty) => {
if (property in properties === false) {
return true;
}
const { unitGroups } = properties[property as keyof typeof properties];
return unitGroups.length !== 0;
const canBeNumber = (property: StyleProperty, value: CssValueInputValue) => {
const unitGroups =
properties[property as keyof typeof properties]?.unitGroups ?? [];
// allow scrubbing css variables with unit value
return unitGroups.length !== 0 || value.type === "unit";
};
// Subjective adjust ment based on how it feels on macbook/trackpad.
@@ -111,7 +110,7 @@ const useScrub = ({
if (
inputRefCurrent === null ||
scrubRefCurrent === null ||
canBeNumber(property) === false
canBeNumber(property, valueRef.current) === false
) {
return;
}
@@ -446,7 +445,6 @@ export const CssValueInput = ({
highlightedIndex,
} = useCombobox<CssValueInputValue>({
// Used for description to match the item when nothing is highlighted yet and value is still in non keyword mode
defaultHighlightedIndex: 0,
items: keywords,
value,
selectedItem: props.value,
@@ -718,7 +716,7 @@ export const CssValueInput = ({
color={value.type === "invalid" ? "error" : undefined}
prefix={finalPrefix}
suffix={suffix}
css={{ cursor: "default", minWidth: "2em" }}
css={{ cursor: "default", minWidth: "3em" }}
text={text}
/>
</ComboboxAnchor>