Skip to content

Commit 45d9317

Browse files
experimental: parse linear-gradient and extract stops, angle, side-or-corner and hints from the gradient. (#4131)
## Description This PR is to kick off the #3448 track. First of everything we need to build the gradient selector/indicator ![image](https://github.com/user-attachments/assets/6901327d-0ba0-4b7d-a3ff-41dbcf806912) Which should be able to parse the gradient and pick the `stops` and `colors` and `hints` from it. ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 5de6) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent 3434322 commit 45d9317

File tree

2 files changed

+337
-0
lines changed

2 files changed

+337
-0
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { test, describe, expect } from "@jest/globals";
2+
import {
3+
parseLinearGradient,
4+
reconstructLinearGradient,
5+
} from "./linear-gradient";
6+
7+
describe("parses linear-gradient", () => {
8+
test("parses gradient without angle, sides and color-stops", () => {
9+
expect(parseLinearGradient("linear-gradient(red, blue, yellow)")).toEqual({
10+
angle: undefined,
11+
sideOrCorner: undefined,
12+
stops: [
13+
{
14+
color: { alpha: 1, b: 0, g: 0, r: 255, type: "rgb" },
15+
hint: undefined,
16+
position: undefined,
17+
},
18+
{
19+
color: { alpha: 1, b: 255, g: 0, r: 0, type: "rgb" },
20+
hint: undefined,
21+
position: undefined,
22+
},
23+
{
24+
color: { alpha: 1, b: 0, g: 255, r: 255, type: "rgb" },
25+
hint: undefined,
26+
position: undefined,
27+
},
28+
],
29+
});
30+
});
31+
32+
test("parses linear-gradient with angle and color-stops", () => {
33+
expect(
34+
parseLinearGradient("linear-gradient(135deg, orange 60% 20%, 40%, cyan)")
35+
).toEqual({
36+
angle: { type: "unit", unit: "deg", value: 135 },
37+
sideOrCorner: undefined,
38+
stops: [
39+
{
40+
color: { alpha: 1, b: 0, g: 165, r: 255, type: "rgb" },
41+
hint: { type: "unit", unit: "%", value: 20 },
42+
position: { type: "unit", unit: "%", value: 60 },
43+
},
44+
{ hint: { type: "unit", unit: "%", value: 40 } },
45+
{
46+
color: { alpha: 1, b: 255, g: 255, r: 0, type: "rgb" },
47+
hint: undefined,
48+
position: undefined,
49+
},
50+
],
51+
});
52+
});
53+
54+
test("parses linear-gradient with side-or-corer and multiple colors without stops", () => {
55+
expect(
56+
parseLinearGradient(
57+
"linear-gradient(to top right, orange, yellow, blue, green)"
58+
)
59+
).toEqual({
60+
angle: undefined,
61+
sideOrCorner: { type: "keyword", value: "to top right" },
62+
stops: [
63+
{
64+
color: { alpha: 1, b: 0, g: 165, r: 255, type: "rgb" },
65+
hint: undefined,
66+
position: undefined,
67+
},
68+
{
69+
color: { alpha: 1, b: 0, g: 255, r: 255, type: "rgb" },
70+
hint: undefined,
71+
position: undefined,
72+
},
73+
{
74+
color: { alpha: 1, b: 255, g: 0, r: 0, type: "rgb" },
75+
hint: undefined,
76+
position: undefined,
77+
},
78+
{
79+
color: { alpha: 1, b: 0, g: 128, r: 0, type: "rgb" },
80+
hint: undefined,
81+
position: undefined,
82+
},
83+
],
84+
});
85+
});
86+
87+
test("parses linear-gradient with multiple angles and color-stops", () => {
88+
expect(
89+
parseLinearGradient(
90+
"linear-gradient(to right, red 20%, orange 20% 40%, yellow 40% 60%, green 60% 80%, blue 80% )"
91+
)
92+
).toEqual({
93+
angle: undefined,
94+
sideOrCorner: { type: "keyword", value: "to right" },
95+
stops: [
96+
{
97+
color: { alpha: 1, b: 0, g: 0, r: 255, type: "rgb" },
98+
hint: undefined,
99+
position: { type: "unit", unit: "%", value: 20 },
100+
},
101+
{
102+
color: { alpha: 1, b: 0, g: 165, r: 255, type: "rgb" },
103+
hint: { type: "unit", unit: "%", value: 40 },
104+
position: { type: "unit", unit: "%", value: 20 },
105+
},
106+
{
107+
color: { alpha: 1, b: 0, g: 255, r: 255, type: "rgb" },
108+
hint: { type: "unit", unit: "%", value: 60 },
109+
position: { type: "unit", unit: "%", value: 40 },
110+
},
111+
{
112+
color: { alpha: 1, b: 0, g: 128, r: 0, type: "rgb" },
113+
hint: { type: "unit", unit: "%", value: 80 },
114+
position: { type: "unit", unit: "%", value: 60 },
115+
},
116+
{
117+
color: { alpha: 1, b: 255, g: 0, r: 0, type: "rgb" },
118+
hint: undefined,
119+
position: { type: "unit", unit: "%", value: 80 },
120+
},
121+
],
122+
});
123+
});
124+
125+
test("parses linear-gradient with rgb values", () => {
126+
const parsed = parseLinearGradient(
127+
"linear-gradient(rgb(255, 0, 0), rgb(0, 255, 0), rgb(0, 0, 255))"
128+
);
129+
if (parsed === undefined) {
130+
throw new Error("parsed is undefined");
131+
}
132+
133+
expect(reconstructLinearGradient(parsed)).toEqual(
134+
"linear-gradient(rgba(255, 0, 0, 1), rgba(0, 255, 0, 1), rgba(0, 0, 255, 1))"
135+
);
136+
});
137+
});
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import * as csstree from "css-tree";
2+
import { cssTryParseValue } from "../parse-css-value";
3+
import { colord, extend } from "colord";
4+
import {
5+
type UnitValue,
6+
type Unit,
7+
toValue,
8+
KeywordValue,
9+
type RgbValue,
10+
} from "@webstudio-is/css-engine";
11+
import namesPlugin from "colord/plugins/names";
12+
13+
extend([namesPlugin]);
14+
15+
interface GradientStop {
16+
color?: RgbValue;
17+
position?: UnitValue;
18+
hint?: UnitValue;
19+
}
20+
21+
interface ParsedGradient {
22+
angle?: UnitValue;
23+
sideOrCorner?: KeywordValue;
24+
stops: GradientStop[];
25+
}
26+
27+
const sideOrCorderIdentifiers = ["to", "top", "bottom", "left", "right"];
28+
29+
// We are currently not supporting color-interpolation-method from the linear-gradient syntax.
30+
// There are multiple reasons to not support it:
31+
// - mdn-data does not have any information about it. There is a PR that is opened about it.
32+
// https://github.com/mdn/data/pull/766 which needs to be released first.
33+
// - we can't use css-tree parser directly and by-pass using css-tree lexer.match.But there are again multiple issues
34+
// css-tree package don't have information about <color-interpolation-method> for [email protected].
35+
// They only added it after [email protected] which has breaking changes for us to upgrade directly.
36+
// - patching the css-tree package is a solution. But the issue was, we need to import esm build of the package.
37+
// But in esm build the patch.json file is merged in build file. So, even if patch the json file for syntaxes it will not help.
38+
39+
export const parseLinearGradient = (
40+
gradient: string
41+
): ParsedGradient | undefined => {
42+
const ast = cssTryParseValue(gradient);
43+
if (ast === undefined) {
44+
return;
45+
}
46+
47+
const match = csstree.lexer.match(
48+
"linear-gradient( [ <angle> | to <side-or-corner> ]? , <color-stop-list> )",
49+
ast
50+
);
51+
if (match.matched === null) {
52+
return;
53+
}
54+
55+
let angle: UnitValue | undefined;
56+
let sideOrCorner: KeywordValue | undefined;
57+
const stops: GradientStop[] = [];
58+
let gradientParts: csstree.CssNode[] = [];
59+
60+
csstree.walk(ast, (node) => {
61+
if (node.type === "Function" && node.name === "linear-gradient") {
62+
for (const item of node.children) {
63+
if (item.type !== "Operator") {
64+
gradientParts.push(item);
65+
}
66+
67+
if (
68+
(item.type === "Operator" && item.value === ",") ||
69+
node.children.last === item
70+
) {
71+
// If the gradientParts lenght is 1, then we need to check if it is angle or not.
72+
// If it's angle, then the value is related to <angle> or else it is a <color-hint>.
73+
if (gradientParts.length === 1) {
74+
if (isAngle(gradientParts[0]) === true) {
75+
angle = mapPercenTageOrDimentionToUnit(gradientParts[0]);
76+
}
77+
78+
if (
79+
gradientParts[0].type === "Percentage" ||
80+
(gradientParts[0].type === "Dimension" &&
81+
["deg", "grad", "rad", "turn"].includes(
82+
gradientParts[0].unit
83+
) === false)
84+
) {
85+
stops.push({
86+
hint: mapPercenTageOrDimentionToUnit(gradientParts[0]),
87+
});
88+
}
89+
}
90+
91+
if (gradientParts.length && isSideOrCorner(gradientParts[0])) {
92+
const value = gradientParts
93+
.map((item) => csstree.generate(item))
94+
.join(" ");
95+
sideOrCorner = { type: "keyword", value };
96+
}
97+
98+
// if there is a color-stop in the gradientParts, then we need to parse it for position and hint.
99+
const colorStop = gradientParts.find(isColorStop);
100+
if (colorStop !== undefined) {
101+
const [_colorStop, position, hint] = gradientParts;
102+
103+
const stop: GradientStop = {
104+
color: getColor(colorStop),
105+
position: mapPercenTageOrDimentionToUnit(position),
106+
hint: mapPercenTageOrDimentionToUnit(hint),
107+
};
108+
109+
stops.push(stop);
110+
}
111+
112+
gradientParts = [];
113+
}
114+
}
115+
}
116+
});
117+
118+
return { angle, sideOrCorner, stops };
119+
};
120+
121+
const mapPercenTageOrDimentionToUnit = (
122+
node?: csstree.CssNode
123+
): UnitValue | undefined => {
124+
if (node === undefined) {
125+
return;
126+
}
127+
128+
if (node.type !== "Percentage" && node.type !== "Dimension") {
129+
return;
130+
}
131+
132+
return {
133+
type: "unit",
134+
value: parseFloat(node.value),
135+
unit: node.type === "Percentage" ? "%" : (node.unit as Unit),
136+
};
137+
};
138+
139+
const isAngle = (node: csstree.CssNode): node is csstree.Dimension =>
140+
node.type === "Dimension" &&
141+
["deg", "grad", "rad", "turn"].includes(node.unit);
142+
143+
const isSideOrCorner = (node: csstree.CssNode): node is csstree.Identifier =>
144+
node.type === "Identifier" && sideOrCorderIdentifiers.includes(node.name);
145+
146+
const isColorStop = (
147+
node: csstree.CssNode
148+
): node is csstree.Identifier | csstree.FunctionNode | csstree.Hash => {
149+
return (
150+
(node.type === "Function" ||
151+
(node.type === "Identifier" &&
152+
sideOrCorderIdentifiers.includes(node.name)) === false ||
153+
node.type === "Hash") &&
154+
colord(csstree.generate(node)).isValid() === true
155+
);
156+
};
157+
158+
const getColor = (
159+
node: csstree.FunctionNode | csstree.Identifier | csstree.Hash
160+
): RgbValue | undefined => {
161+
let color: string;
162+
if (node.type === "Function") {
163+
color = csstree.generate(node);
164+
} else if (node.type === "Identifier") {
165+
color = node.name;
166+
} else {
167+
color = csstree.generate(node);
168+
}
169+
170+
const result = colord(color);
171+
if (result.isValid()) {
172+
const value = result.toRgb();
173+
174+
return {
175+
type: "rgb",
176+
r: value.r,
177+
g: value.g,
178+
b: value.b,
179+
alpha: value.a,
180+
};
181+
}
182+
};
183+
184+
export const reconstructLinearGradient = (parsed: ParsedGradient): string => {
185+
const direction = parsed.angle || parsed.sideOrCorner;
186+
const stops = parsed.stops
187+
.map((stop: GradientStop) => {
188+
let result = toValue(stop.color);
189+
if (stop.position) {
190+
result += ` ${toValue(stop.position)}`;
191+
}
192+
if (stop.hint) {
193+
result += ` ${toValue(stop.hint)}`;
194+
}
195+
return result;
196+
})
197+
.join(", ");
198+
199+
return `linear-gradient(${direction ? toValue(direction) + ", " : ""}${stops})`;
200+
};

0 commit comments

Comments
 (0)