-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathcommon.ts
More file actions
331 lines (294 loc) · 9.15 KB
/
common.ts
File metadata and controls
331 lines (294 loc) · 9.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
import fs from "fs";
import path from "path";
import type { Paint, RGBA } from "@figma/rest-api-spec";
import type {
CSSHexColor,
CSSRGBAColor,
SimplifiedFill,
} from "~/services/simplify-node-response.js";
export type StyleId = `${string}_${string}` & { __brand: "StyleId" };
export interface ColorValue {
hex: CSSHexColor;
opacity: number;
}
/**
* Download Figma image and save it locally
* @param fileName - The filename to save as
* @param localPath - The local path to save to
* @param imageUrl - Image URL (images[nodeId])
* @returns A Promise that resolves to the full file path where the image was saved
* @throws Error if download fails
*/
export async function downloadFigmaImage(
fileName: string,
localPath: string,
imageUrl: string,
): Promise<string> {
try {
// Ensure local path exists
if (!fs.existsSync(localPath)) {
fs.mkdirSync(localPath, { recursive: true });
}
// Build the complete file path
const fullPath = path.join(localPath, fileName);
// Use fetch to download the image
const response = await fetch(imageUrl, {
method: "GET",
});
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
// Create write stream
const writer = fs.createWriteStream(fullPath);
// Get the response as a readable stream and pipe it to the file
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Failed to get response body");
}
return new Promise((resolve, reject) => {
// Process stream
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
writer.end();
break;
}
writer.write(value);
}
} catch (err) {
writer.end();
fs.unlink(fullPath, () => {});
reject(err);
}
};
// Resolve only when the stream is fully written
writer.on('finish', () => {
resolve(fullPath);
});
writer.on("error", (err) => {
reader.cancel();
fs.unlink(fullPath, () => {});
reject(new Error(`Failed to write image: ${err.message}`));
});
processStream();
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Error downloading image: ${errorMessage}`);
}
}
/**
* Remove keys with empty arrays or empty objects from an object.
* @param input - The input object or value.
* @returns The processed object or the original value.
*/
export function removeEmptyKeys<T>(input: T): T {
// If not an object type or null, return directly
if (typeof input !== "object" || input === null) {
return input;
}
// Handle array type
if (Array.isArray(input)) {
return input.map((item) => removeEmptyKeys(item)) as T;
}
// Handle object type
const result = {} as T;
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
const value = input[key];
// Recursively process nested objects
const cleanedValue = removeEmptyKeys(value);
// Skip empty arrays and empty objects
if (
cleanedValue !== undefined &&
!(Array.isArray(cleanedValue) && cleanedValue.length === 0) &&
!(
typeof cleanedValue === "object" &&
cleanedValue !== null &&
Object.keys(cleanedValue).length === 0
)
) {
result[key] = cleanedValue;
}
}
}
return result;
}
/**
* 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})`;
}
/**
* Generate a 6-character random variable ID
* @param prefix - ID prefix
* @returns A 6-character random ID string with prefix
*/
export function generateVarId(prefix: string = "var"): StyleId {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let result = "";
for (let i = 0; i < 6; i++) {
const randomIndex = Math.floor(Math.random() * chars.length);
result += chars[randomIndex];
}
return `${prefix}_${result}` as StyleId;
}
/**
* Generate a CSS shorthand for values that come with top, right, bottom, and left
*
* input: { top: 10, right: 10, bottom: 10, left: 10 }
* output: "10px"
*
* input: { top: 10, right: 20, bottom: 10, left: 20 }
* output: "10px 20px"
*
* input: { top: 10, right: 20, bottom: 30, left: 40 }
* output: "10px 20px 30px 40px"
*
* @param values - The values to generate the shorthand for
* @returns The generated shorthand
*/
export function generateCSSShorthand(
values: {
top: number;
right: number;
bottom: number;
left: number;
},
{
ignoreZero = true,
suffix = "px",
}: {
/**
* If true and all values are 0, return undefined. Defaults to true.
*/
ignoreZero?: boolean;
/**
* The suffix to add to the shorthand. Defaults to "px".
*/
suffix?: string;
} = {},
) {
const { top, right, bottom, left } = values;
if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) {
return undefined;
}
if (top === right && right === bottom && bottom === left) {
return `${top}${suffix}`;
}
if (right === left) {
if (top === bottom) {
return `${top}${suffix} ${right}${suffix}`;
}
return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`;
}
return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`;
}
/**
* Convert a Figma paint (solid, image, gradient) to a SimplifiedFill
* @param raw - The Figma paint to convert
* @returns The converted SimplifiedFill
*/
export function parsePaint(raw: Paint): SimplifiedFill {
if (raw.type === "IMAGE") {
return {
type: "IMAGE",
imageRef: raw.imageRef,
scaleMode: raw.scaleMode,
};
} 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 (
["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes(
raw.type,
)
) {
// treat as GRADIENT_LINEAR
return {
type: raw.type,
gradientHandlePositions: raw.gradientHandlePositions,
gradientStops: raw.gradientStops.map(({ position, color }) => ({
position,
color: convertColor(color),
})),
};
} else {
throw new Error(`Unknown paint type: ${raw.type}`);
}
}
/**
* Check if an element is visible
* @param element - The item to check
* @returns True if the item is visible, false otherwise
*/
export function isVisible(element: { visible?: boolean }): boolean {
return element.visible ?? true;
}
/**
* Rounds a number to two decimal places, suitable for pixel value processing.
* @param num The number to be rounded.
* @returns The rounded number with two decimal places.
* @throws TypeError If the input is not a valid number
*/
export function pixelRound(num: number): number {
if (isNaN(num)) {
throw new TypeError(`Input must be a valid number`);
}
return Number(Number(num).toFixed(1));
}