├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── tasks.json ├── examples ├── basic.figma.json ├── dump-1.json └── dump-2.json ├── jest.config.js ├── manifest.json ├── package.json ├── plugin ├── plugin.ts ├── pluginMessage.ts ├── toolbar.tsx └── ui.tsx ├── scripts └── build.js ├── src ├── applyOverridesToChildren.ts ├── components.test.ts ├── fallbackFonts.ts ├── figma-default-layers.ts ├── figma-json.ts ├── figmaState.ts ├── fonts.test.ts ├── genDefaults.ts ├── index.ts ├── read.ts ├── readBlacklist.test.ts ├── readBlacklist.ts ├── styles.test.ts ├── updateImageHashes.test.ts ├── updateImageHashes.ts └── write.ts ├── tsconfig.json └── yarn.lock /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Jest Tests 2 | 3 | # Run on every push to the repository on any branch 4 | on: 5 | push: 6 | branches: 7 | - "**" 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Install dependencies 18 | run: yarn install 19 | 20 | - name: Run Jest tests 21 | run: yarn test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | plugin/**/*.js 2 | node_modules 3 | dist 4 | 5 | yarn-error.log 6 | 7 | .DS_Store -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | examples 3 | .vscode 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": true, 5 | "trailingComma": "all", 6 | "singleQuote": false, 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "dev", 9 | "problemMatcher": ["$tsc-watch"] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /examples/basic.figma.json: -------------------------------------------------------------------------------- 1 | { 2 | "objects": [ 3 | { 4 | "locked": false, 5 | "visible": true, 6 | "pluginData": { 7 | "com.layershot.meta": "{\"layerClass\":\"UIWindowLayer\",\"viewClass\":\"UIWindow\"}" 8 | }, 9 | "constraints": { 10 | "horizontal": "MIN", 11 | "vertical": "MIN" 12 | }, 13 | "opacity": 1, 14 | "blendMode": "NORMAL", 15 | "isMask": false, 16 | "effects": [], 17 | "effectStyleId": "", 18 | "x": 207, 19 | "y": 448, 20 | "width": 414, 21 | "height": 896, 22 | "rotation": 0, 23 | "relativeTransform": [ 24 | [0, 0, 0], 25 | [0, 0, 0] 26 | ], 27 | "exportSettings": [], 28 | "backgrounds": [], 29 | "backgroundStyleId": "", 30 | "clipsContent": false, 31 | "layoutGrids": [], 32 | "guides": [], 33 | "gridStyleId": "", 34 | "name": "Frame", 35 | "type": "FRAME" 36 | } 37 | ], 38 | "images": {} 39 | } 40 | -------------------------------------------------------------------------------- /examples/dump-1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "FRAME", 4 | "name": "Original", 5 | "visible": true, 6 | "locked": false, 7 | "opacity": 1, 8 | "blendMode": "PASS_THROUGH", 9 | "isMask": false, 10 | "effects": [], 11 | "effectStyleId": "", 12 | "relativeTransform": [ 13 | [1, 0, -468], 14 | [0, 1, -351] 15 | ], 16 | "x": -468, 17 | "y": -351, 18 | "width": 300, 19 | "height": 209, 20 | "rotation": 0, 21 | "children": [ 22 | { 23 | "type": "RECTANGLE", 24 | "name": "image 1", 25 | "visible": true, 26 | "locked": false, 27 | "opacity": 1, 28 | "blendMode": "PASS_THROUGH", 29 | "isMask": false, 30 | "effects": [], 31 | "effectStyleId": "", 32 | "fills": [ 33 | { 34 | "type": "IMAGE", 35 | "visible": true, 36 | "opacity": 1, 37 | "blendMode": "NORMAL", 38 | "scaleMode": "FILL", 39 | "imageTransform": [ 40 | [1, 0, 0], 41 | [0, 1, 0] 42 | ], 43 | "scalingFactor": 0.5, 44 | "filters": { 45 | "exposure": 0, 46 | "contrast": 0, 47 | "saturation": 0, 48 | "temperature": 0, 49 | "tint": 0, 50 | "highlights": 0, 51 | "shadows": 0 52 | }, 53 | "imageHash": "459d44e506315e435060e18b94e213e166c52f62" 54 | } 55 | ], 56 | "fillStyleId": "", 57 | "strokes": [], 58 | "strokeStyleId": "", 59 | "strokeWeight": 1, 60 | "strokeAlign": "INSIDE", 61 | "strokeCap": "NONE", 62 | "strokeJoin": "MITER", 63 | "dashPattern": [], 64 | "relativeTransform": [ 65 | [1, 0, 0], 66 | [0, 1, 109] 67 | ], 68 | "x": 0, 69 | "y": 109, 70 | "width": 300, 71 | "height": 99.65753173828125, 72 | "rotation": 0, 73 | "exportSettings": [], 74 | "constraints": { "horizontal": "MIN", "vertical": "MIN" }, 75 | "cornerRadius": 0, 76 | "cornerSmoothing": 0, 77 | "topLeftRadius": 0, 78 | "topRightRadius": 0, 79 | "bottomLeftRadius": 0, 80 | "bottomRightRadius": 0 81 | }, 82 | { 83 | "type": "ELLIPSE", 84 | "name": "Ellipse 1", 85 | "visible": true, 86 | "locked": false, 87 | "opacity": 1, 88 | "blendMode": "PASS_THROUGH", 89 | "isMask": false, 90 | "effects": [], 91 | "effectStyleId": "", 92 | "fills": [ 93 | { 94 | "type": "SOLID", 95 | "visible": true, 96 | "opacity": 1, 97 | "blendMode": "NORMAL", 98 | "color": { 99 | "r": 0.46666666865348816, 100 | "g": 0.6549019813537598, 101 | "b": 0.7568627595901489 102 | } 103 | }, 104 | { 105 | "type": "GRADIENT_LINEAR", 106 | "visible": true, 107 | "opacity": 1, 108 | "blendMode": "NORMAL", 109 | "gradientStops": [ 110 | { 111 | "color": { 112 | "r": 0.48627451062202454, 113 | "g": 0.7882353067398071, 114 | "b": 0.9529411792755127, 115 | "a": 1 116 | }, 117 | "position": 0 118 | }, 119 | { "color": { "r": 1, "g": 1, "b": 1, "a": 0 }, "position": 1 } 120 | ], 121 | "gradientTransform": [ 122 | [6.123234262925839e-17, 1, 0], 123 | [-1, 6.123234262925839e-17, 1] 124 | ] 125 | } 126 | ], 127 | "fillStyleId": "", 128 | "strokes": [], 129 | "strokeStyleId": "", 130 | "strokeWeight": 1, 131 | "strokeAlign": "INSIDE", 132 | "strokeCap": "NONE", 133 | "strokeJoin": "MITER", 134 | "dashPattern": [], 135 | "relativeTransform": [ 136 | [1, 0, 10], 137 | [0, 1, 10] 138 | ], 139 | "x": 10, 140 | "y": 10, 141 | "width": 32, 142 | "height": 32, 143 | "rotation": 0, 144 | "exportSettings": [], 145 | "constraints": { "horizontal": "MIN", "vertical": "MIN" }, 146 | "cornerRadius": 0, 147 | "cornerSmoothing": 0, 148 | "arcData": { 149 | "startingAngle": 0, 150 | "endingAngle": 6.2831854820251465, 151 | "innerRadius": 0 152 | } 153 | }, 154 | { 155 | "type": "TEXT", 156 | "name": "This landscape is not real. It was generated by a neural network. The network is trained with an adversarial process", 157 | "visible": true, 158 | "locked": false, 159 | "opacity": 1, 160 | "blendMode": "PASS_THROUGH", 161 | "isMask": false, 162 | "effects": [], 163 | "effectStyleId": "", 164 | "fills": [ 165 | { 166 | "type": "SOLID", 167 | "visible": true, 168 | "opacity": 1, 169 | "blendMode": "NORMAL", 170 | "color": { "r": 0.2669270932674408, "g": 0.271484375, "b": 0.3125 } 171 | } 172 | ], 173 | "fillStyleId": "", 174 | "strokes": [], 175 | "strokeStyleId": "", 176 | "strokeWeight": 1, 177 | "strokeAlign": "OUTSIDE", 178 | "strokeCap": "NONE", 179 | "strokeJoin": "MITER", 180 | "dashPattern": [], 181 | "relativeTransform": [ 182 | [1, 0, 50], 183 | [0, 1, 45] 184 | ], 185 | "x": 50, 186 | "y": 45, 187 | "width": 241, 188 | "height": 74, 189 | "rotation": 0, 190 | "exportSettings": [], 191 | "constraints": { "horizontal": "MIN", "vertical": "MIN" }, 192 | "textStyleId": "", 193 | "characters": "This landscape is not real. It was generated by a neural network. The network is trained with an adversarial process", 194 | "autoRename": true, 195 | "fontSize": 12, 196 | "paragraphIndent": 0, 197 | "paragraphSpacing": 60, 198 | "textAlignHorizontal": "LEFT", 199 | "textAlignVertical": "TOP", 200 | "textCase": "ORIGINAL", 201 | "textDecoration": "NONE", 202 | "textAutoResize": "NONE", 203 | "letterSpacing": { "unit": "PERCENT", "value": 0 }, 204 | "lineHeight": { "unit": "PERCENT", "value": 150 }, 205 | "fontName": { "family": "Roboto", "style": "Medium" } 206 | }, 207 | { 208 | "type": "TEXT", 209 | "name": "Surreal Landscape", 210 | "visible": true, 211 | "locked": false, 212 | "opacity": 1, 213 | "blendMode": "PASS_THROUGH", 214 | "isMask": false, 215 | "effects": [], 216 | "effectStyleId": "", 217 | "fills": [ 218 | { 219 | "type": "SOLID", 220 | "visible": true, 221 | "opacity": 1, 222 | "blendMode": "NORMAL", 223 | "color": { "r": 0, "g": 0, "b": 0 } 224 | } 225 | ], 226 | "fillStyleId": "", 227 | "strokes": [], 228 | "strokeStyleId": "", 229 | "strokeWeight": 1, 230 | "strokeAlign": "OUTSIDE", 231 | "strokeCap": "NONE", 232 | "strokeJoin": "MITER", 233 | "dashPattern": [], 234 | "relativeTransform": [ 235 | [1, 0, 50], 236 | [0, 1, 12] 237 | ], 238 | "x": 50, 239 | "y": 12, 240 | "width": 200, 241 | "height": 28, 242 | "rotation": 0, 243 | "exportSettings": [], 244 | "constraints": { "horizontal": "MIN", "vertical": "MIN" }, 245 | "textStyleId": "", 246 | "characters": "Surreal Landscape", 247 | "autoRename": true, 248 | "fontSize": 24, 249 | "paragraphIndent": 0, 250 | "paragraphSpacing": 0, 251 | "textAlignHorizontal": "LEFT", 252 | "textAlignVertical": "TOP", 253 | "textCase": "ORIGINAL", 254 | "textDecoration": "NONE", 255 | "textAutoResize": "WIDTH_AND_HEIGHT", 256 | "letterSpacing": { "unit": "PERCENT", "value": 0 }, 257 | "lineHeight": { "unit": "AUTO" }, 258 | "fontName": { "family": "Roboto", "style": "Medium" } 259 | } 260 | ], 261 | "layoutGrids": [], 262 | "gridStyleId": "", 263 | "backgrounds": [ 264 | { 265 | "type": "SOLID", 266 | "visible": true, 267 | "opacity": 1, 268 | "blendMode": "NORMAL", 269 | "color": { "r": 0.987500011920929, "g": 0.9909999966621399, "b": 1 } 270 | } 271 | ], 272 | "backgroundStyleId": "", 273 | "clipsContent": true, 274 | "guides": [], 275 | "exportSettings": [], 276 | "constraints": { "horizontal": "MIN", "vertical": "MIN" } 277 | } 278 | ] 279 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "esbuild-jest", 4 | }, 5 | roots: ["./src"], 6 | }; 7 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "JSON Plugin", 3 | "id": "763482362622863901", 4 | "api": "1.0.0", 5 | "main": "dist/plugin.js", 6 | "editorType": ["figma"], 7 | "ui": "dist/ui.js" 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-json-plugin", 3 | "version": "0.0.5-alpha.15", 4 | "description": "Dump a hierarchy to JSON within a Figma document, or insert a dumped JSON hierarchy. Intended for use within Figma plugins.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "browser": "dist/browser.js", 9 | "author": "Andrew Pouliot", 10 | "license": "MIT", 11 | "scripts": { 12 | "dev": "concurrently --raw 'yarn build --watch' 'yarn build:types --watch'", 13 | "dev:plugin": "concurrently --raw 'yarn build:plugin --watch' 'yarn build:ui --watch'", 14 | "build": "yarn build:lib && yarn build:types", 15 | "build:lib": "node scripts/build.js", 16 | "build:plugin": "esbuild plugin/plugin.ts --bundle --outfile=dist/plugin.js", 17 | "build:ui": "esbuild plugin/ui.tsx --bundle --outfile=dist/ui.js", 18 | "build:types": "tsc", 19 | "publish-prerelease": "yarn build && yarn publish --prerelease", 20 | "test": "jest", 21 | "test:watch": "jest --watch", 22 | "format": "prettier --write .", 23 | "clean": "rm -rf dist", 24 | "prepack": "yarn clean && yarn build" 25 | }, 26 | "files": [ 27 | "dist/" 28 | ], 29 | "devDependencies": { 30 | "@figma/plugin-typings": "^1.58.0", 31 | "@types/base64-js": "^1.2.5", 32 | "@types/jest": "^28.1.6", 33 | "@types/node": "^12.7.11", 34 | "@types/react": "^16.9.11", 35 | "@types/react-dom": "^16.9.3", 36 | "concurrently": "^7.3.0", 37 | "esbuild": "^0.14.50", 38 | "esbuild-jest": "^0.5.0", 39 | "figma-api-stub": "^0.0.56", 40 | "jest": "^28.1.3", 41 | "prettier": "^2.7.1", 42 | "react": "^16.11.0", 43 | "react-dom": "^16.11.0", 44 | "react-json-view": "^1.19.1", 45 | "typescript": "^4.7.4" 46 | }, 47 | "dependencies": { 48 | "base64-js": "^1.3.1", 49 | "figma-styled-components": "^1.2.2", 50 | "isomorphic-unfetch": "^3.0.0", 51 | "styled-components": "^4.3.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /plugin/plugin.ts: -------------------------------------------------------------------------------- 1 | import { dump, insert } from "../src"; 2 | import * as F from "../src/figma-json"; 3 | import genDefaults from "../src/genDefaults"; 4 | import defaultLayers from "../src/figma-default-layers"; 5 | import { UIToPluginMessage, PluginToUIMessage } from "./pluginMessage"; 6 | 7 | const html = ` 15 |
16 | 17 | `; 18 | 19 | // Cause our plugin to show 20 | figma.showUI(html, { width: 400, height: 400 }); 21 | 22 | console.log("This in plugin:", globalThis); 23 | 24 | // Logs defaults to console, can copy to clipboard in Figma devtools 25 | // showDefaults(); 26 | 27 | let updateEventsPaused = false; 28 | 29 | figma.ui.onmessage = (pluginMessage: any, props: OnMessageProperties) => { 30 | const message = pluginMessage as UIToPluginMessage; 31 | switch (message.type) { 32 | case "insert": { 33 | const { data } = message; 34 | updateEventsPaused = true; 35 | doInsert(data); 36 | updateEventsPaused = false; 37 | break; 38 | } 39 | case "insertTestCases": { 40 | const { data } = message; 41 | data.forEach((d) => doInsert(d)); 42 | break; 43 | } 44 | case "logDefaults": { 45 | logDefaults(); 46 | break; 47 | } 48 | case "ready": { 49 | tellUIAboutStoredText(); 50 | updateUIWithSelection(); 51 | break; 52 | } 53 | } 54 | }; 55 | 56 | figma.on("selectionchange", () => { 57 | console.log("updating after selection change!"); 58 | if (!updateEventsPaused) { 59 | updateUIWithSelection(); 60 | } 61 | }); 62 | 63 | figma.on("close", () => { 64 | console.log("Plugin closing."); 65 | }); 66 | 67 | async function tellUIAboutStoredText() { 68 | const l: F.FrameNode = { 69 | ...defaultLayers.FRAME, 70 | }; 71 | 72 | const basic: F.DumpedFigma = { 73 | objects: [l], 74 | components: {}, 75 | componentSets: {}, 76 | images: {}, 77 | styles: {}, 78 | }; 79 | postMessage({ 80 | type: "updateInsertText", 81 | recentInsertText: JSON.stringify(basic, null, 2), 82 | }); 83 | } 84 | 85 | // Helper to make sure we're only sending valid events to the plugin UI 86 | function postMessage(message: PluginToUIMessage) { 87 | figma.ui.postMessage(message); 88 | } 89 | 90 | function tick(n: number): Promise { 91 | return new Promise((resolve) => { 92 | setTimeout(resolve, n); 93 | }); 94 | } 95 | 96 | async function doInsert(data: F.DumpedFigma) { 97 | await tick(200); 98 | console.log("plugin inserting: ", data); 99 | // TODO: this is broken, not clear why... 100 | const prom = insert(data); 101 | await tick(200); 102 | console.log("promise to insert is ", prom); 103 | await prom; 104 | // insert(data); 105 | console.log("plugin done inserting."); 106 | postMessage({ type: "didInsert" }); 107 | } 108 | 109 | async function logDefaults() { 110 | const defaults = await genDefaults(); 111 | console.log("defaults: ", defaults); 112 | } 113 | 114 | async function updateUIWithSelection() { 115 | try { 116 | // Dump document selection to JSON 117 | const opt = { images: true, styles: true }; 118 | console.log("dumping...", opt); 119 | const data = await dump(figma.currentPage.selection, opt); 120 | postMessage({ type: "update", data }); 121 | } catch (e) { 122 | console.error("error during plugin: ", e); 123 | } finally { 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /plugin/pluginMessage.ts: -------------------------------------------------------------------------------- 1 | import * as F from "../src/figma-json"; 2 | 3 | /// Messages UI code sends to Plugin 4 | 5 | export interface ReadyMessage { 6 | type: "ready"; 7 | } 8 | 9 | export interface InsertMessage { 10 | type: "insert"; 11 | data: F.DumpedFigma; 12 | } 13 | 14 | export interface InsertTestCasesMessage { 15 | type: "insertTestCases"; 16 | data: F.DumpedFigma[]; 17 | } 18 | 19 | export interface LogDefaultsMessage { 20 | type: "logDefaults"; 21 | } 22 | 23 | export type UIToPluginMessage = 24 | | ReadyMessage 25 | | InsertMessage 26 | | InsertTestCasesMessage 27 | | LogDefaultsMessage; 28 | 29 | /// Messages Plugin code sends to UI 30 | 31 | export interface UpdateDumpMessage { 32 | type: "update"; 33 | data: F.DumpedFigma; 34 | } 35 | 36 | export interface UpdateInsertTextMessage { 37 | type: "updateInsertText"; 38 | recentInsertText: string; 39 | } 40 | 41 | export interface DidInsertMessage { 42 | type: "didInsert"; 43 | } 44 | 45 | export type PluginToUIMessage = 46 | | UpdateDumpMessage 47 | | DidInsertMessage 48 | | UpdateInsertTextMessage; 49 | -------------------------------------------------------------------------------- /plugin/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const doNothing = (e: React.MouseEvent) => { 4 | console.log("No event hooked up!"); 5 | }; 6 | 7 | export const InsertButton = ({ 8 | onInsert, 9 | }: { 10 | onInsert: (e: React.MouseEvent) => void; 11 | }) => ( 12 | 24 | ); 25 | 26 | const Toolbar: React.FunctionComponent = ({ children }) => ( 27 |
34 | {children} 35 |
36 | ); 37 | 38 | export default Toolbar; 39 | -------------------------------------------------------------------------------- /plugin/ui.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "react-dom"; 3 | import Toolbar, { InsertButton } from "./toolbar"; 4 | import { PluginToUIMessage } from "./pluginMessage"; 5 | 6 | interface UIState { 7 | dump?: any; 8 | showInsert: boolean; 9 | inserting: boolean; 10 | recentInsertText?: string; 11 | } 12 | 13 | console.log("Starting plugin"); 14 | // declare global onmessage = store.update; 15 | 16 | class InsertUI extends React.Component<{ 17 | recentInsertText?: string; 18 | doInsert: (json: string) => void; 19 | }> { 20 | doInsert = () => { 21 | if (this.textArea !== null) { 22 | const text = this.textArea.value; 23 | if (text !== null) { 24 | const { doInsert } = this.props; 25 | console.log("about to insert", text); 26 | doInsert(text); 27 | } 28 | } 29 | }; 30 | 31 | textArea: HTMLTextAreaElement | null = null; 32 | 33 | render() { 34 | const { recentInsertText } = this.props; 35 | return ( 36 |
44 | 45 | 48 | 49 |