Digamos que tienes un conjunto de íconos en Figma y un proyecto en React/Vue/lo que sea. No es de extrañar que necesites usar esos íconos. ¿Cómo podemos importarlos?
Si eres lo suficientemente terco, puedes descargarlos uno por uno y agregarlos como activos, pero es una forma triste. No me gusta.
Dejemos de estar tristes y seamos geniales. Esto es lo que necesitaremos:
*Sin más preámbulos*
Necesitaremos un token de Figma. No creo que sea necesario explicar cómo hacerlo, por lo que aquí hay una sección de ayuda: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens .
La configuración predeterminada es bastante buena: necesitamos acceso de solo lectura al contenido del archivo.
Usaré los íconos de Material Design (Comunidad) como ejemplo.
Necesitaremos una clave de archivo y el ID del nodo principal:
Ahora que tenemos suficientes requisitos previos, comencemos a codificar. Me gusta que mis scripts estén separados del código principal del proyecto, así que crearé una carpeta scripts
en la raíz y un fetch-icons.ts
en ella. No es necesario hacerlo en TypeScript, pero hace que este artículo sea un poco más sofisticado, así que lo haré.
Sin embargo, esto requerirá un par de dependencias más:
yarn add -D tsx dotenv axios @figma/rest-api-spec
dotenv
está aquí para usar las variables .env
, tsx
—para ejecutar el script TS (elegante, ¿recuerdas?), @figma/rest-api-spec
es para seguridad de tipos, y axios
es solo porque estoy acostumbrado a ello. El único requerido es dotenv
(a menos que estés de acuerdo con agregar la clave API al código), y agregaré la versión "pura" al final. Por ahora, tendremos esta configuración, y esto es con lo que comenzaremos:
import * as dotenv from "dotenv"; dotenv.config(); const PERSONAL_ACCESS_TOKEN = String( process.env.VITE_FIGMA_PERSONAL_ACCESS_TOKEN ); // I've added my Figma token to the .env file, and so should you const FIGMA_API_URL = "https://api.figma.com/v1"; const FILE_KEY = "v50KJO82W9bBJUppE8intT"; // this one is from the URL const NODE_ID = "2402-2207"; // node-id query param, also from the URL
Primero, obtendremos los metadatos del marco principal:
import { GetFileNodesResponse, GetImagesResponse, HasChildrenTrait, } from "@figma/rest-api-spec"; // ... const fetchFrameData = async ( nodeId: string ): Promise<HasChildrenTrait> => { const { data } = await axios.get<GetFileNodesResponse>( `${FIGMA_API_URL}/files/${FILE_KEY}/nodes?ids=${nodeId}`, getConfig() ); return data.nodes[nodeId].document as HasChildrenTrait; };
Devolverá la información sobre sus hijos. Pueden ser imágenes (su tipo será INSTANCE
) u otros marcos que analizaremos de forma recursiva:
const getImageNodes = ( frameData: HasChildrenTrait ): Record<string, string> => { const nodes: Record<string, string> = {}; for (const image of frameData.children.filter( (node) => node.type === 'INSTANCE' )) { // normalizeName simply converts 'check-box' to 'CheckBox' const name = normalizeName(image.name); const id = image.id; nodes[id] = name; } for (const frame of frameData.children.filter( (node) => node.type === 'FRAME' )) { Object.assign(nodes, getImageNodes(frame)); } return nodes; };
Finalmente, cuando tengamos un montón de identificaciones de imágenes, buscaremos las URL de los archivos SVG:
const fetchImageUrls = async ( nodeIds: string[] ): Promise<GetImagesResponse> => { const { data } = await axios.get<GetImagesResponse>( `${FIGMA_API_URL}/images/${FILE_KEY}?ids=${nodeIds.join(",")}&format=svg`, getConfig() ); return data; };
Y terminaremos con una matriz de tales objetos:
{ name: 'CheckBox', url: 'https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/7dd5bc31-4f26-4701-b155-7bf7dea45824' }
Como ya habrás adivinado, los descargaremos todos, pero esa no es la parte más interesante.
Cada vez que obtenemos un SVG, obtenemos algo como esto:
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M17 6H7C3.69 6 1 8.69 [...] 9 17 9Z" fill="black" /> </svg>
Eliminaremos las etiquetas svg
, ya que son idénticas para cada ícono (supongo que tienen el mismo tamaño para simplificar). Pero lo que es más interesante es que reemplazaremos todos los fill=”black”
con un marcador de posición en un literal de plantilla y, como resultado, tendremos una función como esta:
export const IconToggleOn = (color: string) => `<path d="M17 6H7C3.69 [...] 9 17 9Z" fill="${color}"/>`;
Así es como lo conseguimos:
const cleanupSvg = (data: string): string => { const svgTags = /<\/?svg.*?>/g; const colorFills = /fill=["'].+?["']/g; return data .replace(svgTags, "") .replace(colorFills, 'fill="${color}"') .replace(/\n/g, ""); };
Obviamente, no necesitarás reemplazar los colores si necesitas importar algunos logotipos, pero hacer una lógica diferente para ellos será tu tarea.
Bien, aquí está nuestro plan:
No voy a recitar el archivo completo, puedes consultarlo en GitHub . Echemos un vistazo a la descarga en sí:
const FILE_BATCH_SIZE = 10; const fileContent: string[] = []; const getPromise = async (item: IconData) => { try { const { data } = await axios.get<string>( item.url, getConfig("image/svg+xml") // returns necessary options and headers ); return { name: item.name, data }; } catch (err) { console.error(err); } }; for (let i = 0; i < iconsData.length; i += FILE_BATCH_SIZE) { const batch = await Promise.allSettled( iconsData.slice(i, i + FILE_BATCH_SIZE).map(getPromise) ); for (const icon of batch) { if (icon?.status === "fulfilled" && icon.value) { const iconName = `Icon${icon.value.name}`; const svgData = cleanupSvg(icon.value.data); fileContent.push( `export const ${iconName} = (color: string) =>\n \`${svgData}\`;\n` ); } } }
La operación por lotes es importante aquí porque, de lo contrario, a Figma no le agradará que usted decida descargar un par de miles de archivos en paralelo.
Si necesitas obtener un ícono con el nombre que recibes del backend, también puedes crear un diccionario:
const iconArray: string[] = []; // ... for (const icon of batch) { if (icon?.status === "fulfilled" && icon.value) { const iconName = `Icon${icon.value.name}`; const svgData = cleanupSvg(icon.value.data); fileContent.push( `export const ${iconName} = (color: string) =>\n \`${svgData}\`;\n` ); iconArray.push(iconName); // <-- we added this } } // ... when all files are processed fileContent.push( `\r\nexport const iconDictionary: Record<string, (color: string) => string> = {\r\n ${iconArray.join( ",\r\n " )}\r\n};\r\n` );
Este enfoque es peor, ya que todo el conjunto se agregará al paquete incluso si usas solo un ícono a través del diccionario, pero a veces, debes hacer algo desagradable. Además, todos los \r\n
y espacios están aquí para hacer feliz a mi ESLint: es posible que debas ajustar su cantidad.
Aquí viene el último paso; vamos a crear un nuevo archivo:
import path from "path"; import { fileURLToPath } from "url"; // ... const PATH_TO_ICONS = "../src/assets/icons"; const currentPath = fileURLToPath(import.meta.url); const currentDirectory = path.dirname(currentPath); const fileName = "index.ts"; const filePath = path.join( path.resolve(currentDirectory, PATH_TO_ICONS), fileName ); // ... fs.writeFileSync(filePath, fileContent.join(""));
Es una buena idea ordenar los íconos después de obtener las URL, lo cual es tan simple como esto:
iconsData.sort((a, b) => a.name.localeCompare(b.name));
Una lista ordenada será más fácil de leer y, lo que es más importante, tendremos una diferencia legible cada vez que obtengamos nuevos íconos. De lo contrario, el orden no está garantizado y agregar un ícono puede arruinarlo todo.
Necesitamos el componente que acepta la cadena de ícono, el color y el tamaño como propiedades y dibuja algo atractivo:
import React, { useMemo } from "react"; import { IconSvg } from "../types/IconSvg"; interface IconProps { svg: IconSvg; // type IconSvg = (color: string) => string; color?: string; size?: number | string; } const refine = (x: string | number) => { return typeof x === "number" || !/\D+/.test(x) ? `${x}px` : x; }; const MyIcon: React.FC<IconProps> = ({ svg, color, size, }) => { const iconColor = useMemo(() => color ?? "black", [color]); const refinedSize = useMemo(() => refine(size ?? 24), [size]); const currentIcon = useMemo(() => svg(currentColor), [svg, currentColor]); return ( <svg viewBox="0 0 24 24" // make sure it's the same as in Figma dangerouslySetInnerHTML={{ __html: currentIcon }} style={{ width: refinedSize, minWidth: refinedSize, height: refinedSize, minHeight: refinedSize, }} ></svg> ); }; export default MyIcon;
Creo que es bastante sencillo, excepto por el ajuste del tamaño: cuando lo implementé por primera vez, Chrome aceptaba números como propiedades CSS de ancho/alto, pero Firefox no, así que agregué px
. Incluso si ya no es así, dejémoslo como se supone que debe ser según los estándares.
Y aquí está el autocompletado que prometí:
Así lo preparo yo, espero que te resulte útil.
El inconveniente de este enfoque es que los íconos se insertarán en línea y no se almacenarán en caché debido a eso. Sin embargo, no creo que sea un gran problema para unos pocos bytes. Además, puedes agregar rotación, cambio de color al pasar el mouse sobre el ícono, lo que sea, y tendrás una pequeña navaja suiza de esos íconos.
Además, necesitarás un archivo organizado en Figma. Pero, si puedes escribir todo este código, también puedes convencer a tus diseñadores para que lo organicen.
Puede encontrar el código completo, incluidos los componentes React y Vue, y una versión de Node.js con menos dependencias aquí: https://github.com/Smileek/fetch-icons