diff --git a/support-figma/extended-layout-plugin/src/image-res-module.ts b/support-figma/extended-layout-plugin/src/image-res-module.ts index 83da20a34..d2792a1d4 100644 --- a/support-figma/extended-layout-plugin/src/image-res-module.ts +++ b/support-figma/extended-layout-plugin/src/image-res-module.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import * as Utils from "./utils"; +import * as VectorRes from "./vector-res-module"; const IMAGE_HASH_TO_RES_KEY = "image_hash_to_res"; @@ -43,6 +44,7 @@ export async function exportAllImagesAsync() { } save(); + let {vectorResToImageBytesMap, vectorResToNodesMap} = await VectorRes.exportAllVectorsAsync(); figma.showUI(__html__, { width: 600, height: 600 }); figma.ui.postMessage({ @@ -50,6 +52,8 @@ export async function exportAllImagesAsync() { imageBytesArray: Array.from(imageBytesMap), imageNodesArray: Array.from(imageHashToNodesMap), imageResNameArray: Array.from(imageHashToResMap), + vectorBytesArray: Array.from(vectorResToImageBytesMap), + vectorResNameArray: Array.from(vectorResToNodesMap), }); } diff --git a/support-figma/extended-layout-plugin/src/ui.html b/support-figma/extended-layout-plugin/src/ui.html index b6119b927..0cbbf6ca5 100644 --- a/support-figma/extended-layout-plugin/src/ui.html +++ b/support-figma/extended-layout-plugin/src/ui.html @@ -1341,7 +1341,7 @@ // jpeg and png will be downloaded as png. Gif will be downloaded as gif. Other // image types are not supported. - async function processImage(imageHash, imageBytes) { + async function processImage(imageBytes) { const imageType = getImageType(imageBytes); switch(imageType) { case 'png': @@ -1366,7 +1366,7 @@ } // Display the images - async function createImageOutput(imageNodesArray, imageResNameArray, imageBytesArray) { + async function createImageOutput(imageNodesArray, imageResNameArray, imageBytesArray, vectorResNameArray, vectorBytesArray) { const imageInfo = document.getElementById('imageInfo'); imageInfo.innerHTML = ""; @@ -1400,7 +1400,7 @@ resNameElement.appendChild(input); imageInfo.appendChild(resNameElement); - let {element, outputImgBytes, imageFormat} = await processImage(imageHash, imageBytes); + let {element, outputImgBytes, imageFormat} = await processImage(imageBytes); imageInfo.appendChild(element); for (const nodeId of imageNodesMap.get(imageHash)) { @@ -1417,6 +1417,46 @@ jszip.file(`${resName}.${imageFormat}`, outputImgBytes, {base64: true}); } + let vectorResMap = new Map(vectorResNameArray); + + for (const [vectorResName, vectorBytes] of vectorBytesArray) { + const resNameElement = document.createElement('div'); + resNameElement.style.marginTop = '8px'; + resNameElement.textContent = 'Image res name: '; + const resName = vectorResName; + const input = document.createElement("input"); + input.addEventListener('change', function (evt) { + const newValue = this.value; + parent.postMessage({ + pluginMessage: { + msg: 'update-vector-res-name', + resName: newValue, + nodes: vectorResNameMap.get(vectorResName) + } + }, '*'); + }); + input.setAttribute("type", "text"); + input.setAttribute("value", resName); + resNameElement.appendChild(input); + imageInfo.appendChild(resNameElement); + + let {element, outputImgBytes, imageFormat} = await processImage(vectorBytes); + imageInfo.appendChild(element); + + for (const nodeId of vectorResMap.get(vectorResName)) { + const nodeIdElement = document.createElement('div'); + nodeIdElement.style.marginTop = '8px'; + nodeIdElement.textContent = "Node id: "; + nodeIdElement.appendChild(createNodeIdSpan(nodeId)); + imageInfo.appendChild(nodeIdElement); + } + + const divider = document.createElement('hr'); + imageInfo.appendChild(divider); + + jszip.file(`${resName}.${imageFormat}`, outputImgBytes, {base64: true}); + } + const downloadButton = document.getElementById('downloadImageButton'); downloadButton.addEventListener('click', ()=>{ @@ -1570,10 +1610,8 @@ } else if (msg.msg == 'meters-selection') { setMeterData(msg); - } - - if (msg.msg == 'localization') { - openPage('localizationOptions') + } else if (msg.msg == 'localization') { + openPage('localizationOptions'); } else if (msg.msg == 'localization-output') { loadOutputStringData(msg.output); createOutputStringTable('outputStrings'); @@ -1585,7 +1623,7 @@ createOutputStringTable('outputStrings'); } else if (msg.msg == 'image-export') { openPage('imageExport'); - createImageOutput(msg.imageNodesArray, msg.imageResNameArray, msg.imageBytesArray); + createImageOutput(msg.imageNodesArray, msg.imageResNameArray, msg.imageBytesArray, msg.vectorResNameArray, msg.vectorBytesArray); } } diff --git a/support-figma/extended-layout-plugin/src/vector-res-module.ts b/support-figma/extended-layout-plugin/src/vector-res-module.ts new file mode 100644 index 000000000..4f3f33763 --- /dev/null +++ b/support-figma/extended-layout-plugin/src/vector-res-module.ts @@ -0,0 +1,180 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as Utils from "./utils"; + +const IMAGE_REPLACEMENT_RES_NAME = "image_replacement_res_name"; + +// Node id to vector root(frame node) +const vectorFrames = new Map(); +// ResName to image bytes map +const resToImageBytesMap = new Map(); +// ResName to node id array map +const resToNodesMap = new Map>(); + +export async function exportAllVectorsAsync(): Promise<{ + vectorResToImageBytesMap: Map; + vectorResToNodesMap: Map>; +}> { + await figma.loadAllPagesAsync(); + + vectorFrames.clear(); + resToImageBytesMap.clear(); + resToNodesMap.clear(); + + const vectorNodes = figma.root.findAll((node) => node.type === "VECTOR"); + + for (const vectorNode of vectorNodes) { + const frameAncestor = findFrameAncestor(vectorNode); + if (frameAncestor && isVectorRoot(frameAncestor)) { + vectorFrames.set(frameAncestor.id, frameAncestor); + } + } + + for (const [frameNodeId, frameNode] of vectorFrames) { + const imageBytes = await frameNode.exportAsync({format: "PNG", colorProfile: "SRGB", contentsOnly: true }); + const cachedResName = getResName(frameNode); + var resName = cachedResName + ? cachedResName + : `ic_${Utils.toSnakeCase(frameNode.name)}`; + var index = 0; + while (resToImageBytesMap.has(resName)) { + index += 1; + resName = resName + "_" + index; + } + resToImageBytesMap.set(resName, imageBytes); + resToNodesMap.set(resName, [frameNodeId]); + setResName(frameNode, resName); + } + + return { vectorResToImageBytesMap: resToImageBytesMap, vectorResToNodesMap: resToNodesMap }; +} + +function getResName(node: SceneNode): string { + return node.getSharedPluginData( + Utils.SHARED_PLUGIN_NAMESPACE, + IMAGE_REPLACEMENT_RES_NAME + ); +} + +function setResName(node: SceneNode, resName: string) { + node.setSharedPluginData( + Utils.SHARED_PLUGIN_NAMESPACE, + IMAGE_REPLACEMENT_RES_NAME, + resName + ); +} + +function convertToVector(node: SceneNode): string { + if (node.type === "VECTOR") { + const fills = node.fills as ReadonlyArray; + if (fills) { + for (const fill of fills) { + if (fill.type === "SOLID" && fill.visible !== false) { + for (const vectorPath of node.vectorPaths) { + let outputString = ` \n`; + return outputString; + } + } + } + } + } else if (node.type === "GROUP") { + let outputString = ` \n`; + // Recurse into any children. + let maybeParent = node as ChildrenMixin; + if (maybeParent.children) { + for (let child of maybeParent.children) { + outputString = outputString.concat(convertToVector(child)); + } + } + outputString = outputString.concat(" \n"); + return outputString; + } else if (node.type === "FRAME") { + let outputString = `\n`; + outputString = outputString.concat( + `\n` + ); + // Recurse into any children. + let maybeParent = node as ChildrenMixin; + if (maybeParent.children) { + for (let child of maybeParent.children) { + outputString = outputString.concat(convertToVector(child)); + } + } + outputString = outputString.concat(""); + return outputString; + } + return ""; +} + +function findFrameAncestor(node: PageNode | SceneNode): FrameNode | undefined { + if (node.parent?.type === "FRAME") { + return node.parent!!; + } + + if (node.parent?.type === "GROUP") { + return findFrameAncestor(node.parent!!); + } + + return undefined; +} + +function isVectorRoot(frameNode: FrameNode): boolean { + for (const childNode of frameNode.children) { + if (!isVectorOrTextOrVectorGroup(childNode)) { + return false; + } + } + return true; +} + +// Returns true if the node is a vector node or a group node with vector as descendants. +function isVectorOrTextOrVectorGroup(node: SceneNode): boolean { + if (node.type === "VECTOR") { + return true; + } + if (node.type === "TEXT") { + return true; + } + if (node.type === "GROUP") { + return isVectorGroup(node); + } + return false; +} + +function isVectorGroup(groupNode: GroupNode): boolean { + for (const childNode of groupNode.children) { + if (childNode.type === "GROUP") { + if (!isVectorGroup(childNode)) { + return false; + } + } else if (childNode.type !== "VECTOR" && childNode.type != "TEXT") { + return false; + } + } + return true; +} + +function toHex(rgb: RGB) { + return rgbToHex(rgb.r, rgb.g, rgb.b); +} + +function rgbToHex(r: number, g: number, b: number) { + const componentToHex = (c: number) => { + const hex = Math.round(c * 255).toString(16); + return hex.length == 1 ? "0" + hex : hex; + }; + return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b); +}