Skip to content

Commit a037da1

Browse files
authored
Add gradient recipes (#262)
1 parent 5d183ba commit a037da1

32 files changed

+924
-160
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Add gradient recipes",
4+
"packageName": "@adaptive-web/adaptive-ui-designer-core",
5+
"email": "47367562+bheston@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Add gradient recipes",
4+
"packageName": "@adaptive-web/adaptive-ui",
5+
"email": "47367562+bheston@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

package-lock.json

Lines changed: 18 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/adaptive-ui-designer-core/src/registry/recipes.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
accentFillStealth,
88
accentFillSubtle,
99
accentFillSubtleInverse,
10+
accentHueShiftGradientFillSubtleRest,
1011
accentStrokeDiscernible,
1112
accentStrokeReadable,
1213
accentStrokeSafety,
1314
accentStrokeStrong,
1415
accentStrokeSubtle,
16+
accentToHighlightGradientFillSubtleRest,
1517
bodyFontFamily,
1618
bodyFontStyle,
1719
bodyFontWeight,
@@ -50,6 +52,9 @@ import {
5052
fontFamily,
5153
fontStyle,
5254
fontWeight,
55+
gradientAngle,
56+
gradientEndShift,
57+
gradientStartShift,
5358
highlightBaseColor,
5459
highlightFillDiscernible,
5560
highlightFillIdeal,
@@ -212,6 +217,9 @@ const designTokens: DesignTokenStore = [
212217
strokeDiscernibleRestDelta,
213218
strokeReadableRestDelta,
214219
strokeStrongRestDelta,
220+
gradientAngle,
221+
gradientStartShift,
222+
gradientEndShift,
215223
];
216224

217225
const colorTokens: DesignTokenOrGroupStore = [
@@ -276,6 +284,8 @@ const colorTokens: DesignTokenOrGroupStore = [
276284
neutralFillIdeal,
277285
neutralFillDiscernible,
278286
neutralFillReadable,
287+
accentHueShiftGradientFillSubtleRest,
288+
accentToHighlightGradientFillSubtleRest,
279289
// Stroke
280290
focusStroke,
281291
focusStrokeOuter,

packages/adaptive-ui-designer-figma-plugin/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"@csstools/css-parser-algorithms": "^2.2.0",
4343
"@csstools/css-tokenizer": "^2.1.1",
4444
"change-case": "^5.4.4",
45-
"culori": "^3.2.0"
45+
"culori": "^3.2.0",
46+
"transformation-matrix": "^3.0.0"
4647
},
4748
"peerDependencies": {
4849
"@adaptive-web/adaptive-ui": "0.12.0",
@@ -52,6 +53,7 @@
5253
"devDependencies": {
5354
"@figma/eslint-plugin-figma-plugins": "^0.15.0",
5455
"@figma/plugin-typings": "^1.103.0",
56+
"@types/culori": "^2.0.0",
5557
"@typescript-eslint/eslint-plugin": "^6.21.0",
5658
"@typescript-eslint/parser": "^6.21.0",
5759
"concurrently": "^7.6.0",
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Gradient, GradientStop, GradientType, LinearGradient } from "@adaptive-web/adaptive-ui";
2+
import { Rgb } from "culori/fn";
3+
import { compose, rotate, scale, translate } from "transformation-matrix";
4+
import { colorToRgba } from "./utility.js";
5+
6+
// Big thanks here to Michael Yagudaev, https://github.com/yagudaev/css-gradient-to-figma
7+
8+
export function gradientToGradientPaint(gradient: Gradient, width = 1, height = 1): GradientPaint {
9+
const gradientLength = calculateLength(gradient, width, height);
10+
const [sx, sy] = calculateScale(gradient);
11+
const rotationAngle = calculateRotationAngle(gradient);
12+
const [tx, ty] = calculateTranslationToCenter(gradient);
13+
const gradientTransform = compose(
14+
translate(0, 0.5),
15+
scale(sx, sy),
16+
rotate(rotationAngle),
17+
translate(tx, ty)
18+
);
19+
20+
let previousPosition: number | undefined = undefined;
21+
const gradientPaint: GradientPaint = {
22+
type: convertType(gradient.type),
23+
gradientStops: gradient.stops.map((stop, index) => {
24+
const position = getPosition(stop, index, gradient.stops.length, gradientLength, previousPosition);
25+
previousPosition = position;
26+
return {
27+
position,
28+
color: colorToRgba(stop.color.color as Rgb),
29+
};
30+
}),
31+
gradientTransform: [
32+
[gradientTransform.a, gradientTransform.c, gradientTransform.e],
33+
[gradientTransform.b, gradientTransform.d, gradientTransform.f],
34+
],
35+
};
36+
37+
return gradientPaint;
38+
}
39+
40+
function getPosition(
41+
stop: GradientStop,
42+
index: number,
43+
total: number,
44+
gradientLength: number,
45+
previousPosition = 0
46+
): number {
47+
if (total <= 1) return 0;
48+
// browsers will enforce increasing positions (red 50%, blue 0px) becomes (red 50%, blue 50%)
49+
const normalize = (v: number) => Math.max(previousPosition, Math.min(1, v));
50+
if (stop.position) {
51+
if (stop.position.value <= 0) {
52+
// TODO: add support for negative color stops, figma doesn't support it, instead we will
53+
// have to scale the transform to fit the negative color stops
54+
return normalize(0);
55+
}
56+
switch (stop.position.unit) {
57+
case "%":
58+
return normalize(stop.position.value);
59+
case "px":
60+
return normalize(stop.position.value / gradientLength);
61+
default:
62+
console.warn("Unsupported stop position unit: ", stop.position.unit);
63+
}
64+
}
65+
return normalize(index / (total - 1));
66+
}
67+
68+
function convertType(
69+
type: GradientType
70+
): "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" {
71+
switch (type) {
72+
case "conic":
73+
return "GRADIENT_ANGULAR";
74+
case "linear":
75+
return "GRADIENT_LINEAR";
76+
case "radial":
77+
return "GRADIENT_RADIAL";
78+
default:
79+
throw "unsupported gradient type";
80+
}
81+
}
82+
83+
function calculateRotationAngle(gradient: Gradient): number {
84+
// CSS has a top-down default, figma has a right-left default when no angle is specified
85+
// CSS has a default unspecified angle of 180deg, figma has a default unspecified angle of 0deg
86+
const initialRotation = -Math.PI / 2.0; // math rotation with css 180deg default
87+
let additionalRotation = 0.0;
88+
89+
// linear gradients
90+
if (gradient.type === "linear") {
91+
// css angle is clockwise from the y-axis, figma angles are counter-clockwise from the x-axis
92+
additionalRotation = (convertCssAngle((gradient as LinearGradient).angle) + 90) % 360;
93+
return degreesToRadians(additionalRotation);
94+
} else if (gradient.type === "radial") {
95+
// if size is 'furthers-corner' which is the default, then the rotation is 45 to reach corner
96+
// any corner will do, but we will use the bottom right corner
97+
// since the parser is not smart enough to know that, we just assume that for now
98+
additionalRotation = 45;
99+
}
100+
101+
return initialRotation + degreesToRadians(additionalRotation);
102+
}
103+
104+
type FigmaAngle = number; // 0-360, CCW from x-axis
105+
type CssAngle = number; // 0-360, CW from y-axis
106+
107+
function convertCssAngle(angle: CssAngle): FigmaAngle {
108+
// positive angles only
109+
angle = angle < 0 ? 360 + angle : angle;
110+
// convert to CCW angle use by figma
111+
angle = 360 - angle;
112+
return angle % 360;
113+
}
114+
115+
function calculateLength(gradient: Gradient, width: number, height: number): number {
116+
if (gradient.type === "linear") {
117+
// from w3c: abs(W * sin(A)) + abs(H * cos(A))
118+
// https://w3c.github.io/csswg-drafts/css-images-3/#linears
119+
const rads = degreesToRadians(convertCssAngle((gradient as LinearGradient).angle));
120+
return Math.abs(width * Math.sin(rads)) + Math.abs(height * Math.cos(rads));
121+
} else if (gradient.type === "radial") {
122+
// if size is 'furthers-corner' which is the default, then the scale is sqrt(2)
123+
// since the parser is not smart enough to know that, we just assume that for now
124+
return Math.sqrt(2);
125+
}
126+
throw "unsupported gradient type";
127+
}
128+
129+
function calculateScale(gradient: Gradient): [number, number] {
130+
if (gradient.type === "linear") {
131+
// from w3c: abs(W * sin(A)) + abs(H * cos(A))
132+
// https://w3c.github.io/csswg-drafts/css-images-3/#linears
133+
// W and H are unit vectors, so we can just use 1
134+
const angleRad = degreesToRadians(convertCssAngle((gradient as LinearGradient).angle));
135+
const scale =
136+
Math.abs(Math.sin(angleRad)) +
137+
Math.abs(Math.cos(angleRad));
138+
139+
return [1.0 / scale, 1.0 / scale];
140+
} else if (gradient.type === "radial") {
141+
// if size is 'furthers-corner' which is the default, then the scale is sqrt(2)
142+
// since the parser is not smart enough to know that, we just assume that for now
143+
const scale = 1 / Math.sqrt(2);
144+
return [scale, scale];
145+
}
146+
147+
return [1.0, 1.0];
148+
}
149+
150+
function calculateTranslationToCenter(gradient: Gradient): [number, number] {
151+
if (gradient.type === "linear") {
152+
const angle = convertCssAngle((gradient as LinearGradient).angle);
153+
if (angle === 0) {
154+
return [-0.5, -1];
155+
} else if (angle === 90) {
156+
return [-1, -0.5];
157+
} else if (angle === 180) {
158+
return [-0.5, 0];
159+
} else if (angle === 270) {
160+
return [0, -0.5];
161+
} else if (angle > 0 && angle < 90) {
162+
return [-1, -1];
163+
} else if (angle > 90 && angle < 180) {
164+
return [-1, 0];
165+
} else if (angle > 180 && angle < 270) {
166+
return [0, 0];
167+
} else if (angle > 270 && angle < 360) {
168+
return [0, -1];
169+
}
170+
} else if (gradient.type === "radial") {
171+
return [0, 0];
172+
}
173+
174+
return [0, 0];
175+
}
176+
177+
function degreesToRadians(degrees: number) {
178+
return degrees * (Math.PI / 180);
179+
}

0 commit comments

Comments
 (0)