style.ts•24.2 kB
import type {
Node as FigmaDocumentNode,
Paint,
Vector,
RGBA,
Transform,
} from "@figma/rest-api-spec";
import { generateCSSShorthand, isVisible } from "~/utils/common.js";
import { hasValue, isStrokeWeights } from "~/utils/identity.js";
export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
export type CSSHexColor = `#${string}`;
export interface ColorValue {
hex: CSSHexColor;
opacity: number;
}
/**
* Simplified image fill with CSS properties and processing metadata
*
* This type represents an image fill that can be used as either:
* - background-image (when parent node has children)
* - <img> tag (when parent node has no children)
*
* The CSS properties are mutually exclusive based on usage context.
*/
export type SimplifiedImageFill = {
type: "IMAGE";
imageRef: string;
scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH";
/**
* For TILE mode, the scaling factor relative to original image size
*/
scalingFactor?: number;
// CSS properties for background-image usage (when node has children)
backgroundSize?: string;
backgroundRepeat?: string;
// CSS properties for <img> tag usage (when node has no children)
isBackground?: boolean;
objectFit?: string;
// Image processing metadata (NOT for CSS translation)
// Used by download tools to determine post-processing needs
imageDownloadArguments?: {
/**
* Whether image needs cropping based on transform
*/
needsCropping: boolean;
/**
* Whether CSS variables for dimensions are needed to calculate the background size for TILE mode
*
* Figma bases scalingFactor on the image's original size. In CSS, background size (as a percentage)
* is calculated based on the size of the container. We need to pass back the original dimensions
* after processing to calculate the intended background size when translated to code.
*/
requiresImageDimensions: boolean;
/**
* Figma's transform matrix for Sharp processing
*/
cropTransform?: Transform;
/**
* Suggested filename suffix to make cropped images unique
* When the same imageRef is used multiple times with different crops,
* this helps avoid overwriting conflicts
*/
filenameSuffix?: string;
};
};
export type SimplifiedGradientFill = {
type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND";
gradient: string;
};
export type SimplifiedPatternFill = {
type: "PATTERN";
patternSource: {
/**
* Hardcode to expect PNG for now, consider SVG detection in the future.
*
* SVG detection is a bit challenging because the nodeId in question isn't
* guaranteed to be included in the response we're working with. No guaranteed
* way to look into it and see if it's only composed of vector shapes.
*/
type: "IMAGE-PNG";
nodeId: string;
};
backgroundRepeat: string;
backgroundSize: string;
backgroundPosition: string;
};
export type SimplifiedFill =
| SimplifiedImageFill
| SimplifiedGradientFill
| SimplifiedPatternFill
| CSSRGBAColor
| CSSHexColor;
export type SimplifiedStroke = {
colors: SimplifiedFill[];
strokeWeight?: string;
strokeDashes?: number[];
strokeWeights?: string;
};
/**
* Translate Figma scale modes to CSS properties based on usage context
*
* @param scaleMode - The Figma scale mode (FILL, FIT, TILE, STRETCH)
* @param isBackground - Whether this image will be used as background-image (true) or <img> tag (false)
* @param scalingFactor - For TILE mode, the scaling factor relative to original image size
* @returns Object containing CSS properties and processing metadata
*/
function translateScaleMode(
scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH",
hasChildren: boolean,
scalingFactor?: number,
): {
css: Partial<SimplifiedImageFill>;
processing: NonNullable<SimplifiedImageFill["imageDownloadArguments"]>;
} {
const isBackground = hasChildren;
switch (scaleMode) {
case "FILL":
// Image covers entire container, may be cropped
return {
css: isBackground
? { backgroundSize: "cover", backgroundRepeat: "no-repeat", isBackground: true }
: { objectFit: "cover", isBackground: false },
processing: { needsCropping: false, requiresImageDimensions: false },
};
case "FIT":
// Image fits entirely within container, may have empty space
return {
css: isBackground
? { backgroundSize: "contain", backgroundRepeat: "no-repeat", isBackground: true }
: { objectFit: "contain", isBackground: false },
processing: { needsCropping: false, requiresImageDimensions: false },
};
case "TILE":
// Image repeats to fill container at specified scale
// Always treat as background image (can't tile an <img> tag)
return {
css: {
backgroundRepeat: "repeat",
backgroundSize: scalingFactor
? `calc(var(--original-width) * ${scalingFactor}) calc(var(--original-height) * ${scalingFactor})`
: "auto",
isBackground: true,
},
processing: { needsCropping: false, requiresImageDimensions: true },
};
case "STRETCH":
// Figma calls crop "STRETCH" in its API.
return {
css: isBackground
? { backgroundSize: "100% 100%", backgroundRepeat: "no-repeat", isBackground: true }
: { objectFit: "fill", isBackground: false },
processing: { needsCropping: false, requiresImageDimensions: false },
};
default:
return {
css: {},
processing: { needsCropping: false, requiresImageDimensions: false },
};
}
}
/**
* Generate a short hash from a transform matrix to create unique filenames
* @param transform - The transform matrix to hash
* @returns Short hash string for filename suffix
*/
function generateTransformHash(transform: Transform): string {
const values = transform.flat();
const hash = values.reduce((acc, val) => {
// Simple hash function - convert to string and create checksum
const str = val.toString();
for (let i = 0; i < str.length; i++) {
acc = ((acc << 5) - acc + str.charCodeAt(i)) & 0xffffffff;
}
return acc;
}, 0);
// Convert to positive hex string, take first 6 chars
return Math.abs(hash).toString(16).substring(0, 6);
}
/**
* Handle imageTransform for post-processing (not CSS translation)
*
* When Figma includes an imageTransform matrix, it means the image is cropped/transformed.
* This function converts the transform into processing instructions for Sharp.
*
* @param imageTransform - Figma's 2x3 transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]]
* @returns Processing metadata for image cropping
*/
function handleImageTransform(
imageTransform: Transform,
): NonNullable<SimplifiedImageFill["imageDownloadArguments"]> {
const transformHash = generateTransformHash(imageTransform);
return {
needsCropping: true,
requiresImageDimensions: false,
cropTransform: imageTransform,
filenameSuffix: `${transformHash}`,
};
}
/**
* Build simplified stroke information from a Figma node
*
* @param n - The Figma node to extract stroke information from
* @param hasChildren - Whether the node has children (affects paint processing)
* @returns Simplified stroke object with colors and properties
*/
export function buildSimplifiedStrokes(
n: FigmaDocumentNode,
hasChildren: boolean = false,
): SimplifiedStroke {
let strokes: SimplifiedStroke = { colors: [] };
if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) {
strokes.colors = n.strokes.filter(isVisible).map((stroke) => parsePaint(stroke, hasChildren));
}
if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {
strokes.strokeWeight = `${n.strokeWeight}px`;
}
if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) {
strokes.strokeDashes = n.strokeDashes;
}
if (hasValue("individualStrokeWeights", n, isStrokeWeights)) {
strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights);
}
return strokes;
}
/**
* Convert a Figma paint (solid, image, gradient) to a SimplifiedFill
* @param raw - The Figma paint to convert
* @param hasChildren - Whether the node has children (determines CSS properties)
* @returns The converted SimplifiedFill
*/
export function parsePaint(raw: Paint, hasChildren: boolean = false): SimplifiedFill {
if (raw.type === "IMAGE") {
const baseImageFill: SimplifiedImageFill = {
type: "IMAGE",
imageRef: raw.imageRef,
scaleMode: raw.scaleMode as "FILL" | "FIT" | "TILE" | "STRETCH",
scalingFactor: raw.scalingFactor,
};
// Get CSS properties and processing metadata from scale mode
// TILE mode always needs to be treated as background image (can't tile an <img> tag)
const isBackground = hasChildren || baseImageFill.scaleMode === "TILE";
const { css, processing } = translateScaleMode(
baseImageFill.scaleMode,
isBackground,
raw.scalingFactor,
);
// Combine scale mode processing with transform processing if needed
// Transform processing (cropping) takes precedence over scale mode processing
let finalProcessing = processing;
if (raw.imageTransform) {
const transformProcessing = handleImageTransform(raw.imageTransform);
finalProcessing = {
...processing,
...transformProcessing,
// Keep requiresImageDimensions from scale mode (needed for TILE)
requiresImageDimensions:
processing.requiresImageDimensions || transformProcessing.requiresImageDimensions,
};
}
return {
...baseImageFill,
...css,
imageDownloadArguments: finalProcessing,
};
} else if (raw.type === "SOLID") {
// treat as SOLID
const { hex, opacity } = convertColor(raw.color!, raw.opacity);
if (opacity === 1) {
return hex;
} else {
return formatRGBAColor(raw.color!, opacity);
}
} else if (raw.type === "PATTERN") {
return parsePatternPaint(raw);
} else if (
["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes(
raw.type,
)
) {
return {
type: raw.type as
| "GRADIENT_LINEAR"
| "GRADIENT_RADIAL"
| "GRADIENT_ANGULAR"
| "GRADIENT_DIAMOND",
gradient: convertGradientToCss(raw),
};
} else {
throw new Error(`Unknown paint type: ${raw.type}`);
}
}
/**
* Convert a Figma PatternPaint to a CSS-like pattern fill.
*
* Ignores `tileType` and `spacing` from the Figma API currently as there's
* no great way to translate them to CSS.
*
* @param raw - The Figma PatternPaint to convert
* @returns The converted pattern SimplifiedFill
*/
function parsePatternPaint(
raw: Extract<Paint, { type: "PATTERN" }>,
): Extract<SimplifiedFill, { type: "PATTERN" }> {
/**
* The only CSS-like repeat value supported by Figma is repeat.
*
* They also have hexagonal horizontal and vertical repeats, but
* those aren't easy to pull off in CSS, so we just use repeat.
*/
let backgroundRepeat = "repeat";
let horizontal = "left";
switch (raw.horizontalAlignment) {
case "START":
horizontal = "left";
break;
case "CENTER":
horizontal = "center";
break;
case "END":
horizontal = "right";
break;
}
let vertical = "top";
switch (raw.verticalAlignment) {
case "START":
vertical = "top";
break;
case "CENTER":
vertical = "center";
break;
case "END":
vertical = "bottom";
break;
}
return {
type: raw.type,
patternSource: {
type: "IMAGE-PNG",
nodeId: raw.sourceNodeId,
},
backgroundRepeat,
backgroundSize: `${Math.round(raw.scalingFactor * 100)}%`,
backgroundPosition: `${horizontal} ${vertical}`,
};
}
/**
* Convert hex color value and opacity to rgba format
* @param hex - Hexadecimal color value (e.g., "#FF0000" or "#F00")
* @param opacity - Opacity value (0-1)
* @returns Color string in rgba format
*/
export function hexToRgba(hex: string, opacity: number = 1): string {
// Remove possible # prefix
hex = hex.replace("#", "");
// Handle shorthand hex values (e.g., #FFF)
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
// Convert hex to RGB values
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Ensure opacity is in the 0-1 range
const validOpacity = Math.min(Math.max(opacity, 0), 1);
return `rgba(${r}, ${g}, ${b}, ${validOpacity})`;
}
/**
* Convert color from RGBA to { hex, opacity }
*
* @param color - The color to convert, including alpha channel
* @param opacity - The opacity of the color, if not included in alpha channel
* @returns The converted color
**/
export function convertColor(color: RGBA, opacity = 1): ColorValue {
const r = Math.round(color.r * 255);
const g = Math.round(color.g * 255);
const b = Math.round(color.b * 255);
// Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative
const a = Math.round(opacity * color.a * 100) / 100;
const hex = ("#" +
((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor;
return { hex, opacity: a };
}
/**
* Convert color from Figma RGBA to rgba(#, #, #, #) CSS format
*
* @param color - The color to convert, including alpha channel
* @param opacity - The opacity of the color, if not included in alpha channel
* @returns The converted color
**/
export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor {
const r = Math.round(color.r * 255);
const g = Math.round(color.g * 255);
const b = Math.round(color.b * 255);
// Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative
const a = Math.round(opacity * color.a * 100) / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Map gradient stops from Figma's handle-based coordinate system to CSS percentages
*/
function mapGradientStops(
gradient: Extract<
Paint,
{ type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" }
>,
elementBounds: { width: number; height: number } = { width: 1, height: 1 },
): { stops: string; cssGeometry: string } {
const handles = gradient.gradientHandlePositions;
if (!handles || handles.length < 2) {
const stops = gradient.gradientStops
.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
return `${cssColor} ${Math.round(position * 100)}%`;
})
.join(", ");
return { stops, cssGeometry: "0deg" };
}
const [handle1, handle2, handle3] = handles;
switch (gradient.type) {
case "GRADIENT_LINEAR": {
return mapLinearGradient(gradient.gradientStops, handle1, handle2, elementBounds);
}
case "GRADIENT_RADIAL": {
return mapRadialGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds);
}
case "GRADIENT_ANGULAR": {
return mapAngularGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds);
}
case "GRADIENT_DIAMOND": {
return mapDiamondGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds);
}
default: {
const stops = gradient.gradientStops
.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
return `${cssColor} ${Math.round(position * 100)}%`;
})
.join(", ");
return { stops, cssGeometry: "0deg" };
}
}
}
/**
* Map linear gradient from Figma handles to CSS
*/
function mapLinearGradient(
gradientStops: { position: number; color: RGBA }[],
start: Vector,
end: Vector,
elementBounds: { width: number; height: number },
): { stops: string; cssGeometry: string } {
// Calculate the gradient line in element space
const dx = end.x - start.x;
const dy = end.y - start.y;
const gradientLength = Math.sqrt(dx * dx + dy * dy);
// Handle degenerate case
if (gradientLength === 0) {
const stops = gradientStops
.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
return `${cssColor} ${Math.round(position * 100)}%`;
})
.join(", ");
return { stops, cssGeometry: "0deg" };
}
// Calculate angle for CSS
const angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90;
// Find where the extended gradient line intersects the element boundaries
const extendedIntersections = findExtendedLineIntersections(start, end);
if (extendedIntersections.length >= 2) {
// The gradient line extended to fill the element
const fullLineStart = Math.min(extendedIntersections[0], extendedIntersections[1]);
const fullLineEnd = Math.max(extendedIntersections[0], extendedIntersections[1]);
const fullLineLength = fullLineEnd - fullLineStart;
// Map gradient stops from the Figma line segment to the full CSS line
const mappedStops = gradientStops.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
// Position along the Figma gradient line (0 = start handle, 1 = end handle)
const figmaLinePosition = position;
// The Figma line spans from t=0 to t=1
// The full extended line spans from fullLineStart to fullLineEnd
// Map the figma position to the extended line
const tOnExtendedLine = figmaLinePosition * (1 - 0) + 0; // This is just figmaLinePosition
const extendedPosition = (tOnExtendedLine - fullLineStart) / (fullLineEnd - fullLineStart);
const clampedPosition = Math.max(0, Math.min(1, extendedPosition));
return `${cssColor} ${Math.round(clampedPosition * 100)}%`;
});
return {
stops: mappedStops.join(", "),
cssGeometry: `${Math.round(angle)}deg`,
};
}
// Fallback to simple gradient if intersection calculation fails
const mappedStops = gradientStops.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
return `${cssColor} ${Math.round(position * 100)}%`;
});
return {
stops: mappedStops.join(", "),
cssGeometry: `${Math.round(angle)}deg`,
};
}
/**
* Find where the extended gradient line intersects with the element boundaries
*/
function findExtendedLineIntersections(start: Vector, end: Vector): number[] {
const dx = end.x - start.x;
const dy = end.y - start.y;
// Handle degenerate case
if (Math.abs(dx) < 1e-10 && Math.abs(dy) < 1e-10) {
return [];
}
const intersections: number[] = [];
// Check intersection with each edge of the unit square [0,1] x [0,1]
// Top edge (y = 0)
if (Math.abs(dy) > 1e-10) {
const t = -start.y / dy;
const x = start.x + t * dx;
if (x >= 0 && x <= 1) {
intersections.push(t);
}
}
// Bottom edge (y = 1)
if (Math.abs(dy) > 1e-10) {
const t = (1 - start.y) / dy;
const x = start.x + t * dx;
if (x >= 0 && x <= 1) {
intersections.push(t);
}
}
// Left edge (x = 0)
if (Math.abs(dx) > 1e-10) {
const t = -start.x / dx;
const y = start.y + t * dy;
if (y >= 0 && y <= 1) {
intersections.push(t);
}
}
// Right edge (x = 1)
if (Math.abs(dx) > 1e-10) {
const t = (1 - start.x) / dx;
const y = start.y + t * dy;
if (y >= 0 && y <= 1) {
intersections.push(t);
}
}
// Remove duplicates and sort
const uniqueIntersections = [
...new Set(intersections.map((t) => Math.round(t * 1000000) / 1000000)),
];
return uniqueIntersections.sort((a, b) => a - b);
}
/**
* Find where a line intersects with the unit square (0,0) to (1,1)
*/
function findLineIntersections(start: Vector, end: Vector): number[] {
const dx = end.x - start.x;
const dy = end.y - start.y;
const intersections: number[] = [];
// Check intersection with each edge of the unit square
const edges = [
{ x: 0, y: 0, dx: 1, dy: 0 }, // top edge
{ x: 1, y: 0, dx: 0, dy: 1 }, // right edge
{ x: 1, y: 1, dx: -1, dy: 0 }, // bottom edge
{ x: 0, y: 1, dx: 0, dy: -1 }, // left edge
];
for (const edge of edges) {
const t = lineIntersection(start, { x: dx, y: dy }, edge, { x: edge.dx, y: edge.dy });
if (t !== null && t >= 0 && t <= 1) {
intersections.push(t);
}
}
return intersections.sort((a, b) => a - b);
}
/**
* Calculate line intersection parameter
*/
function lineIntersection(
p1: Vector,
d1: Vector,
p2: { x: number; y: number },
d2: Vector,
): number | null {
const denominator = d1.x * d2.y - d1.y * d2.x;
if (Math.abs(denominator) < 1e-10) return null; // Lines are parallel
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const t = (dx * d2.y - dy * d2.x) / denominator;
return t;
}
/**
* Map radial gradient from Figma handles to CSS
*/
function mapRadialGradient(
gradientStops: { position: number; color: RGBA }[],
center: Vector,
edge: Vector,
widthHandle: Vector,
elementBounds: { width: number; height: number },
): { stops: string; cssGeometry: string } {
const centerX = Math.round(center.x * 100);
const centerY = Math.round(center.y * 100);
const stops = gradientStops
.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
return `${cssColor} ${Math.round(position * 100)}%`;
})
.join(", ");
return {
stops,
cssGeometry: `circle at ${centerX}% ${centerY}%`,
};
}
/**
* Map angular gradient from Figma handles to CSS
*/
function mapAngularGradient(
gradientStops: { position: number; color: RGBA }[],
center: Vector,
angleHandle: Vector,
widthHandle: Vector,
elementBounds: { width: number; height: number },
): { stops: string; cssGeometry: string } {
const centerX = Math.round(center.x * 100);
const centerY = Math.round(center.y * 100);
const angle =
Math.atan2(angleHandle.y - center.y, angleHandle.x - center.x) * (180 / Math.PI) + 90;
const stops = gradientStops
.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
return `${cssColor} ${Math.round(position * 100)}%`;
})
.join(", ");
return {
stops,
cssGeometry: `from ${Math.round(angle)}deg at ${centerX}% ${centerY}%`,
};
}
/**
* Map diamond gradient from Figma handles to CSS (approximate with ellipse)
*/
function mapDiamondGradient(
gradientStops: { position: number; color: RGBA }[],
center: Vector,
edge: Vector,
widthHandle: Vector,
elementBounds: { width: number; height: number },
): { stops: string; cssGeometry: string } {
const centerX = Math.round(center.x * 100);
const centerY = Math.round(center.y * 100);
const stops = gradientStops
.map(({ position, color }) => {
const cssColor = formatRGBAColor(color, 1);
return `${cssColor} ${Math.round(position * 100)}%`;
})
.join(", ");
return {
stops,
cssGeometry: `ellipse at ${centerX}% ${centerY}%`,
};
}
/**
* Convert a Figma gradient to CSS gradient syntax
*/
function convertGradientToCss(
gradient: Extract<
Paint,
{ type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" }
>,
): string {
// Sort stops by position to ensure proper order
const sortedGradient = {
...gradient,
gradientStops: [...gradient.gradientStops].sort((a, b) => a.position - b.position),
};
// Map gradient stops using handle-based geometry
const { stops, cssGeometry } = mapGradientStops(sortedGradient);
switch (gradient.type) {
case "GRADIENT_LINEAR": {
return `linear-gradient(${cssGeometry}, ${stops})`;
}
case "GRADIENT_RADIAL": {
return `radial-gradient(${cssGeometry}, ${stops})`;
}
case "GRADIENT_ANGULAR": {
return `conic-gradient(${cssGeometry}, ${stops})`;
}
case "GRADIENT_DIAMOND": {
return `radial-gradient(${cssGeometry}, ${stops})`;
}
default:
return `linear-gradient(0deg, ${stops})`;
}
}