Better Figma Layer Exporter
// ==UserScript==
// @name Better Figma Layer Exporter
// @name:zh-CN Better Figma Layer Exporter
// @namespace https://github.com/XuQK/Better-Figma-Layer-Exporter
// @version 1.1.3
// @license MIT
// @description A more convenient Figma layer export solution, featuring the following main functions: 1. Direct export of selected layers as PNGs and automatically assigning them to their corresponding DPI drawable folders; 2. Support for converting PNGs to WebP format before exporting; 3. Support for exporting SVGs optimized through SVGO.
// @description:zh-CN 更方便的 Figma 图层导出,主要功能:1. 选定图层直接导出为 png 并按 dpi 分配到对应 dpi 的 drawable 文件夹; 2. 支持将 PNG 转换成 WebP 再导出; 3. 支持导出经 SVGO 优化的 svg 图片。
// @author XuQK
// @match https://www.figma.com/*
// @icon https://github.com/XuQK/Better-Figma-Layer-Exporter/blob/master/assets/icon.jpeg?raw=true
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @require https://cdn.jsdelivr.net/npm/sweetalert2@11
// @connect *
// @run-at document-end
// ==/UserScript==
(function () {
"use strict";
const coloredToastStyle = document.createElement("style");
coloredToastStyle.innerHTML = `
.colored-toast.swal2-icon-success {
background-color: #a5dc86 !important;
}
.colored-toast.swal2-icon-error {
background-color: #f27474 !important;
}
.colored-toast.swal2-icon-warning {
background-color: #f8bb86 !important;
}
.colored-toast.swal2-icon-info {
background-color: #3fc3ee !important;
}
.colored-toast.swal2-icon-question {
background-color: #87adbd !important;
}
.colored-toast .swal2-title {
color: white;
}
.colored-toast .swal2-close {
color: white;
}
.colored-toast .swal2-html-container {
color: white;
}
`;
document.head.appendChild(coloredToastStyle);
GM_registerMenuCommand("Settings/设置", showSettingsDialog, "S");
function showSettingsDialog() {
Toast.fire({
title: "Settings / 设置",
html: `
<div style="display: flex; align-items: center">
<label for="kd-figma-token" style="font-size: 18px; width: 10em">Figma token</label>
<input id="kd-figma-token" class="swal2-input" style="margin: 8px" value="${figmaToken}">
</div>
<div style="display: flex; align-items: center">
<label for="kd-server-svg-optimizer" style="font-size: 18px; width: 10em">Svg Optimizer Url</label>
<input id="kd-server-svg-optimizer" class="swal2-input" style="margin: 8px" value="${svgOptimizerRequestUrl}">
</div>
<div style="display: flex; align-items: center">
<label for="kd-server-png-convert-to-webp" style="font-size: 18px; width: 10em">Webp Converter url</label>
<input id="kd-server-png-convert-to-webp" class="swal2-input" style="margin: 8px" value="${pngConvertToWebpRequestUrl}">
</div>
<div style="display: flex; align-items: center">
<label for="kd-svg-precision" style="font-size: 18px; width: 10em">Svg precision</label>
<input id="kd-svg-precision" class="swal2-input" style="margin: 8px" value="${svgPrecision}">
</div>
<div style="display: flex; align-items: center">
<label for="kd-webp-quality" style="font-size: 18px; width: 10em">WebP quality</label>
<input id="kd-webp-quality" class="swal2-input" style="margin: 8px" value="${webpQuality}">
</div>
<div style="display: flex; align-items: center; height: 4em">
<label for="kd-mode" style="font-size: 18px; width: 10em">Day/Night Mode</label>
<input id="kd-mode-day" name="kd-mode" value="day" type="radio" style="margin: 8px">Day</input>
<input id="kd-mode-night" name="kd-mode" value="night" type="radio" style="margin: 8px">Night</input>
</div>
<p></p>
<p style="text-align: start; color: #FF5252">PS:</p>
<p style="text-align: start; font-size: 14px; color: #FF5252">1. SVG 优化和 PNG 转 WebP 的需要后台能力,目前是白嫖的 node 服务器,资源有限,请温柔使用~</p>
<p style="text-align: start; font-size: 14px; color: #FF5252">2. 如果想将此 node 服务器运行在自己本地,参见 <a href="https://github.com/XuQK/Better-Figma-Layer-Exporter#扩展功能" target="_blank">https://github.com/XuQK/Better-Figma-Layer-Exporter#扩展功能</a></p>
</div>
`,
width: 600,
focusConfirm: false,
showCancelButton: true,
didOpen() {
document.getElementById(`kd-mode-${mode}`).checked = true;
},
preConfirm: () => {
return [
document.getElementById("kd-figma-token").value,
document.getElementById("kd-server-svg-optimizer").value,
document.getElementById("kd-server-png-convert-to-webp").value,
document.getElementById("kd-svg-precision").value,
document.getElementById("kd-webp-quality").value,
document.querySelector("input[name='kd-mode']:checked").value
];
}
}).then(value => {
const params = value.value;
figmaToken = params[0];
svgOptimizerRequestUrl = params[1];
pngConvertToWebpRequestUrl = params[2];
svgPrecision = params[3];
webpQuality = params[4];
mode = params[5];
GM_setValue("figmaToken", figmaToken);
GM_setValue("svgOptimizerRequestUrl", svgOptimizerRequestUrl);
GM_setValue("pngConvertToWebpRequestUrl", pngConvertToWebpRequestUrl);
GM_setValue("svgPrecision", svgPrecision);
GM_setValue("webpQuality", webpQuality);
GM_setValue("mode", mode);
});
}
// 默认配置
let figmaToken = GM_getValue("figmaToken", "");
let svgOptimizerRequestUrl = GM_getValue("svgOptimizerRequestUrl", "");
let pngConvertToWebpRequestUrl = GM_getValue("pngConvertToWebpRequestUrl", "");
// svg 专用
let svgPrecision = GM_getValue("svgPrecision", 1);
// png 专用
// webp 转换质量,0-100,默认 75
let webpQuality = GM_getValue("webpQuality", 75);
// 是否暗色模式
let mode = GM_getValue("mode", "day");
class Image {
/**
* @type {string}
*/
url;
/**
* @type {Blob} 从 figma 下载的原始图层内容,可能是 svg,也有可能是 png
*/
originalContent;
/**
* @type {number}
*/
scale;
/**
* @type {Blob} 经处理后的数据,可能是优化后的 svg,也有可能是经 png 转换过后的 webp
*/
processedContent;
/**
* @type {string} 最终创建文件的格式/后缀名
*/
format;
/**
* @type {Blob} 最终存储到文件的数据
*/
finalContent;
/**
* @param id {string}
* @param name {string}
*/
constructor(id, name) {
this.id = id;
this.name = name;
}
}
function dirNameToScaleMap() {
if (mode === "day") {
return _dirNameToScaleMapDay;
} else {
return _dirNameToScaleMapNight;
}
}
function scaleToDirNameMap() {
if (mode === "day") {
return _scaleToDirNameMapDay;
} else {
return _scaleToDirNameMapNight;
}
}
const _dirNameToScaleMapDay = new Map();
_dirNameToScaleMapDay.set("drawable-ldpi", 0.75);
_dirNameToScaleMapDay.set("drawable-mdpi", 1);
_dirNameToScaleMapDay.set("drawable-hdpi", 1.5);
_dirNameToScaleMapDay.set("drawable-xhdpi", 2);
_dirNameToScaleMapDay.set("drawable-xxhdpi", 3);
_dirNameToScaleMapDay.set("drawable-xxxhdpi", 4);
const _scaleToDirNameMapDay = new Map();
_scaleToDirNameMapDay.set(0.75, "drawable-ldpi");
_scaleToDirNameMapDay.set(1, "drawable-mdpi");
_scaleToDirNameMapDay.set(1.5, "drawable-hdpi");
_scaleToDirNameMapDay.set(2, "drawable-xhdpi");
_scaleToDirNameMapDay.set(3, "drawable-xxhdpi");
_scaleToDirNameMapDay.set(4, "drawable-xxxhdpi");
const _dirNameToScaleMapNight = new Map();
_dirNameToScaleMapNight.set("drawable-night-ldpi", 0.75);
_dirNameToScaleMapNight.set("drawable-night-mdpi", 1);
_dirNameToScaleMapNight.set("drawable-night-hdpi", 1.5);
_dirNameToScaleMapNight.set("drawable-night-xhdpi", 2);
_dirNameToScaleMapNight.set("drawable-night-xxhdpi", 3);
_dirNameToScaleMapNight.set("drawable-night-xxxhdpi", 4);
const _scaleToDirNameMapNight = new Map();
_scaleToDirNameMapNight.set(0.75, "drawable-night-ldpi");
_scaleToDirNameMapNight.set(1, "drawable-night-mdpi");
_scaleToDirNameMapNight.set(1.5, "drawable-night-hdpi");
_scaleToDirNameMapNight.set(2, "drawable-night-xhdpi");
_scaleToDirNameMapNight.set(3, "drawable-night-xxhdpi");
_scaleToDirNameMapNight.set(4, "drawable-night-xxxhdpi");
const svgButtonId = "svgo-button";
const svgoButton = document.createElement("button");
svgoButton.id = svgButtonId;
svgoButton.className = "basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk button_row--btnNarrow--bKZDj button_row--btn--0W3Mm basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk";
svgoButton.style.marginTop = "16px";
svgoButton.style.width = "90%";
svgoButton.style.marginLeft = "auto";
svgoButton.style.marginRight = "auto";
svgoButton.innerText = "经 SVGO 优化并导出";
svgoButton.addEventListener("click", function () {
onClickDownloadSvg().then();
});
const pngButtonId = "png-button";
const pngButton = document.createElement("button");
pngButton.id = pngButtonId;
pngButton.className = "basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk button_row--btnNarrow--bKZDj button_row--btn--0W3Mm basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk";
pngButton.style.marginTop = "16px";
pngButton.style.width = "90%";
pngButton.style.marginLeft = "auto";
pngButton.style.marginRight = "auto";
pngButton.innerText = "导出 PNG 到指定 res 目录";
pngButton.addEventListener("click", function () {
onClickDownloadPng(false).then();
});
const webpButtonId = "webp-button";
const webpButton = document.createElement("button");
webpButton.id = webpButtonId;
webpButton.className = "basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk button_row--btnNarrow--bKZDj button_row--btn--0W3Mm basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk";
webpButton.style.width = "90%";
webpButton.style.marginTop = "16px";
webpButton.style.marginLeft = "auto";
webpButton.style.marginRight = "auto";
webpButton.innerText = "导出 WebP 到指定 res 目录";
webpButton.addEventListener("click", function () {
onClickDownloadPng(true).then();
});
// 监听 body 元素变动,根据情况插入导出按钮(对于无编辑器权限的使用者)
new MutationObserver(() => {
try {
let c = null;
const anchorElemForGuest = document.querySelector("div.raw_components--panel--YDedw.export_panel--standalonePanel--yXYPM");
if (anchorElemForGuest !== null) {
c = anchorElemForGuest.parentElement;
} else {
const nodeList = document.querySelectorAll("div.draggable_list--panelTitleText--Bj2Hu")
const anchorElemForOwner = Array.from(nodeList).find(node => node.innerText === "Export")
if (anchorElemForOwner !== null) {
c = anchorElemForOwner.parentElement.parentElement.parentElement.parentElement.parentElement
}
}
if (c !== null) {
if (document.getElementById(svgButtonId) === null) {
c.appendChild(svgoButton);
}
if (document.getElementById(pngButtonId) === null) {
c.appendChild(pngButton);
}
if (document.getElementById(webpButtonId) === null) {
c.appendChild(webpButton);
}
}
} catch (e) {
}
}).observe(document.body, {childList: true, subtree: true});
// (对于有编辑权限的使用者)
const Toast = Swal.mixin({
position: "center",
allowOutsideClick: false
});
// SVGO 优化下载功能 START
async function onClickDownloadSvg() {
const layerList = getSelectedLayerList();
if (layerList.length === 0) {
showError("未选择图层");
return;
}
const fileKey = figma.fileKey;
const dirHandle = await unsafeWindow.showDirectoryPicker({id: `${fileKey}-svg`, mode: "readwrite"});
showExporting();
try {
const finalImageList = await downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList);
const successText = getSuccessText(finalImageList);
showSuccess(successText);
} catch (e) {
console.error(e);
showError(e.toString());
}
}
/**
* 将选中的图层下载为经 svgo 优化过后的 svg 图像,保存到指定地址
* @async
* @param dirHandle {FileSystemDirectoryHandle} 文件操作 Handle
* @param fileKey {string} figma 文件 key
* @param layerList {Image[]} 图层信息,格式为 [{"id": "svg id", "name": "svg name"}]
* @return {Promise<Image[]>}
*/
async function downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList) {
let optimizedImageList;
// 1. 下载源 svg
const imageList = await downloadImageFromFigma(fileKey, layerList, "svg", 1);
if (imageList === undefined || imageList.length === 0) {
throw new Error("从 figma 获取图片失败,请检查网络连接");
}
// 任何一张图层未下载成功,都判定整体失败
if (!imageList.every(image => image.originalContent !== undefined)) {
throw new Error("从 figma 下载图片内容失败,请检查网络连接");
}
// 2. 经 svgo 优化
optimizedImageList = await optimizeSvg(imageList, svgPrecision);
// 3. 保存到指定文件
optimizedImageList.forEach(image => image.finalContent = image.processedContent);
await saveImageWithDifferentDpiToDir(dirHandle, optimizedImageList);
return optimizedImageList;
}
/**
*
* @param imageList {Image[]}
* @param precision {number}
* @returns
*/
async function optimizeSvg(imageList, precision) {
try {
const svgContentList = await Promise.all(imageList.map(image => image.originalContent.text()));
const requestBody = {
precision: precision,
svgContentList: svgContentList
};
const response = await fetch(getSvgOptimizerRequestUrl(), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(requestBody)
});
const responseJson = await response.json();
imageList.forEach((image, index) => {
image.processedContent = new Blob([responseJson[index]], {
type: image.originalContent.type
});
});
return imageList;
} catch (e) {
console.error(e);
throw new e;
}
}
// SVGO 优化下载功能 END
// PNG 下载及转换功能 START
async function onClickDownloadPng(convertToWebp) {
const layerList = getSelectedLayerList();
if (layerList.length === 0) {
showError("未选择图层");
return;
}
const fileKey = figma.fileKey;
let dirHandleId;
if (convertToWebp) {
dirHandleId = `${fileKey}-webp`;
} else {
dirHandleId = `${fileKey}-png`;
}
const dirHandle = await unsafeWindow.showDirectoryPicker({id: dirHandleId, mode: "readwrite"});
showExporting();
const scaleList = await getScaleList(dirHandle);
if (scaleList.length === 0) {
showError("所选目录下需要有指定 dpi 的\"drawable-*dpi\"的文件夹");
return;
}
try {
const finalImageList = await exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList);
let successText;
if (!finalImageList.every(image => image.format === "png")) {
// 表示有导出为 webp 的文件
successText = getSuccessText(finalImageList);
}
showSuccess(successText);
} catch (e) {
console.error(e);
showError(e.toString());
}
}
/**
*
* @param {boolean} convertToWebp 是否需要转换成 webp
* @param {FileSystemDirectoryHandle} dirHandle
* @param {string} fileKey figma 对应的文件 key
* @param {Image[]} layerList 需要导出的图层信息,包括 id 和 name
* @param {number[]} scaleList dpi 对应的缩放倍率
* @returns {Promise<Image[]>}
*/
async function exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList) {
let imageList = await downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList);
if (convertToWebp) {
imageList = await transferPngListToWebp(imageList, webpQuality);
imageList.forEach((image) => {
// 只有在 webp 小于 png 时,才存储为 webp
if (image.processedContent.size > image.originalContent.size) {
image.format = "png";
image.finalContent = image.originalContent;
} else {
image.format = "webp";
image.finalContent = image.processedContent;
}
});
} else {
imageList.forEach((image) => {
image.format = "png";
image.finalContent = image.originalContent;
});
}
await saveImageWithDifferentDpiToDir(dirHandle, imageList);
return imageList;
}
/**
* 通过分析选中目录下的文件夹情况,得出需要下载的 dpi 对应的缩放倍率列表
* @param {FileSystemDirectoryHandle} dirHandle
* @return {Promise<number[]>}
*/
async function getScaleList(dirHandle) {
const scaleList = [];
for await (const file of dirHandle.values()) {
if (file.kind === "directory") {
const scale = dirNameToScaleMap().get(file.name);
if (scale !== undefined) {
scaleList.push(scale);
}
}
}
return scaleList;
}
/**
* 将选中的图层根据给出的 scaleList 下载为 png
* @param {FileSystemDirectoryHandle} dirHandle 文件操作 Handle
* @param {string} fileKey figma 文件 key
* @param {Image[]} layerList 图层信息,格式为 [{"id": "svg id", "name": "svg name"}]
* @param {number[]} scaleList dpi 对应的缩放倍率
* @return {Promise<Image[]>} 从 figma 下载下来的图片内容
*/
async function downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList) {
const imageGroupByScale = await Promise.all(scaleList.map(scale => downloadImageFromFigma(fileKey, layerList, "png", scale)));
/** @type {Image[]} */
const imageList = imageGroupByScale.flat().filter(image => image !== undefined);
if (imageList === undefined || imageList.length === 0) {
throw new Error("从 figma 获取图片失败,请检查网络连接");
}
// 任何一张图层未下载成功,都判定整体失败
if (!imageList.every(image => image.originalContent !== undefined)) {
throw new Error("从 figma 下载图片内容失败,请检查网络连接");
}
return imageList;
}
/**
* 批量转换 png 为 webp
* @param {Image[]} imageList
* @param {number} quality 质量
* @return {Promise<Image[]>} 输出的值比参数 imageList 添加了 processedContent 属性
*
* @throws {Error} 操作失败会抛出异常
*/
async function transferPngListToWebp(imageList, quality) {
try {
const responseList = await Promise.all(
imageList.map(image => {
return fetch(getPngConvertToWebpRequestUrl(), {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"quality": quality
},
body: image.originalContent
});
})
);
for (const image of imageList) {
const index = imageList.indexOf(image);
image.processedContent = await responseList[index].blob();
}
return imageList;
} catch (e) {
console.error(e);
throw new Error("png 转 webp 操作失败,请检查是否开启优化服务器");
}
}
// PNG 下载及转换功能 END
// 公共能力 START
/**
* 获取当前选中的图层,包括 id 和 name
* @return {[Image]}
*/
function getSelectedLayerList() {
return figma.currentPage.selection.map(node => new Image(node.id, node.name.toLowerCase().replace(/[^a-z0-9_]/g, "_")));
}
/**
* 生成的一个随机四位数,并以下划线开头,作为文件的前缀,以防重名时覆盖已有文件
* @return {string}
*/
function getRandomPrefix() {
return "figma" + Math.floor(Math.random() * 9000 + 1000);
}
/**
* 下载选中图层的内容,包括内容指向 url 和具体的文件内容
* @async
* @param {string} figmaFileKey
* @param {string} format 格式 svg, png
* @param {number} scale 缩放大小
* @param {Image[]} layerList 包含有 id 和 name 的图层信息列表
* @returns {Promise<Image[]>} 从 figma 下载下来的图片内容
*/
async function downloadImageFromFigma(figmaFileKey, layerList, format, scale) {
try {
// 此处必须深拷贝
const imageList = layerList.map(layer => new Image(layer.id, layer.name));
const ids = imageList.map(image => image.id);
let url = `https://api.figma.com/v1/images/${figmaFileKey}?ids=${ids.join(",")}&format=${format}&scale=${scale}`;
const res = await fetch(url,
{
headers: {
"X-FIGMA-TOKEN": figmaToken
}
}
);
if (res.status !== 200) return undefined;
const originalImageListJson = await res.json();
imageList.forEach(layer => {
layer.url = originalImageListJson.images[layer.id];
layer.scale = scale;
layer.format = format;
});
// 下载 image 内容
const originalContentList = await Promise.all(imageList.map(image => downloadOriginalImageContent(image.url)));
originalContentList.forEach((originalContent, index) => {
imageList[index].originalContent = originalContent;
});
return imageList;
} catch (e) {
console.error(e);
}
}
/**
* 下载给定的 url 的内容
* @async
* @param url 资源目标 url
* @returns {Promise<Blob>} 下载下来的二进制内容
*/
async function downloadOriginalImageContent(url) {
try {
let res = await fetch(url);
if (res.status === 200) {
// 需要用二进制数据
return await res.blob();
} else {
console.log("错误?" + res.status);
}
} catch (e) {
console.error(e);
}
}
/**
* 保存内容到文件
* @param {FileSystemDirectoryHandle} dirHandle
* @param {Image[]} imageList
*/
async function saveImageWithDifferentDpiToDir(dirHandle, imageList) {
const prefix = getRandomPrefix();
for (const image of imageList) {
/** @type {FileSystemDirectoryHandle} */
let drawableDirHandle;
if (image.format === "svg") {
// svg 图片直接保存到目录下
drawableDirHandle = dirHandle;
} else {
// 其它图片需要保存到对应 dpi 的目录下
const drawableDirName = scaleToDirNameMap().get(image.scale);
drawableDirHandle = await dirHandle.getDirectoryHandle(drawableDirName);
}
const fileHandle = await drawableDirHandle.getFileHandle(`${prefix}_${image.name}.${image.format}`, {create: true});
const writable = await fileHandle.createWritable();
await writable.write(image.finalContent);
await writable.close();
}
}
/**
* 格式化 bytes 数量为可读字符串
* @param {number} bytesSize
* @return {string}
*/
function formatBytes(bytesSize) {
if (bytesSize < 1024) {
return bytesSize + " Bytes";
} else if (bytesSize < 1024 * 1024) {
return (bytesSize / 1024).toFixed(2) + " KB";
} else {
return (bytesSize / (1024 * 1024)).toFixed(2) + " MB";
}
}
/**
* 获取成功提示文字,主要是关于体积缩减大小
* @param {Image[]} finalImageList
* @return {string}
*/
function getSuccessText(finalImageList) {
const originalSize = finalImageList.reduce((accumulator, currentValue) => {
return accumulator + currentValue.originalContent.size;
}, 0);
const finalSize = finalImageList.reduce((accumulator, currentValue) => {
return accumulator + currentValue.finalContent.size;
}, 0);
return `成功缩减体积 ${formatBytes(originalSize - finalSize)}(${((originalSize - finalSize) * 100 / originalSize).toFixed(0)}%)`;
}
function showExporting() {
Toast.fire({
title: "图层导出中...",
didOpen() {
Swal.showLoading();
}
});
}
/**
* @param {string} successText
*/
function showSuccess(successText) {
Toast.fire({
icon: "success",
title: "导出成功",
text: successText
});
}
/**
* @param {string} errorText
*/
function showError(errorText) {
Toast.fire({
icon: "error",
text: errorText,
title: "导出失败,请重试",
});
}
function getSvgOptimizerRequestUrl() {
if (svgOptimizerRequestUrl === "") {
return "https://nifh3bnmc3.hk.aircode.run/svgOptimizer";
} else {
return svgOptimizerRequestUrl;
}
}
function getPngConvertToWebpRequestUrl() {
if (pngConvertToWebpRequestUrl === "") {
return "https://nifh3bnmc3.hk.aircode.run/webpConvetor";
} else {
return pngConvertToWebpRequestUrl;
}
}
// 公共能力 END
})();