├── .editorconfig ├── .gitattributes ├── .gitignore ├── .plugma └── versions.json ├── LICENSE ├── NOTES.md ├── README.md ├── TODO.md ├── dist └── index.js ├── package-lock.json ├── package.json ├── package └── index.ts ├── postcss.config.js ├── public ├── components.html ├── manifest.json └── version-log.html ├── rollup.config.js ├── src ├── PluginUI.svelte ├── Toggle.svelte ├── code.ts ├── figma.d.ts ├── helpers.ts ├── logo.svg ├── main.js ├── pluginGeneration.ts ├── props.ts ├── setCharacters.ts ├── str.ts ├── syntax-theme.css ├── template.html └── widgetGeneration.ts ├── stylup.config.js ├── stylup.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = tab 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | insert_final_newline = false 14 | 15 | [*.{json,md,yml}] 16 | indent_style = space -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /public/build/ 3 | public/index.html 4 | src/build 5 | .DS_Store 6 | public/code.js 7 | -------------------------------------------------------------------------------- /.plugma/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": [ 3 | "First release!" 4 | ] 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Thomas Lowry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | Below is the order of things that need to be checked for 4 | 5 | 1. Create nodes 6 | 1. Walk node 7 | 1. If instance -> Check doesn't already exist -> Push component to array 8 | 2. Push font to array 9 | 3. Create nodes 10 | 1. If component check it doesn't match one in component array 11 | 1. Do not create nested instances 12 | 13 | 3. Create components 14 | 1. Walk nodes 15 | 1. Create nodes 16 | 17 | 2. Do not create nested instances 18 | 3. Do not push component to array 19 | 3. Do not push font to array 20 | 21 | 2. Create fonts 22 | 23 | 24 | Order in which nodes are created 25 | 26 | 1. Create components in reverse 27 | 2. Create all other nodes 28 | 3. If 29 | 30 | 31 | ## Functions 32 | 33 | ### `walkNodes()` 34 | 35 | A function which loops through each node, with special properties. 36 | 37 | ### `createProps()` 38 | 39 | ```js 40 | `${createProps()}` 41 | ``` 42 | 43 | ### `appendNode()` 44 | 45 | ```js 46 | `${Ref(node.parent)}.appendChild(${Ref(node)})\n` 47 | ``` 48 | 49 | ### `createNode()` 50 | 51 | ```js 52 | `// Create ${node.type} 53 | var ${Ref(node.parent)} = figma.create${v.titleCase(node.type)} 54 | ${createProps}` 55 | ``` 56 | 57 | ### `createInstance()` 58 | 59 | ```js 60 | function createInstance(node) { 61 | var components = []; 62 | 63 | walkNodes([node], { 64 | during(node) { 65 | components.push(node.mainComponent) 66 | }, 67 | after(node) { 68 | `// Create INSTANCE 69 | var ${Ref(node)} = ${Ref(node.mainComponent)}.createInstance()\n` 70 | } 71 | }) 72 | } 73 | ``` 74 | 75 | ### `createGroup()` 76 | 77 | ```js 78 | `// Create GROUP 79 | figma.group([${Ref(node.children)}], ${Ref(node.parent)})\n` 80 | ``` 81 | 82 | ### `createComponentSet()` 83 | 84 | ```js 85 | `// Create COMPONENT_SET 86 | var ${Ref(node)} = figma.combineAsVariants([${Ref(node.children).join(", ")}], ${Ref(node.parent)}) 87 | ${createProps}` 88 | ``` 89 | 90 | 91 | ```js 92 | var fonts, components; 93 | 94 | 95 | 96 | walkNodes(nodes, { 97 | during(node) { 98 | // If instance push component to array 99 | createNode() 100 | createInstance() 101 | appendNode() 102 | }, 103 | after(node) { 104 | createGroup() 105 | createComponent(() => { 106 | walkNodes(node.children, { 107 | during(node) { 108 | createProps() 109 | } 110 | }) 111 | } 112 | ) 113 | } 114 | } 115 | ) 116 | 117 | createFonts(fonts) 118 | 119 | ``` 120 | 121 | 122 | ## Questions 123 | 124 | - Should children of COMPONENT_SETs be appended? 125 | - Should Auto Layout be applied after all children are created? 126 | - Should appending happen after or before props are applied? 127 | 128 | ## Future refactoring 129 | 130 | - Consider modifying so that it creates a new object/array which the plugin then creates a string from. This would be better because it would avoid the need to use eval when decoding. 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node Decoder 2 | 3 | Node Decoder is a Figma plugin that generates plugin or widget source code from any Figma design as Javascript and JSX. This is useful for avoiding the need to code visual assets manually when developing for Figma. 4 | 5 | https://www.figma.com/community/plugin/933372797518031971/Node-Decoder 6 | 7 | ## JavaScript API (Alpha) 8 | 9 | To use the Node Decoder plugin in your own plugin, you can use the JavaScript API. It has been adapted so you can use it as a library inside your plugin. 10 | 11 | **Please note**: While you can convert JSON to Figma using the functions below, the JSON needs to reference all of a node's parent relations, ie, its parent, grandparent, great-grandparent, great-great-grandparent etc. I'll provide a custom helper for this at some point. 12 | 13 | ### Getting started 14 | 15 | For now, install it from Github as a node module. 16 | 17 | ```bash 18 | npm install --save-dev https://github.com/gavinmcfarland/figma-node-decoder/tarball/javascript-api 19 | ``` 20 | 21 | Import the helpers into your plugin 22 | 23 | ```js 24 | // code.ts 25 | import { encodeAsync, decodeAsync } from 'figma-node-decoder' 26 | ``` 27 | 28 | Pass in the nodes you want to encode. 29 | 30 | ```js 31 | // Pass nodes as an array and the platform you're encoding 32 | encodeAsync(figma.currentPage.selection, { platform: 'PLUGIN' }).then((string) => { 33 | 34 | // Store it somewhere 35 | figma.root.setPluginData("selectionAsString", string) 36 | }) 37 | ``` 38 | 39 | Decode the string when you want to recreate the nodes. 40 | 41 | ```js 42 | // Grab the string 43 | let selectionAsString = figma.root.getPluginData("selectionAsString") 44 | 45 | // Recreate the nodes from string 46 | decodeAsync(selectionAsString).then(({ nodes }) => { 47 | figma.currentPage.selection = nodes 48 | figma.viewport.scrollAndZoomIntoView(nodes) 49 | figma.closePlugin("Nodes created") 50 | }) 51 | ``` 52 | 53 | ## Development 54 | 55 | ### Setup 56 | ```bash 57 | npm install 58 | ``` 59 | 60 | ### Development 61 | During development, watch your project for changes with the following command. 62 | 63 | ```bash 64 | npm run dev 65 | ``` 66 | 67 | ### Build 68 | When ready to package up your final Figma Plugin: 69 | ```bash 70 | npm run build 71 | ``` 72 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | 1. Decide how to package and publish this as a node module package 2 | 2. Fix issue where styles need font information to be loaded before being set 3 | 3. Encapsulate string in function -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-node-decoder", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "build": "rollup -c", 7 | "dev": "rollup -c -w", 8 | "start": "sirv public" 9 | }, 10 | "devDependencies": { 11 | "@figma/plugin-typings": "^1.63.0", 12 | "@fignite/helpers": "^0.0.0-alpha.10", 13 | "@rollup/plugin-commonjs": "^17.0.0", 14 | "@rollup/plugin-image": "^2.0.6", 15 | "@rollup/plugin-json": "^4.1.0", 16 | "@rollup/plugin-node-resolve": "^11.1.0", 17 | "@rollup/plugin-replace": "^3.0.0", 18 | "@types/common-tags": "^1.8.0", 19 | "@types/lodash": "^4.14.168", 20 | "common-tags": "^1.8.0", 21 | "cssnano": "^4.1.10", 22 | "figma-plugin-ds-svelte": "^1.0.7", 23 | "highlight.js": "^10.5.0", 24 | "lodash": "^4.17.20", 25 | "plugma": "*", 26 | "postcss": "^8.2.4", 27 | "postcss-nested": "^5.0.3", 28 | "rollup": "^2.36.2", 29 | "rollup-plugin-html-bundle": "0.0.3", 30 | "rollup-plugin-livereload": "^2.0.0", 31 | "rollup-plugin-postcss": "^4.0.0", 32 | "rollup-plugin-svelte": "^7.0.0", 33 | "rollup-plugin-svg": "^2.0.0", 34 | "rollup-plugin-terser": "^7.0.2", 35 | "rollup-plugin-typescript": "^1.0.1", 36 | "stylup": "0.0.0-alpha.3", 37 | "svelte": "^3.31.2", 38 | "svelte-highlight": "^0.6.2", 39 | "tslib": "^2.1.0", 40 | "typescript": "^4.1.3", 41 | "voca": "^1.4.0", 42 | "xregexp": "^4.4.1" 43 | }, 44 | "dependencies": { 45 | "autoprefixer": "^10.2.1", 46 | "base-64": "^1.0.0", 47 | "fs-extra": "^9.1.0", 48 | "illuminate-js": "^1.0.0-alpha.2", 49 | "node-syntaxhighlighter": "^0.8.1", 50 | "postcss-logical": "^4.0.2", 51 | "rollup-plugin-node-globals": "^1.4.0", 52 | "rollup-plugin-node-polyfills": "^0.2.1", 53 | "sirv-cli": "^1.0.10", 54 | "uniqid": "^5.2.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package/index.ts: -------------------------------------------------------------------------------- 1 | import { genPluginStr } from "../src/pluginGeneration"; 2 | import { genWidgetStr } from "../src/widgetGeneration"; 3 | 4 | export async function encodeAsync(array, options?) { 5 | console.log(options); 6 | if (options.platform === "PLUGIN" || options.platform === "plugin") { 7 | return await ( 8 | await genPluginStr(array, { 9 | wrapInFunction: true, 10 | includeObject: true, 11 | }) 12 | ).join(""); 13 | } 14 | if (options.platform === "WIDGET" || options.platform === "widget") { 15 | return await genWidgetStr(array); 16 | } 17 | } 18 | 19 | export async function decodeAsync(string, options?) { 20 | let nodes; 21 | 22 | try { 23 | nodes = await eval(string); 24 | } catch { 25 | figma.triggerUndo(); 26 | figma.notify("Error running code"); 27 | } 28 | 29 | return { 30 | nodes, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | // 'postcss-import': {} 4 | // 'postcss-selector-matches': {}, 5 | // 'postcss-for': {}, 6 | // 'postcss-custom-selectors': {}, 7 | // 'postcss-short': {}, 8 | // 'postcss-nested': {}, 9 | 'postcss-logical': {}, 10 | // 'postcss-proportional-spacing': {}, 11 | // 'postcss-pow': {} 12 | // 'autoprefixer': {} 13 | // require('autoprefixer') 14 | // 'postcss-extend-rule': {}, 15 | // 'postcss-flexbugs-fixes': {}, 16 | // 'postcss-preset-env': { 17 | // autoprefixer: { 18 | // flexbox: 'no-2009', 19 | // }, 20 | // stage: 3, 21 | // features: { 22 | // 'custom-properties': false, 23 | // }, 24 | // }, 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Node Decoder", 3 | "id": "933372797518031971", 4 | "api": "1.0.0", 5 | "main": "code.js", 6 | "ui": { 7 | "main": "index.html" 8 | }, 9 | "editorType": ["figma"], 10 | "networkAccess": { 11 | "allowedDomains": ["none"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import livereload from 'rollup-plugin-livereload'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import svg from 'rollup-plugin-svg'; 7 | import typescript from 'rollup-plugin-typescript'; 8 | import replace from '@rollup/plugin-replace' 9 | import json from '@rollup/plugin-json' 10 | import globals from 'rollup-plugin-node-globals' 11 | import nodePolyfills from 'rollup-plugin-node-polyfills' 12 | 13 | /* Post CSS */ 14 | import postcss from 'rollup-plugin-postcss'; 15 | // import cssnano from 'cssnano'; 16 | 17 | 18 | /* Inline to single html */ 19 | import htmlBundle from 'rollup-plugin-html-bundle'; 20 | 21 | import { stylup } from './stylup' 22 | 23 | const production = !process.env.ROLLUP_WATCH; 24 | 25 | export default [{ 26 | input: 'src/main.js', 27 | output: { 28 | format: 'iife', 29 | name: 'ui', 30 | file: 'src/build/bundle.js' 31 | }, 32 | plugins: [ 33 | svelte({ 34 | // enable run-time checks when not in production 35 | // dev: !production, 36 | // preprocess: [stylup] 37 | }), 38 | 39 | // If you have external dependencies installed from 40 | // npm, you'll most likely need these plugins. In 41 | // some cases you'll need additional configuration — 42 | // consult the documentation for details:¡ 43 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 44 | resolve({ 45 | browser: true, 46 | dedupe: importee => importee === 'svelte' || importee.startsWith('svelte/'), 47 | extensions: ['.svelte', '.mjs', '.js', '.json', '.node'] 48 | }), 49 | commonjs(), 50 | svg(), 51 | postcss(), 52 | htmlBundle({ 53 | template: 'src/template.html', 54 | target: 'public/index.html', 55 | inline: true 56 | }), 57 | 58 | // In dev mode, call `npm run start` once 59 | // the bundle has been generated 60 | !production && serve(), 61 | 62 | // Watch the `dist` directory and refresh the 63 | // browser on changes when not in production 64 | !production && livereload('public'), 65 | 66 | // If we're building for production (npm run build 67 | // instead of npm run dev), minify 68 | production && terser() 69 | ], 70 | watch: { 71 | clearScreen: false 72 | } 73 | }, 74 | // Javascript API 75 | { 76 | input: 'package/index.ts', 77 | output: { 78 | file: 'dist/index.js', 79 | format: 'cjs', 80 | name: 'javascript-api' 81 | }, 82 | plugins: [ 83 | typescript(), 84 | resolve(), 85 | json(), 86 | commonjs(), 87 | production && terser() 88 | ] 89 | }, 90 | { 91 | input: 'src/code.ts', 92 | output: { 93 | file: 'public/code.js', 94 | format: 'cjs', 95 | name: 'code' 96 | }, 97 | plugins: [ 98 | typescript(), 99 | nodePolyfills({ include: null, exclude: ['../**/node_modules/voca/*.js'] }), 100 | resolve(), 101 | replace({ 102 | 'process.env.PKG_PATH': JSON.stringify(process.cwd() + '/package.json'), 103 | 'process.env.VERSIONS_PATH': JSON.stringify(process.cwd() + '/.plugma/versions.json') 104 | }), 105 | json(), 106 | commonjs(), 107 | production && terser() 108 | ] 109 | } 110 | ]; 111 | 112 | function serve() { 113 | let started = false; 114 | 115 | return { 116 | writeBundle() { 117 | if (!started) { 118 | started = true; 119 | 120 | require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { 121 | stdio: ['ignore', 'inherit', 'inherit'], 122 | shell: true 123 | }); 124 | } 125 | } 126 | }; 127 | } 128 | -------------------------------------------------------------------------------- /src/PluginUI.svelte: -------------------------------------------------------------------------------- 1 | 172 | 173 | 174 | 175 | 176 | {@html github} 177 | 178 | 179 |
180 |
181 | {#await promise} 182 |

183 | {:then data} 184 | 185 | {/await} 186 |
187 |
188 | 189 | 190 | 191 |
192 |
193 | 194 |
195 |
196 |
197 | {#await promise} 198 |

Formatting code...

199 | {:then data} 200 |
202 | 					{@html highlight(data.value, "js")}
203 | 				
206 | 				
207 | {:catch error} 208 |

{error.message}

209 | {/await} 210 |
211 | 212 | 219 |
220 | 222 | 223 | 224 | 225 |
226 | 227 |
228 |
229 | Refresh 230 |
231 |
232 | Copy 233 |
234 |
235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 380 | -------------------------------------------------------------------------------- /src/Toggle.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
{togglePlatform(platform, "plugin")}} style={checked ? "font-weight: bold; color: rgba(0, 0, 0, 0.8);" : ""}>Plugin 41 | 42 | 43 | 47 | 48 | 49 | {togglePlatform(platform, "widget")}} style={!checked ? "font-weight: bold; color: rgba(0, 0, 0, 0.8);" : ""}>Widget
50 | 51 | 52 | 53 | 125 | -------------------------------------------------------------------------------- /src/code.ts: -------------------------------------------------------------------------------- 1 | import plugma from "plugma"; 2 | import { genWidgetStr } from "./widgetGeneration"; 3 | import { genPluginStr } from "./pluginGeneration"; 4 | import { encodeAsync, decodeAsync } from "../package"; 5 | import { 6 | getClientStorageAsync, 7 | setClientStorageAsync, 8 | updateClientStorageAsync, 9 | setPluginData, 10 | getPluginData, 11 | nodeToObject, 12 | ungroup, 13 | } from "@fignite/helpers"; 14 | import { groupBy } from "lodash"; 15 | 16 | // TODO: Different string output for running code and showing in UI 17 | 18 | console.clear(); 19 | 20 | // console.log("topInstance", getTopInstance(figma.currentPage.selection[0])) 21 | // console.log("parentIstance", getParentInstance(figma.currentPage.selection[0])) 22 | // console.log("location", getNodeLocation(figma.currentPage.selection[0], getTopInstance(figma.currentPage.selection[0]))) 23 | // console.log("counterPart1", getInstanceCounterpartUsingLocation(figma.currentPage.selection[0], getTopInstance(figma.currentPage.selection[0]))) 24 | // console.log("backingNode", getInstanceCounterpartUsingLocation(figma.currentPage.selection[0], getTopInstance(figma.currentPage.selection[0]))) 25 | 26 | plugma((plugin) => { 27 | var origSel = figma.currentPage.selection; 28 | var outputPlatform = ""; 29 | 30 | var cachedPlugin; 31 | var cachedWidget; 32 | 33 | var successful; 34 | 35 | var uiDimensions = { width: 280, height: 420 }; 36 | 37 | plugin.on("code-rendered", () => { 38 | successful = true; 39 | }); 40 | 41 | plugin.on("code-copied", () => { 42 | figma.notify("Copied to clipboard"); 43 | }); 44 | 45 | plugin.on("set-platform", (msg) => { 46 | var platform = msg.platform; 47 | outputPlatform = platform; 48 | const handle = figma.notify("Generating code...", { 49 | timeout: 99999999999, 50 | }); 51 | 52 | setClientStorageAsync("platform", platform); 53 | 54 | if (platform === "plugin") { 55 | if (cachedPlugin) { 56 | handle.cancel(); 57 | figma.ui.postMessage({ 58 | type: "string-received", 59 | value: 60 | "async function main() {\n\n" + 61 | cachedPlugin + 62 | "\n}\n\nmain()", 63 | platform, 64 | }); 65 | } else { 66 | genPluginStr(origSel, { 67 | wrapInFunction: true, 68 | includeObject: true, 69 | }) 70 | .then(async (string) => { 71 | handle.cancel(); 72 | 73 | // figma.showUI(__uiFiles__.main, { width: 320, height: 480 }); 74 | 75 | figma.ui.postMessage({ 76 | type: "string-received", 77 | value: string.join(""), 78 | platform, 79 | }); 80 | 81 | setTimeout(function () { 82 | if (!successful) { 83 | figma.notify("Plugin timed out"); 84 | figma.closePlugin(); 85 | } 86 | }, 8000); 87 | }) 88 | .catch((error) => { 89 | handle.cancel(); 90 | if (error.message === "cannot convert to object") { 91 | figma.closePlugin( 92 | `Could not generate ${platform} code for selection` 93 | ); 94 | } else { 95 | figma.closePlugin(`${error}`); 96 | } 97 | }); 98 | } 99 | } 100 | 101 | if (platform === "widget") { 102 | if (cachedWidget) { 103 | handle.cancel(); 104 | figma.ui.postMessage({ 105 | type: "string-received", 106 | value: cachedWidget, 107 | platform, 108 | }); 109 | } else { 110 | genWidgetStr(origSel) 111 | .then((string) => { 112 | handle.cancel(); 113 | 114 | // figma.showUI(__uiFiles__.main, { width: 320, height: 480 }); 115 | 116 | figma.ui.postMessage({ 117 | type: "string-received", 118 | value: string, 119 | platform, 120 | }); 121 | 122 | setTimeout(function () { 123 | if (!successful) { 124 | figma.notify("Plugin timed out"); 125 | figma.closePlugin(); 126 | } 127 | }, 8000); 128 | }) 129 | .catch((error) => { 130 | handle.cancel(); 131 | if (error.message === "cannot convert to object") { 132 | figma.closePlugin( 133 | `Could not generate ${platform} code for selection` 134 | ); 135 | } else { 136 | figma.closePlugin(`${error}`); 137 | } 138 | }); 139 | } 140 | } 141 | }); 142 | 143 | async function runPlugin(clearCache?) { 144 | const handle = figma.notify("Generating code...", { 145 | timeout: 99999999999, 146 | }); 147 | 148 | let platform = await updateClientStorageAsync( 149 | "platform", 150 | (platform) => { 151 | platform = platform || "plugin"; 152 | outputPlatform = platform; 153 | return platform; 154 | } 155 | ); 156 | 157 | if (clearCache) { 158 | origSel = figma.currentPage.selection; 159 | } 160 | 161 | if (origSel.length > 0) { 162 | // If something is selected create new string and save to client storage 163 | 164 | cachedPlugin = await ( 165 | await genPluginStr(origSel, { platform: "plugin" }) 166 | ).join(""); 167 | cachedWidget = await genWidgetStr(origSel); 168 | 169 | setClientStorageAsync(`cachedPlugin`, cachedPlugin); 170 | setClientStorageAsync(`cachedWidget`, cachedWidget); 171 | } else { 172 | // If no selection then get the cached version 173 | cachedPlugin = await getClientStorageAsync(`cachedPlugin`); 174 | cachedWidget = await getClientStorageAsync(`cachedWidget`); 175 | } 176 | 177 | // restore previous size 178 | let uiSize = await getClientStorageAsync("uiSize"); 179 | 180 | if (!uiSize) { 181 | setClientStorageAsync("uiSize", uiDimensions); 182 | uiSize = uiDimensions; 183 | } 184 | 185 | // if (platform === "plugin") { 186 | // cachedPlugin = encodedString; 187 | // } 188 | 189 | // if (platform === "widget") { 190 | // cachedWidget = encodedString; 191 | // } 192 | 193 | handle.cancel(); 194 | 195 | figma.showUI(__uiFiles__.main, uiSize); 196 | 197 | console.log(platform); 198 | if (platform === "plugin") { 199 | figma.ui.postMessage({ 200 | type: "string-received", 201 | value: 202 | "async function main() {\n\n" + 203 | cachedPlugin + 204 | "\n}\n\nmain()", 205 | platform, 206 | }); 207 | } 208 | 209 | if (platform === "widget") { 210 | figma.ui.postMessage({ 211 | type: "string-received", 212 | value: cachedWidget, 213 | platform, 214 | }); 215 | } 216 | 217 | setTimeout(function () { 218 | if (!successful) { 219 | figma.notify("Plugin timed out"); 220 | figma.closePlugin(); 221 | } 222 | }, 8000); 223 | } 224 | 225 | runPlugin(); 226 | 227 | // figma.on("selectionchange", () => { 228 | // if (figma.currentPage.selection.length > 0) { 229 | // runPlugin(true); 230 | // } 231 | // }); 232 | 233 | plugin.on("run-code", () => { 234 | if (outputPlatform === "plugin") { 235 | // getClientStorageAsync("encodedString").then((string) => { 236 | 237 | let string = cachedPlugin; 238 | 239 | string = 240 | `// Wrap in function 241 | async function createNodes() { 242 | 243 | // Create temporary page to pass nodes to function 244 | let oldPage = figma.currentPage 245 | let newPage = figma.createPage() 246 | figma.currentPage = newPage 247 | ` + 248 | string + 249 | `// Pass children to function 250 | let nodes = figma.currentPage.children 251 | figma.currentPage = oldPage 252 | 253 | for (let i = 0; i < nodes.length; i++) { 254 | let node = nodes[i] 255 | figma.currentPage.appendChild(node) 256 | } 257 | 258 | newPage.remove() 259 | figma.currentPage = oldPage 260 | 261 | return nodes 262 | 263 | } 264 | 265 | createNodes()`; 266 | 267 | decodeAsync(string).then(({ nodes }) => { 268 | function positionInCenter(node) { 269 | // Position newly created table in center of viewport 270 | node.x = figma.viewport.center.x - node.width / 2; 271 | node.y = figma.viewport.center.y - node.height / 2; 272 | } 273 | 274 | let group = figma.group(nodes, figma.currentPage); 275 | 276 | positionInCenter(group); 277 | 278 | nodes = ungroup(group, figma.currentPage); 279 | figma.currentPage.selection = nodes; 280 | // figma.viewport.scrollAndZoomIntoView(nodes) 281 | figma.notify("Code run"); 282 | }); 283 | 284 | // }); 285 | } 286 | }); 287 | 288 | plugin.on("refresh-selection", (msg) => { 289 | if (figma.currentPage.selection.length > 0) { 290 | runPlugin(true); 291 | } else { 292 | figma.notify("Select nodes to refresh"); 293 | } 294 | }); 295 | 296 | plugin.on("resize", (msg) => { 297 | figma.ui.resize(msg.size.width, msg.size.height); 298 | figma.clientStorage.setAsync("uiSize", msg.size).catch((err) => {}); // save size 299 | }); 300 | }); 301 | 302 | // async function encodeAsync(array) { 303 | 304 | // // return nodeToObject(node) 305 | 306 | // return await genPluginStr(array) 307 | 308 | // } 309 | 310 | // async function decodeAsync(string) { 311 | // return eval(string) 312 | // } 313 | 314 | // if (figma.command === "encode") { 315 | // var objects = [] 316 | 317 | // for (var i = 0; i < figma.currentPage.selection.length; i++) { 318 | // var node = figma.currentPage.selection[i] 319 | // var object = nodeToObject(node, false) 320 | // console.log(object) 321 | // objects.push(object) 322 | // } 323 | 324 | // // Can use either nodes directly, or JSON representation of nodes. If using JSON, it must include id's and type's of all parent relations. 325 | // encodeAsync(objects).then((string) => { 326 | // console.log(string) 327 | // setPluginData(figma.root, "selectionAsString", string) 328 | // figma.closePlugin("Selection stored as string") 329 | // }) 330 | // } 331 | 332 | // if (figma.command === "decode") { 333 | // var selectionAsString = getPluginData(figma.root, "selectionAsString") 334 | // // console.log(selectionAsString) 335 | // decodeAsync(selectionAsString).then(() => { 336 | // figma.closePlugin("String converted to node") 337 | // }) 338 | // } 339 | -------------------------------------------------------------------------------- /src/figma.d.ts: -------------------------------------------------------------------------------- 1 | // Figma Plugin API version 1, update 14 2 | 3 | declare global { 4 | // Global variable with Figma's plugin API. 5 | const figma: PluginAPI 6 | const __html__: string 7 | 8 | interface PluginAPI { 9 | readonly apiVersion: "1.0.0" 10 | readonly command: string 11 | readonly viewport: ViewportAPI 12 | closePlugin(message?: string): void 13 | 14 | notify(message: string, options?: NotificationOptions): NotificationHandler 15 | 16 | showUI(html: string, options?: ShowUIOptions): void 17 | readonly ui: UIAPI 18 | 19 | readonly clientStorage: ClientStorageAPI 20 | 21 | getNodeById(id: string): BaseNode | null 22 | getStyleById(id: string): BaseStyle | null 23 | 24 | readonly root: DocumentNode 25 | currentPage: PageNode 26 | 27 | on(type: "selectionchange" | "currentpagechange" | "close", callback: () => void): void 28 | once(type: "selectionchange" | "currentpagechange" | "close", callback: () => void): void 29 | off(type: "selectionchange" | "currentpagechange" | "close", callback: () => void): void 30 | 31 | readonly mixed: unique symbol 32 | 33 | createRectangle(): RectangleNode 34 | createLine(): LineNode 35 | createEllipse(): EllipseNode 36 | createPolygon(): PolygonNode 37 | createStar(): StarNode 38 | createVector(): VectorNode 39 | createText(): TextNode 40 | createFrame(): FrameNode 41 | createComponent(): ComponentNode 42 | createPage(): PageNode 43 | createSlice(): SliceNode 44 | /** 45 | * [DEPRECATED]: This API often fails to create a valid boolean operation. Use figma.union, figma.subtract, figma.intersect and figma.exclude instead. 46 | */ 47 | createBooleanOperation(): BooleanOperationNode 48 | 49 | createPaintStyle(): PaintStyle 50 | createTextStyle(): TextStyle 51 | createEffectStyle(): EffectStyle 52 | createGridStyle(): GridStyle 53 | 54 | // The styles are returned in the same order as displayed in the UI. Only 55 | // local styles are returned. Never styles from team library. 56 | getLocalPaintStyles(): PaintStyle[] 57 | getLocalTextStyles(): TextStyle[] 58 | getLocalEffectStyles(): EffectStyle[] 59 | getLocalGridStyles(): GridStyle[] 60 | 61 | importComponentByKeyAsync(key: string): Promise 62 | importStyleByKeyAsync(key: string): Promise 63 | 64 | listAvailableFontsAsync(): Promise 65 | loadFontAsync(fontName: FontName): Promise 66 | readonly hasMissingFont: boolean 67 | 68 | createNodeFromSvg(svg: string): FrameNode 69 | 70 | createImage(data: Uint8Array): Image 71 | getImageByHash(hash: string): Image 72 | 73 | group(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): GroupNode 74 | flatten(nodes: ReadonlyArray, parent?: BaseNode & ChildrenMixin, index?: number): VectorNode 75 | 76 | union(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): BooleanOperationNode 77 | subtract(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): BooleanOperationNode 78 | intersect(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): BooleanOperationNode 79 | exclude(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): BooleanOperationNode 80 | } 81 | 82 | interface ClientStorageAPI { 83 | getAsync(key: string): Promise 84 | setAsync(key: string, value: any): Promise 85 | } 86 | 87 | interface NotificationOptions { 88 | timeout?: number 89 | } 90 | 91 | interface NotificationHandler { 92 | cancel: () => void 93 | } 94 | 95 | interface ShowUIOptions { 96 | visible?: boolean 97 | width?: number 98 | height?: number 99 | } 100 | 101 | interface UIPostMessageOptions { 102 | origin?: string 103 | } 104 | 105 | interface OnMessageProperties { 106 | origin: string 107 | } 108 | 109 | type MessageEventHandler = (pluginMessage: any, props: OnMessageProperties) => void 110 | 111 | interface UIAPI { 112 | show(): void 113 | hide(): void 114 | resize(width: number, height: number): void 115 | close(): void 116 | 117 | postMessage(pluginMessage: any, options?: UIPostMessageOptions): void 118 | onmessage: MessageEventHandler | undefined 119 | on(type: "message", callback: MessageEventHandler): void 120 | once(type: "message", callback: MessageEventHandler): void 121 | off(type: "message", callback: MessageEventHandler): void 122 | } 123 | 124 | interface ViewportAPI { 125 | center: Vector 126 | zoom: number 127 | scrollAndZoomIntoView(nodes: ReadonlyArray): void 128 | readonly bounds: Rect 129 | } 130 | 131 | //////////////////////////////////////////////////////////////////////////////// 132 | // Datatypes 133 | 134 | type Transform = [ 135 | [number, number, number], 136 | [number, number, number] 137 | ] 138 | 139 | interface Vector { 140 | readonly x: number 141 | readonly y: number 142 | } 143 | 144 | interface Rect { 145 | readonly x: number 146 | readonly y: number 147 | readonly width: number 148 | readonly height: number 149 | } 150 | 151 | interface RGB { 152 | readonly r: number 153 | readonly g: number 154 | readonly b: number 155 | } 156 | 157 | interface RGBA { 158 | readonly r: number 159 | readonly g: number 160 | readonly b: number 161 | readonly a: number 162 | } 163 | 164 | interface FontName { 165 | readonly family: string 166 | readonly style: string 167 | } 168 | 169 | type TextCase = "ORIGINAL" | "UPPER" | "LOWER" | "TITLE" 170 | 171 | type TextDecoration = "NONE" | "UNDERLINE" | "STRIKETHROUGH" 172 | 173 | interface ArcData { 174 | readonly startingAngle: number 175 | readonly endingAngle: number 176 | readonly innerRadius: number 177 | } 178 | 179 | interface ShadowEffect { 180 | readonly type: "DROP_SHADOW" | "INNER_SHADOW" 181 | readonly color: RGBA 182 | readonly offset: Vector 183 | readonly radius: number 184 | readonly visible: boolean 185 | readonly blendMode: BlendMode 186 | } 187 | 188 | interface BlurEffect { 189 | readonly type: "LAYER_BLUR" | "BACKGROUND_BLUR" 190 | readonly radius: number 191 | readonly visible: boolean 192 | } 193 | 194 | type Effect = ShadowEffect | BlurEffect 195 | 196 | type ConstraintType = "MIN" | "CENTER" | "MAX" | "STRETCH" | "SCALE" 197 | 198 | interface Constraints { 199 | readonly horizontal: ConstraintType 200 | readonly vertical: ConstraintType 201 | } 202 | 203 | interface ColorStop { 204 | readonly position: number 205 | readonly color: RGBA 206 | } 207 | 208 | interface ImageFilters { 209 | readonly exposure?: number 210 | readonly contrast?: number 211 | readonly saturation?: number 212 | readonly temperature?: number 213 | readonly tint?: number 214 | readonly highlights?: number 215 | readonly shadows?: number 216 | } 217 | 218 | interface SolidPaint { 219 | readonly type: "SOLID" 220 | readonly color: RGB 221 | 222 | readonly visible?: boolean 223 | readonly opacity?: number 224 | readonly blendMode?: BlendMode 225 | } 226 | 227 | interface GradientPaint { 228 | readonly type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" 229 | readonly gradientTransform: Transform 230 | readonly gradientStops: ReadonlyArray 231 | 232 | readonly visible?: boolean 233 | readonly opacity?: number 234 | readonly blendMode?: BlendMode 235 | } 236 | 237 | interface ImagePaint { 238 | readonly type: "IMAGE" 239 | readonly scaleMode: "FILL" | "FIT" | "CROP" | "TILE" 240 | readonly imageHash: string | null 241 | readonly imageTransform?: Transform // setting for "CROP" 242 | readonly scalingFactor?: number // setting for "TILE" 243 | readonly filters?: ImageFilters 244 | 245 | readonly visible?: boolean 246 | readonly opacity?: number 247 | readonly blendMode?: BlendMode 248 | } 249 | 250 | type Paint = SolidPaint | GradientPaint | ImagePaint 251 | 252 | interface Guide { 253 | readonly axis: "X" | "Y" 254 | readonly offset: number 255 | } 256 | 257 | interface RowsColsLayoutGrid { 258 | readonly pattern: "ROWS" | "COLUMNS" 259 | readonly alignment: "MIN" | "MAX" | "STRETCH" | "CENTER" 260 | readonly gutterSize: number 261 | 262 | readonly count: number // Infinity when "Auto" is set in the UI 263 | readonly sectionSize?: number // Not set for alignment: "STRETCH" 264 | readonly offset?: number // Not set for alignment: "CENTER" 265 | 266 | readonly visible?: boolean 267 | readonly color?: RGBA 268 | } 269 | 270 | interface GridLayoutGrid { 271 | readonly pattern: "GRID" 272 | readonly sectionSize: number 273 | 274 | readonly visible?: boolean 275 | readonly color?: RGBA 276 | } 277 | 278 | type LayoutGrid = RowsColsLayoutGrid | GridLayoutGrid 279 | 280 | interface ExportSettingsConstraints { 281 | readonly type: "SCALE" | "WIDTH" | "HEIGHT" 282 | readonly value: number 283 | } 284 | 285 | interface ExportSettingsImage { 286 | readonly format: "JPG" | "PNG" 287 | readonly contentsOnly?: boolean // defaults to true 288 | readonly suffix?: string 289 | readonly constraint?: ExportSettingsConstraints 290 | } 291 | 292 | interface ExportSettingsSVG { 293 | readonly format: "SVG" 294 | readonly contentsOnly?: boolean // defaults to true 295 | readonly suffix?: string 296 | readonly svgOutlineText?: boolean // defaults to true 297 | readonly svgIdAttribute?: boolean // defaults to false 298 | readonly svgSimplifyStroke?: boolean // defaults to true 299 | } 300 | 301 | interface ExportSettingsPDF { 302 | readonly format: "PDF" 303 | readonly contentsOnly?: boolean // defaults to true 304 | readonly suffix?: string 305 | } 306 | 307 | type ExportSettings = ExportSettingsImage | ExportSettingsSVG | ExportSettingsPDF 308 | 309 | type WindingRule = "NONZERO" | "EVENODD" 310 | 311 | interface VectorVertex { 312 | readonly x: number 313 | readonly y: number 314 | readonly strokeCap?: StrokeCap 315 | readonly strokeJoin?: StrokeJoin 316 | readonly cornerRadius?: number 317 | readonly handleMirroring?: HandleMirroring 318 | } 319 | 320 | interface VectorSegment { 321 | readonly start: number 322 | readonly end: number 323 | readonly tangentStart?: Vector // Defaults to { x: 0, y: 0 } 324 | readonly tangentEnd?: Vector // Defaults to { x: 0, y: 0 } 325 | } 326 | 327 | interface VectorRegion { 328 | readonly windingRule: WindingRule 329 | readonly loops: ReadonlyArray> 330 | } 331 | 332 | interface VectorNetwork { 333 | readonly vertices: ReadonlyArray 334 | readonly segments: ReadonlyArray 335 | readonly regions?: ReadonlyArray // Defaults to [] 336 | } 337 | 338 | interface VectorPath { 339 | readonly windingRule: WindingRule | "NONE" 340 | readonly data: string 341 | } 342 | 343 | type VectorPaths = ReadonlyArray 344 | 345 | interface LetterSpacing { 346 | readonly value: number 347 | readonly unit: "PIXELS" | "PERCENT" 348 | } 349 | 350 | type LineHeight = { 351 | readonly value: number 352 | readonly unit: "PIXELS" | "PERCENT" 353 | } | { 354 | readonly unit: "AUTO" 355 | } 356 | 357 | type BlendMode = 358 | "PASS_THROUGH" | 359 | "NORMAL" | 360 | "DARKEN" | 361 | "MULTIPLY" | 362 | "LINEAR_BURN" | 363 | "COLOR_BURN" | 364 | "LIGHTEN" | 365 | "SCREEN" | 366 | "LINEAR_DODGE" | 367 | "COLOR_DODGE" | 368 | "OVERLAY" | 369 | "SOFT_LIGHT" | 370 | "HARD_LIGHT" | 371 | "DIFFERENCE" | 372 | "EXCLUSION" | 373 | "HUE" | 374 | "SATURATION" | 375 | "COLOR" | 376 | "LUMINOSITY" 377 | 378 | interface Font { 379 | fontName: FontName 380 | } 381 | 382 | type Reaction = { action: Action, trigger: Trigger } 383 | 384 | type Action = 385 | { readonly type: "BACK" | "CLOSE" } | 386 | { readonly type: "URL", url: string } | 387 | { readonly type: "NODE" 388 | readonly destinationId: string | null 389 | readonly navigation: Navigation 390 | readonly transition: Transition | null 391 | readonly preserveScrollPosition: boolean 392 | 393 | // Only present if navigation == "OVERLAY" and the destination uses 394 | // overlay position type "RELATIVE" 395 | readonly overlayRelativePosition?: Vector 396 | } 397 | 398 | interface SimpleTransition { 399 | readonly type: "DISSOLVE" | "SMART_ANIMATE" 400 | readonly easing: Easing 401 | readonly duration: number 402 | } 403 | 404 | interface DirectionalTransition { 405 | readonly type: "MOVE_IN" | "MOVE_OUT" | "PUSH" | "SLIDE_IN" | "SLIDE_OUT" 406 | readonly direction: "LEFT" | "RIGHT" | "TOP" | "BOTTOM" 407 | readonly matchLayers: boolean 408 | 409 | readonly easing: Easing 410 | readonly duration: number 411 | } 412 | 413 | type Transition = SimpleTransition | DirectionalTransition 414 | 415 | type Trigger = 416 | { readonly type: "ON_CLICK" | "ON_HOVER" | "ON_PRESS" | "ON_DRAG" } | 417 | { readonly type: "AFTER_TIMEOUT", readonly timeout: number } | 418 | { readonly type: "MOUSE_ENTER" | "MOUSE_LEAVE" | "MOUSE_UP" | "MOUSE_DOWN" 419 | readonly delay: number 420 | } 421 | 422 | type Navigation = "NAVIGATE" | "SWAP" | "OVERLAY" 423 | 424 | interface Easing { 425 | readonly type: "EASE_IN" | "EASE_OUT" | "EASE_IN_AND_OUT" | "LINEAR" 426 | } 427 | 428 | type OverflowDirection = "NONE" | "HORIZONTAL" | "VERTICAL" | "BOTH" 429 | 430 | type OverlayPositionType = "CENTER" | "TOP_LEFT" | "TOP_CENTER" | "TOP_RIGHT" | "BOTTOM_LEFT" | "BOTTOM_CENTER" | "BOTTOM_RIGHT" | "MANUAL" 431 | 432 | type OverlayBackground = 433 | { readonly type: "NONE" } | 434 | { readonly type: "SOLID_COLOR", readonly color: RGBA } 435 | 436 | type OverlayBackgroundInteraction = "NONE" | "CLOSE_ON_CLICK_OUTSIDE" 437 | 438 | //////////////////////////////////////////////////////////////////////////////// 439 | // Mixins 440 | 441 | interface BaseNodeMixin { 442 | readonly id: string 443 | readonly parent: (BaseNode & ChildrenMixin) | null 444 | name: string // Note: setting this also sets `autoRename` to false on TextNodes 445 | readonly removed: boolean 446 | toString(): string 447 | remove(): void 448 | 449 | getPluginData(key: string): string 450 | setPluginData(key: string, value: string): void 451 | 452 | // Namespace is a string that must be at least 3 alphanumeric characters, and should 453 | // be a name related to your plugin. Other plugins will be able to read this data. 454 | getSharedPluginData(namespace: string, key: string): string 455 | setSharedPluginData(namespace: string, key: string, value: string): void 456 | setRelaunchData(data: { [command: string]: /* description */ string }): void 457 | } 458 | 459 | interface SceneNodeMixin { 460 | visible: boolean 461 | locked: boolean 462 | } 463 | 464 | interface ChildrenMixin { 465 | readonly children: ReadonlyArray 466 | 467 | appendChild(child: SceneNode): void 468 | insertChild(index: number, child: SceneNode): void 469 | 470 | findChildren(callback?: (node: SceneNode) => boolean): SceneNode[] 471 | findChild(callback: (node: SceneNode) => boolean): SceneNode | null 472 | 473 | /** 474 | * If you only need to search immediate children, it is much faster 475 | * to call node.children.filter(callback) or node.findChildren(callback) 476 | */ 477 | findAll(callback?: (node: SceneNode) => boolean): SceneNode[] 478 | 479 | /** 480 | * If you only need to search immediate children, it is much faster 481 | * to call node.children.find(callback) or node.findChild(callback) 482 | */ 483 | findOne(callback: (node: SceneNode) => boolean): SceneNode | null 484 | } 485 | 486 | interface ConstraintMixin { 487 | constraints: Constraints 488 | } 489 | 490 | interface LayoutMixin { 491 | readonly absoluteTransform: Transform 492 | relativeTransform: Transform 493 | x: number 494 | y: number 495 | rotation: number // In degrees 496 | 497 | readonly width: number 498 | readonly height: number 499 | constrainProportions: boolean 500 | 501 | layoutAlign: "MIN" | "CENTER" | "MAX" | "STRETCH" // applicable only inside auto-layout frames 502 | 503 | resize(width: number, height: number): void 504 | resizeWithoutConstraints(width: number, height: number): void 505 | } 506 | 507 | interface BlendMixin { 508 | opacity: number 509 | blendMode: BlendMode 510 | isMask: boolean 511 | effects: ReadonlyArray 512 | effectStyleId: string 513 | } 514 | 515 | interface ContainerMixin { 516 | expanded: boolean 517 | backgrounds: ReadonlyArray // DEPRECATED: use 'fills' instead 518 | backgroundStyleId: string // DEPRECATED: use 'fillStyleId' instead 519 | } 520 | 521 | type StrokeCap = "NONE" | "ROUND" | "SQUARE" | "ARROW_LINES" | "ARROW_EQUILATERAL" 522 | type StrokeJoin = "MITER" | "BEVEL" | "ROUND" 523 | type HandleMirroring = "NONE" | "ANGLE" | "ANGLE_AND_LENGTH" 524 | 525 | interface GeometryMixin { 526 | fills: ReadonlyArray | PluginAPI['mixed'] 527 | strokes: ReadonlyArray 528 | strokeWeight: number 529 | strokeMiterLimit: number 530 | strokeAlign: "CENTER" | "INSIDE" | "OUTSIDE" 531 | strokeCap: StrokeCap | PluginAPI['mixed'] 532 | strokeJoin: StrokeJoin | PluginAPI['mixed'] 533 | dashPattern: ReadonlyArray 534 | fillStyleId: string | PluginAPI['mixed'] 535 | strokeStyleId: string 536 | outlineStroke(): VectorNode | null 537 | } 538 | 539 | interface CornerMixin { 540 | cornerRadius: number | PluginAPI['mixed'] 541 | cornerSmoothing: number 542 | } 543 | 544 | interface RectangleCornerMixin { 545 | topLeftRadius: number 546 | topRightRadius: number 547 | bottomLeftRadius: number 548 | bottomRightRadius: number 549 | } 550 | 551 | interface ExportMixin { 552 | exportSettings: ReadonlyArray 553 | exportAsync(settings?: ExportSettings): Promise // Defaults to PNG format 554 | } 555 | 556 | interface ReactionMixin { 557 | readonly reactions: ReadonlyArray 558 | } 559 | 560 | interface DefaultShapeMixin extends 561 | BaseNodeMixin, SceneNodeMixin, ReactionMixin, 562 | BlendMixin, GeometryMixin, LayoutMixin, 563 | ExportMixin { 564 | } 565 | 566 | interface DefaultFrameMixin extends 567 | BaseNodeMixin, SceneNodeMixin, ReactionMixin, 568 | ChildrenMixin, ContainerMixin, 569 | GeometryMixin, CornerMixin, RectangleCornerMixin, 570 | BlendMixin, ConstraintMixin, LayoutMixin, 571 | ExportMixin { 572 | 573 | layoutMode: "NONE" | "HORIZONTAL" | "VERTICAL" 574 | counterAxisSizingMode: "FIXED" | "AUTO" // applicable only if layoutMode != "NONE" 575 | horizontalPadding: number // applicable only if layoutMode != "NONE" 576 | verticalPadding: number // applicable only if layoutMode != "NONE" 577 | itemSpacing: number // applicable only if layoutMode != "NONE" 578 | 579 | layoutGrids: ReadonlyArray 580 | gridStyleId: string 581 | clipsContent: boolean 582 | guides: ReadonlyArray 583 | 584 | overflowDirection: OverflowDirection 585 | numberOfFixedChildren: number 586 | 587 | readonly overlayPositionType: OverlayPositionType 588 | readonly overlayBackground: OverlayBackground 589 | readonly overlayBackgroundInteraction: OverlayBackgroundInteraction 590 | } 591 | 592 | //////////////////////////////////////////////////////////////////////////////// 593 | // Nodes 594 | 595 | interface DocumentNode extends BaseNodeMixin { 596 | readonly type: "DOCUMENT" 597 | 598 | readonly children: ReadonlyArray 599 | 600 | appendChild(child: PageNode): void 601 | insertChild(index: number, child: PageNode): void 602 | findChildren(callback?: (node: PageNode) => boolean): Array 603 | findChild(callback: (node: PageNode) => boolean): PageNode | null 604 | 605 | /** 606 | * If you only need to search immediate children, it is much faster 607 | * to call node.children.filter(callback) or node.findChildren(callback) 608 | */ 609 | findAll(callback?: (node: PageNode | SceneNode) => boolean): Array 610 | 611 | /** 612 | * If you only need to search immediate children, it is much faster 613 | * to call node.children.find(callback) or node.findChild(callback) 614 | */ 615 | findOne(callback: (node: PageNode | SceneNode) => boolean): PageNode | SceneNode | null 616 | } 617 | 618 | interface PageNode extends BaseNodeMixin, ChildrenMixin, ExportMixin { 619 | 620 | readonly type: "PAGE" 621 | clone(): PageNode 622 | 623 | guides: ReadonlyArray 624 | selection: ReadonlyArray 625 | selectedTextRange: { node: TextNode, start: number, end: number } | null 626 | 627 | backgrounds: ReadonlyArray 628 | 629 | readonly prototypeStartNode: FrameNode | GroupNode | ComponentNode | InstanceNode | null 630 | } 631 | 632 | interface FrameNode extends DefaultFrameMixin { 633 | readonly type: "FRAME" 634 | clone(): FrameNode 635 | } 636 | 637 | interface GroupNode extends 638 | BaseNodeMixin, SceneNodeMixin, ReactionMixin, 639 | ChildrenMixin, ContainerMixin, BlendMixin, 640 | LayoutMixin, ExportMixin { 641 | 642 | readonly type: "GROUP" 643 | clone(): GroupNode 644 | } 645 | 646 | interface SliceNode extends 647 | BaseNodeMixin, SceneNodeMixin, LayoutMixin, 648 | ExportMixin { 649 | 650 | readonly type: "SLICE" 651 | clone(): SliceNode 652 | } 653 | 654 | interface RectangleNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin, RectangleCornerMixin { 655 | readonly type: "RECTANGLE" 656 | clone(): RectangleNode 657 | } 658 | 659 | interface LineNode extends DefaultShapeMixin, ConstraintMixin { 660 | readonly type: "LINE" 661 | clone(): LineNode 662 | } 663 | 664 | interface EllipseNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 665 | readonly type: "ELLIPSE" 666 | clone(): EllipseNode 667 | arcData: ArcData 668 | } 669 | 670 | interface PolygonNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 671 | readonly type: "POLYGON" 672 | clone(): PolygonNode 673 | pointCount: number 674 | } 675 | 676 | interface StarNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 677 | readonly type: "STAR" 678 | clone(): StarNode 679 | pointCount: number 680 | innerRadius: number 681 | } 682 | 683 | interface VectorNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 684 | readonly type: "VECTOR" 685 | clone(): VectorNode 686 | vectorNetwork: VectorNetwork 687 | vectorPaths: VectorPaths 688 | handleMirroring: HandleMirroring | PluginAPI['mixed'] 689 | } 690 | 691 | interface TextNode extends DefaultShapeMixin, ConstraintMixin { 692 | readonly type: "TEXT" 693 | clone(): TextNode 694 | readonly hasMissingFont: boolean 695 | textAlignHorizontal: "LEFT" | "CENTER" | "RIGHT" | "JUSTIFIED" 696 | textAlignVertical: "TOP" | "CENTER" | "BOTTOM" 697 | textAutoResize: "NONE" | "WIDTH_AND_HEIGHT" | "HEIGHT" 698 | paragraphIndent: number 699 | paragraphSpacing: number 700 | autoRename: boolean 701 | 702 | textStyleId: string | PluginAPI['mixed'] 703 | fontSize: number | PluginAPI['mixed'] 704 | fontName: FontName | PluginAPI['mixed'] 705 | textCase: TextCase | PluginAPI['mixed'] 706 | textDecoration: TextDecoration | PluginAPI['mixed'] 707 | letterSpacing: LetterSpacing | PluginAPI['mixed'] 708 | lineHeight: LineHeight | PluginAPI['mixed'] 709 | 710 | characters: string 711 | insertCharacters(start: number, characters: string, useStyle?: "BEFORE" | "AFTER"): void 712 | deleteCharacters(start: number, end: number): void 713 | 714 | getRangeFontSize(start: number, end: number): number | PluginAPI['mixed'] 715 | setRangeFontSize(start: number, end: number, value: number): void 716 | getRangeFontName(start: number, end: number): FontName | PluginAPI['mixed'] 717 | setRangeFontName(start: number, end: number, value: FontName): void 718 | getRangeTextCase(start: number, end: number): TextCase | PluginAPI['mixed'] 719 | setRangeTextCase(start: number, end: number, value: TextCase): void 720 | getRangeTextDecoration(start: number, end: number): TextDecoration | PluginAPI['mixed'] 721 | setRangeTextDecoration(start: number, end: number, value: TextDecoration): void 722 | getRangeLetterSpacing(start: number, end: number): LetterSpacing | PluginAPI['mixed'] 723 | setRangeLetterSpacing(start: number, end: number, value: LetterSpacing): void 724 | getRangeLineHeight(start: number, end: number): LineHeight | PluginAPI['mixed'] 725 | setRangeLineHeight(start: number, end: number, value: LineHeight): void 726 | getRangeFills(start: number, end: number): Paint[] | PluginAPI['mixed'] 727 | setRangeFills(start: number, end: number, value: Paint[]): void 728 | getRangeTextStyleId(start: number, end: number): string | PluginAPI['mixed'] 729 | setRangeTextStyleId(start: number, end: number, value: string): void 730 | getRangeFillStyleId(start: number, end: number): string | PluginAPI['mixed'] 731 | setRangeFillStyleId(start: number, end: number, value: string): void 732 | } 733 | 734 | interface ComponentNode extends DefaultFrameMixin { 735 | readonly type: "COMPONENT" 736 | clone(): ComponentNode 737 | 738 | createInstance(): InstanceNode 739 | description: string 740 | readonly remote: boolean 741 | readonly key: string // The key to use with "importComponentByKeyAsync" 742 | } 743 | 744 | interface InstanceNode extends DefaultFrameMixin { 745 | readonly type: "INSTANCE" 746 | clone(): InstanceNode 747 | masterComponent: ComponentNode 748 | scaleFactor: number 749 | } 750 | 751 | interface BooleanOperationNode extends DefaultShapeMixin, ChildrenMixin, CornerMixin { 752 | readonly type: "BOOLEAN_OPERATION" 753 | clone(): BooleanOperationNode 754 | booleanOperation: "UNION" | "INTERSECT" | "SUBTRACT" | "EXCLUDE" 755 | 756 | expanded: boolean 757 | } 758 | 759 | type BaseNode = 760 | DocumentNode | 761 | PageNode | 762 | SceneNode 763 | 764 | type SceneNode = 765 | SliceNode | 766 | FrameNode | 767 | GroupNode | 768 | ComponentNode | 769 | InstanceNode | 770 | BooleanOperationNode | 771 | VectorNode | 772 | StarNode | 773 | LineNode | 774 | EllipseNode | 775 | PolygonNode | 776 | RectangleNode | 777 | TextNode 778 | 779 | type NodeType = 780 | "DOCUMENT" | 781 | "PAGE" | 782 | "SLICE" | 783 | "FRAME" | 784 | "GROUP" | 785 | "COMPONENT" | 786 | "INSTANCE" | 787 | "BOOLEAN_OPERATION" | 788 | "VECTOR" | 789 | "STAR" | 790 | "LINE" | 791 | "ELLIPSE" | 792 | "POLYGON" | 793 | "RECTANGLE" | 794 | "TEXT" 795 | 796 | //////////////////////////////////////////////////////////////////////////////// 797 | // Styles 798 | type StyleType = "PAINT" | "TEXT" | "EFFECT" | "GRID" 799 | 800 | interface BaseStyle { 801 | readonly id: string 802 | readonly type: StyleType 803 | name: string 804 | description: string 805 | remote: boolean 806 | readonly key: string // The key to use with "importStyleByKeyAsync" 807 | remove(): void 808 | } 809 | 810 | interface PaintStyle extends BaseStyle { 811 | type: "PAINT" 812 | paints: ReadonlyArray 813 | } 814 | 815 | interface TextStyle extends BaseStyle { 816 | type: "TEXT" 817 | fontSize: number 818 | textDecoration: TextDecoration 819 | fontName: FontName 820 | letterSpacing: LetterSpacing 821 | lineHeight: LineHeight 822 | paragraphIndent: number 823 | paragraphSpacing: number 824 | textCase: TextCase 825 | } 826 | 827 | interface EffectStyle extends BaseStyle { 828 | type: "EFFECT" 829 | effects: ReadonlyArray 830 | } 831 | 832 | interface GridStyle extends BaseStyle { 833 | type: "GRID" 834 | layoutGrids: ReadonlyArray 835 | } 836 | 837 | //////////////////////////////////////////////////////////////////////////////// 838 | // Other 839 | 840 | interface Image { 841 | readonly hash: string 842 | getBytesAsync(): Promise 843 | } 844 | } // declare global 845 | 846 | export {} 847 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | 2 | function convertArrayToObject(array, value = undefined) { 3 | return array.reduce(function (obj, name) { 4 | obj[name] = value; 5 | return obj; 6 | }, {}) 7 | } 8 | 9 | // Doesn't work because the component has already been added to the component list by the time the walker gets to the children nodes 10 | export function isInsideComponentThatAlreadyExists(node, allComponents) { 11 | if (node.type === "PAGE") return false 12 | 13 | if (allComponents.some((component) => JSON.stringify(component) === JSON.stringify(node.parent))) { 14 | return true 15 | } 16 | else { 17 | return isInsideComponentThatAlreadyExists(node.parent, allComponents) 18 | } 19 | 20 | 21 | } 22 | 23 | export function isTopLevelInstance(node) { 24 | if (node !== null) { 25 | if (isNestedInstance(node.parent)) { 26 | return true 27 | } 28 | else { 29 | return false 30 | } 31 | } 32 | } 33 | 34 | export function isNestedComponentSet(node) { 35 | if (node !== null) { 36 | if (node.type === "COMPONENT_SET") { 37 | return true 38 | } 39 | else if (node.type === "PAGE") { 40 | return false 41 | } 42 | else { 43 | return isNestedComponentSet(node.parent) 44 | } 45 | } 46 | } 47 | 48 | function isInsideComponent(node: SceneNode): boolean { 49 | const parent = node.parent 50 | if (parent.type === 'COMPONENT') { 51 | return true 52 | } else if (parent.type === 'PAGE') { 53 | return false 54 | } else { 55 | return isInsideComponent(parent as SceneNode) 56 | } 57 | } 58 | 59 | export function getComponentParent(node) { 60 | if (node?.type === "COMPONENT") return node 61 | if (node?.type === "PAGE") return null 62 | if (node?.parent?.type === "COMPONENT") { 63 | return node.parent 64 | } 65 | else { 66 | return getComponentParent(node?.parent) 67 | } 68 | } 69 | 70 | export function getParentInstances(node, instances = []) { 71 | if (node.type === "PAGE") return null 72 | if (node.type === "INSTANCE") { 73 | instances.push(node) 74 | } 75 | if (isInsideInstance(node)) { 76 | return getParentInstances(node.parent, instances) 77 | } else { 78 | return instances 79 | } 80 | } 81 | 82 | export function putValuesIntoArray(value) { 83 | return Array.isArray(value) ? value : [value] 84 | } 85 | 86 | function isFunction(functionToCheck) { 87 | return functionToCheck && {}.toString.call(functionToCheck) === '[object Function]'; 88 | } 89 | 90 | const nodeProps: string[] = [ 91 | 'id', 92 | 'parent', 93 | 'name', 94 | 'removed', 95 | 'visible', 96 | 'locked', 97 | 'children', 98 | 'constraints', 99 | 'absoluteTransform', 100 | 'relativeTransform', 101 | 'x', 102 | 'y', 103 | 'rotation', 104 | 'width', 105 | 'height', 106 | 'constrainProportions', 107 | 'layoutAlign', 108 | 'layoutGrow', 109 | 'opacity', 110 | 'blendMode', 111 | 'isMask', 112 | 'effects', 113 | 'effectStyleId', 114 | 'expanded', 115 | 'backgrounds', 116 | 'backgroundStyleId', 117 | 'fills', 118 | 'strokes', 119 | 'strokeWeight', 120 | 'strokeMiterLimit', 121 | 'strokeAlign', 122 | 'strokeCap', 123 | 'strokeJoin', 124 | 'dashPattern', 125 | 'fillStyleId', 126 | 'strokeStyleId', 127 | 'cornerRadius', 128 | 'cornerSmoothing', 129 | 'topLeftRadius', 130 | 'topRightRadius', 131 | 'bottomLeftRadius', 132 | 'bottomRightRadius', 133 | 'exportSettings', 134 | 'overflowDirection', 135 | 'numberOfFixedChildren', 136 | 'overlayPositionType', 137 | 'overlayBackground', 138 | 'overlayBackgroundInteraction', 139 | 'reactions', 140 | 'description', 141 | 'remote', 142 | 'key', 143 | 'layoutMode', 144 | 'primaryAxisSizingMode', 145 | 'counterAxisSizingMode', 146 | 'primaryAxisAlignItems', 147 | 'counterAxisAlignItems', 148 | 'paddingLeft', 149 | 'paddingRight', 150 | 'paddingTop', 151 | 'paddingBottom', 152 | 'itemSpacing', 153 | // 'horizontalPadding', 154 | // 'verticalPadding', 155 | 'layoutGrids', 156 | 'gridStyleId', 157 | 'clipsContent', 158 | 'guides' 159 | ] 160 | 161 | const readOnly: string[] = [ 162 | 'id', 163 | 'parent', 164 | 'removed', 165 | 'children', 166 | 'absoluteTransform', 167 | 'width', 168 | 'height', 169 | 'overlayPositionType', 170 | 'overlayBackground', 171 | 'overlayBackgroundInteraction', 172 | 'reactions', 173 | 'remote', 174 | 'key', 175 | 'type' 176 | ] 177 | 178 | const instanceProps: string[] = [ 179 | 'rotation', 180 | 'constrainProportions' 181 | ] 182 | 183 | const defaults: string[] = [ 184 | 'name', 185 | 'guides', 186 | 'description', 187 | 'remote', 188 | 'key', 189 | 'reactions', 190 | 'x', 191 | 'y', 192 | 'exportSettings', 193 | 'expanded', 194 | 'isMask', 195 | 'exportSettings', 196 | 'overflowDirection', 197 | 'numberOfFixedChildren', 198 | 'constraints', 199 | 'relativeTransform' 200 | ] 201 | 202 | const mixedProp = { 203 | cornerRadius: [ 204 | 'topleftCornerRadius', 205 | 'topRightCornerRadius', 206 | 'bottomLeftCornerRadius', 207 | 'bottomRightCornerRadius'] 208 | } 209 | 210 | function applyMixedValues(node, prop) { 211 | 212 | 213 | const obj = {}; 214 | 215 | if (mixedProp[prop] && node[prop] === figma.mixed) { 216 | for (let prop of mixedProp[prop]) { 217 | obj[prop] = source[prop] 218 | } 219 | } else { 220 | obj[prop] = node[prop] 221 | } 222 | } 223 | 224 | 225 | export const nodeToObject = (node: any, withoutRelations?: boolean, removeConflicts?: boolean) => { 226 | const props = Object.entries(Object.getOwnPropertyDescriptors(node.__proto__)) 227 | const blacklist = ['parent', 'children', 'removed', 'masterComponent', 'horizontalPadding', 'verticalPadding'] 228 | const obj: any = { id: node.id, type: node.type } 229 | for (const [name, prop] of props) { 230 | if (prop.get && !blacklist.includes(name)) { 231 | try { 232 | if (typeof obj[name] === 'symbol') { 233 | obj[name] = 'Mixed' 234 | } else { 235 | obj[name] = prop.get.call(node) 236 | } 237 | } catch (err) { 238 | obj[name] = undefined 239 | } 240 | } 241 | } 242 | if (node.parent && !withoutRelations) { 243 | obj.parent = { id: node.parent.id, type: node.parent.type } 244 | } 245 | if (node.children && !withoutRelations) { 246 | obj.children = node.children.map((child: any) => nodeToObject(child, withoutRelations)) 247 | } 248 | if (node.masterComponent && !withoutRelations) { 249 | obj.masterComponent = nodeToObject(node.masterComponent, withoutRelations) 250 | } 251 | 252 | if (!removeConflicts) { 253 | !obj.fillStyleId && obj.fills ? delete obj.fillStyleId : delete obj.fills 254 | !obj.strokeStyleId && obj.strokes ? delete obj.strokeStyleId : delete obj.strokes 255 | !obj.backgroundStyleId && obj.backgrounds ? delete obj.backgroundStyleId : delete obj.backgrounds 256 | !obj.effectStyleId && obj.effects ? delete obj.effectStyleId : delete obj.effects 257 | !obj.gridStyleId && obj.layoutGrids ? delete obj.gridStyleId : delete obj.layoutGrids 258 | 259 | if (obj.textStyleId) { 260 | delete obj.fontName 261 | delete obj.fontSize 262 | delete obj.letterSpacing 263 | delete obj.lineHeight 264 | delete obj.paragraphIndent 265 | delete obj.paragraphSpacing 266 | delete obj.textCase 267 | delete obj.textDecoration 268 | } 269 | else { 270 | delete obj.textStyleId 271 | } 272 | 273 | if (obj.cornerRadius !== figma.mixed) { 274 | delete obj.topLeftRadius 275 | delete obj.topRightRadius 276 | delete obj.bottomLeftRadius 277 | delete obj.bottomRightRadius 278 | } 279 | else { 280 | delete obj.cornerRadius 281 | } 282 | } 283 | 284 | return obj 285 | } 286 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import App from './PluginUI'; 2 | 3 | const app = new App({ 4 | target: document.body, 5 | }); 6 | 7 | export default app; -------------------------------------------------------------------------------- /src/pluginGeneration.ts: -------------------------------------------------------------------------------- 1 | import v from "voca"; 2 | import Str from "./str"; 3 | import { 4 | getInstanceCounterpart, 5 | getTopInstance, 6 | getInstanceCounterpartUsingLocation, 7 | getOverrides, 8 | getNodeDepth, 9 | getParentInstance, 10 | getNoneGroupParent, 11 | isInsideInstance, 12 | } from "@fignite/helpers"; 13 | import { putValuesIntoArray, nodeToObject } from "./helpers"; 14 | import { 15 | allowedProps, 16 | defaultPropValues, 17 | textProps, 18 | styleProps, 19 | } from "./props"; 20 | 21 | // TODO: Check for properties that can't be set on instances or nodes inside instances 22 | // TODO: walkNodes and string API could be improved 23 | // TODO: Fix mirror hangding null in vectors 24 | // TODO: Some issues with auto layout, grow 1. These need to be applied to children after all children have been created. 25 | // TODO: How to check for missing fonts 26 | // TODO: Add support for images 27 | // TODO: Find a way to handle exponential numbers better 28 | 29 | // function getParentInstances(node, instances = []) { 30 | // const parent = node.parent 31 | // if (node.type === "PAGE") return null 32 | // if (parent.type === "INSTANCE") { 33 | // instances.push(parent) 34 | // } 35 | // if (isInsideInstance(node)) { 36 | // return getParentInstances(node.parent, instances) 37 | // } else { 38 | // return instances 39 | // } 40 | // } 41 | 42 | function getParentInstances(node, instances = []) { 43 | if (node.type === "PAGE") return null; 44 | if (node.parent.type === "INSTANCE") { 45 | instances.push(node.parent); 46 | } 47 | if (isInsideInstance(node)) { 48 | return getParentInstances(node.parent, instances); 49 | } else { 50 | return instances; 51 | } 52 | } 53 | 54 | function getParentComponents(node, instances = []) { 55 | if (node.type === "PAGE") return null; 56 | if (node.parent.type === "INSTANCE") { 57 | instances.push(node.parent.mainComponent); 58 | } 59 | if (isInsideInstance(node)) { 60 | return getParentInstances(node.parent, instances); 61 | } else { 62 | return instances; 63 | } 64 | } 65 | 66 | function Utf8ArrayToStr(array) { 67 | var out, i, len, c; 68 | var char2, char3; 69 | 70 | out = ""; 71 | len = array.length; 72 | i = 0; 73 | while (i < len) { 74 | c = array[i++]; 75 | switch (c >> 4) { 76 | case 0: 77 | case 1: 78 | case 2: 79 | case 3: 80 | case 4: 81 | case 5: 82 | case 6: 83 | case 7: 84 | // 0xxxxxxx 85 | out += String.fromCharCode(c); 86 | break; 87 | case 12: 88 | case 13: 89 | // 110x xxxx 10xx xxxx 90 | char2 = array[i++]; 91 | out += String.fromCharCode(((c & 0x1f) << 6) | (char2 & 0x3f)); 92 | break; 93 | case 14: 94 | // 1110 xxxx 10xx xxxx 10xx xxxx 95 | char2 = array[i++]; 96 | char3 = array[i++]; 97 | out += String.fromCharCode( 98 | ((c & 0x0f) << 12) | 99 | ((char2 & 0x3f) << 6) | 100 | ((char3 & 0x3f) << 0) 101 | ); 102 | break; 103 | } 104 | } 105 | 106 | return out; 107 | } 108 | 109 | function toArrayBuffer(buf) { 110 | var ab = new ArrayBuffer(buf.length); 111 | var view = new Uint8Array(ab); 112 | for (var i = 0; i < buf.length; ++i) { 113 | view[i] = buf[i]; 114 | } 115 | console.log(ab); 116 | return ab; 117 | } 118 | 119 | function toBuffer(ab) { 120 | var buf = Buffer.alloc(ab.byteLength); 121 | var view = new Uint8Array(ab); 122 | for (var i = 0; i < buf.length; ++i) { 123 | buf[i] = view[i]; 124 | } 125 | console.log(buf); 126 | return buf; 127 | } 128 | 129 | // FIXME: vectorNetwork and vectorPath cannot be over ridden on an instance 130 | 131 | function simpleClone(val) { 132 | return JSON.parse(JSON.stringify(val)); 133 | } 134 | 135 | export async function genPluginStr(origSel, opts?) { 136 | var str = new Str(); 137 | 138 | var fonts; 139 | var allComponents = []; 140 | var discardNodes = []; 141 | 142 | var styles = {}; 143 | var images = []; 144 | // console.log(styles) 145 | 146 | // Provides a reference for the node when printed as a string 147 | function Ref(nodes) { 148 | var result = []; 149 | 150 | // TODO: Needs to somehow replace parent node references of selection with figma.currentPage 151 | // result.push(v.camelCase(node.type) + node.id.replace(/\:|\;/g, "_")) 152 | 153 | nodes = putValuesIntoArray(nodes); 154 | 155 | for (var i = 0; i < nodes.length; i++) { 156 | var node = nodes[i]; 157 | 158 | // console.log("node", node) 159 | 160 | if (node) { 161 | // TODO: Needs to check if node exists inside selection 162 | // figma.currentPage.selection.some((item) => item === node) 163 | // function nodeExistsInSel(nodes = figma.currentPage.selection) { 164 | // for (var i = 0; i < nodes.length; i++) { 165 | // var sourceNode = nodes[i] 166 | // if (sourceNode.id === node.id) { 167 | // return true 168 | // } 169 | // else if (sourceNode.children) { 170 | // return nodeExistsInSel(sourceNode.children) 171 | // } 172 | // } 173 | // } 174 | 175 | // console.log(node.name, node.id, node.type, nodeExistsInSel()) 176 | 177 | if (node.type === "PAGE") { 178 | result.push("figma.currentPage"); 179 | } else { 180 | // If node is nested inside an instance it needs another reference 181 | // if (isInsideInstance(node)) { 182 | // result.push(`figma.getNodeById("I" + ${Ref(node.parent)}.id + ";" + ${Ref(node.parent.mainComponent.children[getNodeIndex(node)])}.id)`) 183 | // } 184 | // else { 185 | // result.push(v.camelCase(node.type) + "_" + node.id.replace(/\:|\;/g, "_") + "_" + node.name.replace(/\:|\;|\/|=/g, "_")) 186 | result.push( 187 | v.camelCase(node.type) + 188 | "_" + 189 | node.id.replace(/\:|\;/g, "_") 190 | ); 191 | // } 192 | } 193 | } 194 | } 195 | 196 | if (result.length === 1) result = result[0]; 197 | 198 | return result; 199 | } 200 | 201 | function StyleRef(style) { 202 | return ( 203 | v.lowerCase(style.name.replace(/\s|\//g, "_").replace(/\./g, "")) + 204 | "_" + 205 | style.key.slice(-4) 206 | ); 207 | } 208 | 209 | // A function that lets you loop through each node and their children, it provides callbacks to reference different parts of the loops life cycle, before, during, or after the loop. 210 | 211 | function walkNodes(nodes, callback?, parent?, selection?, level?) { 212 | let node; 213 | 214 | for (var i = 0; i < nodes.length; i++) { 215 | if (!parent) { 216 | selection = i; 217 | level = 0; 218 | } 219 | 220 | node = nodes[i]; 221 | 222 | // console.log(node.type) 223 | // If main component doesn't exist in document then add it to list to be created 224 | // Don't think this does anything 225 | // if (node.type === "COMPONENT" && node.parent === null) { 226 | // // console.log(node.type, node.mainComponent, node.name, node) 227 | // // FIXME: Don't create a clone becuase this will give it a diffrent id. Instead add it to the page so it can be picked up? Need to then remove it again to clean up the document? Might be better to see where this parent is used and subsitute with `figma.currentPage` 228 | // // console.log(node.name) 229 | 230 | // // If component can't be added to page, then it is from an external library 231 | // // Why am I adding it to the page again? 232 | // try { 233 | // // figma.currentPage.appendChild(node) 234 | // } 235 | // catch (error) { 236 | // // node = node.clone() 237 | // } 238 | 239 | // // discardNodes.push(node) 240 | // } 241 | 242 | let sel = selection; // Index of which top level array the node lives in 243 | let ref = node.type?.toLowerCase() + (i + 1 + level - sel); // Trying to find a way to create a unique identifier based on where node lives in structure 244 | 245 | if (!parent) parent = "figma.currentPage"; 246 | 247 | // These may not be needed now 248 | var obj = { 249 | ref, 250 | level, // How deep down the node lives in the array structure 251 | sel, 252 | parent, 253 | }; 254 | 255 | var stop = false; 256 | if (callback.during) { 257 | // console.log("sibling node", node) 258 | // If boolean value of true returned from createBasic() then this sets a flag to stop iterating children in node 259 | // console.log("node being traversed", node) 260 | 261 | stop = callback.during(node, obj); 262 | } 263 | 264 | if (node.children) { 265 | ++level; 266 | 267 | if (stop && nodes[i + 1]) { 268 | // Iterate the next node 269 | // ++i 270 | 271 | walkNodes([nodes[i + 1]], callback, ref, selection, level); 272 | } else { 273 | walkNodes(node.children, callback, ref, selection, level); 274 | } 275 | 276 | if (callback.after) { 277 | callback.after(node, obj); 278 | } 279 | } 280 | } 281 | } 282 | 283 | function isInstanceDefaultVariant(node) { 284 | if (node.type === "INSTANCE") { 285 | var isInstanceDefaultVariant = true; 286 | var componentSet = node.mainComponent.parent; 287 | if (componentSet) { 288 | if (componentSet.type === "COMPONENT_SET") { 289 | if ( 290 | componentSet !== null && 291 | componentSet.type === "COMPONENT_SET" 292 | ) { 293 | var defaultVariant = componentSet.defaultVariant; 294 | 295 | if ( 296 | defaultVariant && 297 | defaultVariant.id !== node.mainComponent.id 298 | ) { 299 | isInstanceDefaultVariant = false; 300 | } 301 | } 302 | 303 | return isInstanceDefaultVariant; 304 | } 305 | } else { 306 | return false; 307 | } 308 | } else { 309 | // Returns true because is not an instance and therefor should pass 310 | // TODO: Consider changing function to hasComponentBeenSwapped or something similar 311 | return true; 312 | } 313 | } 314 | 315 | function componentHasBeenSwapped(node) { 316 | if (node.type === "INSTANCE") { 317 | if (node.mainComponent.parent) { 318 | if (node.mainComponent.parent.type === "COMPONENT_SET") { 319 | var componentBeenSwapped = false; 320 | var componentSet = node.mainComponent.parent; 321 | if ( 322 | componentSet !== null && 323 | componentSet.type === "COMPONENT_SET" 324 | ) { 325 | var defaultVariant = componentSet.defaultVariant; 326 | 327 | if ( 328 | defaultVariant && 329 | defaultVariant.id !== node.mainComponent.id 330 | ) { 331 | componentBeenSwapped = true; 332 | } 333 | } 334 | 335 | return componentBeenSwapped; 336 | } 337 | } 338 | } 339 | } 340 | 341 | function collectImageHash(node) { 342 | if ("fills" in node) { 343 | for (var i = 0; i < node.fills.length; i++) { 344 | var fill = node.fills[i]; 345 | if (fill.type === "IMAGE") { 346 | images.push(fill.imageHash); 347 | } 348 | } 349 | } 350 | } 351 | 352 | function replaceImageHasWithRef(node) { 353 | if ("fills" in node) { 354 | var fills = simpleClone(node.fills); 355 | for (var i = 0; i < fills.length; i++) { 356 | var fill = fills[i]; 357 | if (fill.type === "IMAGE") { 358 | images.push({ imageHash: fill.imageHash, node }); 359 | fill.imageHash = `${Ref(node)}_image.imageHash`; 360 | } 361 | } 362 | return fills; 363 | } 364 | } 365 | 366 | // async function createImageHash(node) { 367 | // if ('fills' in node) { 368 | // for (var i = 0; i < node.fills.length; i++) { 369 | // var fill = node.fills[i] 370 | // if (fill.type === "IMAGE") { 371 | // // figma.getImageByHash(fill.imageHash).getBytesAsync().then((image) => { 372 | // // str` 373 | // // // Create IMAGE HASH 374 | // // var ${Ref(node)}_image_hash = ${image}\n 375 | // // ` 376 | // // }) 377 | 378 | // return figma.getImageByHash(fill.imageHash).getBytesAsync() 379 | // } 380 | // } 381 | // } 382 | // } 383 | 384 | // createImageHash(node).then((image) => { 385 | // str` 386 | // // // Create IMAGE HASH 387 | // // var ${Ref(node)}_image_hash = ${image}\n 388 | // // ` 389 | 390 | // }) 391 | 392 | function createProps(node, level, options = {}, mainComponent?) { 393 | var string = ""; 394 | var staticPropsStr = ""; 395 | var textPropsString = ""; 396 | var fontsString = ""; 397 | var hasText; 398 | var hasWidthOrHeight = true; 399 | 400 | // collectImageHash(node) 401 | 402 | var object = node.__proto__ ? nodeToObject(node) : node; 403 | 404 | for (let [name, value] of Object.entries(object)) { 405 | // } 406 | 407 | // copyPasteProps(nodeToObject(node), ({ obj, name, value }) => { 408 | if ( 409 | JSON.stringify(value) !== 410 | JSON.stringify(defaultPropValues[node.type][name]) && 411 | allowedProps.includes(name) && 412 | // name !== "key" && 413 | // name !== "mainComponent" && 414 | // name !== "absoluteTransform" && 415 | // name !== "type" && 416 | // name !== "id" && 417 | // name !== "parent" && 418 | // name !== "children" && 419 | // name !== "masterComponent" && 420 | // name !== "mainComponent" && 421 | // name !== "horizontalPadding" && 422 | // name !== "verticalPadding" && 423 | // name !== "reactions" && 424 | // name !== "overlayPositionType" && 425 | // name !== "overflowDirection" && 426 | // name !== "numberOfFixedChildren" && 427 | // name !== "overlayBackground" && 428 | // name !== "overlayBackgroundInteraction" && 429 | // name !== "remote" && 430 | // name !== "defaultVariant" && 431 | // name !== "hasMissingFont" && 432 | // name !== "exportSettings" && 433 | // name !== "variantProperties" && 434 | // name !== "variantGroupProperties" && 435 | // name !== "absoluteRenderBounds" && 436 | // name !== "fillGeometry" && 437 | // name !== "strokeGeometry" && 438 | // name !== "stuckNodes" && 439 | // name !== "componentPropertyReferences" && 440 | // name !== "canUpgradeToNativeBidiSupport" && 441 | // name !== "componentPropertyDefinitions" && 442 | // name !== "componentProperties" && 443 | // // Investigate these ones 444 | // name !== "itemReverseZIndex" && 445 | // name !== "strokesIncludedInLayout" && 446 | !( 447 | (isInsideInstance(node) || node.type === "INSTANCE") && 448 | name === "vectorNetwork" 449 | ) && 450 | !( 451 | (isInsideInstance(node) || node.type === "INSTANCE") && 452 | name === "vectorPaths" 453 | ) 454 | ) { 455 | // TODO: ^ Add some of these exclusions to nodeToObject() 456 | 457 | var overriddenProp = true; 458 | var counterSizingIsFixed = false; 459 | var primarySizingIsFixed = false; 460 | var shouldResizeWidth = false; 461 | var shouldResizeHeight = false; 462 | 463 | if (node.type === "INSTANCE" && !isInsideInstance(node)) { 464 | overriddenProp = 465 | JSON.stringify(node[name]) !== 466 | JSON.stringify(mainComponent[name]); 467 | } 468 | 469 | if (node.type === "INSTANCE") { 470 | if (node.width !== node.mainComponent?.width) { 471 | if (node.primaryAxisSizingMode === "FIXED") { 472 | shouldResizeWidth = true; 473 | } 474 | } 475 | 476 | if (node.height !== node.mainComponent?.height) { 477 | if (node.counterAxisSizingMode === "FIXED") { 478 | shouldResizeHeight = true; 479 | } 480 | } 481 | } else { 482 | shouldResizeHeight = true; 483 | shouldResizeWidth = true; 484 | } 485 | 486 | // Applies property overrides of instances (currently only activates characters) 487 | if (isInsideInstance(node)) { 488 | var parentInstance = getParentInstance(node); 489 | // var depthOfNode = getNodeDepth(node, parentInstance) 490 | 491 | // Add these exclusions to getOverrides helper 492 | // if (!('horizontalPadding' in node) || !('verticalPadding' in node)) { 493 | 494 | if (typeof getOverrides(node, name) !== "undefined") { 495 | } else { 496 | overriddenProp = false; 497 | } 498 | // } 499 | } 500 | 501 | if (overriddenProp) { 502 | // Can't override certain properties on nodes which are part of instance 503 | if ( 504 | !( 505 | isInsideInstance(node) && 506 | (name === "x" || 507 | name === "y" || 508 | name === "relativeTransform") 509 | ) 510 | ) { 511 | // Add resize 512 | 513 | if (options?.resize !== false) { 514 | // FIXME: This is being ignored when default of node is true for width, but not for height 515 | if ( 516 | (name === "width" || name === "height") && 517 | hasWidthOrHeight 518 | ) { 519 | hasWidthOrHeight = false; 520 | 521 | // This checks if the instance is set to fixed sizing, if so it checks if it's different from the main component to determine if it should be resized 522 | if (shouldResizeHeight || shouldResizeWidth) { 523 | // Round widths/heights less than 0.001 to 0.01 because API does not accept less than 0.01 for frames/components/component sets 524 | // Need to round super high relative transform numbers 525 | var width = node.width.toFixed(10); 526 | var height = node.height.toFixed(10); 527 | 528 | // FIXME: Should this apply to all nodes types? 529 | if ( 530 | (node.type === "FRAME" || 531 | node.type === "COMPONENT" || 532 | node.type === "RECTANGLE" || 533 | node.type === "INSTANCE") && 534 | node.width < 0.01 535 | ) 536 | width = 0.01; 537 | if ( 538 | (node.type === "FRAME" || 539 | node.type === "COMPONENT" || 540 | node.type === "RECTANGLE" || 541 | node.type === "INSTANCE") && 542 | node.height < 0.01 543 | ) 544 | height = 0.01; 545 | 546 | // Lines have to have a height of 0 547 | if (node.type === "LINE") height = 0; 548 | 549 | if ( 550 | (node.type === "FRAME" && 551 | node.width < 0.01) || 552 | node.height < 0.01 553 | ) { 554 | string += ` ${Ref( 555 | node 556 | )}.resizeWithoutConstraints(${width}, ${height})\n`; 557 | } else { 558 | string += ` ${Ref( 559 | node 560 | )}.resize(${width}, ${height})\n`; 561 | } 562 | 563 | // Need to check for sizing property first because not all nodes have this property eg TEXT, LINE, RECTANGLE 564 | // This is to reset the sizing of either the width of height because it has been overriden by the resize method 565 | if ( 566 | node.primaryAxisSizingMode && 567 | node.primaryAxisSizingMode !== "FIXED" 568 | ) { 569 | string += ` ${Ref( 570 | node 571 | )}.primaryAxisSizingMode = ${JSON.stringify( 572 | node.primaryAxisSizingMode 573 | )}\n`; 574 | } 575 | 576 | if ( 577 | node.counterAxisSizingMode && 578 | node.counterAxisSizingMode !== "FIXED" 579 | ) { 580 | string += ` ${Ref( 581 | node 582 | )}.counterAxisSizingMode = ${JSON.stringify( 583 | node.counterAxisSizingMode 584 | )}\n`; 585 | } 586 | } 587 | } 588 | } 589 | 590 | // If styles 591 | 592 | let style; 593 | if (styleProps.includes(name)) { 594 | var styleId = node[name]; 595 | styles[name] = styles[name] || []; 596 | 597 | // Get the style 598 | style = figma.getStyleById(styleId); 599 | 600 | // Push to array if unique 601 | if ( 602 | !styles[name].some( 603 | (item) => 604 | JSON.stringify(item.id) === 605 | JSON.stringify(style.id) 606 | ) 607 | ) { 608 | styles[name].push(style); 609 | } 610 | 611 | // Assign style to node 612 | if (name !== "textStyleId") { 613 | string += ` ${Ref(node)}.${name} = ${StyleRef( 614 | style 615 | )}.id\n`; 616 | } 617 | } 618 | 619 | // If text prop 620 | if (textProps.includes(name)) { 621 | if (name === "textStyleId") { 622 | textPropsString += ` ${Ref( 623 | node 624 | )}.${name} = ${StyleRef(style)}.id\n`; 625 | } else { 626 | textPropsString += ` ${Ref( 627 | node 628 | )}.${name} = ${JSON.stringify(value)}\n`; 629 | } 630 | } 631 | 632 | // If a text node 633 | if (name === "characters") { 634 | hasText = true; 635 | fonts = fonts || []; 636 | if ( 637 | !fonts.some( 638 | (item) => 639 | JSON.stringify(item) === 640 | JSON.stringify(node.fontName) 641 | ) 642 | ) { 643 | fonts.push(node.fontName); 644 | } 645 | 646 | if (node.fontName) { 647 | fontsString += `\ 648 | ${Ref(node)}.fontName = { 649 | family: ${JSON.stringify(node.fontName?.family)}, 650 | style: ${JSON.stringify(node.fontName?.style)} 651 | }`; 652 | } 653 | } 654 | 655 | if ( 656 | name !== "width" && 657 | name !== "height" && 658 | !textProps.includes(name) && 659 | !styleProps.includes(name) 660 | ) { 661 | // FIXME: Need a less messy way to do this on all numbers 662 | // Need to round super high relative transform numbers 663 | if (name === "relativeTransform") { 664 | var newValue = [ 665 | [0, 0, 0], 666 | [0, 0, 0], 667 | ]; 668 | newValue[0][0] = +value[0][0].toFixed(10); 669 | newValue[0][1] = +value[0][1].toFixed(10); 670 | newValue[0][2] = +value[0][2].toFixed(10); 671 | 672 | newValue[1][0] = +value[1][0].toFixed(10); 673 | newValue[1][1] = +value[1][1].toFixed(10); 674 | newValue[1][2] = +value[1][2].toFixed(10); 675 | 676 | value = newValue; 677 | } 678 | if (options?.[name] !== false) { 679 | // Disabled for now because I'm not sure how to programmatically add images. I think might have to include function to convert bytes to array 680 | // if (name === "fills") { 681 | // var newValueX = JSON.stringify(replaceImageHasWithRef(node)) 682 | 683 | // staticPropsStr += `${Ref(node)}.fills = ${newValueX}\n` 684 | // } 685 | // else { 686 | staticPropsStr += ` ${Ref( 687 | node 688 | )}.${name} = ${JSON.stringify(value)}\n`; 689 | // } 690 | } 691 | } 692 | } 693 | } 694 | } 695 | } 696 | 697 | var loadFontsString = ""; 698 | 699 | if (hasText) { 700 | loadFontsString = `\n // Font properties 701 | ${fontsString} 702 | ${textPropsString}`; 703 | } 704 | 705 | string += `${staticPropsStr}`; 706 | string += ` ${loadFontsString}`; 707 | 708 | // TODO: Need to create another function for lifecylce of any node and add this to bottom 709 | // if (opts?.includeObject) { 710 | // if (level === 0) { 711 | // string += `nodes.push(${Ref(node)})\n`; 712 | // } 713 | // } 714 | str`${string}`; 715 | } 716 | 717 | function appendNode(node) { 718 | // If parent is a group type node then append to nearest none group parent 719 | if (node.parent) { 720 | if ( 721 | node.parent?.type === "BOOLEAN_OPERATION" || 722 | node.parent?.type === "GROUP" 723 | ) { 724 | str` ${Ref(getNoneGroupParent(node))}.appendChild(${Ref( 725 | node 726 | )})\n`; 727 | } else if (node.parent?.type === "COMPONENT_SET") { 728 | // Currently set to do nothing, but should it append to something? Is there a way? 729 | // str`${Ref(getNoneGroupParent(node))}.appendChild(${Ref(node)})\n` 730 | } else { 731 | str` ${Ref(node.parent)}.appendChild(${Ref(node)})\n`; 732 | } 733 | } 734 | } 735 | 736 | function createBasic(node, level, options = {}) { 737 | if (node.type === "COMPONENT") { 738 | // If node being visited matches a component already visited (ie, already created?), then set falg to true so loop stops traversing 739 | if ( 740 | allComponents.some( 741 | (component) => 742 | JSON.stringify(component) === JSON.stringify(node) 743 | ) 744 | ) { 745 | return true; 746 | } 747 | } 748 | 749 | if ( 750 | node.type !== "GROUP" && 751 | node.type !== "INSTANCE" && 752 | node.type !== "COMPONENT_SET" && 753 | node.type !== "BOOLEAN_OPERATION" && 754 | !isInsideInstance(node) 755 | ) { 756 | // If it's a component first check if it's been added to the list before creating, if not then create it and add it to the list (only creates frame) 757 | 758 | if ( 759 | !allComponents.some( 760 | (component) => 761 | JSON.stringify(component) === JSON.stringify(node) 762 | ) 763 | ) { 764 | str` 765 | 766 | // Create ${node.type} 767 | var ${Ref(node)} = figma.create${v.titleCase(node.type)}()\n`; 768 | 769 | if (node.type !== "COMPONENT" || options?.append !== false) { 770 | appendNode(node); 771 | } 772 | 773 | createProps(node, level); 774 | 775 | // else if (options?.append !== false) { 776 | // if (node.type !== "COMPONENT") { 777 | // appendNode(node) 778 | // } 779 | 780 | // } 781 | 782 | allComponents.push(node); 783 | } 784 | } 785 | 786 | function createRefToInstanceNode(node) { 787 | // FIXME: I think this needs to include the ids of several nested instances. In order to do that, references need to be made for them even if there no overrides 788 | // This dynamically creates the reference to nodes nested inside instances. I consists of two parts. The first is the id of the parent instance. The second part is the id of the current instance counterpart node. 789 | 790 | // var childRef = "" 791 | // // if (getNodeDepth(node, getParentInstance(node)) > 0) { 792 | 793 | // // console.log("----") 794 | // // console.log("instanceNode", node) 795 | // // console.log("counterpart", getInstanceCounterpart(node)) 796 | // // console.log("nodeDepth", getNodeDepth(node, findParentInstance(node))) 797 | // // console.log("instanceParent", findParentInstance(node)) 798 | 799 | // // FIXME: In some cases counterpart is returned as undefined. I think because layer might be hidden?. Tried again with layer hidden and issue didn't happen again. Maybe a figma bug. Perhaps to workaround, unhide layer and hide again. 800 | // if (typeof getInstanceCounterpartUsingLocation(node) === 'undefined') { 801 | // console.warn("Can't get location of counterpart", node) 802 | // } 803 | // else { 804 | // childRef = ` + ";" + ${Ref(getInstanceCounterpartUsingLocation(node))}.id` 805 | // } 806 | 807 | // // } 808 | 809 | // var letterI = `"I" +` 810 | 811 | // if (getParentInstance(node).id.startsWith("I")) { 812 | // letterI = `` 813 | // } 814 | 815 | // TODO: Try getting all the ids of the parents 816 | // TODO: 1. Get all the nodes of the parent instannces 817 | // 2. Output the id 818 | // 3. output the id of the original component 819 | 820 | var letterI = `"I" + `; 821 | 822 | if (getParentInstance(node).id.startsWith("I")) { 823 | letterI = ``; 824 | } 825 | 826 | // // Does it only need the top instance? 827 | // var parentInstances = getParentInstances(node) 828 | // var string = "" 829 | // if (parentInstances) { 830 | // // parentInstances.shift() 831 | // console.log(parentInstances) 832 | // var array = [] 833 | // for (var i = 0; i < parentInstances.length; i++) { 834 | // var instance = parentInstances[i] 835 | 836 | // array.push(`${Ref(instance)}.id`) 837 | // } 838 | 839 | // string = array.join(` + ";" + `) 840 | // } 841 | 842 | var child = `${Ref( 843 | getInstanceCounterpartUsingLocation( 844 | node, 845 | getParentInstance(node) 846 | ) 847 | )}.id`; 848 | var ref = `${letterI}${Ref( 849 | getParentInstance(node) 850 | )}.id + ";" + ${child}`; 851 | // if (node.id === figma.currentPage.selection[0].id) { 852 | // console.log(">>>>>", figma.currentPage.selection[0].id, ref) 853 | // } 854 | 855 | // console.log(getParentInstances(node).join(";")) 856 | 857 | return `var ${Ref(node)} = figma.getNodeById(${ref})`; 858 | } 859 | 860 | // Create overides for nodes inside instances 861 | 862 | // if (!('horizontalPadding' in node) || !('verticalPadding' in node)) { 863 | // if (getOverrides(node)) { 864 | if (isInsideInstance(node)) { 865 | // if (node.type === "INSTANCE") { 866 | // if (isInstanceDefaultVariant(node)) { 867 | // str` 868 | // // Component wasn't swapped by user 869 | // var ${Ref(node)}` 870 | // } 871 | // } 872 | 873 | str` 874 | // Ref to SUB NODE 875 | ${createRefToInstanceNode(node)}\n`; 876 | 877 | if (getOverrides(node)) { 878 | // If overrides exist apply them 879 | createProps(node, level); 880 | } 881 | } 882 | // } 883 | // } 884 | 885 | // Swap instances if different from default variant 886 | if (node.type === "INSTANCE") { 887 | // console.log("node name", node.name) 888 | // Swap if not the default variant 889 | // if (!isInstanceDefaultVariant(node)) { 890 | // console.log("node name swapped", node.name) 891 | 892 | // NOTE: Cannot use node ref when instance/node nested inside instance because not created by plugin. Must use an alternative method to identify instance to swap. Cannot use getNodeById unless you know what the node id will be. So what we do here, is dynamically lookup the id by combining the dynamic ids of several node references. This might need to work for more than one level of instances nested inside an instance. 893 | // if (isInsideInstance(node)) { 894 | // str` 895 | // // Swap COMPONENT 896 | // ${createRefToInstanceNode(node)}\n` 897 | // } 898 | // if (node.id === figma.currentPage.selection[0].id) { 899 | // console.log(">>>>>", " has been swapped") 900 | // } 901 | 902 | // NOTE: Decided to always swap the component because can't know if it's correct or not. 903 | str` 904 | 905 | // Swap COMPONENT 906 | ${Ref(node)}.swapComponent(${Ref(node.mainComponent)})\n`; 907 | 908 | // } 909 | } 910 | } 911 | 912 | function createInstance(node, level) { 913 | var mainComponent; 914 | 915 | if (node.type === "INSTANCE") { 916 | mainComponent = node.mainComponent; 917 | } 918 | 919 | // console.log("node", node.type, node.mainComponent) 920 | 921 | if (node.type === "INSTANCE") { 922 | // If main component not selected by user 923 | // Grab all components and add to list 924 | // If main component of instance not already visited, ie (selected by the user), then create it and it's children 925 | 926 | if (!allComponents.includes(mainComponent)) { 927 | createNode(mainComponent, { append: false }); 928 | } 929 | } 930 | 931 | if (node.type === "INSTANCE" && !isInsideInstance(node)) { 932 | str` 933 | 934 | // Create INSTANCE 935 | var ${Ref(node)} = ${Ref(mainComponent)}.createInstance()\n`; 936 | 937 | appendNode(node); 938 | 939 | // Need to reference main component so that createProps can check if props are overriden 940 | createProps(node, level, {}, mainComponent); 941 | } 942 | 943 | // Once component has been created add it to array of all components 944 | if (node.type === "INSTANCE") { 945 | if ( 946 | !allComponents.some( 947 | (component) => 948 | JSON.stringify(component) === 949 | JSON.stringify(mainComponent) 950 | ) 951 | ) { 952 | allComponents.push(mainComponent); 953 | } 954 | } 955 | } 956 | 957 | function createGroup(node, level) { 958 | if (node.type === "GROUP" && !isInsideInstance(node)) { 959 | var children: any = Ref(node.children); 960 | if (Array.isArray(children)) { 961 | children = Ref(node.children).join(", "); 962 | } 963 | var parent; 964 | if ( 965 | node.parent?.type === "GROUP" || 966 | node.parent?.type === "COMPONENT_SET" || 967 | node.parent?.type === "BOOLEAN_OPERATION" 968 | ) { 969 | parent = `${Ref(getNoneGroupParent(node))}`; 970 | // parent = `figma.currentPage` 971 | } else { 972 | parent = `${Ref(node.parent)}`; 973 | } 974 | str` 975 | 976 | // Create GROUP 977 | var ${Ref(node)} = figma.group([${children}], ${parent})\n`; 978 | createProps(node, level, { 979 | resize: false, 980 | relativeTransform: false, 981 | x: false, 982 | y: false, 983 | rotation: false, 984 | }); 985 | } 986 | } 987 | 988 | function createBooleanOperation(node, level) { 989 | // Boolean can not be created if inside instance 990 | // TODO: When boolean objects are created they loose their coordinates? 991 | // TODO: Don't resize boolean objects 992 | if (node.type === "BOOLEAN_OPERATION" && !isInsideInstance(node)) { 993 | var children: any = Ref(node.children); 994 | if (Array.isArray(children)) { 995 | children = Ref(node.children).join(", "); 996 | } 997 | var parent; 998 | if ( 999 | node.parent?.type === "GROUP" || 1000 | node.parent?.type === "COMPONENT_SET" || 1001 | node.parent?.type === "BOOLEAN_OPERATION" 1002 | ) { 1003 | parent = `${Ref(getNoneGroupParent(node))}`; 1004 | } else { 1005 | parent = `${Ref(node.parent)}`; 1006 | } 1007 | str` 1008 | 1009 | // Create BOOLEAN_OPERATION 1010 | var ${Ref(node)} = figma.${v.lowerCase( 1011 | node.booleanOperation 1012 | )}([${children}], ${parent})\n`; 1013 | 1014 | var x = node.parent.x - node.x; 1015 | var y = node.parent.y - node.y; 1016 | 1017 | // TODO: Don't apply relativeTransform, x, y, or rotation to booleans 1018 | createProps(node, level, { 1019 | resize: false, 1020 | relativeTransform: false, 1021 | x: false, 1022 | y: false, 1023 | rotation: false, 1024 | }); 1025 | } 1026 | } 1027 | 1028 | function createComponentSet(node, level) { 1029 | // FIXME: What should happen when the parent is a group? The component set can't be added to a appended to a group. It therefore must be added to the currentPage, and then grouped by the group function? 1030 | if (node.type === "COMPONENT_SET") { 1031 | var children: any = Ref(node.children); 1032 | if (Array.isArray(children)) { 1033 | children = Ref(node.children).join(", "); 1034 | } 1035 | var parent; 1036 | if ( 1037 | node.parent?.type === "GROUP" || 1038 | node.parent?.type === "COMPONENT_SET" || 1039 | node.parent?.type === "BOOLEAN_OPERATION" 1040 | ) { 1041 | parent = `${Ref(getNoneGroupParent(node))}`; 1042 | } else { 1043 | parent = `${Ref(node.parent)}`; 1044 | } 1045 | 1046 | str` 1047 | 1048 | // Create COMPONENT_SET 1049 | var ${Ref(node)} = figma.combineAsVariants([${children}], ${parent})\n`; 1050 | 1051 | createProps(node, level); 1052 | } 1053 | } 1054 | 1055 | function createNode(nodes, options?) { 1056 | nodes = putValuesIntoArray(nodes); 1057 | 1058 | walkNodes(nodes, { 1059 | during(node, { ref, level, sel, parent }) { 1060 | createInstance(node, level); 1061 | 1062 | return createBasic(node, level, options); 1063 | }, 1064 | after(node, { ref, level, sel, parent }) { 1065 | createGroup(node, level); 1066 | createBooleanOperation(node, level); 1067 | createComponentSet(node, level); 1068 | }, 1069 | }); 1070 | } 1071 | 1072 | // figma.showUI(__html__, { width: 320, height: 480 }); 1073 | 1074 | var selection = origSel; 1075 | 1076 | // for (var i = 0; i < selection.length; i++) { 1077 | createNode(selection); 1078 | // } 1079 | 1080 | async function generateImages() { 1081 | var array = []; 1082 | if (images && images.length > 0) { 1083 | for (var i = 0; i < images.length; i++) { 1084 | var { imageHash } = images[i]; 1085 | 1086 | var imageBytes = await figma 1087 | .getImageByHash(imageHash) 1088 | .getBytesAsync(); 1089 | 1090 | // Commented for now because causing syntax highlighter to crash 1091 | array.push(` 1092 | // Create IMAGE HASH 1093 | // var image = figma.createImage(toBuffer(${imageBytes}))\n`); 1094 | } 1095 | } 1096 | 1097 | return array; 1098 | } 1099 | 1100 | // Create styles 1101 | if (styles) { 1102 | var styleString = ""; 1103 | for (let [key, value] of Object.entries(styles)) { 1104 | for (let i = 0; i < value.length; i++) { 1105 | var style = value[i]; 1106 | 1107 | if ( 1108 | style.type === "PAINT" || 1109 | style.type === "EFFECT" || 1110 | style.type === "GRID" 1111 | ) { 1112 | let nameOfProperty; 1113 | 1114 | if (style.type === "GRID") { 1115 | nameOfProperty = "layoutGrids"; 1116 | } else { 1117 | nameOfProperty = v.camelCase(style.type) + "s"; 1118 | } 1119 | 1120 | styleString += ` 1121 | 1122 | // Create STYLE 1123 | var ${StyleRef(style)} = figma.create${v.titleCase(style.type)}Style() 1124 | ${StyleRef(style)}.name = ${JSON.stringify(style.name)} 1125 | ${StyleRef(style)}.${nameOfProperty} = ${JSON.stringify(style[nameOfProperty])} 1126 | `; 1127 | } 1128 | 1129 | if (style.type === "TEXT") { 1130 | let nameOfProperty = ""; 1131 | 1132 | styleString += `\ 1133 | 1134 | // Create STYLE 1135 | var ${StyleRef(style)} = figma.create${v.titleCase(style.type)}Style() 1136 | ${StyleRef(style)}.name = ${JSON.stringify(style.name)} 1137 | ${StyleRef(style)}.fontName = ${JSON.stringify(style.fontName)} 1138 | ${StyleRef(style)}.fontSize = ${JSON.stringify(style.fontSize)} 1139 | ${StyleRef(style)}.letterSpacing = ${JSON.stringify(style.letterSpacing)} 1140 | ${StyleRef(style)}.lineHeight = ${JSON.stringify(style.lineHeight)} 1141 | ${StyleRef(style)}.paragraphIndent = ${JSON.stringify(style.paragraphIndent)} 1142 | ${StyleRef(style)}.paragraphSpacing = ${JSON.stringify(style.paragraphSpacing)} 1143 | ${StyleRef(style)}.textCase = ${JSON.stringify(style.textCase)} 1144 | ${StyleRef(style)}.textDecoration = ${JSON.stringify(style.textDecoration)} 1145 | `; 1146 | } 1147 | } 1148 | } 1149 | str.prepend`${styleString}`; 1150 | } 1151 | 1152 | if (fonts) { 1153 | str.prepend` 1154 | // Load FONTS 1155 | async function loadFonts() { 1156 | await Promise.all([ 1157 | ${fonts.map((font) => { 1158 | return `figma.loadFontAsync({ 1159 | family: ${JSON.stringify(font?.family)}, 1160 | style: ${JSON.stringify(font?.style)} 1161 | })`; 1162 | })} 1163 | ]) 1164 | } 1165 | 1166 | await loadFonts()`; 1167 | } 1168 | 1169 | // Remove nodes created for temporary purpose 1170 | for (var i = 0; i < discardNodes.length; i++) { 1171 | var node = discardNodes[i]; 1172 | // console.log(node) 1173 | 1174 | // Cannot remove node. Is it because it is from another file? 1175 | // TEMP FIX: Check node exists before trying to remove 1176 | 1177 | if (figma.getNodeById(node.id) && node.parent !== null) node.remove(); 1178 | } 1179 | 1180 | if (opts?.wrapInFunction) { 1181 | if (opts?.includeObject) { 1182 | str` 1183 | // Pass children to function 1184 | let nodes = figma.currentPage.children 1185 | figma.currentPage = oldPage 1186 | 1187 | for (let i = 0; i < nodes.length; i++) { 1188 | let node = nodes[i] 1189 | figma.currentPage.appendChild(node) 1190 | } 1191 | 1192 | newPage.remove() 1193 | 1194 | return nodes\n`; 1195 | } 1196 | // Wrap in function 1197 | str` 1198 | }\n 1199 | createNodes() 1200 | `; 1201 | } 1202 | 1203 | if (opts?.wrapInFunction) { 1204 | if (opts?.includeObject) { 1205 | str.prepend` 1206 | // Create temporary page to pass nodes to function 1207 | let oldPage = figma.currentPage 1208 | let newPage = figma.createPage() 1209 | figma.currentPage = newPage 1210 | `; 1211 | } 1212 | 1213 | // Wrap in function 1214 | str.prepend` 1215 | // Wrap in function 1216 | async function createNodes() { 1217 | `; 1218 | } 1219 | 1220 | // var imageArray = await generateImages() 1221 | 1222 | // var imageString = "" 1223 | // if (imageArray && imageArray.length > 0) { 1224 | // imageString = imageArray.join() 1225 | // } 1226 | 1227 | return [ 1228 | ...str() 1229 | .replace(/^\n|\n$/g, "") 1230 | .match(/(?=[\s\S])(?:.*\n?){1,8}/g), 1231 | ]; 1232 | 1233 | // result = result.join("").replace(/^\n|\n$/g, "") 1234 | } 1235 | -------------------------------------------------------------------------------- /src/props.ts: -------------------------------------------------------------------------------- 1 | // These are the default values that nodes get when they are created using the API, not via the editor. They are then used to make sure that these props and values are added to nodes created using 2 | 3 | export const allowedProps = [ 4 | "name", 5 | "visible", 6 | "locked", 7 | "opacity", 8 | "blendMode", 9 | "isMask", 10 | "effects", 11 | "relativeTransform", 12 | // "absoluteTransform", 13 | "x", 14 | "y", 15 | "width", 16 | "height", 17 | "rotation", 18 | "layoutAlign", 19 | "constrainProportions", 20 | "layoutGrow", 21 | "exportSettings", 22 | "fills", 23 | "strokes", 24 | "strokeWeight", 25 | "strokeAlign", 26 | "strokeCap", 27 | "strokeJoin", 28 | "strokeMiterLimit", 29 | "dashPattern", 30 | "cornerRadius", 31 | "cornerSmoothing", 32 | "topLeftRadius", 33 | "topRightRadius", 34 | "bottomLeftRadius", 35 | "bottomRightRadius", 36 | "paddingLeft", 37 | "paddingRight", 38 | "paddingTop", 39 | "paddingBottom", 40 | "primaryAxisAlignItems", 41 | "counterAxisAlignItems", 42 | "primaryAxisSizingMode", 43 | "layoutPositioning", 44 | "strokeTopWeight", 45 | "strokeBottomWeight", 46 | "strokeLeftWeight", 47 | "strokeRightWeight", 48 | "layoutGrids", 49 | "clipsContent", 50 | "guides", 51 | "expanded", 52 | "constraints", 53 | "layoutMode", 54 | "counterAxisSizingMode", 55 | "itemSpacing", 56 | "overflowDirection", 57 | "numberOfFixedChildren", 58 | // "overlayPositionType", 59 | // "overlayBackground", 60 | // "overlayBackgroundInteraction", 61 | "reactions", 62 | "hyperlink", 63 | "characters", 64 | "lineHeight", 65 | "listSpacing", 66 | "fontName", 67 | "textAutoResize", 68 | "autoRename", 69 | "paints", 70 | "textDecoration", 71 | "textCase", 72 | "paragraphSpacing", 73 | "paragraphIndent", 74 | "fontSize", 75 | "fillStyleId", 76 | "backgroundStyleId", 77 | "strokeStyleId", 78 | "documentationLinks", 79 | "description", 80 | "vectorNetwork", 81 | "vectorPaths", 82 | "strokesIncludedInLayout", 83 | "itemReverseZIndex", 84 | ]; 85 | 86 | // name !== "key" && 87 | // name !== "mainComponent" && 88 | // name !== "absoluteTransform" && 89 | // name !== "type" && 90 | // name !== "id" && 91 | // name !== "parent" && 92 | // name !== "children" && 93 | // name !== "masterComponent" && 94 | // name !== "mainComponent" && 95 | // name !== "horizontalPadding" && 96 | // name !== "verticalPadding" && 97 | // name !== "reactions" && 98 | // name !== "overlayPositionType" && 99 | // name !== "overflowDirection" && 100 | // name !== "numberOfFixedChildren" && 101 | // name !== "overlayBackground" && 102 | // name !== "overlayBackgroundInteraction" && 103 | // name !== "remote" && 104 | // name !== "defaultVariant" && 105 | // name !== "hasMissingFont" && 106 | // name !== "exportSettings" && 107 | // name !== "variantProperties" && 108 | // name !== "variantGroupProperties" && 109 | // name !== "absoluteRenderBounds" && 110 | // name !== "fillGeometry" && 111 | // name !== "strokeGeometry" && 112 | // name !== "stuckNodes" && 113 | // name !== "componentPropertyReferences" && 114 | // name !== "canUpgradeToNativeBidiSupport" && 115 | // name !== "componentPropertyDefinitions" && 116 | // name !== "componentProperties" && 117 | // // Investigate these ones 118 | // name !== "itemReverseZIndex" && 119 | // name !== "strokesIncludedInLayout" && 120 | 121 | const exportPropValues = { 122 | exportSettings: [], 123 | }; 124 | 125 | const prototypingPropValues = { 126 | overflowDirection: "NONE", 127 | numberOfFixedChildren: 0, 128 | }; 129 | 130 | const sceneNodePropValues = { 131 | visible: true, 132 | locked: false, 133 | }; 134 | 135 | const containerPropValues = { 136 | expanded: true, 137 | backgrounds: [ 138 | { 139 | type: "SOLID", 140 | visible: true, 141 | opacity: 1, 142 | blendMode: "NORMAL", 143 | color: { 144 | r: 1, 145 | g: 1, 146 | b: 1, 147 | }, 148 | }, 149 | ], 150 | }; 151 | 152 | const cornerPropValues = { 153 | cornerRadius: 0, 154 | cornerSmoothing: 0, 155 | topLeftRadius: 0, 156 | topRightRadius: 0, 157 | bottomLeftRadius: 0, 158 | bottomRightRadius: 0, 159 | }; 160 | 161 | const layoutPropValues = { 162 | absoluteTransform: [], 163 | relativeTransform: [], 164 | x: 0, 165 | y: 0, 166 | rotation: 0, 167 | width: 0, 168 | height: 0, 169 | 170 | constrainProportions: false, 171 | constraints: { 172 | horizontal: "MIN", 173 | vertical: "MIN", 174 | }, 175 | layoutAlign: "INHERIT", 176 | layoutGrow: 0, 177 | }; 178 | 179 | const geometryPropValues = { 180 | fills: [ 181 | { 182 | type: "SOLID", 183 | visible: true, 184 | opacity: 1, 185 | blendMode: "NORMAL", 186 | color: { 187 | r: 1, 188 | g: 1, 189 | b: 1, 190 | }, 191 | }, 192 | ], 193 | // strokes: [], Despite being default, it's needed for vectors? 194 | strokeWeight: 1, 195 | strokeMiterLimit: 4, 196 | strokeAlign: "INSIDE", 197 | strokeCap: "NONE", 198 | strokeJoin: "MITER", 199 | dashPattern: [], 200 | fillStyleId: "", 201 | strokeStyleId: "", 202 | }; 203 | 204 | const textPropValues = { 205 | fontSize: 12, 206 | hasMissingFont: false, 207 | paragraphIndent: 0, 208 | paragraphSpacing: 0, 209 | textAlignHorizontal: "LEFT", 210 | textAlignVertical: "TOP", 211 | textAutoResize: "WIDTH_AND_HEIGHT", 212 | textCase: "ORIGINAL", 213 | textDecoration: "NONE", 214 | textStyleId: "", 215 | letterSpacing: { 216 | unit: "PERCENT", 217 | value: 0, 218 | }, 219 | characters: "", 220 | autoRename: true, 221 | }; 222 | 223 | const baseFramePropValues = { 224 | ...containerPropValues, 225 | ...layoutPropValues, 226 | layoutMode: "NONE", 227 | primaryAxisSizingMode: "AUTO", 228 | counterAxisSizingMode: "FIXED", 229 | 230 | primaryAxisAlignItems: "MIN", 231 | counterAxisAlignItems: "MIN", 232 | 233 | paddingLeft: 0, 234 | paddingRight: 0, 235 | paddingTop: 0, 236 | paddingBottom: 0, 237 | itemSpacing: 0, 238 | 239 | // verticalPadding: 0, 240 | // horizontalPadding: 0, 241 | 242 | layoutGrids: [], 243 | gridStyleId: "", 244 | clipsContent: true, 245 | guides: [], 246 | }; 247 | 248 | const blendPropValues = { 249 | opacity: 1, 250 | blendMode: "PASS_THROUGH", 251 | isMask: false, 252 | effects: [], 253 | }; 254 | 255 | export const defaultPropValues: {} = { 256 | FRAME: { 257 | name: "Frame", 258 | visible: true, 259 | locked: false, 260 | opacity: 1, 261 | blendMode: "PASS_THROUGH", 262 | isMask: false, 263 | effects: [], 264 | relativeTransform: [ 265 | [1, 0, 0], 266 | [0, 1, 0], 267 | ], 268 | absoluteTransform: [ 269 | [1, 0, 0], 270 | [0, 1, 0], 271 | ], 272 | x: 0, 273 | y: 0, 274 | width: 100, 275 | height: 100, 276 | rotation: 0, 277 | layoutAlign: "INHERIT", 278 | constrainProportions: false, 279 | layoutGrow: 0, 280 | exportSettings: [], 281 | fills: [ 282 | { 283 | type: "SOLID", 284 | visible: true, 285 | opacity: 1, 286 | blendMode: "NORMAL", 287 | color: { 288 | r: 1, 289 | g: 1, 290 | b: 1, 291 | }, 292 | }, 293 | ], 294 | strokes: [], 295 | strokeWeight: 1, 296 | strokeAlign: "INSIDE", 297 | strokeCap: "NONE", 298 | strokeJoin: "MITER", 299 | strokeMiterLimit: 4, 300 | dashPattern: [], 301 | cornerRadius: 0, 302 | cornerSmoothing: 0, 303 | topLeftRadius: 0, 304 | topRightRadius: 0, 305 | bottomLeftRadius: 0, 306 | bottomRightRadius: 0, 307 | paddingLeft: 0, 308 | paddingRight: 0, 309 | paddingTop: 0, 310 | paddingBottom: 0, 311 | primaryAxisAlignItems: "MIN", 312 | counterAxisAlignItems: "MIN", 313 | primaryAxisSizingMode: "AUTO", 314 | layoutGrids: [], 315 | backgrounds: [ 316 | { 317 | type: "SOLID", 318 | visible: true, 319 | opacity: 1, 320 | blendMode: "NORMAL", 321 | color: { 322 | r: 1, 323 | g: 1, 324 | b: 1, 325 | }, 326 | }, 327 | ], 328 | clipsContent: true, 329 | guides: [], 330 | expanded: true, 331 | constraints: { 332 | horizontal: "MIN", 333 | vertical: "MIN", 334 | }, 335 | layoutMode: "NONE", 336 | counterAxisSizingMode: "FIXED", 337 | itemSpacing: 0, 338 | overflowDirection: "NONE", 339 | numberOfFixedChildren: 0, 340 | overlayPositionType: "CENTER", 341 | overlayBackground: { 342 | type: "NONE", 343 | }, 344 | overlayBackgroundInteraction: "NONE", 345 | reactions: [], 346 | layoutPositioning: "AUTO", 347 | itemReverseZIndex: false, 348 | strokesIncludedInLayout: false, 349 | }, 350 | GROUP: {}, 351 | SLICE: { 352 | name: "Slice", 353 | visible: true, 354 | locked: false, 355 | relativeTransform: [ 356 | [1, 0, 0], 357 | [0, 1, 0], 358 | ], 359 | absoluteTransform: [ 360 | [1, 0, 0], 361 | [0, 1, 0], 362 | ], 363 | x: 0, 364 | y: 0, 365 | width: 100, 366 | height: 100, 367 | rotation: 0, 368 | layoutAlign: "INHERIT", 369 | constrainProportions: false, 370 | layoutGrow: 0, 371 | exportSettings: [], 372 | }, 373 | BOOLEAN_OPERATION: {}, 374 | RECTANGLE: { 375 | name: "Rectangle", 376 | visible: true, 377 | locked: false, 378 | opacity: 1, 379 | blendMode: "PASS_THROUGH", 380 | isMask: false, 381 | effects: [], 382 | fills: [ 383 | { 384 | type: "SOLID", 385 | visible: true, 386 | opacity: 1, 387 | blendMode: "NORMAL", 388 | color: { 389 | r: 0.7686274647712708, 390 | g: 0.7686274647712708, 391 | b: 0.7686274647712708, 392 | }, 393 | }, 394 | ], 395 | strokes: [], 396 | strokeWeight: 1, 397 | strokeAlign: "INSIDE", 398 | strokeCap: "NONE", 399 | strokeJoin: "MITER", 400 | strokeMiterLimit: 4, 401 | dashPattern: [], 402 | relativeTransform: [ 403 | [1, 0, 0], 404 | [0, 1, 0], 405 | ], 406 | absoluteTransform: [ 407 | [1, 0, 0], 408 | [0, 1, 0], 409 | ], 410 | x: 0, 411 | y: 0, 412 | width: 100, 413 | height: 100, 414 | rotation: 0, 415 | layoutAlign: "INHERIT", 416 | constrainProportions: false, 417 | layoutGrow: 0, 418 | exportSettings: [], 419 | constraints: { 420 | horizontal: "MIN", 421 | vertical: "MIN", 422 | }, 423 | cornerRadius: 0, 424 | cornerSmoothing: 0, 425 | topLeftRadius: 0, 426 | topRightRadius: 0, 427 | bottomLeftRadius: 0, 428 | bottomRightRadius: 0, 429 | reactions: [], 430 | layoutPositioning: "AUTO", 431 | }, 432 | LINE: { 433 | name: "Line", 434 | visible: true, 435 | locked: false, 436 | opacity: 1, 437 | blendMode: "PASS_THROUGH", 438 | isMask: false, 439 | effects: [], 440 | fills: [], 441 | strokes: [ 442 | { 443 | type: "SOLID", 444 | visible: true, 445 | opacity: 1, 446 | blendMode: "NORMAL", 447 | color: { 448 | r: 0, 449 | g: 0, 450 | b: 0, 451 | }, 452 | }, 453 | ], 454 | strokeWeight: 1, 455 | strokeAlign: "CENTER", 456 | strokeCap: "NONE", 457 | strokeJoin: "MITER", 458 | strokeMiterLimit: 4, 459 | dashPattern: [], 460 | relativeTransform: [ 461 | [1, 0, 0], 462 | [0, 1, 0], 463 | ], 464 | absoluteTransform: [ 465 | [1, 0, 0], 466 | [0, 1, 0], 467 | ], 468 | x: 0, 469 | y: 0, 470 | width: 100, 471 | height: 0, 472 | rotation: 0, 473 | layoutAlign: "INHERIT", 474 | constrainProportions: false, 475 | layoutGrow: 0, 476 | exportSettings: [], 477 | constraints: { 478 | horizontal: "MIN", 479 | vertical: "MIN", 480 | }, 481 | reactions: [], 482 | layoutPositioning: "AUTO", 483 | }, 484 | ELLIPSE: { 485 | name: "Ellipse", 486 | visible: true, 487 | locked: false, 488 | opacity: 1, 489 | blendMode: "PASS_THROUGH", 490 | isMask: false, 491 | effects: [], 492 | fills: [ 493 | { 494 | type: "SOLID", 495 | visible: true, 496 | opacity: 1, 497 | blendMode: "NORMAL", 498 | color: { 499 | r: 0.7686274647712708, 500 | g: 0.7686274647712708, 501 | b: 0.7686274647712708, 502 | }, 503 | }, 504 | ], 505 | strokes: [], 506 | strokeWeight: 1, 507 | strokeAlign: "INSIDE", 508 | strokeCap: "NONE", 509 | strokeJoin: "MITER", 510 | strokeMiterLimit: 4, 511 | dashPattern: [], 512 | relativeTransform: [ 513 | [1, 0, 0], 514 | [0, 1, 0], 515 | ], 516 | absoluteTransform: [ 517 | [1, 0, 0], 518 | [0, 1, 0], 519 | ], 520 | x: 0, 521 | y: 0, 522 | width: 100, 523 | height: 100, 524 | rotation: 0, 525 | layoutAlign: "INHERIT", 526 | constrainProportions: false, 527 | layoutGrow: 0, 528 | exportSettings: [], 529 | constraints: { 530 | horizontal: "MIN", 531 | vertical: "MIN", 532 | }, 533 | cornerRadius: 0, 534 | cornerSmoothing: 0, 535 | arcData: { 536 | startingAngle: 0, 537 | endingAngle: 6.2831854820251465, 538 | innerRadius: 0, 539 | }, 540 | reactions: [], 541 | layoutPositioning: "AUTO", 542 | }, 543 | POLYGON: { 544 | name: "Polygon", 545 | visible: true, 546 | locked: false, 547 | opacity: 1, 548 | blendMode: "PASS_THROUGH", 549 | isMask: false, 550 | effects: [], 551 | fills: [ 552 | { 553 | type: "SOLID", 554 | visible: true, 555 | opacity: 1, 556 | blendMode: "NORMAL", 557 | color: { 558 | r: 0.7686274647712708, 559 | g: 0.7686274647712708, 560 | b: 0.7686274647712708, 561 | }, 562 | }, 563 | ], 564 | strokes: [], 565 | strokeWeight: 1, 566 | strokeAlign: "INSIDE", 567 | strokeCap: "NONE", 568 | strokeJoin: "MITER", 569 | strokeMiterLimit: 4, 570 | dashPattern: [], 571 | relativeTransform: [ 572 | [1, 0, 0], 573 | [0, 1, 0], 574 | ], 575 | absoluteTransform: [ 576 | [1, 0, 0], 577 | [0, 1, 0], 578 | ], 579 | x: 0, 580 | y: 0, 581 | width: 100, 582 | height: 100, 583 | rotation: 0, 584 | layoutAlign: "INHERIT", 585 | constrainProportions: false, 586 | layoutGrow: 0, 587 | exportSettings: [], 588 | constraints: { 589 | horizontal: "MIN", 590 | vertical: "MIN", 591 | }, 592 | cornerRadius: 0, 593 | cornerSmoothing: 0, 594 | pointCount: 3, 595 | reactions: [], 596 | layoutPositioning: "AUTO", 597 | }, 598 | STAR: { 599 | name: "Star", 600 | visible: true, 601 | locked: false, 602 | opacity: 1, 603 | blendMode: "PASS_THROUGH", 604 | isMask: false, 605 | effects: [], 606 | fills: [ 607 | { 608 | type: "SOLID", 609 | visible: true, 610 | opacity: 1, 611 | blendMode: "NORMAL", 612 | color: { 613 | r: 0.7686274647712708, 614 | g: 0.7686274647712708, 615 | b: 0.7686274647712708, 616 | }, 617 | }, 618 | ], 619 | strokes: [], 620 | strokeWeight: 1, 621 | strokeAlign: "INSIDE", 622 | strokeCap: "NONE", 623 | strokeJoin: "MITER", 624 | strokeMiterLimit: 4, 625 | dashPattern: [], 626 | relativeTransform: [ 627 | [1, 0, 0], 628 | [0, 1, 0], 629 | ], 630 | absoluteTransform: [ 631 | [1, 0, 0], 632 | [0, 1, 0], 633 | ], 634 | x: 0, 635 | y: 0, 636 | width: 100, 637 | height: 100, 638 | rotation: 0, 639 | layoutAlign: "INHERIT", 640 | constrainProportions: false, 641 | layoutGrow: 0, 642 | exportSettings: [], 643 | constraints: { 644 | horizontal: "MIN", 645 | vertical: "MIN", 646 | }, 647 | cornerRadius: 0, 648 | cornerSmoothing: 0, 649 | pointCount: 5, 650 | innerRadius: 0.3819660246372223, 651 | reactions: [], 652 | layoutPositioning: "AUTO", 653 | }, 654 | VECTOR: { 655 | name: "Vector", 656 | visible: true, 657 | locked: false, 658 | opacity: 1, 659 | blendMode: "PASS_THROUGH", 660 | isMask: false, 661 | effects: [], 662 | fills: [], 663 | strokes: [ 664 | { 665 | type: "SOLID", 666 | visible: true, 667 | opacity: 1, 668 | blendMode: "NORMAL", 669 | color: { 670 | r: 0, 671 | g: 0, 672 | b: 0, 673 | }, 674 | }, 675 | ], 676 | strokeWeight: 1, 677 | strokeAlign: "CENTER", 678 | strokeCap: "NONE", 679 | strokeJoin: "MITER", 680 | strokeMiterLimit: 4, 681 | dashPattern: [], 682 | relativeTransform: [ 683 | [1, 0, 0], 684 | [0, 1, 0], 685 | ], 686 | absoluteTransform: [ 687 | [1, 0, 0], 688 | [0, 1, 0], 689 | ], 690 | x: 0, 691 | y: 0, 692 | width: 100, 693 | height: 100, 694 | rotation: 0, 695 | layoutAlign: "INHERIT", 696 | constrainProportions: false, 697 | layoutGrow: 0, 698 | exportSettings: [], 699 | constraints: { 700 | horizontal: "MIN", 701 | vertical: "MIN", 702 | }, 703 | cornerRadius: 0, 704 | cornerSmoothing: 0, 705 | vectorNetwork: { 706 | regions: [], 707 | segments: [], 708 | vertices: [], 709 | }, 710 | vectorPaths: [], 711 | handleMirroring: "NONE", 712 | reactions: [], 713 | layoutPositioning: "AUTO", 714 | }, 715 | TEXT: { 716 | name: "Text", 717 | visible: true, 718 | locked: false, 719 | opacity: 1, 720 | blendMode: "PASS_THROUGH", 721 | isMask: false, 722 | effects: [], 723 | fills: [ 724 | { 725 | type: "SOLID", 726 | visible: true, 727 | opacity: 1, 728 | blendMode: "NORMAL", 729 | color: { 730 | r: 0, 731 | g: 0, 732 | b: 0, 733 | }, 734 | }, 735 | ], 736 | strokes: [], 737 | strokeWeight: 1, 738 | strokeAlign: "OUTSIDE", 739 | strokeCap: "NONE", 740 | strokeJoin: "MITER", 741 | strokeMiterLimit: 4, 742 | dashPattern: [], 743 | relativeTransform: [ 744 | [1, 0, 0], 745 | [0, 1, 0], 746 | ], 747 | absoluteTransform: [ 748 | [1, 0, 0], 749 | [0, 1, 0], 750 | ], 751 | x: 0, 752 | y: 0, 753 | width: 0, 754 | height: 14, 755 | rotation: 0, 756 | layoutAlign: "INHERIT", 757 | constrainProportions: false, 758 | layoutGrow: 0, 759 | exportSettings: [], 760 | constraints: { 761 | horizontal: "MIN", 762 | vertical: "MIN", 763 | }, 764 | hasMissingFont: false, 765 | autoRename: true, 766 | fontSize: 12, 767 | paragraphIndent: 0, 768 | paragraphSpacing: 0, 769 | textAlignHorizontal: "LEFT", 770 | textAlignVertical: "TOP", 771 | textCase: "ORIGINAL", 772 | textDecoration: "NONE", 773 | textAutoResize: "", // It's actually WIDTH_AND_HEIGHT but when node is resizes, it gets reset, so we add it anyway 774 | letterSpacing: { 775 | unit: "PERCENT", 776 | value: 0, 777 | }, 778 | lineHeight: { 779 | unit: "AUTO", 780 | }, 781 | fontName: { 782 | family: "Roboto", 783 | style: "Regular", 784 | }, 785 | reactions: [], 786 | hyperlink: null, 787 | layoutPositioning: "AUTO", 788 | }, 789 | COMPONENT: { 790 | name: "Component", 791 | visible: true, 792 | locked: false, 793 | opacity: 1, 794 | blendMode: "PASS_THROUGH", 795 | isMask: false, 796 | effects: [], 797 | relativeTransform: [ 798 | [1, 0, 0], 799 | [0, 1, 0], 800 | ], 801 | absoluteTransform: [ 802 | [1, 0, 0], 803 | [0, 1, 0], 804 | ], 805 | x: 0, 806 | y: 0, 807 | width: 100, 808 | height: 100, 809 | rotation: 0, 810 | layoutAlign: "INHERIT", 811 | constrainProportions: false, 812 | layoutGrow: 0, 813 | exportSettings: [], 814 | fills: [ 815 | { 816 | type: "SOLID", 817 | visible: false, 818 | opacity: 1, 819 | blendMode: "NORMAL", 820 | color: { 821 | r: 1, 822 | g: 1, 823 | b: 1, 824 | }, 825 | }, 826 | ], 827 | strokes: [], 828 | strokeWeight: 1, 829 | strokeAlign: "INSIDE", 830 | strokeCap: "NONE", 831 | strokeJoin: "MITER", 832 | strokeMiterLimit: 4, 833 | dashPattern: [], 834 | cornerRadius: 0, 835 | cornerSmoothing: 0, 836 | topLeftRadius: 0, 837 | topRightRadius: 0, 838 | bottomLeftRadius: 0, 839 | bottomRightRadius: 0, 840 | paddingLeft: 0, 841 | paddingRight: 0, 842 | paddingTop: 0, 843 | paddingBottom: 0, 844 | primaryAxisAlignItems: "MIN", 845 | counterAxisAlignItems: "MIN", 846 | primaryAxisSizingMode: "AUTO", 847 | layoutGrids: [], 848 | backgrounds: [ 849 | { 850 | type: "SOLID", 851 | visible: false, 852 | opacity: 1, 853 | blendMode: "NORMAL", 854 | color: { 855 | r: 1, 856 | g: 1, 857 | b: 1, 858 | }, 859 | }, 860 | ], 861 | clipsContent: false, 862 | guides: [], 863 | expanded: true, 864 | constraints: { 865 | horizontal: "MIN", 866 | vertical: "MIN", 867 | }, 868 | layoutMode: "NONE", 869 | counterAxisSizingMode: "FIXED", 870 | itemSpacing: 0, 871 | overflowDirection: "NONE", 872 | numberOfFixedChildren: 0, 873 | overlayPositionType: "CENTER", 874 | overlayBackground: { 875 | type: "NONE", 876 | }, 877 | overlayBackgroundInteraction: "NONE", 878 | remote: false, 879 | reactions: [], 880 | description: "", 881 | documentationLinks: [], 882 | layoutPositioning: "AUTO", 883 | itemReverseZIndex: false, 884 | strokesIncludedInLayout: false, 885 | }, 886 | COMPONENT_SET: {}, 887 | INSTANCE: { 888 | x: 0, 889 | y: 0, 890 | scaleFactor: 1, 891 | }, 892 | }; 893 | 894 | export const readOnlyProps: string[] = [ 895 | "id", 896 | "parent", 897 | "removed", 898 | "children", 899 | "width", 900 | "height", 901 | "overlayPositionType", 902 | "overlayBackground", 903 | "overlayBackgroundInteraction", 904 | "reactions", 905 | "remote", 906 | "key", 907 | "type", 908 | "defaultVariant", 909 | "hasMissingFont", 910 | "characters", // Not a readonly prop 911 | // 'relativeTransform', // Need to check if same as default x y coordinates to avoid unnecessary code 912 | "absoluteTransform", 913 | // 'horizontalPadding', // Not a readonly prop, just want to ignore 914 | // 'verticalPadding', // Not a readonly prop, just want to ignore 915 | "mainComponent", // Not a readonly prop, just want to ignore 916 | "masterComponent", // Not a readonly prop, just want to ignore 917 | ]; 918 | 919 | export const textProps: string[] = [ 920 | "characters", 921 | "fontSize", 922 | "fontName", 923 | "textStyleId", 924 | "textCase", 925 | "textDecoration", 926 | "letterSpacing", 927 | "lineHeight", 928 | "textAlignVertical", 929 | "textAlignHorizontal", 930 | "textAutoResize", 931 | "listSpacing", 932 | ]; 933 | 934 | export const styleProps: string[] = [ 935 | "fillStyleId", 936 | "strokeStyleId", 937 | "textStyleId", 938 | "effectStyleId", 939 | "gridStyleId", 940 | "backgroundStyleId", 941 | ]; 942 | 943 | export var dynamicProps = ["width", "height"]; 944 | -------------------------------------------------------------------------------- /src/setCharacters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This helper will check font and set fallback before set characters to node. Useful to work with TextNode content changes. 3 | * For example: 4 | * ```diff 5 | * const text = "New text for label"; 6 | * const labelNode = figma.currentPage.findOne((el) => el.type === "TEXT"); 7 | * - await figma.loadFontAsync({ 8 | * - family: labelNode.fontName.family, 9 | * - style: labelNode.fontName.style 10 | * - }) 11 | * - labelNode.characters = text; 12 | * + await setCharacters(labelNode, text); 13 | * ``` 14 | * 15 | * Provided example doesn't handle many annoying cases like, not existed or multiple fonts, which expand code a lot. `setCharacters` cover this cases and reducing noise. 16 | * 17 | * @param node Target node to set characters 18 | * @param characters String of characters to set 19 | * @param options Parser options 20 | * @param options.fallbackFont Font that will be applied to target node, if original will fail to load. By default is "Roboto Regular" 21 | * @param options.smartStrategy Parser stragtegy, that allows to set font family and styles to characters in more flexible way 22 | */ 23 | 24 | import { uniqBy } from '../node_modules/lodash/lodash' 25 | 26 | interface FontLinearItem { 27 | family: string 28 | style: string 29 | start?: number 30 | delimiter: '\n' | ' ' 31 | } 32 | 33 | export const setCharacters = async ( 34 | node: TextNode, 35 | characters: string, 36 | options?: { 37 | smartStrategy?: 'prevail' | 'strict' | 'experimental' 38 | fallbackFont?: FontName 39 | } 40 | ): Promise => { 41 | const fallbackFont = options?.fallbackFont || { 42 | family: 'Roboto', 43 | style: 'Regular' 44 | } 45 | try { 46 | if (node.fontName === figma.mixed) { 47 | if (options?.smartStrategy === 'prevail') { 48 | const fontHashTree: { [key: string]: number } = {} 49 | for (let i = 1; i < node.characters.length; i++) { 50 | const charFont = node.getRangeFontName(i - 1, i) as FontName 51 | const key = `${charFont.family}::${charFont.style}` 52 | fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1 53 | } 54 | const prevailedTreeItem = Object.entries(fontHashTree).sort( 55 | (a, b) => b[1] - a[1] 56 | )[0] 57 | const [family, style] = prevailedTreeItem[0].split('::') 58 | const prevailedFont = { 59 | family, 60 | style 61 | } as FontName 62 | await figma.loadFontAsync(prevailedFont) 63 | node.fontName = prevailedFont 64 | } else if (options?.smartStrategy === 'strict') { 65 | return setCharactersWithStrictMatchFont(node, characters, fallbackFont) 66 | } else if (options?.smartStrategy === 'experimental') { 67 | return setCharactersWithSmartMatchFont(node, characters, fallbackFont) 68 | } else { 69 | const firstCharFont = node.getRangeFontName(0, 1) as FontName 70 | await figma.loadFontAsync(firstCharFont) 71 | node.fontName = firstCharFont 72 | } 73 | } else { 74 | await figma.loadFontAsync({ 75 | family: node.fontName.family, 76 | style: node.fontName.style 77 | }) 78 | } 79 | } catch (err) { 80 | console.warn( 81 | `Failed to load "${node.fontName['family']} ${node.fontName['style']}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`, 82 | err 83 | ) 84 | await figma.loadFontAsync(fallbackFont) 85 | node.fontName = fallbackFont 86 | } 87 | try { 88 | node.characters = characters 89 | return true 90 | } catch (err) { 91 | console.warn(`Failed to set characters. Skipped.`, err) 92 | return false 93 | } 94 | } 95 | 96 | const setCharactersWithStrictMatchFont = async ( 97 | node: TextNode, 98 | characters: string, 99 | fallbackFont?: FontName 100 | ): Promise => { 101 | const fontHashTree: { [key: string]: string } = {} 102 | for (let i = 1; i < node.characters.length; i++) { 103 | const startIdx = i - 1 104 | const startCharFont = node.getRangeFontName(startIdx, i) as FontName 105 | const startCharFontVal = `${startCharFont.family}::${startCharFont.style}` 106 | while (i < node.characters.length) { 107 | i++ 108 | const charFont = node.getRangeFontName(i - 1, i) as FontName 109 | if (startCharFontVal !== `${charFont.family}::${charFont.style}`) { 110 | break 111 | } 112 | } 113 | fontHashTree[`${startIdx}_${i}`] = startCharFontVal 114 | } 115 | await figma.loadFontAsync(fallbackFont) 116 | node.fontName = fallbackFont 117 | node.characters = characters 118 | console.log(fontHashTree) 119 | await Promise.all( 120 | Object.keys(fontHashTree).map(async (range) => { 121 | console.log(range, fontHashTree[range]) 122 | const [start, end] = range.split('_') 123 | const [family, style] = fontHashTree[range].split('::') 124 | const matchedFont = { 125 | family, 126 | style 127 | } as FontName 128 | await figma.loadFontAsync(matchedFont) 129 | return node.setRangeFontName(Number(start), Number(end), matchedFont) 130 | }) 131 | ) 132 | return true 133 | } 134 | 135 | const getDelimiterPos = ( 136 | str: string, 137 | delimiter: string, 138 | startIdx = 0, 139 | endIdx: number = str.length 140 | ): [number, number][] => { 141 | const indices = [] 142 | let temp = startIdx 143 | for (let i = startIdx; i < endIdx; i++) { 144 | if (str[i] === delimiter && i + startIdx !== endIdx && temp !== i + startIdx) { 145 | indices.push([temp, i + startIdx]) 146 | temp = i + startIdx + 1 147 | } 148 | } 149 | temp !== endIdx && indices.push([temp, endIdx]) 150 | return indices.filter(Boolean) 151 | } 152 | 153 | const buildLinearOrder = (node: TextNode) => { 154 | const fontTree: FontLinearItem[] = [] 155 | const newLinesPos = getDelimiterPos(node.characters, '\n') 156 | newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => { 157 | const newLinesRangeFont = node.getRangeFontName(newLinesRangeStart, newLinesRangeEnd) 158 | if (newLinesRangeFont === figma.mixed) { 159 | const spacesPos = getDelimiterPos( 160 | node.characters, 161 | ' ', 162 | newLinesRangeStart, 163 | newLinesRangeEnd 164 | ) 165 | spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => { 166 | const spacesRangeFont = node.getRangeFontName(spacesRangeStart, spacesRangeEnd) 167 | if (spacesRangeFont === figma.mixed) { 168 | const spacesRangeFont = node.getRangeFontName( 169 | spacesRangeStart, 170 | spacesRangeStart[0] 171 | ) as FontName 172 | fontTree.push({ 173 | start: spacesRangeStart, 174 | delimiter: ' ', 175 | family: spacesRangeFont.family, 176 | style: spacesRangeFont.style 177 | }) 178 | } else { 179 | fontTree.push({ 180 | start: spacesRangeStart, 181 | delimiter: ' ', 182 | family: spacesRangeFont.family, 183 | style: spacesRangeFont.style 184 | }) 185 | } 186 | }) 187 | } else { 188 | fontTree.push({ 189 | start: newLinesRangeStart, 190 | delimiter: '\n', 191 | family: newLinesRangeFont.family, 192 | style: newLinesRangeFont.style 193 | }) 194 | } 195 | }) 196 | return fontTree 197 | .sort((a, b) => +a.start - +b.start) 198 | .map(({ family, style, delimiter }) => ({ family, style, delimiter })) 199 | } 200 | 201 | const setCharactersWithSmartMatchFont = async ( 202 | node: TextNode, 203 | characters: string, 204 | fallbackFont?: FontName 205 | ): Promise => { 206 | const rangeTree = buildLinearOrder(node) 207 | const fontsToLoad = uniqBy(rangeTree, ({ family, style }) => `${family}::${style}`).map( 208 | ({ family, style }): FontName => ({ 209 | family, 210 | style 211 | }) 212 | ) 213 | 214 | await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync)) 215 | 216 | node.fontName = fallbackFont 217 | node.characters = characters 218 | 219 | let prevPos = 0 220 | rangeTree.forEach(({ family, style, delimiter }) => { 221 | if (prevPos < node.characters.length) { 222 | const delimeterPos = node.characters.indexOf(delimiter, prevPos) 223 | const endPos = delimeterPos > prevPos ? delimeterPos : node.characters.length 224 | const matchedFont = { 225 | family, 226 | style 227 | } 228 | node.setRangeFontName(prevPos, endPos, matchedFont) 229 | prevPos = endPos + 1 230 | } 231 | }) 232 | return true 233 | } -------------------------------------------------------------------------------- /src/str.ts: -------------------------------------------------------------------------------- 1 | import { stripIndent } from 'common-tags/dist/common-tags.min.js' 2 | 3 | function trim(str) { 4 | // return str.replace(/\n$/g, ""); 5 | return str 6 | }; 7 | 8 | function removeIndent(str) { 9 | var indentMatches = /\s*\n(\s+)/.exec(str); 10 | if (indentMatches) { 11 | var indent = indentMatches[1]; 12 | str = str.replace(new RegExp("^" + indent, "mg"), ""); 13 | } 14 | // Remove new line at start of string 15 | str = str.replace(/^\n/, "") 16 | return str; 17 | } 18 | 19 | 20 | 21 | // function Str(this: any) { 22 | export class Str { 23 | constructor() { 24 | var output = ""; 25 | 26 | function init(strings?: any, ...values: any): any { 27 | 28 | if (Array.isArray(strings)) { 29 | let str = ''; 30 | 31 | strings.forEach((string, a) => { 32 | // Avoids zeros being squished 33 | if (values[a] === 0) values[a] = values[a].toString() 34 | 35 | str += string + (values[a] || ''); 36 | 37 | }); 38 | 39 | output += str 40 | 41 | } 42 | 43 | if (!strings) { 44 | return output 45 | } 46 | } 47 | 48 | init.prepend = function (strings?: any, ...values: any): any { 49 | 50 | if (Array.isArray(strings)) { 51 | let str = ''; 52 | 53 | strings.forEach((string, a) => { 54 | // Avoids zeros being squished 55 | if (values[a] === 0) values[a] = values[a].toString() 56 | 57 | str += string + (values[a] || ''); 58 | 59 | }); 60 | 61 | // output = removeIndent(str) + output 62 | output = str + output 63 | 64 | } 65 | } 66 | 67 | return init 68 | // } 69 | } 70 | } 71 | 72 | 73 | export default Str 74 | -------------------------------------------------------------------------------- /src/syntax-theme.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Github-like theme for Prism.js 3 | * @author Luke Askew http://github.com/lukeaskew 4 | */ 5 | code, 6 | code[class*='language-'], 7 | pre[class*='language-'] { 8 | color: #333; 9 | text-align: left; 10 | white-space: pre; 11 | word-spacing: normal; 12 | tab-size: 4; 13 | hyphens: none; 14 | font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; 15 | line-height: 1.4; 16 | direction: ltr; 17 | cursor: text; 18 | } 19 | 20 | pre[class*='language-'] { 21 | overflow: auto; 22 | margin: 1em 0; 23 | padding: 1.2em; 24 | border-radius: 3px; 25 | font-size: 85%; 26 | } 27 | 28 | p code, 29 | li code, 30 | table code { 31 | margin: 0; 32 | border-radius: 3px; 33 | padding: 0.2em 0; 34 | font-size: 85%; 35 | } 36 | p code:before, p code:after, 37 | li code:before, 38 | li code:after, 39 | table code:before, 40 | table code:after { 41 | letter-spacing: -0.2em; 42 | content: '\00a0'; 43 | } 44 | 45 | code, 46 | :not(pre) > code[class*='language-'], 47 | pre[class*='language-'] { 48 | background: #f7f7f7; 49 | } 50 | 51 | :not(pre) > code[class*='language-'] { 52 | padding: 0.1em; 53 | border-radius: 0.3em; 54 | } 55 | 56 | .token.comment, .token.prolog, .token.doctype, .token.cdata { 57 | color: #969896; 58 | } 59 | .token.punctuation, .token.string, .token.atrule, .token.attr-value { 60 | color: #183691; 61 | } 62 | .token.property, .token.tag { 63 | color: #63a35c; 64 | } 65 | .token.boolean, .token.number { 66 | color: #0086b3; 67 | } 68 | .token.selector, .token.attr-name, .token.attr-value .punctuation:first-child, .token.keyword, .token.regex, .token.important { 69 | color: #795da3; 70 | } 71 | .token.operator, .token.entity, .token.url, .language-css .token.string { 72 | color: #a71d5d; 73 | } 74 | .token.entity { 75 | cursor: help; 76 | } 77 | 78 | .namespace { 79 | opacity: 0.7; 80 | } -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/widgetGeneration.ts: -------------------------------------------------------------------------------- 1 | import { isArray } from "lodash"; 2 | import v from "voca"; 3 | 4 | // function isObj(value) { 5 | // return typeof value === 'object' && 6 | // !Array.isArray(value) && 7 | // value !== null 8 | // } 9 | 10 | function Utf8ArrayToStr(array) { 11 | var out, i, len, c; 12 | var char2, char3; 13 | 14 | out = ""; 15 | len = array.length; 16 | i = 0; 17 | while (i < len) { 18 | c = array[i++]; 19 | switch (c >> 4) { 20 | case 0: 21 | case 1: 22 | case 2: 23 | case 3: 24 | case 4: 25 | case 5: 26 | case 6: 27 | case 7: 28 | // 0xxxxxxx 29 | out += String.fromCharCode(c); 30 | break; 31 | case 12: 32 | case 13: 33 | // 110x xxxx 10xx xxxx 34 | char2 = array[i++]; 35 | out += String.fromCharCode(((c & 0x1f) << 6) | (char2 & 0x3f)); 36 | break; 37 | case 14: 38 | // 1110 xxxx 10xx xxxx 10xx xxxx 39 | char2 = array[i++]; 40 | char3 = array[i++]; 41 | out += String.fromCharCode( 42 | ((c & 0x0f) << 12) | 43 | ((char2 & 0x3f) << 6) | 44 | ((char3 & 0x3f) << 0) 45 | ); 46 | break; 47 | } 48 | } 49 | 50 | return out; 51 | } 52 | 53 | function isSymbol(x) { 54 | return ( 55 | typeof x === "symbol" || 56 | (typeof x === "object" && 57 | Object.prototype.toString.call(x) === "[object Symbol]") 58 | ); 59 | } 60 | 61 | function isObj(val) { 62 | if (val === null) { 63 | return false; 64 | } 65 | return typeof val === "function" || typeof val === "object"; 66 | } 67 | 68 | function isStr(val) { 69 | if (typeof val === "string" || val instanceof String) return val; 70 | } 71 | 72 | function simpleClone(val) { 73 | return JSON.parse(JSON.stringify(val)); 74 | } 75 | 76 | function componentToHex(c) { 77 | c = Math.floor(c * 255); 78 | var hex = c.toString(16); 79 | return hex.length == 1 ? "0" + hex : hex; 80 | } 81 | 82 | function rgbToHex(rgb) { 83 | if (rgb) { 84 | let { r, g, b } = rgb; 85 | return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b); 86 | } 87 | } 88 | 89 | function hexToRgb(hex) { 90 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 91 | return result 92 | ? { 93 | r: parseInt(result[1], 16) / 255, 94 | g: parseInt(result[2], 16) / 255, 95 | b: parseInt(result[3], 16) / 255, 96 | } 97 | : null; 98 | } 99 | 100 | async function walkNodes(nodes, callback) { 101 | var string = ""; 102 | var depth = 0; 103 | var count = 0; 104 | let tab = `\t`; 105 | 106 | function* processNodes(nodes) { 107 | const len = nodes.length; 108 | if (len === 0) { 109 | return; 110 | } 111 | 112 | for (var i = 0; i < len; i++) { 113 | var node = nodes[i]; 114 | let { 115 | before, 116 | during, 117 | after, 118 | stop = false, 119 | skip = false, 120 | } = yield node; 121 | 122 | if (skip) { 123 | } else { 124 | let children = node.children; 125 | 126 | if (before) { 127 | // console.log("before", before(node)) 128 | string += tab.repeat(depth) + before(); 129 | } 130 | 131 | if (!stop) { 132 | if (children) { 133 | if (during && typeof during() !== "undefined") { 134 | // console.log(during()) 135 | string += tab.repeat(depth) + during(); 136 | } 137 | 138 | yield* processNodes(children); 139 | } else if (node.characters) { 140 | if (during) { 141 | string += tab.repeat(depth + 1) + during(); 142 | } 143 | } 144 | } 145 | 146 | if (after) { 147 | // console.log("after", after(node)) 148 | string += tab.repeat(depth) + after(); 149 | depth--; 150 | } 151 | } 152 | } 153 | } 154 | 155 | console.log("Generating widget code..."); 156 | 157 | var tree = processNodes(nodes); 158 | var res = tree.next(); 159 | 160 | while (!res.done) { 161 | // console.log(res.value); 162 | let node = res.value; 163 | let component; 164 | 165 | function sanitiseValue(value) { 166 | if (typeof value !== "undefined") { 167 | function doThingOnValue(value) { 168 | // Convert snakeCase and upperCase to kebabCase and lowercase 169 | if (isStr(value)) { 170 | value = v.lowerCase(v.kebabCase(value)); 171 | } 172 | 173 | if (value === "min") value = "start"; 174 | if (value === "max") value = "end"; 175 | // Set to undefined to remove, as this is space = "auto" in widget land 176 | if (value === "space-between") value = undefined; 177 | 178 | return value; 179 | } 180 | 181 | var newValue; 182 | 183 | if (isObj(value)) { 184 | var cloneValue = simpleClone(value); 185 | for (let [key, value] of Object.entries(cloneValue)) { 186 | cloneValue[key] = doThingOnValue(value); 187 | 188 | // if (key === "opacity") { 189 | 190 | // // cloneValue['color']['a'] = "test" 191 | // // console.log(cloneValue) 192 | // Object.defineProperty(cloneValue['color'], 'a', Object.getOwnPropertyDescriptor(cloneValue, 'opacity')); 193 | // // console.log(cloneValue) 194 | // } 195 | 196 | // Convert radius to blur for effects 197 | if (key === "radius") { 198 | // Use this to rename property 199 | Object.defineProperty( 200 | cloneValue, 201 | "blur", 202 | Object.getOwnPropertyDescriptor( 203 | cloneValue, 204 | "radius" 205 | ) 206 | ); 207 | delete cloneValue["radius"]; 208 | } 209 | 210 | // console.log(key, value) 211 | } 212 | 213 | newValue = cloneValue; 214 | } 215 | 216 | if (Array.isArray(value)) { 217 | var cloneValue = simpleClone(value); 218 | for (var i = 0; i < cloneValue.length; i++) { 219 | var item = cloneValue[i]; 220 | for (let [key, value] of Object.entries(item)) { 221 | item[key] = doThingOnValue(value); 222 | 223 | // if (key === "opacity") { 224 | // console.log(item[key]) 225 | // } 226 | 227 | // Convert radius to blur for effects 228 | if (key === "radius") { 229 | // Use this to rename property 230 | Object.defineProperty( 231 | item, 232 | "blur", 233 | Object.getOwnPropertyDescriptor( 234 | item, 235 | "radius" 236 | ) 237 | ); 238 | delete item["radius"]; 239 | } 240 | 241 | // console.log(key, value) 242 | } 243 | cloneValue[i] = item; 244 | } 245 | newValue = cloneValue; 246 | } 247 | 248 | if (isStr(value)) { 249 | newValue = doThingOnValue(value); 250 | } 251 | 252 | if (!isNaN(value)) { 253 | newValue = value; 254 | } 255 | 256 | return newValue; 257 | } 258 | } 259 | 260 | function genWidthHeightProps(node) { 261 | var width = node.width; 262 | var height = node.height; 263 | 264 | width = (() => { 265 | if (node.width < 0.01) { 266 | return 0.01; 267 | } else { 268 | return node.width; 269 | } 270 | })(); 271 | 272 | height = (() => { 273 | if (node.height < 0.01) { 274 | return 0.01; 275 | } else { 276 | return node.height; 277 | } 278 | })(); 279 | 280 | // console.log({ 281 | // parentLayoutMode: node.parent.layoutMode, 282 | // layoutMode: node.layoutMode, 283 | // counterAxisSizingMode: node.counterAxisSizingMode, 284 | // primaryAxisSizingMode: node.primaryAxisSizingMode, 285 | // layoutAlign: node.layoutAlign, 286 | // layoutGrow: node.layoutGrow 287 | // }) 288 | 289 | // if (node.layoutMode && node.layoutMode !== "NONE") { 290 | if ( 291 | (node.layoutMode === "HORIZONTAL" && 292 | node.primaryAxisSizingMode === "AUTO") || 293 | (node.layoutMode === "VERTICAL" && 294 | node.counterAxisSizingMode === "AUTO") || 295 | ((node.parent.layoutMode === "NONE" || 296 | !node.parent.layoutMode) && 297 | node.layoutMode === "HORIZONTAL" && 298 | node.primaryAxisSizingMode === "AUTO" && 299 | node.layoutGrow === 0) || 300 | ((node.parent.layoutMode === "NONE" || 301 | !node.parent.layoutMode) && 302 | node.layoutMode === "VERTICAL" && 303 | node.counterAxisSizingMode === "AUTO" && 304 | node.layoutGrow === 0) 305 | ) { 306 | width = "hug-contents"; 307 | } 308 | if ( 309 | (node.layoutMode === "HORIZONTAL" && 310 | node.counterAxisSizingMode === "AUTO") || 311 | (node.layoutMode === "VERTICAL" && 312 | node.primaryAxisSizingMode === "AUTO") || 313 | ((node.parent.layoutMode === "NONE" || 314 | !node.parent.layoutMode) && 315 | node.layoutMode === "VERTICAL" && 316 | node.primaryAxisSizingMode === "AUTO" && 317 | node.layoutGrow === 0) || 318 | ((node.parent.layoutMode === "NONE" || 319 | !node.parent.layoutMode) && 320 | node.layoutMode === "HORIZONTAL" && 321 | node.counterAxisSizingMode === "AUTO" && 322 | node.layoutGrow === 0) 323 | ) { 324 | height = "hug-contents"; 325 | } 326 | 327 | if ( 328 | (node.parent.layoutMode === "HORIZONTAL" && 329 | node.layoutGrow === 1) || 330 | (node.parent.layoutMode === "VERTICAL" && 331 | node.layoutAlign === "STRETCH") 332 | ) { 333 | width = "fill-parent"; 334 | } 335 | 336 | if ( 337 | (node.parent.layoutMode === "HORIZONTAL" && 338 | node.layoutAlign === "STRETCH") || 339 | (node.parent.layoutMode === "VERTICAL" && node.layoutGrow === 1) 340 | ) { 341 | height = "fill-parent"; 342 | } 343 | 344 | // FIXME: Add rules to prevent width and height being added to text unless fixed 345 | 346 | if (node.textAutoResize === "HEIGHT") { 347 | height = "hug-contents"; 348 | } 349 | 350 | if (node.textAutoResize === "WIDTH_AND_HEIGHT") { 351 | height = "hug-contents"; 352 | width = "hug-contents"; 353 | } 354 | // } 355 | 356 | var obj = { 357 | width, 358 | height, 359 | }; 360 | 361 | // console.log(obj) 362 | 363 | return obj; 364 | } 365 | 366 | var props = { 367 | ...genWidthHeightProps(node), 368 | name: node.name, 369 | hidden: !node.visible, 370 | x: (() => { 371 | // if (node.constraints?.horizontal) { 372 | // return sanitiseValue(node.constraints?.horizontal) 373 | // } 374 | // else { 375 | return node.x; 376 | // } 377 | })(), 378 | y: (() => { 379 | // if (node.constraints?.vertical) { 380 | // return sanitiseValue(node.constraints?.vertical) 381 | // } 382 | // else { 383 | return node.y; 384 | // } 385 | })(), 386 | blendMode: sanitiseValue(node.blendMode), 387 | opacity: node.opacity, 388 | // effect: Effect, 389 | fill: (() => { 390 | if (node.fills && node.fills.length > 0) { 391 | if (node.fills[0].visible) { 392 | return sanitiseValue(node.fills[0]); 393 | } 394 | 395 | // if (node.fills[0].opacity === 1) { 396 | // return rgbToHex(node.fills[0]?.color) 397 | // } 398 | // else { 399 | // console.log("Fill cannot have opacity") 400 | // return undefined 401 | // } 402 | } 403 | })(), 404 | // stroke: rgbToHex(node.strokes[0]?.color), // Will support GradientPaint in future 405 | stroke: (() => { 406 | if (node.strokes && node.strokes.length > 0) { 407 | if (node.strokes[0].visible) { 408 | return sanitiseValue(node.strokes[0]); 409 | } 410 | 411 | // if (node.strokes[0].opacity === 1) { 412 | // return rgbToHex(node.strokes[0]?.color) 413 | // } 414 | // else { 415 | // console.log("Stroke cannot have opacity") 416 | // return undefined 417 | // } 418 | } 419 | })(), 420 | strokeWidth: node.strokeWeight, 421 | strokeAlign: sanitiseValue(node.strokeAlign), 422 | rotation: node.rotation, 423 | cornerRadius: { 424 | topLeft: node.topLeftRadius, 425 | topRight: node.topRightRadius, 426 | bottomLeft: node.bottomLeftRadius, 427 | bottomRight: node.bottomRightRadius, 428 | }, 429 | padding: { 430 | top: node.paddingBottom, 431 | right: node.paddingRight, 432 | bottom: node.paddingBottom, 433 | left: node.paddingLeft, 434 | }, 435 | spacing: (() => { 436 | if ( 437 | node.primaryAxisAlignItems === "SPACE_BETWEEN" || 438 | node.counterAxisAlignItems === "SPACE_BETWEEN" 439 | ) { 440 | return "auto"; 441 | } else { 442 | return node.itemSpacing; 443 | } 444 | })(), 445 | effect: (() => { 446 | if (node.effects && node.effects.length > 0) { 447 | return sanitiseValue(node.effects); 448 | } 449 | })(), 450 | 451 | // effect: sanitiseValue(node.effects[0]), 452 | direction: sanitiseValue(node.layoutMode), 453 | fontSize: node.fontSize, 454 | fontFamily: node.fontName?.family, 455 | fontWeight: (() => { 456 | if (node.fontName) return sanitiseValue(node.fontName.style); 457 | // switch (node.fontName?.style) { 458 | 459 | // case "Thin": 460 | // return 100 461 | // break 462 | // case "ExtraLight": 463 | // return 200 464 | // break 465 | // case "Medium": 466 | // return 300 467 | // break 468 | // case "Normal": 469 | // return 400 470 | // break 471 | // case "Medium": 472 | // return 500 473 | // break 474 | // case "SemiBold" && "Semi Bold": 475 | // return 600 476 | // break 477 | // case "Bold": 478 | // return 700 479 | // break 480 | // case "ExtraBold": 481 | // return 800 482 | // break 483 | // case "Black" && "Heavy": 484 | // return 900 485 | // break 486 | // default: 400 487 | // } 488 | })(), 489 | textDecoration: sanitiseValue(node.textDecoration), 490 | horizontalAlignText: sanitiseValue(node.textAlignHorizontal), 491 | verticalAlignText: sanitiseValue(node.textAlignVertical), 492 | lineHeight: (() => { 493 | if (node.lineHeight) { 494 | return sanitiseValue(node.lineHeight.value); 495 | } 496 | })(), 497 | letterSpacing: (() => { 498 | if (node.letterSpacing?.unit) { 499 | var unit; 500 | if (node.letterSpacing.unit === "PERCENT") { 501 | unit = "%"; 502 | } 503 | if (node.letterSpacing.unit === "PIXELS") { 504 | unit = "px"; 505 | } 506 | return node.letterSpacing.value + unit; 507 | } 508 | })(), 509 | textCase: sanitiseValue(node.textCase), 510 | horizontalAlignItems: (() => { 511 | if (node.layoutMode === "HORIZONTAL") { 512 | return sanitiseValue(node.primaryAxisAlignItems); 513 | } 514 | if (node.layoutMode === "VERTICAL") { 515 | return sanitiseValue(node.counterAxisAlignItems); 516 | } 517 | })(), 518 | verticalAlignItems: (() => { 519 | if (node.layoutMode === "HORIZONTAL") { 520 | return sanitiseValue(node.counterAxisAlignItems); 521 | } 522 | if (node.layoutMode === "VERTICAL") { 523 | return sanitiseValue(node.primaryAxisAlignItems); 524 | } 525 | })(), 526 | overflow: (() => { 527 | if (node.clipsContent) { 528 | return "hidden"; 529 | } else { 530 | return "visible"; 531 | } 532 | })(), 533 | }; 534 | 535 | var defaultPropValues = { 536 | Frame: { 537 | name: "", 538 | hidden: false, 539 | x: 0, 540 | y: 0, 541 | blendMode: "normal", 542 | opacity: 1, 543 | effect: [], 544 | fill: [], 545 | stroke: [], 546 | strokeWidth: 1, 547 | strokeAlign: "inside", 548 | rotation: 0, 549 | cornerRadius: 0, 550 | overflow: "scroll", 551 | width: 100, 552 | height: 100, 553 | }, 554 | AutoLayout: { 555 | name: "", 556 | hidden: false, 557 | x: 0, 558 | y: 0, 559 | blendMode: "normal", 560 | opacity: 1, 561 | effect: [], 562 | fill: [], 563 | stroke: [], 564 | strokeWidth: 1, 565 | strokeAlign: "inside", 566 | rotation: 0, 567 | flipVertical: false, 568 | cornerRadius: 0, 569 | overflow: "scroll", 570 | width: "hug-contents", 571 | height: "hug-contents", 572 | direction: "horizontal", 573 | spacing: 0, 574 | padding: 0, 575 | horizontalAlignItems: "start", 576 | verticalAlignItems: "start", 577 | }, 578 | Text: { 579 | name: "", 580 | hidden: false, 581 | x: 0, 582 | y: 0, 583 | blendMode: "normal", 584 | opacity: 1, 585 | effect: [], 586 | width: "hug-contents", 587 | height: "hug-contents", 588 | rotation: 0, 589 | flipVertical: false, 590 | fontFamily: "Roboto", 591 | horizontalAlignText: "left", 592 | verticalAlignText: "top", 593 | letterSpacing: 0, 594 | lineHeight: "auto", 595 | textDecoration: "none", 596 | textCase: "original", 597 | fontSize: 16, 598 | italic: false, 599 | fill: { 600 | type: "solid", 601 | color: "#000000", 602 | blendMode: "normal", 603 | }, 604 | fontWeight: 400, 605 | paragraphIndent: 0, 606 | paragraphSpacing: 0, 607 | }, 608 | Rectangle: { 609 | name: "", 610 | hidden: false, 611 | x: 0, 612 | y: 0, 613 | blendMode: "normal", 614 | opacity: 1, 615 | effect: [], 616 | fill: [], 617 | stroke: [], 618 | strokeWidth: 1, 619 | strokeAlign: "inside", 620 | rotation: 0, 621 | flipVertical: false, 622 | cornerRadius: 0, 623 | width: 100, 624 | height: 100, 625 | }, 626 | Ellipse: { 627 | name: "", 628 | hidden: false, 629 | x: 0, 630 | y: 0, 631 | blendMode: "normal", 632 | opacity: 1, 633 | effect: [], 634 | fill: [], 635 | stroke: [], 636 | strokeWidth: 1, 637 | strokeAlign: "inside", 638 | rotation: 0, 639 | flipVertical: false, 640 | width: 100, 641 | height: 100, 642 | }, 643 | SVG: { 644 | width: 100, 645 | height: 100, 646 | x: 0, 647 | y: 0, 648 | }, 649 | }; 650 | 651 | if ( 652 | node.type === "FRAME" || 653 | node.type === "GROUP" || 654 | node.type === "INSTANCE" || 655 | node.type === "COMPONENT" 656 | ) { 657 | if (node.layoutMode && node.layoutMode !== "NONE") { 658 | component = "AutoLayout"; 659 | } else { 660 | component = "Frame"; 661 | } 662 | } 663 | if (node.type === "TEXT") { 664 | component = "Text"; 665 | } 666 | 667 | if (node.type === "ELLIPSE") { 668 | component = "Ellipse"; 669 | } 670 | 671 | if (node.type === "RECTANGLE" || node.type === "LINE") { 672 | component = "Rectangle"; 673 | } 674 | 675 | if ( 676 | node.type === "VECTOR" || 677 | node.type === "BOOLEAN_OPERATION" || 678 | node.type === "POLYGON" || 679 | node.type === "STAR" || 680 | (node.exportSettings && node.exportSettings[0]?.format === "SVG") 681 | ) { 682 | component = "SVG"; 683 | } 684 | 685 | var svg, 686 | stop = false; 687 | 688 | if (component === "SVG") { 689 | if (node.visible) { 690 | svg = await node.exportAsync({ format: "SVG" }); 691 | } else { 692 | // Skip component 693 | component = "skip"; 694 | } 695 | 696 | // Don't iterate children 697 | stop = true; 698 | } 699 | 700 | if (!node.visible) { 701 | // Skip component 702 | component = "skip"; 703 | } 704 | 705 | function genProps() { 706 | var array = []; 707 | for (let [key, value] of Object.entries(props) as any) { 708 | // If default props for component 709 | if (component && defaultPropValues[component]) { 710 | // Ignore undefined values 711 | if (typeof value !== "undefined") { 712 | // Check property exists for component 713 | if (key in defaultPropValues[component]) { 714 | if ( 715 | JSON.stringify( 716 | defaultPropValues[component][key] 717 | ) !== JSON.stringify(value) 718 | ) { 719 | // Certain values need to be wrapped in curly braces 720 | if (!isSymbol(value)) { 721 | if (isNaN(value)) { 722 | if ( 723 | typeof value === "object" && 724 | value !== null 725 | ) { 726 | value = `{${JSON.stringify( 727 | value 728 | )}}`; 729 | } else { 730 | value = `${JSON.stringify(value)}`; 731 | } 732 | } else { 733 | value = `{${value}}`; 734 | } 735 | 736 | // Don't add tabs on first prop 737 | if (array.length === 0) { 738 | array.push(`${key}=${value}`); 739 | } else { 740 | array.push( 741 | `${tab.repeat( 742 | depth 743 | )}${key}=${value}` 744 | ); 745 | } 746 | } 747 | } 748 | } 749 | } 750 | } 751 | } 752 | return array.join("\n"); 753 | } 754 | 755 | // res = callback({ tree, res, node }) 756 | 757 | // Need to use express `callback() || {}` incase the calback returns a nullish value 758 | 759 | // if (node.type === "VECTOR") { 760 | // node.exportAsync({ 761 | // format: "SVG" 762 | // }).then((svg) => { 763 | // res = tree.next(callback(node, component, genProps(), svg) || {}) 764 | // }) 765 | 766 | // } 767 | // else { 768 | 769 | // if (component === "SVG") { 770 | // res = tree.next(await callback(node, component, genProps()) || {}) 771 | // console.log(res) 772 | // } 773 | // else { 774 | 775 | if (component !== "skip") { 776 | res = tree.next( 777 | await callback(node, component, genProps(), stop, svg) 778 | ); 779 | } else { 780 | res = tree.next({ skip: true }); 781 | } 782 | 783 | // } 784 | 785 | count++; 786 | depth++; 787 | } 788 | 789 | if (string === "") { 790 | throw "No output generated from selection"; 791 | } 792 | 793 | return string; 794 | } 795 | 796 | export async function genWidgetStr(origSel) { 797 | return walkNodes(origSel, async (node, component, props, stop, svg) => { 798 | if (component) { 799 | return { 800 | stop, 801 | before() { 802 | if (component === "SVG") { 803 | // await new Promise((resolve) => { 804 | // figma.showUI(` 805 | // 824 | // `, { visible: false }) 825 | 826 | // figma.ui.postMessage({ type: 'decode-svg', value: svg }) 827 | 828 | // figma.ui.onmessage = (msg) => { 829 | // if (msg.type === "encoded-image") { 830 | // console.log(msg.value) 831 | // } 832 | 833 | // } 834 | // resolve() 835 | 836 | // }) 837 | 838 | return `<${component} ${props} overflow="visible" src={\`${Utf8ArrayToStr( 839 | svg 840 | )}\`} />\n`; 841 | } else { 842 | return `<${component} ${props}>\n`; 843 | } 844 | }, 845 | during() { 846 | if (component === "Text") { 847 | return `${node.characters}\n`; 848 | } 849 | }, 850 | after() { 851 | if (component !== "SVG") { 852 | return `\n`; 853 | } else { 854 | return ``; 855 | } 856 | }, 857 | }; 858 | } else { 859 | console.log("Node doesn't exist as a React component"); 860 | return `Node doesn't exist as a React component`; 861 | } 862 | }); 863 | } 864 | -------------------------------------------------------------------------------- /stylup.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | classes: [ 3 | { 4 | name: 'spacing', 5 | class: ['p', 'm'], 6 | children: [ 7 | 't', 8 | 'r', 9 | 'b', 10 | 'l' 11 | ], 12 | style({ rule, args, str }) { 13 | if (args) { 14 | let values = args; 15 | 16 | switch (values.length) { 17 | case 1: 18 | values.push(values[0]); 19 | case 2: 20 | values.push(values[0]); 21 | case 3: 22 | values.push(values[1]); 23 | } 24 | 25 | for (let [index, side] of rule.children.entries()) { 26 | str`--${rule.class}${side}: ${values[index]};\n` 27 | } 28 | 29 | return str() 30 | } 31 | } 32 | }, 33 | { 34 | name: 'colour', 35 | class: 'c', 36 | style({ rule, str }) { 37 | if (rule.args) { 38 | 39 | str`--${rule.class}: ${rule.args[0]};\n` 40 | 41 | return str() 42 | } 43 | } 44 | }, 45 | { 46 | name: 'background color', 47 | class: 'bgc', 48 | style({ rule, str }) { 49 | if (rule.args) { 50 | 51 | str`--${rule.class}: ${rule.args[0]};\n` 52 | 53 | return str() 54 | } 55 | } 56 | }, 57 | { 58 | name: 'width', 59 | class: 'w', 60 | style({ rule, str }) { 61 | if (rule.args) { 62 | str`--${rule.class}: ${rule.args[0]};\n` 63 | 64 | return str() 65 | } 66 | } 67 | }, 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /stylup.js: -------------------------------------------------------------------------------- 1 | import stylupProcessor from 'stylup'; 2 | 3 | var matchRecursive = function () { 4 | var htmlOnly = /(?<=\<[^>]*)/gmi 5 | 6 | var formatParts = /^([\S\s]+?)\.\.\.([\S\s]+)/, 7 | metaChar = /[-[\]{}()*+?.\\^$|,]/g, 8 | escape = function (str) { 9 | return str.replace(metaChar, "\\$&"); 10 | }; 11 | 12 | return function (str, format) { 13 | var p = formatParts.exec(format); 14 | if (!p) throw new Error("format must include start and end tokens separated by '...'"); 15 | if (p[1] == p[2]) throw new Error("start and end format tokens cannot be identical"); 16 | 17 | var opener = p[1], 18 | closer = p[2], 19 | 20 | /* Use an optimized regex when opener and closer are one character each */ 21 | iterator = new RegExp(format.length == 5 ? "[" + escape(opener + closer) + "]" : escape(opener) + "|" + escape(closer), "g"), 22 | results = [], 23 | openTokens, matchStartIndex, match; 24 | 25 | // console.log(iterator.toString()) 26 | var endOfLast = 0 27 | var lengthOfStr = str.length 28 | 29 | do { 30 | openTokens = 0; 31 | while (match = iterator.exec(str)) { 32 | var matchEndIndex = match.index + 1 33 | if (match[0] == opener) { 34 | if (!openTokens) 35 | matchStartIndex = iterator.lastIndex - 1; 36 | openTokens++; 37 | } else if (openTokens) { 38 | openTokens--; 39 | if (!openTokens) { 40 | results.push(str.slice(endOfLast, matchStartIndex)); 41 | if (str[matchStartIndex - 1] === "=") { 42 | results.push(`"` + str.slice(matchStartIndex, matchEndIndex) + `"`); 43 | } 44 | else { 45 | results.push(str.slice(matchStartIndex, matchEndIndex)); 46 | } 47 | endOfLast = matchEndIndex 48 | } 49 | } 50 | } 51 | results.push(str.slice(endOfLast, lengthOfStr)); 52 | } while (openTokens && (iterator.lastIndex = matchStartIndex)); 53 | 54 | return results; 55 | }; 56 | }(); 57 | 58 | var example = `
 {
66 | 		if (contenteditable !== undefined && contenteditable !== 'false') {
67 | 			code = target.innerText;
68 | 			if (block.querySelector('code') == null) {
69 | 				block.innerHTML = '';
70 | 			}
71 | 		}
72 |     }}>`
73 | 
74 | function processForSvelte(content) {
75 |     return matchRecursive(content, "{...}").join("")
76 | }
77 | 
78 | export const stylup = {
79 |     markup({ content, filename }) {
80 |         // phtml trips over sveltes markup attribute={handlerbars}. So this replaces those occurances with attribute="{handlebars}"
81 |         content = processForSvelte(content)
82 |         return stylupProcessor.process(content, { from: filename }).then(result => ({ code: result.html, map: null }));
83 |     }
84 | }


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |     "compilerOptions": {
 3 |         "target": "es2017",
 4 |         "typeRoots": [
 5 |             "./node_modules/@types",
 6 |             "./node_modules/@figma"
 7 |         ],
 8 |         "moduleResolution": "node",
 9 |         "resolveJsonModule": true,
10 |         "allowSyntheticDefaultImports": true
11 |     }
12 | }


--------------------------------------------------------------------------------