Skip to content

Commit 12d8002

Browse files
experimental: add gradient control for updating the color stops using ui
1 parent 8bd3897 commit 12d8002

File tree

6 files changed

+238
-2
lines changed

6 files changed

+238
-2
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {
2+
parseLinearGradient,
3+
type ParsedGradient,
4+
} from "@webstudio-is/css-data";
5+
import { GradientControl } from "./gradient-control";
6+
import { toValue } from "@webstudio-is/css-engine";
7+
8+
export default {
9+
title: "Library/GradientControl",
10+
};
11+
12+
export const GradientWithoutAngle = () => {
13+
return (
14+
<GradientControl
15+
gradient={
16+
parseLinearGradient(
17+
"linear-gradient(#e66465 0%, #9198e5 100%)"
18+
) as ParsedGradient
19+
}
20+
onChange={() => {}}
21+
/>
22+
);
23+
};
24+
25+
// The GradientControl is to just modify the stop values or add new ones.
26+
// It always shows the angle as 90deg, unless the stops can't be showin in a rectangle.
27+
// So, the gradient shouldn't modify even if the stop values are changed at the end,
28+
export const GradientWithAngle = () => {
29+
return (
30+
<GradientControl
31+
gradient={
32+
parseLinearGradient(
33+
"linear-gradient(145deg, #ff00fa 0%, #00f497 34% 34%, #ffa800 56% 56%, #00eaff 100%)"
34+
) as ParsedGradient
35+
}
36+
onChange={(value) => {
37+
if (toValue(value.angle) !== "145deg") {
38+
throw new Error(
39+
`Gradient control modified the angle that is passed. \nReceived ${JSON.stringify(value.angle, null, 2)}`
40+
);
41+
}
42+
}}
43+
/>
44+
);
45+
};
46+
47+
export const GradientWithSideOrCorner = () => {
48+
return (
49+
<GradientControl
50+
gradient={
51+
parseLinearGradient(
52+
"linear-gradient(to left top, blue 0%, red 100%)"
53+
) as ParsedGradient
54+
}
55+
onChange={(value) => {
56+
if (toValue(value.sideOrCorner) !== "to left top") {
57+
throw new Error(
58+
`Gradient control modified the side-or-corner value that is passed. \nReceived ${JSON.stringify(value.sideOrCorner, null, 2)}`
59+
);
60+
}
61+
}}
62+
/>
63+
);
64+
};
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { toValue, UnitValue } from "@webstudio-is/css-engine";
2+
import { Root, Range, Thumb, Track } from "@radix-ui/react-slider";
3+
import { useEffect, useState, useCallback } from "react";
4+
import {
5+
reconstructLinearGradient,
6+
type GradientStop,
7+
type ParsedGradient,
8+
} from "@webstudio-is/css-data";
9+
import { styled, theme, Flex } from "@webstudio-is/design-system";
10+
11+
type GradientControlProps = {
12+
gradient: ParsedGradient;
13+
onChange: (value: ParsedGradient) => void;
14+
};
15+
16+
const defaultAngle: UnitValue = {
17+
type: "unit",
18+
value: 90,
19+
unit: "deg",
20+
};
21+
22+
export const GradientControl = (props: GradientControlProps) => {
23+
const [stops, setStops] = useState<Array<GradientStop>>(props.gradient.stops);
24+
const [selectedStop, setSelectedStop] = useState<number | undefined>();
25+
const positions = stops.map((stop) => stop.position?.value) as number[];
26+
const background = reconstructLinearGradient({
27+
stops,
28+
sideOrCorner: props.gradient.sideOrCorner,
29+
angle: defaultAngle,
30+
});
31+
32+
useEffect(() => {
33+
const newStops: Array<GradientStop> = [];
34+
for (const stop of props.gradient.stops || []) {
35+
if (stop.color !== undefined && stop.position?.value !== undefined) {
36+
newStops.push({
37+
color: stop.color,
38+
position: stop.position,
39+
});
40+
}
41+
}
42+
setStops(newStops);
43+
}, [props.gradient]);
44+
45+
const handleValueChange = useCallback(
46+
(newPositions: number[]) => {
47+
const newStops: GradientStop[] = stops.map((stop, index) => ({
48+
...stop,
49+
position: { type: "unit", value: newPositions[index], unit: "%" },
50+
}));
51+
52+
setStops(newStops);
53+
props.onChange({
54+
angle: props.gradient.angle,
55+
stops,
56+
sideOrCorner: props.gradient.sideOrCorner,
57+
});
58+
},
59+
[stops, props]
60+
);
61+
62+
const handleKeyDown = useCallback(
63+
(event: React.KeyboardEvent) => {
64+
if (event.key === "Backspace" && selectedStop !== undefined) {
65+
const newStops = stops;
66+
newStops.splice(selectedStop, 1);
67+
setStops(newStops);
68+
setSelectedStop(undefined);
69+
}
70+
},
71+
[stops, selectedStop]
72+
);
73+
74+
return (
75+
<Flex
76+
align="end"
77+
css={{
78+
width: theme.spacing[28],
79+
height: theme.spacing[14],
80+
}}
81+
>
82+
<SliderRoot
83+
css={{ background }}
84+
max={100}
85+
step={1}
86+
value={positions}
87+
onValueChange={handleValueChange}
88+
onKeyDown={handleKeyDown}
89+
>
90+
<Track>
91+
<SliderRange css={{ cursor: "copy" }} />
92+
</Track>
93+
{stops.map((stop, index) => (
94+
<SliderThumb
95+
key={index}
96+
onClick={() => {
97+
setSelectedStop(index);
98+
}}
99+
style={{
100+
background: toValue(stop.color),
101+
}}
102+
/>
103+
))}
104+
</SliderRoot>
105+
</Flex>
106+
);
107+
};
108+
109+
const SliderRoot = styled(Root, {
110+
position: "relative",
111+
width: "100%",
112+
height: theme.spacing[9],
113+
border: `1px solid ${theme.colors.borderInfo}`,
114+
borderRadius: theme.borderRadius[3],
115+
touchAction: "none",
116+
userSelect: "none",
117+
});
118+
119+
const SliderRange = styled(Range, {
120+
position: "absolute",
121+
background: "transparent",
122+
borderRadius: theme.borderRadius[3],
123+
});
124+
125+
const SliderThumb = styled(Thumb, {
126+
position: "absolute",
127+
width: theme.spacing[9],
128+
height: theme.spacing[9],
129+
border: `1px solid ${theme.colors.borderInfo}`,
130+
borderRadius: theme.borderRadius[3],
131+
top: `-${theme.spacing[11]}`,
132+
translate: "-9px",
133+
});
134+
135+
export default GradientControl;

apps/builder/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@nanostores/react": "^0.7.1",
4141
"@radix-ui/react-select": "^2.1.1",
4242
"@radix-ui/react-tooltip": "^1.1.2",
43+
"@radix-ui/react-slider": "^1.2.0",
4344
"@react-aria/interactions": "^3.19.0",
4445
"@react-aria/utils": "^3.21.0",
4546
"@remix-run/node": "^2.11.0",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./transition";
22
export * from "./shadow-properties-extractor";
3+
export * from "./linear-gradient";

packages/css-data/src/property-parsers/linear-gradient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import namesPlugin from "colord/plugins/names";
1212

1313
extend([namesPlugin]);
1414

15-
interface GradientStop {
15+
export interface GradientStop {
1616
color?: RgbValue;
1717
position?: UnitValue;
1818
hint?: UnitValue;
1919
}
2020

21-
interface ParsedGradient {
21+
export interface ParsedGradient {
2222
angle?: UnitValue;
2323
sideOrCorner?: KeywordValue;
2424
stops: GradientStop[];

pnpm-lock.yaml

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)