Better Figma Layer Exporter

更方便的 Figma 图层导出,主要功能:1. 选定图层直接导出为 png 并按 dpi 分配到对应 dpi 的 drawable 文件夹; 2. 支持将 PNG 转换成 WebP 再导出; 3. 支持导出经 SVGO 优化的 svg 图片。

  1. // ==UserScript==
  2. // @name Better Figma Layer Exporter
  3. // @name:zh-CN Better Figma Layer Exporter
  4. // @namespace https://github.com/XuQK/Better-Figma-Layer-Exporter
  5. // @version 1.1.3
  6. // @license MIT
  7. // @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.
  8. // @description:zh-CN 更方便的 Figma 图层导出,主要功能:1. 选定图层直接导出为 png 并按 dpi 分配到对应 dpi 的 drawable 文件夹; 2. 支持将 PNG 转换成 WebP 再导出; 3. 支持导出经 SVGO 优化的 svg 图片。
  9. // @author XuQK
  10. // @match https://www.figma.com/*
  11. // @icon https://github.com/XuQK/Better-Figma-Layer-Exporter/blob/master/assets/icon.jpeg?raw=true
  12. // @grant unsafeWindow
  13. // @grant GM_setValue
  14. // @grant GM_getValue
  15. // @grant GM_registerMenuCommand
  16. // @require https://cdn.jsdelivr.net/npm/sweetalert2@11
  17. // @connect *
  18. // @run-at document-end
  19. // ==/UserScript==
  20.  
  21. (function () {
  22. "use strict";
  23.  
  24. const coloredToastStyle = document.createElement("style");
  25. coloredToastStyle.innerHTML = `
  26. .colored-toast.swal2-icon-success {
  27. background-color: #a5dc86 !important;
  28. }
  29.  
  30. .colored-toast.swal2-icon-error {
  31. background-color: #f27474 !important;
  32. }
  33.  
  34. .colored-toast.swal2-icon-warning {
  35. background-color: #f8bb86 !important;
  36. }
  37.  
  38. .colored-toast.swal2-icon-info {
  39. background-color: #3fc3ee !important;
  40. }
  41.  
  42. .colored-toast.swal2-icon-question {
  43. background-color: #87adbd !important;
  44. }
  45.  
  46. .colored-toast .swal2-title {
  47. color: white;
  48. }
  49.  
  50. .colored-toast .swal2-close {
  51. color: white;
  52. }
  53.  
  54. .colored-toast .swal2-html-container {
  55. color: white;
  56. }
  57. `;
  58. document.head.appendChild(coloredToastStyle);
  59.  
  60. GM_registerMenuCommand("Settings/设置", showSettingsDialog, "S");
  61.  
  62. function showSettingsDialog() {
  63. Toast.fire({
  64. title: "Settings / 设置",
  65. html: `
  66. <div style="display: flex; align-items: center">
  67. <label for="kd-figma-token" style="font-size: 18px; width: 10em">Figma token</label>
  68. <input id="kd-figma-token" class="swal2-input" style="margin: 8px" value="${figmaToken}">
  69. </div>
  70. <div style="display: flex; align-items: center">
  71. <label for="kd-server-svg-optimizer" style="font-size: 18px; width: 10em">Svg Optimizer Url</label>
  72. <input id="kd-server-svg-optimizer" class="swal2-input" style="margin: 8px" value="${svgOptimizerRequestUrl}">
  73. </div>
  74. <div style="display: flex; align-items: center">
  75. <label for="kd-server-png-convert-to-webp" style="font-size: 18px; width: 10em">Webp Converter url</label>
  76. <input id="kd-server-png-convert-to-webp" class="swal2-input" style="margin: 8px" value="${pngConvertToWebpRequestUrl}">
  77. </div>
  78. <div style="display: flex; align-items: center">
  79. <label for="kd-svg-precision" style="font-size: 18px; width: 10em">Svg precision</label>
  80. <input id="kd-svg-precision" class="swal2-input" style="margin: 8px" value="${svgPrecision}">
  81. </div>
  82. <div style="display: flex; align-items: center">
  83. <label for="kd-webp-quality" style="font-size: 18px; width: 10em">WebP quality</label>
  84. <input id="kd-webp-quality" class="swal2-input" style="margin: 8px" value="${webpQuality}">
  85. </div>
  86. <div style="display: flex; align-items: center; height: 4em">
  87. <label for="kd-mode" style="font-size: 18px; width: 10em">Day/Night Mode</label>
  88. <input id="kd-mode-day" name="kd-mode" value="day" type="radio" style="margin: 8px">Day</input>
  89. <input id="kd-mode-night" name="kd-mode" value="night" type="radio" style="margin: 8px">Night</input>
  90. </div>
  91. <p></p>
  92. <p style="text-align: start; color: #FF5252">PS:</p>
  93. <p style="text-align: start; font-size: 14px; color: #FF5252">1. SVG 优化和 PNG WebP 的需要后台能力,目前是白嫖的 node 服务器,资源有限,请温柔使用~</p>
  94. <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>
  95. </div>
  96. `,
  97. width: 600,
  98. focusConfirm: false,
  99. showCancelButton: true,
  100. didOpen() {
  101. document.getElementById(`kd-mode-${mode}`).checked = true;
  102. },
  103. preConfirm: () => {
  104. return [
  105. document.getElementById("kd-figma-token").value,
  106. document.getElementById("kd-server-svg-optimizer").value,
  107. document.getElementById("kd-server-png-convert-to-webp").value,
  108. document.getElementById("kd-svg-precision").value,
  109. document.getElementById("kd-webp-quality").value,
  110. document.querySelector("input[name='kd-mode']:checked").value
  111. ];
  112. }
  113. }).then(value => {
  114. const params = value.value;
  115. figmaToken = params[0];
  116. svgOptimizerRequestUrl = params[1];
  117. pngConvertToWebpRequestUrl = params[2];
  118. svgPrecision = params[3];
  119. webpQuality = params[4];
  120. mode = params[5];
  121.  
  122. GM_setValue("figmaToken", figmaToken);
  123. GM_setValue("svgOptimizerRequestUrl", svgOptimizerRequestUrl);
  124. GM_setValue("pngConvertToWebpRequestUrl", pngConvertToWebpRequestUrl);
  125. GM_setValue("svgPrecision", svgPrecision);
  126. GM_setValue("webpQuality", webpQuality);
  127. GM_setValue("mode", mode);
  128. });
  129. }
  130.  
  131. // 默认配置
  132. let figmaToken = GM_getValue("figmaToken", "");
  133. let svgOptimizerRequestUrl = GM_getValue("svgOptimizerRequestUrl", "");
  134. let pngConvertToWebpRequestUrl = GM_getValue("pngConvertToWebpRequestUrl", "");
  135. // svg 专用
  136. let svgPrecision = GM_getValue("svgPrecision", 1);
  137. // png 专用
  138. // webp 转换质量,0-100,默认 75
  139. let webpQuality = GM_getValue("webpQuality", 75);
  140. // 是否暗色模式
  141. let mode = GM_getValue("mode", "day");
  142.  
  143. class Image {
  144. /**
  145. * @type {string}
  146. */
  147. url;
  148.  
  149. /**
  150. * @type {Blob} 从 figma 下载的原始图层内容,可能是 svg,也有可能是 png
  151. */
  152. originalContent;
  153.  
  154. /**
  155. * @type {number}
  156. */
  157. scale;
  158.  
  159. /**
  160. * @type {Blob} 经处理后的数据,可能是优化后的 svg,也有可能是经 png 转换过后的 webp
  161. */
  162. processedContent;
  163.  
  164. /**
  165. * @type {string} 最终创建文件的格式/后缀名
  166. */
  167. format;
  168.  
  169. /**
  170. * @type {Blob} 最终存储到文件的数据
  171. */
  172. finalContent;
  173.  
  174. /**
  175. * @param id {string}
  176. * @param name {string}
  177. */
  178. constructor(id, name) {
  179. this.id = id;
  180. this.name = name;
  181. }
  182. }
  183.  
  184. function dirNameToScaleMap() {
  185. if (mode === "day") {
  186. return _dirNameToScaleMapDay;
  187. } else {
  188. return _dirNameToScaleMapNight;
  189. }
  190. }
  191.  
  192. function scaleToDirNameMap() {
  193. if (mode === "day") {
  194. return _scaleToDirNameMapDay;
  195. } else {
  196. return _scaleToDirNameMapNight;
  197. }
  198. }
  199.  
  200. const _dirNameToScaleMapDay = new Map();
  201. _dirNameToScaleMapDay.set("drawable-ldpi", 0.75);
  202. _dirNameToScaleMapDay.set("drawable-mdpi", 1);
  203. _dirNameToScaleMapDay.set("drawable-hdpi", 1.5);
  204. _dirNameToScaleMapDay.set("drawable-xhdpi", 2);
  205. _dirNameToScaleMapDay.set("drawable-xxhdpi", 3);
  206. _dirNameToScaleMapDay.set("drawable-xxxhdpi", 4);
  207.  
  208. const _scaleToDirNameMapDay = new Map();
  209. _scaleToDirNameMapDay.set(0.75, "drawable-ldpi");
  210. _scaleToDirNameMapDay.set(1, "drawable-mdpi");
  211. _scaleToDirNameMapDay.set(1.5, "drawable-hdpi");
  212. _scaleToDirNameMapDay.set(2, "drawable-xhdpi");
  213. _scaleToDirNameMapDay.set(3, "drawable-xxhdpi");
  214. _scaleToDirNameMapDay.set(4, "drawable-xxxhdpi");
  215.  
  216. const _dirNameToScaleMapNight = new Map();
  217. _dirNameToScaleMapNight.set("drawable-night-ldpi", 0.75);
  218. _dirNameToScaleMapNight.set("drawable-night-mdpi", 1);
  219. _dirNameToScaleMapNight.set("drawable-night-hdpi", 1.5);
  220. _dirNameToScaleMapNight.set("drawable-night-xhdpi", 2);
  221. _dirNameToScaleMapNight.set("drawable-night-xxhdpi", 3);
  222. _dirNameToScaleMapNight.set("drawable-night-xxxhdpi", 4);
  223.  
  224. const _scaleToDirNameMapNight = new Map();
  225. _scaleToDirNameMapNight.set(0.75, "drawable-night-ldpi");
  226. _scaleToDirNameMapNight.set(1, "drawable-night-mdpi");
  227. _scaleToDirNameMapNight.set(1.5, "drawable-night-hdpi");
  228. _scaleToDirNameMapNight.set(2, "drawable-night-xhdpi");
  229. _scaleToDirNameMapNight.set(3, "drawable-night-xxhdpi");
  230. _scaleToDirNameMapNight.set(4, "drawable-night-xxxhdpi");
  231.  
  232. const svgButtonId = "svgo-button";
  233. const svgoButton = document.createElement("button");
  234. svgoButton.id = svgButtonId;
  235. 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";
  236. svgoButton.style.marginTop = "16px";
  237. svgoButton.style.width = "90%";
  238. svgoButton.style.marginLeft = "auto";
  239. svgoButton.style.marginRight = "auto";
  240. svgoButton.innerText = "经 SVGO 优化并导出";
  241. svgoButton.addEventListener("click", function () {
  242. onClickDownloadSvg().then();
  243. });
  244.  
  245. const pngButtonId = "png-button";
  246. const pngButton = document.createElement("button");
  247. pngButton.id = pngButtonId;
  248. 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";
  249. pngButton.style.marginTop = "16px";
  250. pngButton.style.width = "90%";
  251. pngButton.style.marginLeft = "auto";
  252. pngButton.style.marginRight = "auto";
  253. pngButton.innerText = "导出 PNG 到指定 res 目录";
  254. pngButton.addEventListener("click", function () {
  255. onClickDownloadPng(false).then();
  256. });
  257.  
  258. const webpButtonId = "webp-button";
  259. const webpButton = document.createElement("button");
  260. webpButton.id = webpButtonId;
  261. 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";
  262. webpButton.style.width = "90%";
  263. webpButton.style.marginTop = "16px";
  264. webpButton.style.marginLeft = "auto";
  265. webpButton.style.marginRight = "auto";
  266. webpButton.innerText = "导出 WebP 到指定 res 目录";
  267. webpButton.addEventListener("click", function () {
  268. onClickDownloadPng(true).then();
  269. });
  270.  
  271. // 监听 body 元素变动,根据情况插入导出按钮(对于无编辑器权限的使用者)
  272. new MutationObserver(() => {
  273. try {
  274. let c = null;
  275. const anchorElemForGuest = document.querySelector("div.raw_components--panel--YDedw.export_panel--standalonePanel--yXYPM");
  276. if (anchorElemForGuest !== null) {
  277. c = anchorElemForGuest.parentElement;
  278. } else {
  279. const nodeList = document.querySelectorAll("div.draggable_list--panelTitleText--Bj2Hu")
  280. const anchorElemForOwner = Array.from(nodeList).find(node => node.innerText === "Export")
  281. if (anchorElemForOwner !== null) {
  282. c = anchorElemForOwner.parentElement.parentElement.parentElement.parentElement.parentElement
  283. }
  284. }
  285. if (c !== null) {
  286. if (document.getElementById(svgButtonId) === null) {
  287. c.appendChild(svgoButton);
  288. }
  289. if (document.getElementById(pngButtonId) === null) {
  290. c.appendChild(pngButton);
  291. }
  292. if (document.getElementById(webpButtonId) === null) {
  293. c.appendChild(webpButton);
  294. }
  295. }
  296. } catch (e) {
  297. }
  298. }).observe(document.body, {childList: true, subtree: true});
  299. // (对于有编辑权限的使用者)
  300.  
  301. const Toast = Swal.mixin({
  302. position: "center",
  303. allowOutsideClick: false
  304. });
  305.  
  306. // SVGO 优化下载功能 START
  307. async function onClickDownloadSvg() {
  308. const layerList = getSelectedLayerList();
  309. if (layerList.length === 0) {
  310. showError("未选择图层");
  311. return;
  312. }
  313. const fileKey = figma.fileKey;
  314. const dirHandle = await unsafeWindow.showDirectoryPicker({id: `${fileKey}-svg`, mode: "readwrite"});
  315. showExporting();
  316. try {
  317. const finalImageList = await downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList);
  318. const successText = getSuccessText(finalImageList);
  319. showSuccess(successText);
  320. } catch (e) {
  321. console.error(e);
  322. showError(e.toString());
  323. }
  324. }
  325.  
  326. /**
  327. * 将选中的图层下载为经 svgo 优化过后的 svg 图像,保存到指定地址
  328. * @async
  329. * @param dirHandle {FileSystemDirectoryHandle} 文件操作 Handle
  330. * @param fileKey {string} figma 文件 key
  331. * @param layerList {Image[]} 图层信息,格式为 [{"id": "svg id", "name": "svg name"}]
  332. * @return {Promise<Image[]>}
  333. */
  334. async function downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList) {
  335. let optimizedImageList;
  336. // 1. 下载源 svg
  337. const imageList = await downloadImageFromFigma(fileKey, layerList, "svg", 1);
  338. if (imageList === undefined || imageList.length === 0) {
  339. throw new Error("从 figma 获取图片失败,请检查网络连接");
  340. }
  341. // 任何一张图层未下载成功,都判定整体失败
  342. if (!imageList.every(image => image.originalContent !== undefined)) {
  343. throw new Error("从 figma 下载图片内容失败,请检查网络连接");
  344. }
  345. // 2. 经 svgo 优化
  346. optimizedImageList = await optimizeSvg(imageList, svgPrecision);
  347. // 3. 保存到指定文件
  348. optimizedImageList.forEach(image => image.finalContent = image.processedContent);
  349. await saveImageWithDifferentDpiToDir(dirHandle, optimizedImageList);
  350. return optimizedImageList;
  351. }
  352.  
  353. /**
  354. *
  355. * @param imageList {Image[]}
  356. * @param precision {number}
  357. * @returns
  358. */
  359. async function optimizeSvg(imageList, precision) {
  360. try {
  361. const svgContentList = await Promise.all(imageList.map(image => image.originalContent.text()));
  362. const requestBody = {
  363. precision: precision,
  364. svgContentList: svgContentList
  365. };
  366. const response = await fetch(getSvgOptimizerRequestUrl(), {
  367. method: "POST",
  368. headers: {"Content-Type": "application/json"},
  369. body: JSON.stringify(requestBody)
  370. });
  371. const responseJson = await response.json();
  372. imageList.forEach((image, index) => {
  373. image.processedContent = new Blob([responseJson[index]], {
  374. type: image.originalContent.type
  375. });
  376. });
  377.  
  378. return imageList;
  379. } catch (e) {
  380. console.error(e);
  381. throw new e;
  382. }
  383. }
  384.  
  385. // SVGO 优化下载功能 END
  386.  
  387. // PNG 下载及转换功能 START
  388. async function onClickDownloadPng(convertToWebp) {
  389. const layerList = getSelectedLayerList();
  390. if (layerList.length === 0) {
  391. showError("未选择图层");
  392. return;
  393. }
  394. const fileKey = figma.fileKey;
  395. let dirHandleId;
  396. if (convertToWebp) {
  397. dirHandleId = `${fileKey}-webp`;
  398. } else {
  399. dirHandleId = `${fileKey}-png`;
  400. }
  401. const dirHandle = await unsafeWindow.showDirectoryPicker({id: dirHandleId, mode: "readwrite"});
  402. showExporting();
  403. const scaleList = await getScaleList(dirHandle);
  404. if (scaleList.length === 0) {
  405. showError("所选目录下需要有指定 dpi 的\"drawable-*dpi\"的文件夹");
  406. return;
  407. }
  408. try {
  409. const finalImageList = await exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList);
  410. let successText;
  411. if (!finalImageList.every(image => image.format === "png")) {
  412. // 表示有导出为 webp 的文件
  413. successText = getSuccessText(finalImageList);
  414. }
  415. showSuccess(successText);
  416. } catch (e) {
  417. console.error(e);
  418. showError(e.toString());
  419. }
  420. }
  421.  
  422. /**
  423. *
  424. * @param {boolean} convertToWebp 是否需要转换成 webp
  425. * @param {FileSystemDirectoryHandle} dirHandle
  426. * @param {string} fileKey figma 对应的文件 key
  427. * @param {Image[]} layerList 需要导出的图层信息,包括 id 和 name
  428. * @param {number[]} scaleList dpi 对应的缩放倍率
  429. * @returns {Promise<Image[]>}
  430. */
  431. async function exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList) {
  432. let imageList = await downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList);
  433. if (convertToWebp) {
  434. imageList = await transferPngListToWebp(imageList, webpQuality);
  435. imageList.forEach((image) => {
  436. // 只有在 webp 小于 png 时,才存储为 webp
  437. if (image.processedContent.size > image.originalContent.size) {
  438. image.format = "png";
  439. image.finalContent = image.originalContent;
  440. } else {
  441. image.format = "webp";
  442. image.finalContent = image.processedContent;
  443. }
  444. });
  445. } else {
  446. imageList.forEach((image) => {
  447. image.format = "png";
  448. image.finalContent = image.originalContent;
  449. });
  450. }
  451. await saveImageWithDifferentDpiToDir(dirHandle, imageList);
  452. return imageList;
  453. }
  454.  
  455. /**
  456. * 通过分析选中目录下的文件夹情况,得出需要下载的 dpi 对应的缩放倍率列表
  457. * @param {FileSystemDirectoryHandle} dirHandle
  458. * @return {Promise<number[]>}
  459. */
  460. async function getScaleList(dirHandle) {
  461. const scaleList = [];
  462. for await (const file of dirHandle.values()) {
  463. if (file.kind === "directory") {
  464. const scale = dirNameToScaleMap().get(file.name);
  465. if (scale !== undefined) {
  466. scaleList.push(scale);
  467. }
  468. }
  469. }
  470. return scaleList;
  471. }
  472.  
  473. /**
  474. * 将选中的图层根据给出的 scaleList 下载为 png
  475. * @param {FileSystemDirectoryHandle} dirHandle 文件操作 Handle
  476. * @param {string} fileKey figma 文件 key
  477. * @param {Image[]} layerList 图层信息,格式为 [{"id": "svg id", "name": "svg name"}]
  478. * @param {number[]} scaleList dpi 对应的缩放倍率
  479. * @return {Promise<Image[]>} 从 figma 下载下来的图片内容
  480. */
  481. async function downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList) {
  482. const imageGroupByScale = await Promise.all(scaleList.map(scale => downloadImageFromFigma(fileKey, layerList, "png", scale)));
  483. /** @type {Image[]} */
  484. const imageList = imageGroupByScale.flat().filter(image => image !== undefined);
  485. if (imageList === undefined || imageList.length === 0) {
  486. throw new Error("从 figma 获取图片失败,请检查网络连接");
  487. }
  488. // 任何一张图层未下载成功,都判定整体失败
  489. if (!imageList.every(image => image.originalContent !== undefined)) {
  490. throw new Error("从 figma 下载图片内容失败,请检查网络连接");
  491. }
  492. return imageList;
  493. }
  494.  
  495. /**
  496. * 批量转换 png 为 webp
  497. * @param {Image[]} imageList
  498. * @param {number} quality 质量
  499. * @return {Promise<Image[]>} 输出的值比参数 imageList 添加了 processedContent 属性
  500. *
  501. * @throws {Error} 操作失败会抛出异常
  502. */
  503. async function transferPngListToWebp(imageList, quality) {
  504. try {
  505. const responseList = await Promise.all(
  506. imageList.map(image => {
  507. return fetch(getPngConvertToWebpRequestUrl(), {
  508. method: "POST",
  509. headers: {
  510. "Content-Type": "application/octet-stream",
  511. "quality": quality
  512. },
  513. body: image.originalContent
  514. });
  515. })
  516. );
  517.  
  518. for (const image of imageList) {
  519. const index = imageList.indexOf(image);
  520. image.processedContent = await responseList[index].blob();
  521. }
  522. return imageList;
  523. } catch (e) {
  524. console.error(e);
  525. throw new Error("png 转 webp 操作失败,请检查是否开启优化服务器");
  526. }
  527. }
  528.  
  529. // PNG 下载及转换功能 END
  530.  
  531. // 公共能力 START
  532. /**
  533. * 获取当前选中的图层,包括 id 和 name
  534. * @return {[Image]}
  535. */
  536. function getSelectedLayerList() {
  537. return figma.currentPage.selection.map(node => new Image(node.id, node.name.toLowerCase().replace(/[^a-z0-9_]/g, "_")));
  538. }
  539.  
  540. /**
  541. * 生成的一个随机四位数,并以下划线开头,作为文件的前缀,以防重名时覆盖已有文件
  542. * @return {string}
  543. */
  544. function getRandomPrefix() {
  545. return "figma" + Math.floor(Math.random() * 9000 + 1000);
  546. }
  547.  
  548. /**
  549. * 下载选中图层的内容,包括内容指向 url 和具体的文件内容
  550. * @async
  551. * @param {string} figmaFileKey
  552. * @param {string} format 格式 svg, png
  553. * @param {number} scale 缩放大小
  554. * @param {Image[]} layerList 包含有 id 和 name 的图层信息列表
  555. * @returns {Promise<Image[]>} 从 figma 下载下来的图片内容
  556. */
  557. async function downloadImageFromFigma(figmaFileKey, layerList, format, scale) {
  558. try {
  559. // 此处必须深拷贝
  560. const imageList = layerList.map(layer => new Image(layer.id, layer.name));
  561. const ids = imageList.map(image => image.id);
  562. let url = `https://api.figma.com/v1/images/${figmaFileKey}?ids=${ids.join(",")}&format=${format}&scale=${scale}`;
  563. const res = await fetch(url,
  564. {
  565. headers: {
  566. "X-FIGMA-TOKEN": figmaToken
  567. }
  568. }
  569. );
  570. if (res.status !== 200) return undefined;
  571. const originalImageListJson = await res.json();
  572. imageList.forEach(layer => {
  573. layer.url = originalImageListJson.images[layer.id];
  574. layer.scale = scale;
  575. layer.format = format;
  576. });
  577. // 下载 image 内容
  578. const originalContentList = await Promise.all(imageList.map(image => downloadOriginalImageContent(image.url)));
  579. originalContentList.forEach((originalContent, index) => {
  580. imageList[index].originalContent = originalContent;
  581. });
  582. return imageList;
  583. } catch (e) {
  584. console.error(e);
  585. }
  586. }
  587.  
  588. /**
  589. * 下载给定的 url 的内容
  590. * @async
  591. * @param url 资源目标 url
  592. * @returns {Promise<Blob>} 下载下来的二进制内容
  593. */
  594. async function downloadOriginalImageContent(url) {
  595. try {
  596. let res = await fetch(url);
  597. if (res.status === 200) {
  598. // 需要用二进制数据
  599. return await res.blob();
  600. } else {
  601. console.log("错误?" + res.status);
  602. }
  603. } catch (e) {
  604. console.error(e);
  605. }
  606. }
  607.  
  608. /**
  609. * 保存内容到文件
  610. * @param {FileSystemDirectoryHandle} dirHandle
  611. * @param {Image[]} imageList
  612. */
  613. async function saveImageWithDifferentDpiToDir(dirHandle, imageList) {
  614. const prefix = getRandomPrefix();
  615. for (const image of imageList) {
  616. /** @type {FileSystemDirectoryHandle} */
  617. let drawableDirHandle;
  618. if (image.format === "svg") {
  619. // svg 图片直接保存到目录下
  620. drawableDirHandle = dirHandle;
  621. } else {
  622. // 其它图片需要保存到对应 dpi 的目录下
  623. const drawableDirName = scaleToDirNameMap().get(image.scale);
  624. drawableDirHandle = await dirHandle.getDirectoryHandle(drawableDirName);
  625. }
  626. const fileHandle = await drawableDirHandle.getFileHandle(`${prefix}_${image.name}.${image.format}`, {create: true});
  627. const writable = await fileHandle.createWritable();
  628. await writable.write(image.finalContent);
  629. await writable.close();
  630. }
  631. }
  632.  
  633. /**
  634. * 格式化 bytes 数量为可读字符串
  635. * @param {number} bytesSize
  636. * @return {string}
  637. */
  638. function formatBytes(bytesSize) {
  639. if (bytesSize < 1024) {
  640. return bytesSize + " Bytes";
  641. } else if (bytesSize < 1024 * 1024) {
  642. return (bytesSize / 1024).toFixed(2) + " KB";
  643. } else {
  644. return (bytesSize / (1024 * 1024)).toFixed(2) + " MB";
  645. }
  646. }
  647.  
  648. /**
  649. * 获取成功提示文字,主要是关于体积缩减大小
  650. * @param {Image[]} finalImageList
  651. * @return {string}
  652. */
  653. function getSuccessText(finalImageList) {
  654. const originalSize = finalImageList.reduce((accumulator, currentValue) => {
  655. return accumulator + currentValue.originalContent.size;
  656. }, 0);
  657. const finalSize = finalImageList.reduce((accumulator, currentValue) => {
  658. return accumulator + currentValue.finalContent.size;
  659. }, 0);
  660. return `成功缩减体积 ${formatBytes(originalSize - finalSize)}(${((originalSize - finalSize) * 100 / originalSize).toFixed(0)}%)`;
  661. }
  662.  
  663. function showExporting() {
  664. Toast.fire({
  665. title: "图层导出中...",
  666. didOpen() {
  667. Swal.showLoading();
  668. }
  669. });
  670. }
  671.  
  672. /**
  673. * @param {string} successText
  674. */
  675. function showSuccess(successText) {
  676. Toast.fire({
  677. icon: "success",
  678. title: "导出成功",
  679. text: successText
  680. });
  681. }
  682.  
  683. /**
  684. * @param {string} errorText
  685. */
  686. function showError(errorText) {
  687. Toast.fire({
  688. icon: "error",
  689. text: errorText,
  690. title: "导出失败,请重试",
  691. });
  692. }
  693.  
  694. function getSvgOptimizerRequestUrl() {
  695. if (svgOptimizerRequestUrl === "") {
  696. return "https://nifh3bnmc3.hk.aircode.run/svgOptimizer";
  697. } else {
  698. return svgOptimizerRequestUrl;
  699. }
  700. }
  701.  
  702. function getPngConvertToWebpRequestUrl() {
  703. if (pngConvertToWebpRequestUrl === "") {
  704. return "https://nifh3bnmc3.hk.aircode.run/webpConvetor";
  705. } else {
  706. return pngConvertToWebpRequestUrl;
  707. }
  708. }
  709.  
  710. // 公共能力 END
  711.  
  712. })();