Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add gradient recipes",
"packageName": "@adaptive-web/adaptive-ui-designer-core",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add gradient recipes",
"packageName": "@adaptive-web/adaptive-ui",
"email": "[email protected]",
"dependentChangeType": "patch"
}
19 changes: 18 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions packages/adaptive-ui-designer-core/src/registry/recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
accentFillStealth,
accentFillSubtle,
accentFillSubtleInverse,
accentHueShiftGradientFillSubtleRest,
accentStrokeDiscernible,
accentStrokeReadable,
accentStrokeSafety,
accentStrokeStrong,
accentStrokeSubtle,
accentToHighlightGradientFillSubtleRest,
bodyFontFamily,
bodyFontStyle,
bodyFontWeight,
Expand Down Expand Up @@ -50,6 +52,9 @@ import {
fontFamily,
fontStyle,
fontWeight,
gradientAngle,
gradientEndShift,
gradientStartShift,
highlightBaseColor,
highlightFillDiscernible,
highlightFillIdeal,
Expand Down Expand Up @@ -212,6 +217,9 @@ const designTokens: DesignTokenStore = [
strokeDiscernibleRestDelta,
strokeReadableRestDelta,
strokeStrongRestDelta,
gradientAngle,
gradientStartShift,
gradientEndShift,
];

const colorTokens: DesignTokenOrGroupStore = [
Expand Down Expand Up @@ -276,6 +284,8 @@ const colorTokens: DesignTokenOrGroupStore = [
neutralFillIdeal,
neutralFillDiscernible,
neutralFillReadable,
accentHueShiftGradientFillSubtleRest,
accentToHighlightGradientFillSubtleRest,
// Stroke
focusStroke,
focusStrokeOuter,
Expand Down
4 changes: 3 additions & 1 deletion packages/adaptive-ui-designer-figma-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"@csstools/css-parser-algorithms": "^2.2.0",
"@csstools/css-tokenizer": "^2.1.1",
"change-case": "^5.4.4",
"culori": "^3.2.0"
"culori": "^3.2.0",
"transformation-matrix": "^3.0.0"
},
"peerDependencies": {
"@adaptive-web/adaptive-ui": "0.12.0",
Expand All @@ -52,6 +53,7 @@
"devDependencies": {
"@figma/eslint-plugin-figma-plugins": "^0.15.0",
"@figma/plugin-typings": "^1.103.0",
"@types/culori": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"concurrently": "^7.6.0",
Expand Down
179 changes: 179 additions & 0 deletions packages/adaptive-ui-designer-figma-plugin/src/figma/gradient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Gradient, GradientStop, GradientType, LinearGradient } from "@adaptive-web/adaptive-ui";
import { Rgb } from "culori/fn";
import { compose, rotate, scale, translate } from "transformation-matrix";
import { colorToRgba } from "./utility.js";

// Big thanks here to Michael Yagudaev, https://github.com/yagudaev/css-gradient-to-figma

export function gradientToGradientPaint(gradient: Gradient, width = 1, height = 1): GradientPaint {
const gradientLength = calculateLength(gradient, width, height);
const [sx, sy] = calculateScale(gradient);
const rotationAngle = calculateRotationAngle(gradient);
const [tx, ty] = calculateTranslationToCenter(gradient);
const gradientTransform = compose(
translate(0, 0.5),
scale(sx, sy),
rotate(rotationAngle),
translate(tx, ty)
);

let previousPosition: number | undefined = undefined;
const gradientPaint: GradientPaint = {
type: convertType(gradient.type),
gradientStops: gradient.stops.map((stop, index) => {
const position = getPosition(stop, index, gradient.stops.length, gradientLength, previousPosition);
previousPosition = position;
return {
position,
color: colorToRgba(stop.color.color as Rgb),
};
}),
gradientTransform: [
[gradientTransform.a, gradientTransform.c, gradientTransform.e],
[gradientTransform.b, gradientTransform.d, gradientTransform.f],
],
};

return gradientPaint;
}

function getPosition(
stop: GradientStop,
index: number,
total: number,
gradientLength: number,
previousPosition = 0
): number {
if (total <= 1) return 0;
// browsers will enforce increasing positions (red 50%, blue 0px) becomes (red 50%, blue 50%)
const normalize = (v: number) => Math.max(previousPosition, Math.min(1, v));
if (stop.position) {
if (stop.position.value <= 0) {
// TODO: add support for negative color stops, figma doesn't support it, instead we will
// have to scale the transform to fit the negative color stops
return normalize(0);
}
switch (stop.position.unit) {
case "%":
return normalize(stop.position.value);
case "px":
return normalize(stop.position.value / gradientLength);
default:
console.warn("Unsupported stop position unit: ", stop.position.unit);
}
}
return normalize(index / (total - 1));
}

function convertType(
type: GradientType
): "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" {
switch (type) {
case "conic":
return "GRADIENT_ANGULAR";
case "linear":
return "GRADIENT_LINEAR";
case "radial":
return "GRADIENT_RADIAL";
default:
throw "unsupported gradient type";
}
}

function calculateRotationAngle(gradient: Gradient): number {
// CSS has a top-down default, figma has a right-left default when no angle is specified
// CSS has a default unspecified angle of 180deg, figma has a default unspecified angle of 0deg
const initialRotation = -Math.PI / 2.0; // math rotation with css 180deg default
let additionalRotation = 0.0;

// linear gradients
if (gradient.type === "linear") {
// css angle is clockwise from the y-axis, figma angles are counter-clockwise from the x-axis
additionalRotation = (convertCssAngle((gradient as LinearGradient).angle) + 90) % 360;
return degreesToRadians(additionalRotation);
} else if (gradient.type === "radial") {
// if size is 'furthers-corner' which is the default, then the rotation is 45 to reach corner
// any corner will do, but we will use the bottom right corner
// since the parser is not smart enough to know that, we just assume that for now
additionalRotation = 45;
}

return initialRotation + degreesToRadians(additionalRotation);
}

type FigmaAngle = number; // 0-360, CCW from x-axis
type CssAngle = number; // 0-360, CW from y-axis

function convertCssAngle(angle: CssAngle): FigmaAngle {
// positive angles only
angle = angle < 0 ? 360 + angle : angle;
// convert to CCW angle use by figma
angle = 360 - angle;
return angle % 360;
}

function calculateLength(gradient: Gradient, width: number, height: number): number {
if (gradient.type === "linear") {
// from w3c: abs(W * sin(A)) + abs(H * cos(A))
// https://w3c.github.io/csswg-drafts/css-images-3/#linears
const rads = degreesToRadians(convertCssAngle((gradient as LinearGradient).angle));
return Math.abs(width * Math.sin(rads)) + Math.abs(height * Math.cos(rads));
} else if (gradient.type === "radial") {
// if size is 'furthers-corner' which is the default, then the scale is sqrt(2)
// since the parser is not smart enough to know that, we just assume that for now
return Math.sqrt(2);
}
throw "unsupported gradient type";
}

function calculateScale(gradient: Gradient): [number, number] {
if (gradient.type === "linear") {
// from w3c: abs(W * sin(A)) + abs(H * cos(A))
// https://w3c.github.io/csswg-drafts/css-images-3/#linears
// W and H are unit vectors, so we can just use 1
const angleRad = degreesToRadians(convertCssAngle((gradient as LinearGradient).angle));
const scale =
Math.abs(Math.sin(angleRad)) +
Math.abs(Math.cos(angleRad));

return [1.0 / scale, 1.0 / scale];
} else if (gradient.type === "radial") {
// if size is 'furthers-corner' which is the default, then the scale is sqrt(2)
// since the parser is not smart enough to know that, we just assume that for now
const scale = 1 / Math.sqrt(2);
return [scale, scale];
}

return [1.0, 1.0];
}

function calculateTranslationToCenter(gradient: Gradient): [number, number] {
if (gradient.type === "linear") {
const angle = convertCssAngle((gradient as LinearGradient).angle);
if (angle === 0) {
return [-0.5, -1];
} else if (angle === 90) {
return [-1, -0.5];
} else if (angle === 180) {
return [-0.5, 0];
} else if (angle === 270) {
return [0, -0.5];
} else if (angle > 0 && angle < 90) {
return [-1, -1];
} else if (angle > 90 && angle < 180) {
return [-1, 0];
} else if (angle > 180 && angle < 270) {
return [0, 0];
} else if (angle > 270 && angle < 360) {
return [0, -1];
}
} else if (gradient.type === "radial") {
return [0, 0];
}

return [0, 0];
}

function degreesToRadians(degrees: number) {
return degrees * (Math.PI / 180);
}
Loading
Loading