Better Figma Layer Exporter

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

当前为 2023-04-13 提交的版本,查看 最新版本

  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.1
  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. const container = document.querySelector("div.raw_components--panel--YDedw.export_panel--standalonePanel--yXYPM");
  274. if (container !== null) {
  275. if (document.getElementById(svgButtonId) === null) {
  276. container.parentElement.appendChild(svgoButton);
  277. }
  278. if (document.getElementById(pngButtonId) === null) {
  279. container.parentElement.appendChild(pngButton);
  280. }
  281. if (document.getElementById(webpButtonId) === null) {
  282. container.parentElement.appendChild(webpButton);
  283. }
  284. }
  285. }).observe(document.body, {childList: true, subtree: true});
  286.  
  287. const Toast = Swal.mixin({
  288. position: "center",
  289. allowOutsideClick: false
  290. });
  291.  
  292. // SVGO 优化下载功能 START
  293. async function onClickDownloadSvg() {
  294. const layerList = getSelectedLayerList();
  295. if (layerList.length === 0) {
  296. showError("未选择图层");
  297. return;
  298. }
  299. const fileKey = figma.fileKey;
  300. const dirHandle = await unsafeWindow.showDirectoryPicker({id: `${fileKey}-svg`, mode: "readwrite"});
  301. showExporting();
  302. try {
  303. const finalImageList = await downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList);
  304. const successText = getSuccessText(finalImageList);
  305. showSuccess(successText);
  306. } catch (e) {
  307. console.error(e);
  308. showError(e.toString());
  309. }
  310. }
  311.  
  312. /**
  313. * 将选中的图层下载为经 svgo 优化过后的 svg 图像,保存到指定地址
  314. * @async
  315. * @param dirHandle {FileSystemDirectoryHandle} 文件操作 Handle
  316. * @param fileKey {string} figma 文件 key
  317. * @param layerList {Image[]} 图层信息,格式为 [{"id": "svg id", "name": "svg name"}]
  318. * @return {Promise<Image[]>}
  319. */
  320. async function downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList) {
  321. let optimizedImageList;
  322. // 1. 下载源 svg
  323. const imageList = await downloadImageFromFigma(fileKey, layerList, "svg", 1);
  324. if (imageList === undefined || imageList.length === 0) {
  325. throw new Error("从 figma 获取图片失败,请检查网络连接");
  326. }
  327. // 任何一张图层未下载成功,都判定整体失败
  328. if (!imageList.every(image => image.originalContent !== undefined)) {
  329. throw new Error("从 figma 下载图片内容失败,请检查网络连接");
  330. }
  331. // 2. 经 svgo 优化
  332. optimizedImageList = await optimizeSvg(imageList, svgPrecision);
  333. // 3. 保存到指定文件
  334. optimizedImageList.forEach(image => image.finalContent = image.processedContent);
  335. await saveImageWithDifferentDpiToDir(dirHandle, optimizedImageList);
  336. return optimizedImageList;
  337. }
  338.  
  339. /**
  340. *
  341. * @param imageList {Image[]}
  342. * @param precision {number}
  343. * @returns
  344. */
  345. async function optimizeSvg(imageList, precision) {
  346. try {
  347. const svgContentList = await Promise.all(imageList.map(image => image.originalContent.text()));
  348. const requestBody = {
  349. precision: precision,
  350. svgContentList: svgContentList
  351. };
  352. const response = await fetch(getSvgOptimizerRequestUrl(), {
  353. method: "POST",
  354. headers: {"Content-Type": "application/json"},
  355. body: JSON.stringify(requestBody)
  356. });
  357. const responseJson = await response.json();
  358. imageList.forEach((image, index) => {
  359. image.processedContent = new Blob([responseJson[index]], {
  360. type: image.originalContent.type
  361. });
  362. });
  363.  
  364. return imageList;
  365. } catch (e) {
  366. console.error(e);
  367. throw new e;
  368. }
  369. }
  370.  
  371. // SVGO 优化下载功能 END
  372.  
  373. // PNG 下载及转换功能 START
  374. async function onClickDownloadPng(convertToWebp) {
  375. const layerList = getSelectedLayerList();
  376. if (layerList.length === 0) {
  377. showError("未选择图层");
  378. return;
  379. }
  380. const fileKey = figma.fileKey;
  381. let dirHandleId;
  382. if (convertToWebp) {
  383. dirHandleId = `${fileKey}-webp`;
  384. } else {
  385. dirHandleId = `${fileKey}-png`;
  386. }
  387. const dirHandle = await unsafeWindow.showDirectoryPicker({id: dirHandleId, mode: "readwrite"});
  388. showExporting();
  389. const scaleList = await getScaleList(dirHandle);
  390. if (scaleList.length === 0) {
  391. showError("所选目录下需要有指定 dpi 的\"drawable-*dpi\"的文件夹");
  392. return;
  393. }
  394. try {
  395. const finalImageList = await exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList);
  396. let successText;
  397. if (!finalImageList.every(image => image.format === "png")) {
  398. // 表示有导出为 webp 的文件
  399. successText = getSuccessText(finalImageList);
  400. }
  401. showSuccess(successText);
  402. } catch (e) {
  403. console.error(e);
  404. showError(e.toString());
  405. }
  406. }
  407.  
  408. /**
  409. *
  410. * @param {boolean} convertToWebp 是否需要转换成 webp
  411. * @param {FileSystemDirectoryHandle} dirHandle
  412. * @param {string} fileKey figma 对应的文件 key
  413. * @param {Image[]} layerList 需要导出的图层信息,包括 id 和 name
  414. * @param {number[]} scaleList dpi 对应的缩放倍率
  415. * @returns {Promise<Image[]>}
  416. */
  417. async function exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList) {
  418. let imageList = await downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList);
  419. if (convertToWebp) {
  420. imageList = await transferPngListToWebp(imageList, webpQuality);
  421. imageList.forEach((image) => {
  422. // 只有在 webp 小于 png 时,才存储为 webp
  423. if (image.processedContent.size > image.originalContent.size) {
  424. image.format = "png";
  425. image.finalContent = image.originalContent;
  426. } else {
  427. image.format = "webp";
  428. image.finalContent = image.processedContent;
  429. }
  430. });
  431. } else {
  432. imageList.forEach((image) => {
  433. image.format = "png";
  434. image.finalContent = image.originalContent;
  435. });
  436. }
  437. await saveImageWithDifferentDpiToDir(dirHandle, imageList);
  438. return imageList;
  439. }
  440.  
  441. /**
  442. * 通过分析选中目录下的文件夹情况,得出需要下载的 dpi 对应的缩放倍率列表
  443. * @param {FileSystemDirectoryHandle} dirHandle
  444. * @return {Promise<number[]>}
  445. */
  446. async function getScaleList(dirHandle) {
  447. const scaleList = [];
  448. for await (const file of dirHandle.values()) {
  449. if (file.kind === "directory") {
  450. const scale = dirNameToScaleMap().get(file.name);
  451. if (scale !== undefined) {
  452. scaleList.push(scale);
  453. }
  454. }
  455. }
  456. return scaleList;
  457. }
  458.  
  459. /**
  460. * 将选中的图层根据给出的 scaleList 下载为 png
  461. * @param {FileSystemDirectoryHandle} dirHandle 文件操作 Handle
  462. * @param {string} fileKey figma 文件 key
  463. * @param {Image[]} layerList 图层信息,格式为 [{"id": "svg id", "name": "svg name"}]
  464. * @param {number[]} scaleList dpi 对应的缩放倍率
  465. * @return {Promise<Image[]>} 从 figma 下载下来的图片内容
  466. */
  467. async function downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList) {
  468. const imageGroupByScale = await Promise.all(scaleList.map(scale => downloadImageFromFigma(fileKey, layerList, "png", scale)));
  469. /** @type {Image[]} */
  470. const imageList = imageGroupByScale.flat().filter(image => image !== undefined);
  471. if (imageList === undefined || imageList.length === 0) {
  472. throw new Error("从 figma 获取图片失败,请检查网络连接");
  473. }
  474. // 任何一张图层未下载成功,都判定整体失败
  475. if (!imageList.every(image => image.originalContent !== undefined)) {
  476. throw new Error("从 figma 下载图片内容失败,请检查网络连接");
  477. }
  478. return imageList;
  479. }
  480.  
  481. /**
  482. * 批量转换 png 为 webp
  483. * @param {Image[]} imageList
  484. * @param {number} quality 质量
  485. * @return {Promise<Image[]>} 输出的值比参数 imageList 添加了 processedContent 属性
  486. *
  487. * @throws {Error} 操作失败会抛出异常
  488. */
  489. async function transferPngListToWebp(imageList, quality) {
  490. try {
  491. const responseList = await Promise.all(
  492. imageList.map(image => {
  493. return fetch(getPngConvertToWebpRequestUrl(), {
  494. method: "POST",
  495. headers: {
  496. "Content-Type": "application/octet-stream",
  497. "quality": quality
  498. },
  499. body: image.originalContent
  500. });
  501. })
  502. );
  503.  
  504. for (const image of imageList) {
  505. const index = imageList.indexOf(image);
  506. image.processedContent = await responseList[index].blob();
  507. }
  508. return imageList;
  509. } catch (e) {
  510. console.error(e);
  511. throw new Error("png 转 webp 操作失败,请检查是否开启优化服务器");
  512. }
  513. }
  514.  
  515. // PNG 下载及转换功能 END
  516.  
  517. // 公共能力 START
  518. /**
  519. * 获取当前选中的图层,包括 id 和 name
  520. * @return {[Image]}
  521. */
  522. function getSelectedLayerList() {
  523. return figma.currentPage.selection.map(node => new Image(node.id, node.name.toLowerCase().replace(/[^a-z0-9_]/g, "")));
  524. }
  525.  
  526. /**
  527. * 生成的一个随机四位数,并以下划线开头,作为文件的前缀,以防重名时覆盖已有文件
  528. * @return {string}
  529. */
  530. function getRandomPrefix() {
  531. return "_" + Math.floor(Math.random() * 9000 + 1000);
  532. }
  533.  
  534. /**
  535. * 下载选中图层的内容,包括内容指向 url 和具体的文件内容
  536. * @async
  537. * @param {string} figmaFileKey
  538. * @param {string} format 格式 svg, png
  539. * @param {number} scale 缩放大小
  540. * @param {Image[]} layerList 包含有 id 和 name 的图层信息列表
  541. * @returns {Promise<Image[]>} 从 figma 下载下来的图片内容
  542. */
  543. async function downloadImageFromFigma(figmaFileKey, layerList, format, scale) {
  544. try {
  545. // 此处必须深拷贝
  546. const imageList = layerList.map(layer => new Image(layer.id, layer.name));
  547. const ids = imageList.map(image => image.id);
  548. let url = `https://api.figma.com/v1/images/${figmaFileKey}?ids=${ids.join(",")}&format=${format}&scale=${scale}`;
  549. const res = await fetch(url,
  550. {
  551. headers: {
  552. "X-FIGMA-TOKEN": figmaToken
  553. }
  554. }
  555. );
  556. if (res.status !== 200) return undefined;
  557. const originalImageListJson = await res.json();
  558. imageList.forEach(layer => {
  559. layer.url = originalImageListJson.images[layer.id];
  560. layer.scale = scale;
  561. layer.format = format;
  562. });
  563. // 下载 image 内容
  564. const originalContentList = await Promise.all(imageList.map(image => downloadOriginalImageContent(image.url)));
  565. originalContentList.forEach((originalContent, index) => {
  566. imageList[index].originalContent = originalContent;
  567. });
  568. return imageList;
  569. } catch (e) {
  570. console.error(e);
  571. }
  572. }
  573.  
  574. /**
  575. * 下载给定的 url 的内容
  576. * @async
  577. * @param url 资源目标 url
  578. * @returns {Promise<Blob>} 下载下来的二进制内容
  579. */
  580. async function downloadOriginalImageContent(url) {
  581. try {
  582. let res = await fetch(url);
  583. if (res.status === 200) {
  584. // 需要用二进制数据
  585. return await res.blob();
  586. } else {
  587. console.log("错误?" + res.status);
  588. }
  589. } catch (e) {
  590. console.error(e);
  591. }
  592. }
  593.  
  594. /**
  595. * 保存内容到文件
  596. * @param {FileSystemDirectoryHandle} dirHandle
  597. * @param {Image[]} imageList
  598. */
  599. async function saveImageWithDifferentDpiToDir(dirHandle, imageList) {
  600. const prefix = getRandomPrefix();
  601. for (const image of imageList) {
  602. /** @type {FileSystemDirectoryHandle} */
  603. let drawableDirHandle;
  604. if (image.format === "svg") {
  605. // svg 图片直接保存到目录下
  606. drawableDirHandle = dirHandle;
  607. } else {
  608. // 其它图片需要保存到对应 dpi 的目录下
  609. const drawableDirName = scaleToDirNameMap().get(image.scale);
  610. drawableDirHandle = await dirHandle.getDirectoryHandle(drawableDirName);
  611. }
  612. const fileHandle = await drawableDirHandle.getFileHandle(`${prefix}_${image.name}.${image.format}`, {create: true});
  613. const writable = await fileHandle.createWritable();
  614. await writable.write(image.finalContent);
  615. await writable.close();
  616. }
  617. }
  618.  
  619. /**
  620. * 格式化 bytes 数量为可读字符串
  621. * @param {number} bytesSize
  622. * @return {string}
  623. */
  624. function formatBytes(bytesSize) {
  625. if (bytesSize < 1024) {
  626. return bytesSize + " Bytes";
  627. } else if (bytesSize < 1024 * 1024) {
  628. return (bytesSize / 1024).toFixed(2) + " KB";
  629. } else {
  630. return (bytesSize / (1024 * 1024)).toFixed(2) + " MB";
  631. }
  632. }
  633.  
  634. /**
  635. * 获取成功提示文字,主要是关于体积缩减大小
  636. * @param {Image[]} finalImageList
  637. * @return {string}
  638. */
  639. function getSuccessText(finalImageList) {
  640. const originalSize = finalImageList.reduce((accumulator, currentValue) => {
  641. return accumulator + currentValue.originalContent.size;
  642. }, 0);
  643. const finalSize = finalImageList.reduce((accumulator, currentValue) => {
  644. return accumulator + currentValue.finalContent.size;
  645. }, 0);
  646. return `成功缩减体积 ${formatBytes(originalSize - finalSize)}(${((originalSize - finalSize) * 100 / originalSize).toFixed(0)}%)`;
  647. }
  648.  
  649. function showExporting() {
  650. Toast.fire({
  651. title: "图层导出中...",
  652. didOpen() {
  653. Swal.showLoading();
  654. }
  655. });
  656. }
  657.  
  658. /**
  659. * @param {string} successText
  660. */
  661. function showSuccess(successText) {
  662. Toast.fire({
  663. icon: "success",
  664. title: "导出成功",
  665. text: successText
  666. });
  667. }
  668.  
  669. /**
  670. * @param {string} errorText
  671. */
  672. function showError(errorText) {
  673. Toast.fire({
  674. icon: "error",
  675. text: errorText,
  676. title: "导出失败,请重试",
  677. });
  678. }
  679.  
  680. function getSvgOptimizerRequestUrl() {
  681. if (svgOptimizerRequestUrl === "") {
  682. return "https://nifh3bnmc3.hk.aircode.run/svgOptimizer";
  683. } else {
  684. return svgOptimizerRequestUrl;
  685. }
  686. }
  687.  
  688. function getPngConvertToWebpRequestUrl() {
  689. if (pngConvertToWebpRequestUrl === "") {
  690. return "https://nifh3bnmc3.hk.aircode.run/webpConvetor";
  691. } else {
  692. return pngConvertToWebpRequestUrl;
  693. }
  694. }
  695.  
  696. // 公共能力 END
  697.  
  698. })();