diff --git a/.changeset/wet-cameras-argue.md b/.changeset/wet-cameras-argue.md new file mode 100644 index 0000000..ce9aca0 --- /dev/null +++ b/.changeset/wet-cameras-argue.md @@ -0,0 +1,7 @@ +--- +"@icona/generator": minor +"@icona/types": minor +"@icona/utils": minor +--- + +feat: Extract PNG images by scale diff --git a/figma-plugin/common/constants.ts b/figma-plugin/common/constants.ts index 7e310d4..a9d272d 100644 --- a/figma-plugin/common/constants.ts +++ b/figma-plugin/common/constants.ts @@ -3,5 +3,5 @@ export const FRAME_NAME = "icona-frame"; export const KEY = { GITHUB_API_KEY: "github-api-key", GITHUB_REPO_URL: "github-repo-url", - DEPLOY_WITH_PNG: "deploy-with-png", + PNG_OPTION: "png-option", }; diff --git a/figma-plugin/common/fromPlugin.ts b/figma-plugin/common/fromPlugin.ts index e1fc1d8..663f391 100644 --- a/figma-plugin/common/fromPlugin.ts +++ b/figma-plugin/common/fromPlugin.ts @@ -1,6 +1,8 @@ import { emit as e, on as o } from "@create-figma-plugin/utilities"; import type { IconaIconData } from "@icona/types"; +import type { ExportOptions } from "./types.js"; + interface UserInfoPayload { name: string; id: string; @@ -15,7 +17,7 @@ interface GetGithubApiKeyPayload { } interface GetDeployWithPngPayload { - deployWithPng?: boolean; + options: ExportOptions; } interface GetIconPreviewPayload { diff --git a/figma-plugin/common/fromUi.ts b/figma-plugin/common/fromUi.ts index 80d18d4..1d1ba40 100644 --- a/figma-plugin/common/fromUi.ts +++ b/figma-plugin/common/fromUi.ts @@ -1,6 +1,8 @@ import { emit as e, on as o } from "@create-figma-plugin/utilities"; import type { IconaIconData } from "@icona/types"; +import type { ExportOptions } from "./types.js"; + interface GithubData { owner: string; name: string; @@ -10,13 +12,11 @@ interface GithubData { interface IconaMetaData { githubData: GithubData; icons: Record; - options?: { - withPng?: boolean; - }; + options: ExportOptions; } interface SetPngOptionPayload { - withPng: boolean; + options: ExportOptions; } interface SetGithubUrlPayload { diff --git a/figma-plugin/common/types.ts b/figma-plugin/common/types.ts index 8e08b48..b8867d5 100644 --- a/figma-plugin/common/types.ts +++ b/figma-plugin/common/types.ts @@ -4,9 +4,11 @@ export interface GithubData { apiKey: string; } -export interface IconaMetaData { - githubData: GithubData; - options?: { - withPng?: boolean; +export interface ExportOptions { + png: { + x1: boolean; + x2: boolean; + x3: boolean; + x4: boolean; }; } diff --git a/figma-plugin/plugin-src/code.ts b/figma-plugin/plugin-src/code.ts index 5f40dd9..11b93a1 100644 --- a/figma-plugin/plugin-src/code.ts +++ b/figma-plugin/plugin-src/code.ts @@ -6,7 +6,7 @@ import { listenSetGithubApiKey, listenSetGithubUrl, } from "./listeners"; -import { getAssetInIconFrame } from "./service"; +import { getAssetFramesInFrame, getSvgFromExtractedNodes } from "./service"; import { getLocalData } from "./utils"; function sendUserInfo() { @@ -21,11 +21,15 @@ function sendUserInfo() { async function sendStorageData() { const repoUrl = await getLocalData(KEY.GITHUB_REPO_URL); const apiKey = await getLocalData(KEY.GITHUB_API_KEY); - const deployWithPng = await getLocalData(KEY.DEPLOY_WITH_PNG); + const pngOption = await getLocalData(KEY.PNG_OPTION); emit("GET_GITHUB_REPO_URL", { repoUrl }); emit("GET_GITHUB_API_KEY", { apiKey }); - emit("GET_DEPLOY_WITH_PNG", { deployWithPng }); + emit("GET_DEPLOY_WITH_PNG", { + options: pngOption || { + png: { x1: false, x2: false, x3: false, x4: false }, + }, + }); } async function setPreviewIcons() { @@ -37,13 +41,11 @@ async function setPreviewIcons() { figma.notify("Icona frame not found"); return; } else { - const svgDatas = await getAssetInIconFrame(iconaFrame.id, { - withPng: await getLocalData(KEY.DEPLOY_WITH_PNG), - }); + const targetFrame = figma.getNodeById(iconaFrame.id) as FrameNode; + const assetFrames = getAssetFramesInFrame(targetFrame); + const datas = await getSvgFromExtractedNodes(assetFrames); - emit("GET_ICON_PREVIEW", { - icons: svgDatas, - }); + emit("GET_ICON_PREVIEW", { icons: datas }); } } diff --git a/figma-plugin/plugin-src/listeners.ts b/figma-plugin/plugin-src/listeners.ts index a6c6c71..edd25c7 100644 --- a/figma-plugin/plugin-src/listeners.ts +++ b/figma-plugin/plugin-src/listeners.ts @@ -1,17 +1,36 @@ -import { KEY } from "../common/constants.js"; +import { FRAME_NAME, KEY } from "../common/constants.js"; import { emit } from "../common/fromPlugin.js"; import { on } from "../common/fromUi.js"; import { createGithubClient } from "./github.js"; +import { exportFromIconaIconData, getAssetFramesInFrame } from "./service.js"; import { setLocalData } from "./utils.js"; export function listenDeployIcon() { - on("DEPLOY_ICON", async ({ githubData, icons }) => { + on("DEPLOY_ICON", async ({ githubData, icons, options }) => { try { const { owner, name, apiKey } = githubData; + const pngOption = options.png; const { createDeployPR } = createGithubClient(owner, name, apiKey); - await createDeployPR(icons); + const iconaFrame = figma.currentPage.findOne((node) => { + return node.name === FRAME_NAME; + }); + + if (!iconaFrame) { + figma.notify("Icona frame not found"); + return; + } + + const targetFrame = figma.getNodeById(iconaFrame.id) as FrameNode; + const assetFrames = getAssetFramesInFrame(targetFrame); + + const iconaData = await exportFromIconaIconData(assetFrames, icons, { + png: pngOption, + }); + + await createDeployPR(iconaData); + emit("DEPLOY_DONE", null); figma.notify("Icons deployed", { timeout: 5000 }); } catch (error) { @@ -35,8 +54,9 @@ export function listenSetGithubUrl() { setLocalData(KEY.GITHUB_REPO_URL, url); }); } + export function listenPngOption() { - on("SET_PNG_OPTION", ({ withPng }) => { - setLocalData(KEY.DEPLOY_WITH_PNG, withPng); + on("SET_PNG_OPTION", ({ options }) => { + setLocalData(KEY.PNG_OPTION, options); }); } diff --git a/figma-plugin/plugin-src/service.ts b/figma-plugin/plugin-src/service.ts index 2d1cbf6..1f98fec 100644 --- a/figma-plugin/plugin-src/service.ts +++ b/figma-plugin/plugin-src/service.ts @@ -2,6 +2,8 @@ import type { IconaIconData } from "@icona/types"; import { Base64 } from "js-base64"; +import type { ExportOptions } from "../common/types"; + type TargetNode = | ComponentNode | InstanceNode @@ -9,7 +11,7 @@ type TargetNode = | ComponentSetNode | FrameNode | GroupNode; -type Extracted = { +type ExtractedNode = { id: string; name: string; }; @@ -41,7 +43,7 @@ const makeComponentName = ({ const findComponentInNode = ( node: TargetNode, setName?: string, -): Extracted | Extracted[] => { +): ExtractedNode | ExtractedNode[] => { switch (node.type) { case "FRAME": case "GROUP": @@ -70,17 +72,8 @@ const findComponentInNode = ( } }; -export async function getAssetInIconFrame( - iconFrameId: string, - options?: { - withPng?: boolean; - }, -): Promise> { - const frame = figma.getNodeById(iconFrameId) as FrameNode; - - const withPng = options?.withPng ?? true; - - const targetNodes = frame.children.flatMap((child) => { +export function getAssetFramesInFrame(targetFrame: FrameNode): ExtractedNode[] { + const targetNodes = targetFrame.children.flatMap((child) => { if ( child.type === "COMPONENT" || child.type === "INSTANCE" || @@ -94,43 +87,26 @@ export async function getAssetInIconFrame( return []; }); - const targetComponents = targetNodes.filter((component) => component); + return targetNodes.filter((component) => component); +} +export async function getSvgFromExtractedNodes(nodes: ExtractedNode[]) { const datas = await Promise.allSettled( - targetComponents.map(async (component) => { - const data = {} as IconaIconData; + nodes.map(async (component) => { const node = figma.getNodeById(component.id) as ComponentNode; - // base - data.style = { - width: node.width, - height: node.height, + return { + name: component.name, + svg: await node.exportAsync({ + format: "SVG_STRING", + svgIdAttribute: true, + }), }; - data.name = component.name; - - // svg - const svg = await node.exportAsync({ - format: "SVG_STRING", - svgIdAttribute: true, - }); - data.svg = svg; - - // png - if (withPng) { - const png = await node.exportAsync({ format: "PNG" }); - const base64String = Base64.fromUint8Array(png); - data.png = base64String; - } - - return data; }), ); const dataMap = datas.reduce((acc, cur) => { - if (cur.status === "rejected") { - console.error(cur.reason); - } - + if (cur.status === "rejected") console.error(cur.reason); if (cur.status === "fulfilled") { const { name, ...rest } = cur.value as IconaIconData; acc[name] = { @@ -144,3 +120,64 @@ export async function getAssetInIconFrame( return dataMap; } + +export async function exportFromIconaIconData( + nodes: ExtractedNode[], + iconaData: Record, + options: ExportOptions, +) { + const result = iconaData; + + nodes.forEach(async (component) => { + const node = figma.getNodeById(component.id) as ComponentNode; + + const exportDatas = await Promise.allSettled( + Object.entries(options.png).map(async ([key, value]) => { + const scale = Number(key.replace("x", "")); + + if (!value) { + return { + scale: `${scale}x`, + data: "", + }; + } + + const exportData = await node.exportAsync({ + format: "PNG", + constraint: { + type: "SCALE", + value: scale, + }, + }); + + const base64String = Base64.fromUint8Array(exportData); + + return { + scale: `${scale}x`, + data: base64String, + }; + }), + ); + + const pngDatas = exportDatas.reduce((acc, cur) => { + if (cur.status === "rejected") console.error(cur.reason); + if (cur.status === "fulfilled") { + const { scale, data } = cur.value as { + scale: string; + data: string; + }; + acc[scale] = data; + } + + return acc; + }, {} as Record); + + // name = "icon_name" + result[component.name] = { + ...result[component.name], + ...pngDatas, + }; + }); + + return result; +} diff --git a/figma-plugin/ui-src/contexts/AppContext.tsx b/figma-plugin/ui-src/contexts/AppContext.tsx index e0ede5b..97572f6 100644 --- a/figma-plugin/ui-src/contexts/AppContext.tsx +++ b/figma-plugin/ui-src/contexts/AppContext.tsx @@ -21,8 +21,15 @@ type State = { iconPreview: Record; - /* status */ - isDeployWithPng: boolean; + // Options + pngOption: { + x1: boolean; + x2: boolean; + x3: boolean; + x4: boolean; + }; + + // Status isDeploying: boolean; }; @@ -47,10 +54,17 @@ function reducer(state: State, action: Actions): State { switch (action.name) { /* from Plugin */ case "GET_DEPLOY_WITH_PNG": { - const { deployWithPng = false } = action.payload; + const { options } = action.payload; + const png = options.png || { x1: false, x2: false, x3: false, x4: false }; + return { ...state, - isDeployWithPng: deployWithPng, + pngOption: { + x1: png.x1 || false, + x2: png.x2 || false, + x3: png.x3 || false, + x4: png.x4 || false, + }, }; } case "GET_GITHUB_API_KEY": { @@ -128,7 +142,9 @@ function reducer(state: State, action: Actions): State { return { ...state, - isDeployWithPng: action.payload.withPng, + pngOption: { + ...action.payload.options.png, + }, }; } @@ -164,8 +180,15 @@ export function AppProvider({ children }: { children: React.ReactNode }) { githubApiKey: "", githubRepositoryUrl: "", + // Options + pngOption: { + x1: false, + x2: false, + x3: false, + x4: false, + }, + // Status - isDeployWithPng: true, isDeploying: false, }); diff --git a/figma-plugin/ui-src/pages/Deploy.css.ts b/figma-plugin/ui-src/pages/Deploy.css.ts index 8df38c2..5296c08 100644 --- a/figma-plugin/ui-src/pages/Deploy.css.ts +++ b/figma-plugin/ui-src/pages/Deploy.css.ts @@ -6,6 +6,17 @@ export const container = style({ flexDirection: "column", }); +export const optionContainer = style({ + display: "flex", + flexDirection: "column", + flexWrap: "wrap", + borderRadius: "4px", + border: "1px solid #ddd", + + padding: "6px", + marginTop: "10px", +}); + export const preview = style({ display: "flex", alignItems: "center", diff --git a/figma-plugin/ui-src/pages/Deploy.tsx b/figma-plugin/ui-src/pages/Deploy.tsx index 779e0eb..e2a7ed3 100644 --- a/figma-plugin/ui-src/pages/Deploy.tsx +++ b/figma-plugin/ui-src/pages/Deploy.tsx @@ -2,6 +2,7 @@ import { Box, Button, Checkbox, + Flex, Spinner, Text, Tooltip, @@ -21,7 +22,7 @@ const Deploy = () => { iconPreview, githubApiKey, githubRepositoryUrl, - isDeployWithPng, + pngOption, } = useAppState(); const icons = Object.entries(iconPreview); const { track } = useJune(); @@ -32,6 +33,9 @@ const Deploy = () => { payload: { icons: iconPreview, githubData, + options: { + png: pngOption, + }, }, }); track({ @@ -94,26 +98,112 @@ const Deploy = () => { )} - { - dispatch({ - name: "SET_PNG_OPTION", - payload: { - withPng: !isDeployWithPng, - }, - }); - }} - > - - with png + + Export Options + + + + + PNG - - will deploy with png data as base64. - - you can convert png file with `@icona/generator` - + will deploy with png data as base64. + + you can convert png file with `@icona/generator` + + + { + dispatch({ + name: "SET_PNG_OPTION", + payload: { + options: { + png: { + ...pngOption, + x1: !pngOption.x1, + }, + }, + }, + }); + }} + > + + x1 + + + + { + dispatch({ + name: "SET_PNG_OPTION", + payload: { + options: { + png: { + ...pngOption, + x2: !pngOption.x2, + }, + }, + }, + }); + }} + > + + x2 + + + + { + dispatch({ + name: "SET_PNG_OPTION", + payload: { + options: { + png: { + ...pngOption, + x3: !pngOption.x3, + }, + }, + }, + }); + }} + > + + x3 + + + + { + dispatch({ + name: "SET_PNG_OPTION", + payload: { + options: { + png: { + ...pngOption, + x4: !pngOption.x4, + }, + }, + }, + }); + }} + > + + x4 + + + + + + + Previews + {icons.map(([name, data]) => { diff --git a/packages/generator/src/core/png.ts b/packages/generator/src/core/png.ts index 41910fa..2fb5cd5 100644 --- a/packages/generator/src/core/png.ts +++ b/packages/generator/src/core/png.ts @@ -6,7 +6,7 @@ import { makeFolderIfNotExistFromRoot, } from "@icona/utils"; import { writeFileSync } from "fs"; -import { resolve } from "path"; +import { join, resolve } from "path"; interface GeneratePNGFunction { /** @@ -23,6 +23,7 @@ export const generatePNG = ({ }: GeneratePNGFunction) => { const projectPath = getProjectRootPath(); const path = config.path || "png"; + const scales = ["x1", "x2", "x3", "x4"] as const; if (!icons) { throw new Error("There is no icons data"); @@ -31,19 +32,27 @@ export const generatePNG = ({ const iconData = Object.entries(icons); if (iconData.length !== 0) { makeFolderIfNotExistFromRoot(path); + scales.forEach((scale) => { + makeFolderIfNotExistFromRoot(join(path, scale)); + }); } if (config.genMode === "recreate") { - deleteAllFilesInDir(resolve(projectPath, path)); + scales.forEach((scale) => { + deleteAllFilesInDir(resolve(projectPath, join(path, scale))); + }); } // TODO: Name transform option iconData.forEach(([name, data]) => { - const { png } = data; - if (!png) return; + scales.forEach((scale) => { + const base64 = data.png[scale]; + if (!base64) return; - const buffer = Buffer.from(png, "base64"); - const pngPath = resolve(projectPath, path, `${name}.png`); - writeFileSync(pngPath, buffer); + const buffer = Buffer.from(base64, "base64"); + const filePath = resolve(projectPath, join(path, scale, `${name}.png`)); + + writeFileSync(filePath, buffer); + }); }); }; diff --git a/packages/types/src/data.d.ts b/packages/types/src/data.d.ts index aca33b1..d54b313 100644 --- a/packages/types/src/data.d.ts +++ b/packages/types/src/data.d.ts @@ -9,5 +9,10 @@ export interface IconaIconData { name: string; style: Style; svg: string; - png?: Base64; + png: { + x1: Base64 | null; + x2: Base64 | null; + x3: Base64 | null; + x4: Base64 | null; + }; }