├── .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 |
55 | );
56 | }
57 | }
58 |
59 | class UI extends React.Component {
60 | state: UIState = {
61 | dump: undefined,
62 | showInsert: false,
63 | inserting: false,
64 | };
65 |
66 | onMessage = (e: MessageEvent) => {
67 | console.log("on message", e);
68 | const {
69 | data: { pluginMessage },
70 | } = e;
71 | const message = pluginMessage as PluginToUIMessage;
72 |
73 | switch (message.type) {
74 | case "didInsert":
75 | this.setState({ inserting: false });
76 | return;
77 | case "update":
78 | const { data } = message;
79 | this.setState({ dump: data });
80 | return;
81 | case "updateInsertText":
82 | const { recentInsertText } = message;
83 | this.setState({ recentInsertText });
84 | return;
85 | }
86 | };
87 |
88 | componentDidMount() {
89 | console.log("Did mount");
90 | parent.postMessage({ pluginMessage: { type: "ready" } }, "*");
91 | window.addEventListener("message", this.onMessage);
92 | }
93 |
94 | doPaste = (e: React.ClipboardEvent) => {
95 | console.log("Did paste!");
96 | const str = e.clipboardData.getData("text");
97 | if (typeof str === "string") {
98 | this.doInsert(str);
99 | e.preventDefault();
100 | }
101 | };
102 |
103 | doInsert = (json: string) => {
104 | const data = JSON.parse(json);
105 | if (typeof data !== "object") {
106 | return;
107 | }
108 | try {
109 | this.setState({ showInsert: false, inserting: true });
110 | parent.postMessage({ pluginMessage: { type: "insert", data } }, "*");
111 | } finally {
112 | }
113 | };
114 |
115 | // Usage:
116 | // 1. Add any figma-json files you want to insert to the plugin folder
117 | // 2. Import them: e.g. import * as test1 from "./test1.json";
118 | // 3. Add them to the array below: [test1, test2, ...]
119 | insertTestCases = () => {
120 | parent.postMessage(
121 | {
122 | pluginMessage: {
123 | type: "insertTestCases",
124 | data: [],
125 | },
126 | },
127 | "*",
128 | );
129 | };
130 |
131 | onInsert = (e: React.MouseEvent) => {
132 | console.log("asked to insert");
133 | this.setState({ showInsert: true });
134 | };
135 |
136 | logDefaults = () => {
137 | parent.postMessage({ pluginMessage: { type: "logDefaults" } }, "*");
138 | };
139 |
140 | render() {
141 | const { dump, showInsert, inserting, recentInsertText } = this.state;
142 | if (inserting) {
143 | return Inserting...
;
144 | } else if (showInsert) {
145 | return (
146 |
150 | );
151 | } else if (dump === undefined) {
152 | return Waiting for data...
;
153 | } else {
154 | return (
155 |
163 |
164 |
165 |
171 |
174 |
175 |
176 | {JSON.stringify(
177 | dump,
178 | (key: string, value: any) =>
179 | value instanceof Uint8Array ? `<${value.length} bytes>` : value,
180 | 2,
181 | )}
182 |
183 | {/*
*/}
184 |
185 | );
186 | }
187 | }
188 | }
189 |
190 | const elem = document.getElementById("react-page");
191 |
192 | render(, elem);
193 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | const { build } = require("esbuild");
2 | const { dependencies, devDependencies } = require("../package.json");
3 |
4 | const entryPoints = ["src/index.ts"];
5 | const settings = {
6 | entryPoints,
7 | platform: "node",
8 | bundle: true,
9 | external: [...Object.keys(dependencies), ...Object.keys(devDependencies)],
10 | };
11 |
12 | const buildESM = () =>
13 | build({
14 | ...settings,
15 | format: "esm",
16 | outfile: "dist/index.mjs",
17 | });
18 |
19 | const buildCJS = () =>
20 | build({
21 | ...settings,
22 | format: "cjs",
23 | outfile: "dist/index.js",
24 | });
25 |
26 | const buildAll = () => Promise.all([buildESM(), buildCJS()]);
27 | buildAll();
28 |
--------------------------------------------------------------------------------
/src/applyOverridesToChildren.ts:
--------------------------------------------------------------------------------
1 | import * as F from "./figma-json";
2 |
3 | // We only support a subset of properties currently
4 | export type SupportedProperties = "characters" | "opacity";
5 |
6 | function isSupported(
7 | property: F.NodeChangeProperty,
8 | ): property is SupportedProperties {
9 | return property === "characters" || property === "opacity";
10 | }
11 |
12 | function filterNulls(arr: (T | null)[]): T[] {
13 | return arr.filter((n) => n !== null) as T[];
14 | }
15 |
16 | export function applyOverridesToChildren(
17 | instance: InstanceNode,
18 | f: F.InstanceNode,
19 | ) {
20 | const { overrides } = f;
21 | if (!overrides) {
22 | return;
23 | }
24 | // Remove overrides that aren't supported
25 | const supportedOverrides = filterNulls(
26 | overrides.map(
27 | ({ id, overriddenFields }): [string, SupportedProperties[]] | null => {
28 | const sp = overriddenFields.filter(isSupported);
29 | return sp.length > 0 ? [id, sp] : null;
30 | },
31 | ),
32 | );
33 | // Overridden fields are keyed by node id
34 | const overriddenMap = new Map(
35 | supportedOverrides,
36 | );
37 | _recursive(instance, f, overriddenMap);
38 | }
39 |
40 | function _recursive(
41 | n: SceneNode & ChildrenMixin,
42 | f: F.SceneNode & F.ChildrenMixin,
43 | overriddenMap: Map,
44 | ) {
45 | // Recursively find correspondences between n's children and j's children
46 | if (n.children.length !== f.children.length) {
47 | console.warn(
48 | `Instance children length mismatch ${n.children.length} vs ${f.children.length}: `,
49 | n,
50 | f,
51 | );
52 | }
53 | for (let [node, json] of zip(n.children, f.children)) {
54 | // Basic sanity check that we're looking at the same thing
55 | if (node.type !== json.type) {
56 | console.warn(
57 | `Instance children type mismatch: ${node.type} !== ${json.type}`,
58 | );
59 | }
60 | // We know that these scene nodes correspond, so apply overrides
61 | _applyOverrides(node, json, overriddenMap);
62 | // Recurse
63 | if ("children" in node && "children" in json) {
64 | _recursive(node, json, overriddenMap);
65 | }
66 | }
67 | }
68 |
69 | function _applyOverrides(
70 | n: SceneNode,
71 | j: F.SceneNode,
72 | overriddenMap: Map,
73 | ) {
74 | // Do we have overrides for this node?
75 | const overriddenFields = overriddenMap.get(j.id);
76 | if (overriddenFields) {
77 | for (let property of overriddenFields) {
78 | const v = j[property as keyof F.SceneNode];
79 | //console.log(`assigning override ${n.id}.${property} = ${j.id})${property} (${v})`);
80 | // @ts-expect-error We know that this property exists because we filtered it
81 | n[property as any] = v;
82 | }
83 | }
84 | }
85 |
86 | function* zip(a: Iterable, b: Iterable): IterableIterator<[A, B]> {
87 | let iterA = a[Symbol.iterator]();
88 | let iterB = b[Symbol.iterator]();
89 | let nextA = iterA.next();
90 | let nextB = iterB.next();
91 | while (!nextA.done && !nextB.done) {
92 | yield [nextA.value, nextB.value];
93 | nextA = iterA.next();
94 | nextB = iterB.next();
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/components.test.ts:
--------------------------------------------------------------------------------
1 | import * as F from "./figma-json";
2 | import defaultLayers from "./figma-default-layers";
3 | import { dump } from "./read";
4 |
5 | // Inject a fake figma object into the global scope as a hack
6 | beforeAll(() => {
7 | (globalThis as any).figma = {};
8 | });
9 |
10 | test("Takes components in the document and produces component ids like the REST api in the result", async () => {
11 | const sourceComponent = {
12 | ...defaultLayers.FRAME,
13 | type: "COMPONENT",
14 | name: "Info Button",
15 | key: "6848d756da66e55b42f79c0728e351ad",
16 | id: "123:456",
17 | backgrounds: [{ type: "IMAGE", imageHash: "A", scaleMode: "FILL" }],
18 | documentationLinks: [],
19 | description: "",
20 | remote: false,
21 | parent: null,
22 | };
23 |
24 | const sourceInstance = {
25 | ...defaultLayers.FRAME,
26 | type: "INSTANCE",
27 | name: "Info Button",
28 | mainComponent: sourceComponent,
29 | children: [],
30 | };
31 |
32 | const d = await dump([sourceInstance as any as InstanceNode]);
33 |
34 | const expected: F.ComponentMap = {
35 | "123:456": {
36 | key: "6848d756da66e55b42f79c0728e351ad",
37 | name: "Info Button",
38 | description: "",
39 | remote: false,
40 | documentationLinks: [],
41 | },
42 | };
43 | expect(d.components).toEqual(expected);
44 | expect(d.componentSets).toEqual({});
45 | });
46 |
47 | // Component Sets
48 |
49 | test("Takes component sets in the document and produces component set ids like the REST api in the result", async () => {
50 | const componentDefaults = {
51 | documentationLinks: [],
52 | description: "A rounded button with a few variants",
53 | remote: false,
54 | };
55 | const sourceComponent = {
56 | ...defaultLayers.FRAME,
57 | type: "COMPONENT",
58 | name: "type=primary, size=large",
59 | key: "6848d756da66e55b42f79c0728e351ad",
60 | id: "123:456",
61 | parent: null as SceneNode | null,
62 | backgrounds: [{ type: "IMAGE", imageHash: "A", scaleMode: "FILL" }],
63 | ...componentDefaults,
64 | description: "The primary large button",
65 | };
66 |
67 | const sourceInstance = {
68 | ...defaultLayers.FRAME,
69 | type: "INSTANCE",
70 | name: "Checkout Button",
71 | mainComponent: sourceComponent,
72 | children: [],
73 | };
74 |
75 | const sourceComponentSet = {
76 | ...defaultLayers.FRAME,
77 | type: "COMPONENT_SET",
78 | name: "Rounded Button",
79 | key: "83218ac34c1834c26781fe4bde918ee4",
80 | id: "123:789",
81 | // This isn't used currently, but just making sure we simulate the real thing
82 | children: [sourceComponent as any as ComponentNode],
83 | ...componentDefaults,
84 | };
85 |
86 | sourceComponent["parent"] = sourceComponentSet as any as ComponentSetNode;
87 |
88 | const d = await dump([sourceInstance as any as InstanceNode]);
89 |
90 | const components: F.ComponentMap = {
91 | "123:456": {
92 | key: "6848d756da66e55b42f79c0728e351ad",
93 | name: "type=primary, size=large",
94 | description: "The primary large button",
95 | remote: false,
96 | documentationLinks: [],
97 | componentSetId: "123:789",
98 | },
99 | };
100 | const componentSets: F.ComponentSetMap = {
101 | "123:789": {
102 | key: "83218ac34c1834c26781fe4bde918ee4",
103 | name: "Rounded Button",
104 | description: "A rounded button with a few variants",
105 | documentationLinks: [],
106 | remote: false,
107 | },
108 | };
109 | expect(d.components).toEqual(components);
110 | expect(d.componentSets).toEqual(componentSets);
111 | });
112 |
--------------------------------------------------------------------------------
/src/fallbackFonts.ts:
--------------------------------------------------------------------------------
1 | import * as F from "./figma-json";
2 |
3 | export const fallbackFonts: F.FontName[] = [
4 | { family: "Inter", style: "Regular" },
5 | { family: "Inter", style: "Thin" },
6 | { family: "Inter", style: "Extra Light" },
7 | { family: "Inter", style: "Light" },
8 | { family: "Inter", style: "Medium" },
9 | { family: "Inter", style: "Semi Bold" },
10 | { family: "Inter", style: "Bold" },
11 | { family: "Inter", style: "Extra Bold" },
12 | { family: "Inter", style: "Black" },
13 | ];
14 |
--------------------------------------------------------------------------------
/src/figma-default-layers.ts:
--------------------------------------------------------------------------------
1 | import * as F from "./figma-json";
2 |
3 | // Defaults that non-Figma environments like tests/APIs can use to create figma-json.
4 | // Update by clicking the plugin's "Log defaults" button and copying the output.
5 | const defaultLayers: {
6 | RECTANGLE: F.RectangleNode;
7 | LINE: F.LineNode;
8 | ELLIPSE: F.EllipseNode;
9 | POLYGON: F.PolygonNode;
10 | STAR: F.StarNode;
11 | VECTOR: F.VectorNode;
12 | TEXT: F.TextNode;
13 | FRAME: F.FrameNode;
14 | PAGE: F.PageNode;
15 | } = {
16 | RECTANGLE: {
17 | id: "_",
18 | name: "Rectangle",
19 | visible: true,
20 | locked: false,
21 | componentPropertyReferences: null,
22 | opacity: 1,
23 | blendMode: "PASS_THROUGH",
24 | isMask: false,
25 | effects: [],
26 | effectStyleId: "",
27 | fills: [
28 | {
29 | type: "SOLID",
30 | visible: true,
31 | opacity: 1,
32 | blendMode: "NORMAL",
33 | color: {
34 | r: 0.8509804010391235,
35 | g: 0.8509804010391235,
36 | b: 0.8509804010391235,
37 | },
38 | },
39 | ],
40 | fillStyleId: "",
41 | strokes: [],
42 | strokeStyleId: "",
43 | strokeWeight: 1,
44 | strokeAlign: "INSIDE",
45 | strokeJoin: "MITER",
46 | dashPattern: [],
47 | strokeCap: "NONE",
48 | strokeMiterLimit: 4,
49 | fillGeometry: [
50 | {
51 | windingRule: "NONZERO",
52 | data: "M0 0L100 0L100 100L0 100L0 0Z",
53 | },
54 | ],
55 | strokeGeometry: [],
56 | relativeTransform: [
57 | [1, 0, 0],
58 | [0, 1, 0],
59 | ],
60 | x: 0,
61 | y: 0,
62 | width: 100,
63 | height: 100,
64 | rotation: 0,
65 | layoutAlign: "INHERIT",
66 | constrainProportions: false,
67 | layoutGrow: 0,
68 | layoutPositioning: "AUTO",
69 | exportSettings: [],
70 | constraints: {
71 | horizontal: "MIN",
72 | vertical: "MIN",
73 | },
74 | cornerRadius: 0,
75 | cornerSmoothing: 0,
76 | topLeftRadius: 0,
77 | topRightRadius: 0,
78 | bottomLeftRadius: 0,
79 | bottomRightRadius: 0,
80 | reactions: [],
81 | strokeTopWeight: 1,
82 | strokeBottomWeight: 1,
83 | strokeLeftWeight: 1,
84 | strokeRightWeight: 1,
85 | type: "RECTANGLE",
86 | },
87 | LINE: {
88 | id: "_",
89 | name: "Line",
90 | visible: true,
91 | locked: false,
92 | componentPropertyReferences: null,
93 | opacity: 1,
94 | blendMode: "PASS_THROUGH",
95 | isMask: false,
96 | effects: [],
97 | effectStyleId: "",
98 | fills: [],
99 | fillStyleId: "",
100 | strokes: [
101 | {
102 | type: "SOLID",
103 | visible: true,
104 | opacity: 1,
105 | blendMode: "NORMAL",
106 | color: {
107 | r: 0,
108 | g: 0,
109 | b: 0,
110 | },
111 | },
112 | ],
113 | strokeStyleId: "",
114 | strokeWeight: 1,
115 | strokeAlign: "CENTER",
116 | strokeJoin: "MITER",
117 | dashPattern: [],
118 | strokeCap: "NONE",
119 | strokeMiterLimit: 4,
120 | fillGeometry: [],
121 | strokeGeometry: [
122 | {
123 | windingRule: "NONZERO",
124 | data: "M0 0L100 0L100 -1L0 -1L0 0Z",
125 | },
126 | ],
127 | relativeTransform: [
128 | [1, 0, 0],
129 | [0, 1, 0],
130 | ],
131 | x: 0,
132 | y: 0,
133 | width: 100,
134 | height: 0,
135 | rotation: 0,
136 | layoutAlign: "INHERIT",
137 | constrainProportions: false,
138 | layoutGrow: 0,
139 | layoutPositioning: "AUTO",
140 | exportSettings: [],
141 | constraints: {
142 | horizontal: "MIN",
143 | vertical: "MIN",
144 | },
145 | reactions: [],
146 | type: "LINE",
147 | },
148 | ELLIPSE: {
149 | id: "_",
150 | name: "Ellipse",
151 | visible: true,
152 | locked: false,
153 | componentPropertyReferences: null,
154 | opacity: 1,
155 | blendMode: "PASS_THROUGH",
156 | isMask: false,
157 | effects: [],
158 | effectStyleId: "",
159 | fills: [
160 | {
161 | type: "SOLID",
162 | visible: true,
163 | opacity: 1,
164 | blendMode: "NORMAL",
165 | color: {
166 | r: 0.8509804010391235,
167 | g: 0.8509804010391235,
168 | b: 0.8509804010391235,
169 | },
170 | },
171 | ],
172 | fillStyleId: "",
173 | strokes: [],
174 | strokeStyleId: "",
175 | strokeWeight: 1,
176 | strokeAlign: "INSIDE",
177 | strokeJoin: "MITER",
178 | dashPattern: [],
179 | strokeCap: "NONE",
180 | strokeMiterLimit: 4,
181 | fillGeometry: [
182 | {
183 | windingRule: "NONZERO",
184 | data: "M100 50C100 77.6142 77.6142 100 50 100C22.3858 100 0 77.6142 0 50C0 22.3858 22.3858 0 50 0C77.6142 0 100 22.3858 100 50Z",
185 | },
186 | ],
187 | strokeGeometry: [],
188 | relativeTransform: [
189 | [1, 0, 0],
190 | [0, 1, 0],
191 | ],
192 | x: 0,
193 | y: 0,
194 | width: 100,
195 | height: 100,
196 | rotation: 0,
197 | layoutAlign: "INHERIT",
198 | constrainProportions: false,
199 | layoutGrow: 0,
200 | layoutPositioning: "AUTO",
201 | exportSettings: [],
202 | constraints: {
203 | horizontal: "MIN",
204 | vertical: "MIN",
205 | },
206 | cornerRadius: 0,
207 | cornerSmoothing: 0,
208 | arcData: {
209 | startingAngle: 0,
210 | endingAngle: 6.2831854820251465,
211 | innerRadius: 0,
212 | },
213 | reactions: [],
214 | type: "ELLIPSE",
215 | },
216 | POLYGON: {
217 | id: "_",
218 | name: "Polygon",
219 | visible: true,
220 | locked: false,
221 | componentPropertyReferences: null,
222 | opacity: 1,
223 | blendMode: "PASS_THROUGH",
224 | isMask: false,
225 | effects: [],
226 | effectStyleId: "",
227 | fills: [
228 | {
229 | type: "SOLID",
230 | visible: true,
231 | opacity: 1,
232 | blendMode: "NORMAL",
233 | color: {
234 | r: 0.8509804010391235,
235 | g: 0.8509804010391235,
236 | b: 0.8509804010391235,
237 | },
238 | },
239 | ],
240 | fillStyleId: "",
241 | strokes: [],
242 | strokeStyleId: "",
243 | strokeWeight: 1,
244 | strokeAlign: "INSIDE",
245 | strokeJoin: "MITER",
246 | dashPattern: [],
247 | strokeCap: "NONE",
248 | strokeMiterLimit: 4,
249 | fillGeometry: [
250 | {
251 | windingRule: "NONZERO",
252 | data: "M50 0L93.3013 75L6.69873 75L50 0Z",
253 | },
254 | ],
255 | strokeGeometry: [],
256 | relativeTransform: [
257 | [1, 0, 0],
258 | [0, 1, 0],
259 | ],
260 | x: 0,
261 | y: 0,
262 | width: 100,
263 | height: 100,
264 | rotation: 0,
265 | layoutAlign: "INHERIT",
266 | constrainProportions: false,
267 | layoutGrow: 0,
268 | layoutPositioning: "AUTO",
269 | exportSettings: [],
270 | constraints: {
271 | horizontal: "MIN",
272 | vertical: "MIN",
273 | },
274 | cornerRadius: 0,
275 | cornerSmoothing: 0,
276 | pointCount: 3,
277 | reactions: [],
278 | type: "POLYGON",
279 | },
280 | STAR: {
281 | id: "_",
282 | name: "Star",
283 | visible: true,
284 | locked: false,
285 | componentPropertyReferences: null,
286 | opacity: 1,
287 | blendMode: "PASS_THROUGH",
288 | isMask: false,
289 | effects: [],
290 | effectStyleId: "",
291 | fills: [
292 | {
293 | type: "SOLID",
294 | visible: true,
295 | opacity: 1,
296 | blendMode: "NORMAL",
297 | color: {
298 | r: 0.8509804010391235,
299 | g: 0.8509804010391235,
300 | b: 0.8509804010391235,
301 | },
302 | },
303 | ],
304 | fillStyleId: "",
305 | strokes: [],
306 | strokeStyleId: "",
307 | strokeWeight: 1,
308 | strokeAlign: "INSIDE",
309 | strokeJoin: "MITER",
310 | dashPattern: [],
311 | strokeCap: "NONE",
312 | strokeMiterLimit: 4,
313 | fillGeometry: [
314 | {
315 | windingRule: "NONZERO",
316 | data: "M50 0L61.2257 34.5491L97.5528 34.5491L68.1636 55.9017L79.3893 90.4509L50 69.0983L20.6107 90.4509L31.8364 55.9017L2.44717 34.5491L38.7743 34.5491L50 0Z",
317 | },
318 | ],
319 | strokeGeometry: [],
320 | relativeTransform: [
321 | [1, 0, 0],
322 | [0, 1, 0],
323 | ],
324 | x: 0,
325 | y: 0,
326 | width: 100,
327 | height: 100,
328 | rotation: 0,
329 | layoutAlign: "INHERIT",
330 | constrainProportions: false,
331 | layoutGrow: 0,
332 | layoutPositioning: "AUTO",
333 | exportSettings: [],
334 | constraints: {
335 | horizontal: "MIN",
336 | vertical: "MIN",
337 | },
338 | cornerRadius: 0,
339 | cornerSmoothing: 0,
340 | pointCount: 5,
341 | innerRadius: 0.3819660246372223,
342 | reactions: [],
343 | type: "STAR",
344 | },
345 | VECTOR: {
346 | id: "_",
347 | name: "Vector",
348 | visible: true,
349 | locked: false,
350 | componentPropertyReferences: null,
351 | opacity: 1,
352 | blendMode: "PASS_THROUGH",
353 | isMask: false,
354 | effects: [],
355 | effectStyleId: "",
356 | fills: [],
357 | fillStyleId: "",
358 | strokes: [
359 | {
360 | type: "SOLID",
361 | visible: true,
362 | opacity: 1,
363 | blendMode: "NORMAL",
364 | color: {
365 | r: 0,
366 | g: 0,
367 | b: 0,
368 | },
369 | },
370 | ],
371 | strokeStyleId: "",
372 | strokeWeight: 1,
373 | strokeAlign: "CENTER",
374 | strokeJoin: "MITER",
375 | dashPattern: [],
376 | strokeCap: "NONE",
377 | strokeMiterLimit: 4,
378 | fillGeometry: [],
379 | strokeGeometry: [],
380 | relativeTransform: [
381 | [1, 0, 0],
382 | [0, 1, 0],
383 | ],
384 | x: 0,
385 | y: 0,
386 | width: 100,
387 | height: 100,
388 | rotation: 0,
389 | layoutAlign: "INHERIT",
390 | constrainProportions: false,
391 | layoutGrow: 0,
392 | layoutPositioning: "AUTO",
393 | exportSettings: [],
394 | constraints: {
395 | horizontal: "MIN",
396 | vertical: "MIN",
397 | },
398 | cornerRadius: 0,
399 | cornerSmoothing: 0,
400 | vectorPaths: [],
401 | handleMirroring: "NONE",
402 | reactions: [],
403 | type: "VECTOR",
404 | },
405 | TEXT: {
406 | id: "_",
407 | name: "Text",
408 | visible: true,
409 | locked: false,
410 | componentPropertyReferences: null,
411 | opacity: 1,
412 | blendMode: "PASS_THROUGH",
413 | isMask: false,
414 | effects: [],
415 | effectStyleId: "",
416 | fills: [
417 | {
418 | type: "SOLID",
419 | visible: true,
420 | opacity: 1,
421 | blendMode: "NORMAL",
422 | color: {
423 | r: 0,
424 | g: 0,
425 | b: 0,
426 | },
427 | },
428 | ],
429 | fillStyleId: "",
430 | strokes: [],
431 | strokeStyleId: "",
432 | strokeWeight: 1,
433 | strokeAlign: "OUTSIDE",
434 | strokeJoin: "MITER",
435 | dashPattern: [],
436 | strokeCap: "NONE",
437 | strokeMiterLimit: 4,
438 | fillGeometry: [],
439 | strokeGeometry: [],
440 | relativeTransform: [
441 | [1, 0, 0],
442 | [0, 1, 0],
443 | ],
444 | x: 0,
445 | y: 0,
446 | width: 0,
447 | height: 15,
448 | rotation: 0,
449 | layoutAlign: "INHERIT",
450 | constrainProportions: false,
451 | layoutGrow: 0,
452 | layoutPositioning: "AUTO",
453 | exportSettings: [],
454 | constraints: {
455 | horizontal: "MIN",
456 | vertical: "MIN",
457 | },
458 | characters: "",
459 | fontSize: 12,
460 | paragraphIndent: 0,
461 | paragraphSpacing: 0,
462 | textCase: "ORIGINAL",
463 | textDecoration: "NONE",
464 | letterSpacing: {
465 | unit: "PERCENT",
466 | value: 0,
467 | },
468 | lineHeight: {
469 | unit: "AUTO",
470 | },
471 | fontName: {
472 | family: "Inter",
473 | style: "Regular",
474 | },
475 | fontWeight: 400,
476 | hyperlink: null,
477 | autoRename: true,
478 | textAlignHorizontal: "LEFT",
479 | textAlignVertical: "TOP",
480 | textAutoResize: "WIDTH_AND_HEIGHT",
481 | textStyleId: "",
482 | reactions: [],
483 | type: "TEXT",
484 | },
485 | FRAME: {
486 | id: "_",
487 | name: "Frame",
488 | visible: true,
489 | locked: false,
490 | componentPropertyReferences: null,
491 | opacity: 1,
492 | blendMode: "PASS_THROUGH",
493 | isMask: false,
494 | effects: [],
495 | effectStyleId: "",
496 | relativeTransform: [
497 | [1, 0, 0],
498 | [0, 1, 0],
499 | ],
500 | x: 0,
501 | y: 0,
502 | width: 100,
503 | height: 100,
504 | rotation: 0,
505 | layoutAlign: "INHERIT",
506 | constrainProportions: false,
507 | layoutGrow: 0,
508 | layoutPositioning: "AUTO",
509 | children: [],
510 | exportSettings: [],
511 | fills: [
512 | {
513 | type: "SOLID",
514 | visible: true,
515 | opacity: 1,
516 | blendMode: "NORMAL",
517 | color: {
518 | r: 1,
519 | g: 1,
520 | b: 1,
521 | },
522 | },
523 | ],
524 | fillStyleId: "",
525 | strokes: [],
526 | strokeStyleId: "",
527 | strokeWeight: 1,
528 | strokeAlign: "INSIDE",
529 | strokeJoin: "MITER",
530 | dashPattern: [],
531 | strokeCap: "NONE",
532 | strokeMiterLimit: 4,
533 | fillGeometry: [
534 | {
535 | windingRule: "NONZERO",
536 | data: "M0 0L100 0L100 100L0 100L0 0Z",
537 | },
538 | ],
539 | strokeGeometry: [],
540 | cornerRadius: 0,
541 | cornerSmoothing: 0,
542 | topLeftRadius: 0,
543 | topRightRadius: 0,
544 | bottomLeftRadius: 0,
545 | bottomRightRadius: 0,
546 | paddingLeft: 0,
547 | paddingRight: 0,
548 | paddingTop: 0,
549 | paddingBottom: 0,
550 | primaryAxisAlignItems: "MIN",
551 | counterAxisAlignItems: "MIN",
552 | primaryAxisSizingMode: "AUTO",
553 | strokeTopWeight: 1,
554 | strokeBottomWeight: 1,
555 | strokeLeftWeight: 1,
556 | strokeRightWeight: 1,
557 | layoutGrids: [],
558 | gridStyleId: "",
559 | clipsContent: true,
560 | guides: [],
561 | expanded: true,
562 | constraints: {
563 | horizontal: "MIN",
564 | vertical: "MIN",
565 | },
566 | layoutMode: "NONE",
567 | counterAxisSizingMode: "FIXED",
568 | itemSpacing: 0,
569 | overflowDirection: "NONE",
570 | numberOfFixedChildren: 0,
571 | overlayPositionType: "CENTER",
572 | overlayBackground: {
573 | type: "NONE",
574 | },
575 | overlayBackgroundInteraction: "NONE",
576 | itemReverseZIndex: false,
577 | strokesIncludedInLayout: false,
578 | reactions: [],
579 | type: "FRAME",
580 | },
581 | PAGE: {
582 | id: "_",
583 | name: "Page",
584 | children: [],
585 | guides: [],
586 | selection: [],
587 | selectedTextRange: null,
588 | backgrounds: [
589 | {
590 | type: "SOLID",
591 | visible: true,
592 | opacity: 1,
593 | blendMode: "NORMAL",
594 | color: {
595 | r: 0.9624999761581421,
596 | g: 0.9624999761581421,
597 | b: 0.9624999761581421,
598 | },
599 | },
600 | ],
601 | exportSettings: [],
602 | prototypeStartNode: null,
603 | flowStartingPoints: [],
604 | prototypeBackgrounds: [
605 | {
606 | type: "SOLID",
607 | visible: true,
608 | opacity: 0,
609 | blendMode: "NORMAL",
610 | color: {
611 | r: 0,
612 | g: 0,
613 | b: 0,
614 | },
615 | },
616 | ],
617 | type: "PAGE",
618 | },
619 | };
620 |
621 | export default defaultLayers;
622 |
--------------------------------------------------------------------------------
/src/figma-json.ts:
--------------------------------------------------------------------------------
1 | // based on plugin-api.d.ts
2 |
3 | /*
4 | CONVERSION:
5 | Mixed => Mixed
6 |
7 | */
8 |
9 | ////////////////////////////////////////////////////////////////////////////////
10 | // Dump that includes nodes and images
11 |
12 | export type Base64String = string;
13 |
14 | /** "components": {
15 | "102:9236": {
16 | "key": "4bae8a3530377b33f040438a0fe00a9055736b17",
17 | "name": "icon / 24 / dasboard",
18 | "description": "",
19 | "remote": true,
20 | "documentationLinks": []
21 | },
22 | */
23 |
24 | // TODO: extend PublishableMixin (publishStatus is missing)
25 | export interface ComponentInfo {
26 | key: string;
27 | name: string;
28 | description: string;
29 | remote: boolean;
30 | componentSetId?: string;
31 | documentationLinks: ReadonlyArray;
32 | }
33 |
34 | /**
35 | * "componentSets": {
36 | "102:9330": {
37 | "key": "480be1061cf75ce3874204fe4987860c4e732623",
38 | "name": "面积图",
39 | "description": "",
40 | "remote": true
41 | }
42 |
43 | * */
44 | // TODO: extend PublishableMixin (publishStatus is missing)
45 | export interface ComponentSetInfo {
46 | key: string;
47 | name: string;
48 | description: string;
49 | remote: boolean;
50 | documentationLinks: ReadonlyArray;
51 | }
52 |
53 | export interface StyleInfo {
54 | key: string;
55 | name: string;
56 | styleType: StyleType;
57 | remote: boolean;
58 | description: string;
59 | }
60 |
61 | export type ComponentMap = Record;
62 | export type ComponentSetMap = Record;
63 | export type ImageMap = { [hash: string]: Uint8Array };
64 | export type StyleMap = Record;
65 |
66 | export interface DumpedFigma {
67 | objects: SceneNode[];
68 | components: ComponentMap;
69 | componentSets: ComponentSetMap;
70 | images: ImageMap;
71 | styles: StyleMap;
72 | }
73 |
74 | ////////////////////////////////////////////////////////////////////////////////
75 | // Datatypes
76 |
77 | // Need this because it's used by `overrides`
78 | export type NodeChangeProperty =
79 | | "pointCount"
80 | | "name"
81 | | "width"
82 | | "height"
83 | | "parent"
84 | | "pluginData"
85 | | "constraints"
86 | | "locked"
87 | | "visible"
88 | | "opacity"
89 | | "blendMode"
90 | | "layoutGrids"
91 | | "guides"
92 | | "characters"
93 | | "styledTextSegments"
94 | | "vectorNetwork"
95 | | "effects"
96 | | "exportSettings"
97 | | "arcData"
98 | | "autoRename"
99 | | "fontName"
100 | | "innerRadius"
101 | | "fontSize"
102 | | "lineHeight"
103 | | "paragraphIndent"
104 | | "paragraphSpacing"
105 | | "letterSpacing"
106 | | "textAlignHorizontal"
107 | | "textAlignVertical"
108 | | "textCase"
109 | | "textDecoration"
110 | | "textAutoResize"
111 | | "fills"
112 | | "topLeftRadius"
113 | | "topRightRadius"
114 | | "bottomLeftRadius"
115 | | "bottomRightRadius"
116 | | "constrainProportions"
117 | | "strokes"
118 | | "strokeWeight"
119 | | "strokeAlign"
120 | | "strokeCap"
121 | | "strokeJoin"
122 | | "strokeMiterLimit"
123 | | "booleanOperation"
124 | | "overflowDirection"
125 | | "dashPattern"
126 | | "backgrounds"
127 | | "handleMirroring"
128 | | "cornerRadius"
129 | | "cornerSmoothing"
130 | | "relativeTransform"
131 | | "x"
132 | | "y"
133 | | "rotation"
134 | | "isMask"
135 | | "clipsContent"
136 | | "type"
137 | | "overlayPositionType"
138 | | "overlayBackgroundInteraction"
139 | | "overlayBackground"
140 | | "prototypeStartNode"
141 | | "prototypeBackgrounds"
142 | | "expanded"
143 | | "fillStyleId"
144 | | "strokeStyleId"
145 | | "backgroundStyleId"
146 | | "textStyleId"
147 | | "effectStyleId"
148 | | "gridStyleId"
149 | | "description"
150 | | "layoutMode"
151 | | "paddingLeft"
152 | | "paddingTop"
153 | | "paddingRight"
154 | | "paddingBottom"
155 | | "itemSpacing"
156 | | "layoutAlign"
157 | | "counterAxisSizingMode"
158 | | "primaryAxisSizingMode"
159 | | "primaryAxisAlignItems"
160 | | "counterAxisAlignItems"
161 | | "layoutGrow"
162 | | "layoutPositioning"
163 | | "itemReverseZIndex"
164 | | "hyperlink"
165 | | "mediaData"
166 | | "stokeTopWeight"
167 | | "strokeBottomWeight"
168 | | "strokeLeftWeight"
169 | | "strokeRightWeight"
170 | | "reactions"
171 | | "flowStartingPoints"
172 | | "shapeType"
173 | | "connectorStart"
174 | | "connectorEnd"
175 | | "connectorLineType"
176 | | "connectorStartStrokeCap"
177 | | "connectorEndStrokeCap"
178 | | "codeLanguage"
179 | | "widgetSyncedState"
180 | | "componentPropertyDefinitions"
181 | | "componentPropertyReferences"
182 | | "componentProperties"
183 | | "embedData"
184 | | "linkUnfurlData"
185 | | "text"
186 | | "authorVisible"
187 | | "authorName"
188 | | "code"
189 | | "textBackground";
190 |
191 | // This has to be something convertible to JSON and comparable
192 | export const MixedValue = "__Symbol(figma.mixed)__";
193 | export type Mixed = typeof MixedValue;
194 |
195 | export type Transform = [[number, number, number], [number, number, number]];
196 |
197 | export type JSON = any;
198 |
199 | export interface Vector {
200 | readonly x: number;
201 | readonly y: number;
202 | }
203 |
204 | export interface Rect {
205 | readonly x: number;
206 | readonly y: number;
207 | readonly width: number;
208 | readonly height: number;
209 | }
210 |
211 | export interface RGB {
212 | readonly r: number;
213 | readonly g: number;
214 | readonly b: number;
215 | }
216 |
217 | export interface RGBA {
218 | readonly r: number;
219 | readonly g: number;
220 | readonly b: number;
221 | readonly a: number;
222 | }
223 |
224 | export interface FontName {
225 | readonly family: string;
226 | readonly style: string;
227 | }
228 |
229 | export type TextCase = "ORIGINAL" | "UPPER" | "LOWER" | "TITLE";
230 |
231 | export type TextDecoration = "NONE" | "UNDERLINE" | "STRIKETHROUGH";
232 |
233 | export interface ArcData {
234 | readonly startingAngle: number;
235 | readonly endingAngle: number;
236 | readonly innerRadius: number;
237 | }
238 |
239 | export interface DropShadowEffect {
240 | readonly type: "DROP_SHADOW";
241 | readonly color: RGBA;
242 | readonly offset: Vector;
243 | readonly radius: number;
244 | readonly spread?: number;
245 | readonly visible: boolean;
246 | readonly blendMode: BlendMode;
247 | readonly showShadowBehindNode?: boolean;
248 | }
249 |
250 | export interface InnerShadowEffect {
251 | readonly type: "INNER_SHADOW";
252 | readonly color: RGBA;
253 | readonly offset: Vector;
254 | readonly radius: number;
255 | readonly spread?: number;
256 | readonly visible: boolean;
257 | readonly blendMode: BlendMode;
258 | }
259 |
260 | export interface BlurEffect {
261 | readonly type: "LAYER_BLUR" | "BACKGROUND_BLUR";
262 | readonly radius: number;
263 | readonly visible: boolean;
264 | }
265 |
266 | export type Effect = DropShadowEffect | InnerShadowEffect | BlurEffect;
267 |
268 | export type ConstraintType = "MIN" | "CENTER" | "MAX" | "STRETCH" | "SCALE";
269 |
270 | export interface Constraints {
271 | readonly horizontal: ConstraintType;
272 | readonly vertical: ConstraintType;
273 | }
274 |
275 | export interface ColorStop {
276 | readonly position: number;
277 | readonly color: RGBA;
278 | }
279 |
280 | export interface ImageFilters {
281 | readonly exposure?: number;
282 | readonly contrast?: number;
283 | readonly saturation?: number;
284 | readonly temperature?: number;
285 | readonly tint?: number;
286 | readonly highlights?: number;
287 | readonly shadows?: number;
288 | }
289 |
290 | export interface SolidPaint {
291 | readonly type: "SOLID";
292 | readonly color: RGB;
293 |
294 | readonly visible?: boolean;
295 | readonly opacity?: number;
296 | readonly blendMode?: BlendMode;
297 | }
298 |
299 | export interface GradientPaint {
300 | readonly type:
301 | | "GRADIENT_LINEAR"
302 | | "GRADIENT_RADIAL"
303 | | "GRADIENT_ANGULAR"
304 | | "GRADIENT_DIAMOND";
305 | readonly gradientTransform: Transform;
306 | readonly gradientStops: ReadonlyArray;
307 |
308 | readonly visible?: boolean;
309 | readonly opacity?: number;
310 | readonly blendMode?: BlendMode;
311 | }
312 |
313 | export interface ImagePaint {
314 | readonly type: "IMAGE";
315 | readonly scaleMode: "FILL" | "FIT" | "CROP" | "TILE";
316 | readonly imageHash: string | null;
317 | readonly imageTransform?: Transform; // setting for "CROP"
318 | readonly scalingFactor?: number; // setting for "TILE"
319 | readonly rotation?: number; // setting for "FILL" | "FIT" | "TILE"
320 | readonly filters?: ImageFilters;
321 | readonly visible?: boolean;
322 | readonly opacity?: number;
323 | readonly blendMode?: BlendMode;
324 | }
325 |
326 | export interface VideoPaint {
327 | readonly type: "VIDEO";
328 | readonly scaleMode: "FILL" | "FIT" | "CROP" | "TILE";
329 | readonly videoHash: string | null;
330 | readonly videoTransform?: Transform;
331 | readonly scalingFactor?: number;
332 | readonly rotation?: number;
333 | readonly filters?: ImageFilters;
334 | readonly visible?: boolean;
335 | readonly opacity?: number;
336 | readonly blendMode?: BlendMode;
337 | }
338 |
339 | export type Paint = SolidPaint | GradientPaint | ImagePaint | VideoPaint;
340 |
341 | export interface Guide {
342 | readonly axis: "X" | "Y";
343 | readonly offset: number;
344 | }
345 |
346 | export interface RowsColsLayoutGrid {
347 | readonly pattern: "ROWS" | "COLUMNS";
348 | readonly alignment: "MIN" | "MAX" | "STRETCH" | "CENTER";
349 | readonly gutterSize: number;
350 |
351 | readonly count: number; // Infinity when "Auto" is set in the UI
352 | readonly sectionSize?: number; // Not set for alignment: "STRETCH"
353 | readonly offset?: number; // Not set for alignment: "CENTER"
354 |
355 | readonly visible?: boolean;
356 | readonly color?: RGBA;
357 | }
358 |
359 | export interface GridLayoutGrid {
360 | readonly pattern: "GRID";
361 | readonly sectionSize: number;
362 |
363 | readonly visible?: boolean;
364 | readonly color?: RGBA;
365 | }
366 |
367 | export type LayoutGrid = RowsColsLayoutGrid | GridLayoutGrid;
368 |
369 | export interface ExportSettingsConstraints {
370 | readonly type: "SCALE" | "WIDTH" | "HEIGHT";
371 | readonly value: number;
372 | }
373 |
374 | export interface ExportSettingsImage {
375 | readonly format: "JPG" | "PNG";
376 | readonly contentsOnly?: boolean; // defaults to true
377 | readonly useAbsoluteBounds?: boolean; // defaults to false
378 | readonly suffix?: string;
379 | readonly constraint?: ExportSettingsConstraints;
380 | }
381 |
382 | export interface ExportSettingsSVG {
383 | readonly format: "SVG";
384 | readonly contentsOnly?: boolean; // defaults to true
385 | readonly useAbsoluteBounds?: boolean; // defaults to false
386 | readonly suffix?: string;
387 | readonly svgOutlineText?: boolean; // defaults to true
388 | readonly svgIdAttribute?: boolean; // defaults to false
389 | readonly svgSimplifyStroke?: boolean; // defaults to true
390 | }
391 |
392 | export interface ExportSettingsPDF {
393 | readonly format: "PDF";
394 | readonly contentsOnly?: boolean; // defaults to true
395 | readonly useAbsoluteBounds?: boolean; // defaults to false
396 | readonly suffix?: string;
397 | }
398 |
399 | export type ExportSettings =
400 | | ExportSettingsImage
401 | | ExportSettingsSVG
402 | | ExportSettingsPDF;
403 |
404 | export type WindingRule = "NONZERO" | "EVENODD";
405 |
406 | export interface VectorVertex {
407 | readonly x: number;
408 | readonly y: number;
409 | readonly strokeCap?: StrokeCap;
410 | readonly strokeJoin?: StrokeJoin;
411 | readonly cornerRadius?: number;
412 | readonly handleMirroring?: HandleMirroring;
413 | }
414 |
415 | export interface VectorSegment {
416 | readonly start: number;
417 | readonly end: number;
418 | readonly tangentStart?: Vector; // Defaults to { x: 0, y: 0 }
419 | readonly tangentEnd?: Vector; // Defaults to { x: 0, y: 0 }
420 | }
421 |
422 | export interface VectorRegion {
423 | readonly windingRule: WindingRule;
424 | readonly loops: ReadonlyArray>;
425 | readonly fills?: ReadonlyArray;
426 | readonly fillStyleId?: string;
427 | }
428 |
429 | export interface VectorNetwork {
430 | readonly vertices: ReadonlyArray;
431 | readonly segments: ReadonlyArray;
432 | readonly regions?: ReadonlyArray; // Defaults to []
433 | }
434 |
435 | export interface VectorPath {
436 | readonly windingRule: WindingRule | "NONE";
437 | readonly data: string;
438 | }
439 |
440 | export type VectorPaths = ReadonlyArray;
441 |
442 | export interface LetterSpacing {
443 | readonly value: number;
444 | readonly unit: "PIXELS" | "PERCENT";
445 | }
446 |
447 | export type LineHeight =
448 | | {
449 | readonly value: number;
450 | readonly unit: "PIXELS" | "PERCENT";
451 | }
452 | | {
453 | readonly unit: "AUTO";
454 | };
455 |
456 | export type HyperlinkTarget = {
457 | type: "URL" | "NODE";
458 | value: string;
459 | };
460 |
461 | export type TextListOptions = {
462 | type: "ORDERED" | "UNORDERED" | "NONE";
463 | };
464 |
465 | export type BlendMode =
466 | | "PASS_THROUGH"
467 | | "NORMAL"
468 | | "DARKEN"
469 | | "MULTIPLY"
470 | | "LINEAR_BURN"
471 | | "COLOR_BURN"
472 | | "LIGHTEN"
473 | | "SCREEN"
474 | | "LINEAR_DODGE"
475 | | "COLOR_DODGE"
476 | | "OVERLAY"
477 | | "SOFT_LIGHT"
478 | | "HARD_LIGHT"
479 | | "DIFFERENCE"
480 | | "EXCLUSION"
481 | | "HUE"
482 | | "SATURATION"
483 | | "COLOR"
484 | | "LUMINOSITY";
485 |
486 | export interface Font {
487 | fontName: FontName;
488 | }
489 |
490 | export interface StyledTextSegment {
491 | characters: string;
492 | start: number;
493 | end: number;
494 | fontSize: number;
495 | fontName: FontName;
496 | fontWeight: number;
497 | textDecoration: TextDecoration;
498 | textCase: TextCase;
499 | lineHeight: LineHeight;
500 | letterSpacing: LetterSpacing;
501 | fills: Paint[];
502 | textStyleId: string;
503 | fillStyleId: string;
504 | listOptions: TextListOptions;
505 | indentation: number;
506 | hyperlink: HyperlinkTarget | null;
507 | }
508 |
509 | export type Reaction = { action: Action | null; trigger: Trigger | null };
510 |
511 | export type Action =
512 | | { readonly type: "BACK" | "CLOSE" }
513 | | { readonly type: "URL"; url: string }
514 | | {
515 | readonly type: "UPDATE_MEDIA_RUNTIME";
516 | readonly mediaAction: "PLAY" | "PAUSE" | "TOGGLE_PLAY_PAUSE";
517 | }
518 | | {
519 | readonly type: "NODE";
520 | readonly destinationId: string | null;
521 | readonly navigation: Navigation;
522 | readonly transition: Transition | null;
523 | readonly preserveScrollPosition: boolean;
524 |
525 | // Only present if navigation == "OVERLAY" and the destination uses
526 | // overlay position type "RELATIVE"
527 | readonly overlayRelativePosition?: Vector;
528 | readonly resetVideoPosition?: boolean;
529 | };
530 |
531 | export interface SimpleTransition {
532 | readonly type: "DISSOLVE" | "SMART_ANIMATE" | "SCROLL_ANIMATE";
533 | readonly easing: Easing;
534 | readonly duration: number;
535 | }
536 |
537 | export interface DirectionalTransition {
538 | readonly type: "MOVE_IN" | "MOVE_OUT" | "PUSH" | "SLIDE_IN" | "SLIDE_OUT";
539 | readonly direction: "LEFT" | "RIGHT" | "TOP" | "BOTTOM";
540 | readonly matchLayers: boolean;
541 |
542 | readonly easing: Easing;
543 | readonly duration: number;
544 | }
545 |
546 | export type Transition = SimpleTransition | DirectionalTransition;
547 |
548 | export type Trigger =
549 | | { readonly type: "ON_CLICK" | "ON_HOVER" | "ON_PRESS" | "ON_DRAG" }
550 | | {
551 | readonly type: "AFTER_TIMEOUT";
552 | readonly timeout: number;
553 | }
554 | | {
555 | readonly type: "MOUSE_ENTER" | "MOUSE_LEAVE" | "MOUSE_UP" | "MOUSE_DOWN";
556 | readonly delay: number;
557 | }
558 | | {
559 | readonly type: "ON_KEY_DOWN";
560 | readonly device:
561 | | "KEYBOARD"
562 | | "XBOX_ONE"
563 | | "PS4"
564 | | "SWITCH_PRO"
565 | | "UNKNOWN_CONTROLLER";
566 | readonly keyCodes: ReadonlyArray;
567 | };
568 |
569 | export type Navigation =
570 | | "NAVIGATE"
571 | | "SWAP"
572 | | "OVERLAY"
573 | | "SCROLL_TO"
574 | | "CHANGE_TO";
575 |
576 | export interface Easing {
577 | readonly type:
578 | | "EASE_IN"
579 | | "EASE_OUT"
580 | | "EASE_IN_AND_OUT"
581 | | "LINEAR"
582 | | "EASE_IN_BACK"
583 | | "EASE_OUT_BACK"
584 | | "EASE_IN_AND_OUT_BACK"
585 | | "CUSTOM_CUBIC_BEZIER";
586 | readonly easingFunctionCubicBezier?: EasingFunctionBezier;
587 | }
588 |
589 | export interface EasingFunctionBezier {
590 | x1: number;
591 | y1: number;
592 | x2: number;
593 | y2: number;
594 | }
595 |
596 | export type OverflowDirection = "NONE" | "HORIZONTAL" | "VERTICAL" | "BOTH";
597 |
598 | export type OverlayPositionType =
599 | | "CENTER"
600 | | "TOP_LEFT"
601 | | "TOP_CENTER"
602 | | "TOP_RIGHT"
603 | | "BOTTOM_LEFT"
604 | | "BOTTOM_CENTER"
605 | | "BOTTOM_RIGHT"
606 | | "MANUAL";
607 |
608 | export type OverlayBackground =
609 | | { readonly type: "NONE" }
610 | | { readonly type: "SOLID_COLOR"; readonly color: RGBA };
611 |
612 | export type OverlayBackgroundInteraction = "NONE" | "CLOSE_ON_CLICK_OUTSIDE";
613 |
614 | export type PublishStatus = "UNPUBLISHED" | "CURRENT" | "CHANGED";
615 |
616 | export interface ConnectorEndpointPosition {
617 | position: { x: number; y: number };
618 | }
619 |
620 | export interface ConnectorEndpointPositionAndEndpointNodeId {
621 | position: { x: number; y: number };
622 | endpointNodeId: string;
623 | }
624 |
625 | export interface ConnectorEndpointEndpointNodeIdAndMagnet {
626 | endpointNodeId: string;
627 | magnet: "NONE" | "AUTO" | "TOP" | "LEFT" | "BOTTOM" | "RIGHT";
628 | }
629 |
630 | export type ConnectorEndpoint =
631 | | ConnectorEndpointPosition
632 | | ConnectorEndpointEndpointNodeIdAndMagnet
633 | | ConnectorEndpointPositionAndEndpointNodeId;
634 |
635 | export type ConnectorStrokeCap =
636 | | "NONE"
637 | | "ARROW_EQUILATERAL"
638 | | "ARROW_LINES"
639 | | "TRIANGLE_FILLED"
640 | | "DIAMOND_FILLED"
641 | | "CIRCLE_FILLED";
642 |
643 | ////////////////////////////////////////////////////////////////////////////////
644 | // Mixins
645 |
646 | export interface BaseNodeMixin extends PluginDataMixin {
647 | readonly id: string;
648 | // CONVERSION: important to not have parent because we have nesting
649 | // readonly parent: (BaseNode & ChildrenMixin) | null;
650 | name: string; // Note: setting this also sets `autoRename` to false on TextNodes
651 | // CONVERSION: excluding removed because it doesn't provide value
652 | // readonly removed: boolean;
653 | // TODO: Add relaunch data?
654 | }
655 |
656 | export interface PluginDataMixin {
657 | readonly pluginData?: { [key: string]: string };
658 |
659 | // Namespace is a string that must be at least 3 alphanumeric characters, and should
660 | // be a name related to your plugin. Other plugins will be able to read this data.
661 | readonly sharedPluginData?: {
662 | [namespace: string]: { [key: string]: string };
663 | };
664 | }
665 |
666 | export interface SceneNodeMixin {
667 | visible: boolean;
668 | locked: boolean;
669 | // CONVERSION: excluding stuckNodes because it's a little cursed
670 | // readonly stuckNodes: SceneNode[];
671 | // CONVERSION: excluding attached... for now because it's more effort than it's worth
672 | // readonly attachedConnectors: ConnectorNode[];
673 | componentPropertyReferences:
674 | | {
675 | [nodeProperty in "visible" | "characters" | "mainComponent"]: string;
676 | }
677 | | null;
678 | }
679 |
680 | export interface StickableMixin {
681 | stuckTo: SceneNode | null;
682 | }
683 |
684 | export interface ChildrenMixin {
685 | readonly children: ReadonlyArray;
686 | }
687 |
688 | export interface ConstraintMixin {
689 | constraints: Constraints;
690 | }
691 |
692 | export interface LayoutMixin {
693 | // CONVERSION: should we use absoluteBounds?
694 | // readonly absoluteTransform: Transform;
695 | // CONVERSION: relativeTransform's presence depends on the `geometry` option
696 | relativeTransform?: Transform;
697 | x: number;
698 | y: number;
699 | rotation: number; // In degrees
700 |
701 | readonly width: number;
702 | readonly height: number;
703 | // CONVERSION: should we store absoluteBounds?
704 | // readonly absoluteRenderBounds: Rect | null;
705 | // readonly absoluteBoundingBox: Rect | null;
706 | constrainProportions: boolean;
707 |
708 | layoutAlign: "MIN" | "CENTER" | "MAX" | "STRETCH" | "INHERIT"; // applicable only inside auto-layout frames
709 | layoutGrow: number;
710 | layoutPositioning: "AUTO" | "ABSOLUTE";
711 | }
712 |
713 | export interface BlendMixin extends MinimalBlendMixin {
714 | isMask: boolean;
715 | effects: ReadonlyArray;
716 | effectStyleId: string;
717 | }
718 |
719 | export interface ContainerMixin {
720 | expanded: boolean;
721 | // DEPRECATED: use 'fills' instead
722 | // backgrounds: ReadonlyArray;
723 | // DEPRECATED: use 'fillStyleId' instead
724 | // backgroundStyleId: string;
725 | }
726 |
727 | export type StrokeCap =
728 | | "NONE"
729 | | "ROUND"
730 | | "SQUARE"
731 | | "ARROW_LINES"
732 | | "ARROW_EQUILATERAL";
733 | export type StrokeJoin = "MITER" | "BEVEL" | "ROUND";
734 | export type HandleMirroring = "NONE" | "ANGLE" | "ANGLE_AND_LENGTH";
735 |
736 | export interface MinimalStrokesMixin {
737 | strokes: ReadonlyArray;
738 | strokeStyleId: string;
739 | strokeWeight: number | Mixed;
740 | strokeJoin: StrokeJoin | Mixed;
741 | strokeAlign: "CENTER" | "INSIDE" | "OUTSIDE";
742 | dashPattern: ReadonlyArray;
743 | // CONVERSION: strokeGeometry's presence depends on the `geometry` option
744 | strokeGeometry?: VectorPaths;
745 | }
746 |
747 | export interface IndividualStrokesMixin {
748 | strokeTopWeight: number;
749 | strokeBottomWeight: number;
750 | strokeLeftWeight: number;
751 | strokeRightWeight: number;
752 | }
753 |
754 | export interface MinimalFillsMixin {
755 | fills: ReadonlyArray | Mixed;
756 | fillStyleId: string | Mixed;
757 | }
758 |
759 | export interface GeometryMixin extends MinimalStrokesMixin, MinimalFillsMixin {
760 | strokeCap: StrokeCap | Mixed;
761 | strokeMiterLimit: number;
762 | // CONVERSION: fillGeometry's presence depends on the `geometry` option
763 | fillGeometry?: VectorPaths;
764 | }
765 |
766 | export interface CornerMixin {
767 | cornerRadius: number | Mixed;
768 | cornerSmoothing: number;
769 | }
770 |
771 | export interface RectangleCornerMixin {
772 | topLeftRadius: number;
773 | topRightRadius: number;
774 | bottomLeftRadius: number;
775 | bottomRightRadius: number;
776 | }
777 |
778 | export interface ExportMixin {
779 | exportSettings: ReadonlyArray;
780 | }
781 |
782 | export interface FramePrototypingMixin {
783 | overflowDirection: OverflowDirection;
784 | numberOfFixedChildren: number;
785 |
786 | readonly overlayPositionType: OverlayPositionType;
787 | readonly overlayBackground: OverlayBackground;
788 | readonly overlayBackgroundInteraction: OverlayBackgroundInteraction;
789 | }
790 |
791 | export interface VectorLikeMixin {
792 | // vectorNetwork: VectorNetwork;
793 | vectorPaths: VectorPaths;
794 | handleMirroring: HandleMirroring | Mixed;
795 | }
796 | export interface ReactionMixin {
797 | reactions: ReadonlyArray;
798 | }
799 |
800 | export interface DocumentationLink {
801 | readonly uri: string;
802 | }
803 |
804 | export interface PublishableMixin {
805 | description: string;
806 | documentationLinks: ReadonlyArray;
807 | readonly remote: boolean;
808 | readonly key: string; // The key to use with "importComponentByKeyAsync", "importComponentSetByKeyAsync", and "importStyleByKeyAsync"
809 | // CONVERSION: should we expose this?
810 | readonly publishStatus: PublishStatus;
811 | // getPublishStatusAsync(): Promise
812 | }
813 |
814 | export interface DefaultShapeMixin
815 | extends BaseNodeMixin,
816 | SceneNodeMixin,
817 | ReactionMixin,
818 | BlendMixin,
819 | GeometryMixin,
820 | LayoutMixin,
821 | ExportMixin {}
822 |
823 | export interface BaseFrameMixin
824 | extends BaseNodeMixin,
825 | SceneNodeMixin,
826 | ChildrenMixin,
827 | ContainerMixin,
828 | GeometryMixin,
829 | CornerMixin,
830 | RectangleCornerMixin,
831 | BlendMixin,
832 | ConstraintMixin,
833 | LayoutMixin,
834 | ExportMixin,
835 | IndividualStrokesMixin {
836 | layoutMode: "NONE" | "HORIZONTAL" | "VERTICAL";
837 | primaryAxisSizingMode: "FIXED" | "AUTO"; // applicable only if layoutMode != "NONE"
838 | counterAxisSizingMode: "FIXED" | "AUTO"; // applicable only if layoutMode != "NONE"
839 |
840 | primaryAxisAlignItems: "MIN" | "MAX" | "CENTER" | "SPACE_BETWEEN"; // applicable only if layoutMode != "NONE"
841 | counterAxisAlignItems: "MIN" | "MAX" | "CENTER" | "BASELINE"; // applicable only if layoutMode != "NONE"
842 |
843 | paddingLeft: number; // applicable only if layoutMode != "NONE"
844 | paddingRight: number; // applicable only if layoutMode != "NONE"
845 | paddingTop: number; // applicable only if layoutMode != "NONE"
846 | paddingBottom: number; // applicable only if layoutMode != "NONE"
847 | itemSpacing: number; // applicable only if layoutMode != "NONE"
848 | itemReverseZIndex: boolean; // applicable only if layoutMode != "NONE"
849 | strokesIncludedInLayout: boolean; // applicable only if layoutMode != "NONE"
850 |
851 | // CONVERSION: excluding because deprecated
852 | // horizontalPadding: number;
853 | // verticalPadding: number;
854 |
855 | layoutGrids: ReadonlyArray;
856 | gridStyleId: string;
857 | clipsContent: boolean;
858 | guides: ReadonlyArray;
859 | }
860 |
861 | export interface DefaultFrameMixin
862 | extends BaseFrameMixin,
863 | FramePrototypingMixin,
864 | ReactionMixin {}
865 |
866 | export interface OpaqueNodeMixin
867 | extends BaseNodeMixin,
868 | SceneNodeMixin,
869 | ExportMixin {
870 | // readonly absoluteTransform: Transform;
871 | // CONVERSION: relativeTransform's presence depends on the `geometry` option
872 | relativeTransform?: Transform;
873 | x: number;
874 | y: number;
875 | readonly width: number;
876 | readonly height: number;
877 | // readonly absoluteBoundingBox: Rect | null;
878 | }
879 |
880 | export interface MinimalBlendMixin {
881 | opacity: number;
882 | blendMode: BlendMode;
883 | }
884 |
885 | export interface VariantMixin {
886 | readonly variantProperties: { [property: string]: string } | null;
887 | }
888 |
889 | interface ComponentPropertiesMixin {
890 | readonly componentPropertyDefinitions: ComponentPropertyDefinitions;
891 | }
892 |
893 | export interface TextSublayerNode extends MinimalFillsMixin {
894 | // readonly hasMissingFont: boolean;
895 |
896 | paragraphIndent: number;
897 | paragraphSpacing: number;
898 |
899 | fontSize: number | Mixed;
900 | fontName: FontName | Mixed;
901 | readonly fontWeight: number | Mixed;
902 | textCase: TextCase | Mixed;
903 | textDecoration: TextDecoration | Mixed;
904 | letterSpacing: LetterSpacing | Mixed;
905 | lineHeight: LineHeight | Mixed;
906 | hyperlink: HyperlinkTarget | null | Mixed;
907 |
908 | characters: string;
909 |
910 | // CONVERSION: this is our own representation of getStyledTextSegments()
911 | // but commenting out until we support it
912 | // styledTextSegments: ReadonlyArray;
913 | }
914 |
915 | ////////////////////////////////////////////////////////////////////////////////
916 | // Nodes
917 |
918 | export interface DocumentNode extends BaseNodeMixin {
919 | readonly type: "DOCUMENT";
920 |
921 | readonly children: ReadonlyArray;
922 | }
923 |
924 | export interface PageNode extends BaseNodeMixin, ChildrenMixin, ExportMixin {
925 | readonly type: "PAGE";
926 |
927 | guides: ReadonlyArray;
928 | selection: ReadonlyArray;
929 | selectedTextRange: { node: TextNode; start: number; end: number } | null;
930 | flowStartingPoints: ReadonlyArray<{ nodeId: string; name: string }>;
931 |
932 | backgrounds: ReadonlyArray;
933 |
934 | prototypeBackgrounds: ReadonlyArray;
935 |
936 | readonly prototypeStartNode:
937 | | FrameNode
938 | | GroupNode
939 | | ComponentNode
940 | | InstanceNode
941 | | null;
942 | }
943 |
944 | export interface FrameNode extends DefaultFrameMixin {
945 | readonly type: "FRAME";
946 | }
947 |
948 | export interface GroupNode
949 | extends BaseNodeMixin,
950 | SceneNodeMixin,
951 | ReactionMixin,
952 | ChildrenMixin,
953 | ContainerMixin,
954 | BlendMixin,
955 | LayoutMixin,
956 | ExportMixin {
957 | readonly type: "GROUP";
958 | }
959 |
960 | export interface SliceNode
961 | extends BaseNodeMixin,
962 | SceneNodeMixin,
963 | LayoutMixin,
964 | ExportMixin {
965 | readonly type: "SLICE";
966 | }
967 |
968 | export interface RectangleNode
969 | extends DefaultShapeMixin,
970 | ConstraintMixin,
971 | CornerMixin,
972 | RectangleCornerMixin,
973 | IndividualStrokesMixin {
974 | readonly type: "RECTANGLE";
975 | }
976 |
977 | export interface LineNode extends DefaultShapeMixin, ConstraintMixin {
978 | readonly type: "LINE";
979 | }
980 |
981 | export interface EllipseNode
982 | extends DefaultShapeMixin,
983 | ConstraintMixin,
984 | CornerMixin {
985 | readonly type: "ELLIPSE";
986 |
987 | arcData: ArcData;
988 | }
989 |
990 | export interface PolygonNode
991 | extends DefaultShapeMixin,
992 | ConstraintMixin,
993 | CornerMixin {
994 | readonly type: "POLYGON";
995 |
996 | pointCount: number;
997 | }
998 |
999 | export interface StarNode
1000 | extends DefaultShapeMixin,
1001 | ConstraintMixin,
1002 | CornerMixin {
1003 | readonly type: "STAR";
1004 |
1005 | pointCount: number;
1006 | innerRadius: number;
1007 | }
1008 |
1009 | export interface VectorNode
1010 | extends DefaultShapeMixin,
1011 | ConstraintMixin,
1012 | CornerMixin,
1013 | VectorLikeMixin {
1014 | readonly type: "VECTOR";
1015 | }
1016 |
1017 | export interface TextNode
1018 | extends DefaultShapeMixin,
1019 | ConstraintMixin,
1020 | TextSublayerNode {
1021 | readonly type: "TEXT";
1022 |
1023 | textAlignHorizontal: "LEFT" | "CENTER" | "RIGHT" | "JUSTIFIED";
1024 | textAlignVertical: "TOP" | "CENTER" | "BOTTOM";
1025 | textAutoResize: "NONE" | "WIDTH_AND_HEIGHT" | "HEIGHT" | "TRUNCATE";
1026 | autoRename: boolean;
1027 |
1028 | textStyleId: string | Mixed;
1029 | }
1030 |
1031 | export type ComponentPropertyType =
1032 | | "BOOLEAN"
1033 | | "TEXT"
1034 | | "INSTANCE_SWAP"
1035 | | "VARIANT";
1036 |
1037 | export type InstanceSwapPreferredValue = {
1038 | type: "COMPONENT" | "COMPONENT_SET";
1039 | key: string;
1040 | };
1041 |
1042 | export type ComponentPropertyOptions = {
1043 | preferredValues?: InstanceSwapPreferredValue[];
1044 | };
1045 |
1046 | export type ComponentPropertyDefinitions = {
1047 | [propertyName: string]: {
1048 | type: ComponentPropertyType;
1049 | defaultValue: string | boolean;
1050 | preferredValues?: InstanceSwapPreferredValue[];
1051 | variantOptions?: string[];
1052 | };
1053 | };
1054 |
1055 | export interface ComponentSetNode
1056 | extends BaseFrameMixin,
1057 | PublishableMixin,
1058 | ComponentPropertiesMixin {
1059 | readonly type: "COMPONENT_SET";
1060 | readonly defaultVariant: ComponentNode;
1061 | readonly variantGroupProperties: {
1062 | [property: string]: { values: string[] };
1063 | };
1064 | }
1065 |
1066 | export interface ComponentNode
1067 | extends DefaultFrameMixin,
1068 | PublishableMixin,
1069 | VariantMixin,
1070 | ComponentPropertiesMixin {
1071 | readonly type: "COMPONENT";
1072 | // CONVERSION: leaving out to avoid circular deps.
1073 | // readonly instances: InstanceNode[];
1074 | }
1075 |
1076 | export interface ComponentProperties {
1077 | [propertyName: string]: {
1078 | type: ComponentPropertyType;
1079 | value: string | boolean;
1080 | preferredValues?: InstanceSwapPreferredValue[];
1081 | };
1082 | }
1083 |
1084 | export interface InstanceNode extends DefaultFrameMixin, VariantMixin {
1085 | readonly type: "INSTANCE";
1086 | // CONVERSION: Use the componentId to look up the component in the result instead of recursively defining this
1087 | componentId: string;
1088 | // mainComponent: ComponentNode | null;
1089 | readonly componentProperties: ComponentProperties;
1090 | scaleFactor: number;
1091 | // CONVERSION: leaving out for now to avoid circular deps
1092 | // readonly exposedInstances: InstanceNode[];
1093 | isExposedInstance: boolean;
1094 | readonly overrides: {
1095 | id: string;
1096 | overriddenFields: NodeChangeProperty[];
1097 | }[];
1098 | }
1099 |
1100 | export interface BooleanOperationNode
1101 | extends DefaultShapeMixin,
1102 | ChildrenMixin,
1103 | CornerMixin {
1104 | readonly type: "BOOLEAN_OPERATION";
1105 | booleanOperation: "UNION" | "INTERSECT" | "SUBTRACT" | "EXCLUDE";
1106 | expanded: boolean;
1107 | }
1108 |
1109 | export interface StickyNode
1110 | extends OpaqueNodeMixin,
1111 | MinimalFillsMixin,
1112 | MinimalBlendMixin {
1113 | readonly type: "STICKY";
1114 | readonly text: TextSublayerNode;
1115 | authorVisible: boolean;
1116 | authorName: string;
1117 | }
1118 |
1119 | export interface StampNode
1120 | extends DefaultShapeMixin,
1121 | ConstraintMixin,
1122 | StickableMixin {
1123 | readonly type: "STAMP";
1124 | }
1125 |
1126 | export interface HighlightNode
1127 | extends DefaultShapeMixin,
1128 | ConstraintMixin,
1129 | CornerMixin,
1130 | ReactionMixin,
1131 | VectorLikeMixin,
1132 | StickableMixin {
1133 | readonly type: "HIGHLIGHT";
1134 | }
1135 |
1136 | export interface WashiTapeNode extends DefaultShapeMixin, StickableMixin {
1137 | readonly type: "WASHI_TAPE";
1138 | }
1139 |
1140 | export interface ShapeWithTextNode
1141 | extends OpaqueNodeMixin,
1142 | MinimalFillsMixin,
1143 | MinimalBlendMixin,
1144 | MinimalStrokesMixin {
1145 | readonly type: "SHAPE_WITH_TEXT";
1146 | shapeType:
1147 | | "SQUARE"
1148 | | "ELLIPSE"
1149 | | "ROUNDED_RECTANGLE"
1150 | | "DIAMOND"
1151 | | "TRIANGLE_UP"
1152 | | "TRIANGLE_DOWN"
1153 | | "PARALLELOGRAM_RIGHT"
1154 | | "PARALLELOGRAM_LEFT"
1155 | | "ENG_DATABASE"
1156 | | "ENG_QUEUE"
1157 | | "ENG_FILE"
1158 | | "ENG_FOLDER";
1159 | readonly text: TextSublayerNode;
1160 | readonly cornerRadius?: number;
1161 | rotation: number;
1162 | }
1163 |
1164 | export interface CodeBlockNode extends OpaqueNodeMixin, MinimalBlendMixin {
1165 | readonly type: "CODE_BLOCK";
1166 | code: string;
1167 | codeLanguage:
1168 | | "TYPESCRIPT"
1169 | | "CPP"
1170 | | "RUBY"
1171 | | "CSS"
1172 | | "JAVASCRIPT"
1173 | | "HTML"
1174 | | "JSON"
1175 | | "GRAPHQL"
1176 | | "PYTHON"
1177 | | "GO"
1178 | | "SQL"
1179 | | "SWIFT"
1180 | | "KOTLIN"
1181 | | "RUST"
1182 | | "BASH";
1183 | }
1184 |
1185 | export interface LayerSublayerNode {
1186 | fills: Paint[] | Mixed;
1187 | }
1188 |
1189 | export interface ConnectorNode
1190 | extends OpaqueNodeMixin,
1191 | MinimalBlendMixin,
1192 | MinimalStrokesMixin {
1193 | readonly type: "CONNECTOR";
1194 | readonly text: TextSublayerNode;
1195 | readonly textBackground: LayerSublayerNode;
1196 | readonly cornerRadius?: number;
1197 | connectorLineType: "ELBOWED" | "STRAIGHT";
1198 | connectorStart: ConnectorEndpoint;
1199 | connectorEnd: ConnectorEndpoint;
1200 | connectorStartStrokeCap: ConnectorStrokeCap;
1201 | connectorEndStrokeCap: ConnectorStrokeCap;
1202 | rotation: number;
1203 | }
1204 |
1205 | export interface WidgetNode extends OpaqueNodeMixin, StickableMixin {
1206 | readonly type: "WIDGET";
1207 | readonly widgetId: string;
1208 | readonly widgetSyncedState: {
1209 | [key: string]: any;
1210 | };
1211 | }
1212 |
1213 | export interface EmbedData {
1214 | srcUrl: string;
1215 | canonicalUrl: string | null;
1216 | title: string | null;
1217 | description: string | null;
1218 | provider: string | null;
1219 | }
1220 | export interface EmbedNode extends OpaqueNodeMixin, SceneNodeMixin {
1221 | readonly type: "EMBED";
1222 | readonly embedData: EmbedData;
1223 | }
1224 |
1225 | export interface LinkUnfurlData {
1226 | url: string;
1227 | title: string | null;
1228 | description: string | null;
1229 | provider: string | null;
1230 | }
1231 | export interface LinkUnfurlNode extends OpaqueNodeMixin, SceneNodeMixin {
1232 | readonly type: "LINK_UNFURL";
1233 | readonly linkUnfurlData: LinkUnfurlData;
1234 | }
1235 |
1236 | export interface MediaData {
1237 | hash: string;
1238 | }
1239 | export interface MediaNode extends OpaqueNodeMixin {
1240 | readonly type: "MEDIA";
1241 | readonly mediaData: MediaData;
1242 | }
1243 |
1244 | export interface SectionNode
1245 | extends ChildrenMixin,
1246 | MinimalFillsMixin,
1247 | OpaqueNodeMixin {
1248 | readonly type: "SECTION";
1249 | }
1250 |
1251 | export type BaseNode = DocumentNode | PageNode | SceneNode;
1252 |
1253 | export type SceneNode =
1254 | | SliceNode
1255 | | FrameNode
1256 | | GroupNode
1257 | | ComponentSetNode
1258 | | ComponentNode
1259 | | InstanceNode
1260 | | BooleanOperationNode
1261 | | VectorNode
1262 | | StarNode
1263 | | LineNode
1264 | | EllipseNode
1265 | | PolygonNode
1266 | | RectangleNode
1267 | | TextNode
1268 | | StickyNode
1269 | | ConnectorNode
1270 | | ShapeWithTextNode
1271 | | CodeBlockNode
1272 | | StampNode
1273 | | WidgetNode
1274 | | EmbedNode
1275 | | LinkUnfurlNode
1276 | | MediaNode
1277 | | SectionNode
1278 | | HighlightNode
1279 | | WashiTapeNode;
1280 |
1281 | export type NodeType = BaseNode["type"];
1282 |
1283 | ////////////////////////////////////////////////////////////////////////////////
1284 | // Styles
1285 | export type StyleType = "PAINT" | "TEXT" | "EFFECT" | "GRID";
1286 |
1287 | export type InheritedStyleField =
1288 | | "fillStyleId"
1289 | | "strokeStyleId"
1290 | | "backgroundStyleId"
1291 | | "textStyleId"
1292 | | "effectStyleId"
1293 | | "gridStyleId"
1294 | | "strokeStyleId";
1295 |
1296 | export interface StyleConsumers {
1297 | node: SceneNode;
1298 | fields: InheritedStyleField[];
1299 | }
1300 |
1301 | export interface BaseStyle extends PublishableMixin, PluginDataMixin {
1302 | readonly id: string;
1303 | readonly type: StyleType;
1304 | // CONVERSION: excluding consumers because we don't need it
1305 | // leaving in the interface for future reference
1306 | // readonly consumers: StyleConsumers[];
1307 | name: string;
1308 | }
1309 |
1310 | export interface PaintStyle extends BaseStyle {
1311 | type: "PAINT";
1312 | paints: ReadonlyArray;
1313 | }
1314 |
1315 | export interface TextStyle extends BaseStyle {
1316 | type: "TEXT";
1317 | fontSize: number;
1318 | textDecoration: TextDecoration;
1319 | fontName: FontName;
1320 | letterSpacing: LetterSpacing;
1321 | lineHeight: LineHeight;
1322 | paragraphIndent: number;
1323 | paragraphSpacing: number;
1324 | textCase: TextCase;
1325 | }
1326 |
1327 | export interface EffectStyle extends BaseStyle {
1328 | type: "EFFECT";
1329 | effects: ReadonlyArray;
1330 | }
1331 |
1332 | export interface GridStyle extends BaseStyle {
1333 | type: "GRID";
1334 | layoutGrids: ReadonlyArray;
1335 | }
1336 |
1337 | ////////////////////////////////////////////////////////////////////////////////
1338 | // Other
1339 |
1340 | export interface Image {
1341 | readonly hash: string;
1342 | // TODO: bytes?
1343 | //getBytesAsync(): Promise
1344 | // TODO: Ensure we save the width and height
1345 | readonly width: number;
1346 | readonly height: number;
1347 | }
1348 |
1349 | export interface Video {
1350 | readonly hash: string;
1351 | }
1352 |
--------------------------------------------------------------------------------
/src/figmaState.ts:
--------------------------------------------------------------------------------
1 | let skipState: boolean | undefined;
2 |
3 | export function saveFigmaState(skipInvisibleInstanceChildren: boolean) {
4 | if ("figma" in globalThis) {
5 | // Capture original value in case we change it.
6 | skipState = figma.skipInvisibleInstanceChildren;
7 | figma.skipInvisibleInstanceChildren = skipInvisibleInstanceChildren;
8 | }
9 | }
10 | export function restoreFigmaState() {
11 | if ("figma" in globalThis && skipState !== undefined) {
12 | figma.skipInvisibleInstanceChildren = skipState;
13 | skipState = undefined;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/fonts.test.ts:
--------------------------------------------------------------------------------
1 | import { createFigma } from "figma-api-stub";
2 | import { Inter } from "figma-api-stub/dist/fonts";
3 |
4 | import * as F from "./figma-json";
5 | import { fontsToLoad, loadFonts, applyFontName, encodeFont } from "./write";
6 | import { dump } from "./read";
7 | import { fallbackFonts } from "./fallbackFonts";
8 |
9 | const notInstalledFontFamily = "My Custom Font";
10 |
11 | beforeEach(() => {
12 | (globalThis as any).figma = createFigma({});
13 |
14 | // Replace figma-api-stub's mock with one that fails to load the not installed font family.
15 | const loadFontAsyncMock = jest.fn(async (font: FontName) => {
16 | return new Promise((resolve, reject) => {
17 | if (font.family === notInstalledFontFamily) {
18 | reject("Font not found");
19 | } else {
20 | resolve();
21 | }
22 | });
23 | });
24 |
25 | figma.loadFontAsync = loadFontAsyncMock;
26 | });
27 |
28 | // TODO: Add a test to check if it ignores figma.mixed
29 | test("Finds fonts to load", async () => {
30 | const installedFont = {
31 | family: "DIN Alternate",
32 | style: "Regular",
33 | };
34 | const notInstalledFont = {
35 | family: notInstalledFontFamily,
36 | style: "Regular",
37 | };
38 |
39 | const text1 = figma.createText();
40 | text1.fontName = Inter[0].fontName;
41 | text1.characters = "Text 1";
42 |
43 | const text2 = figma.createText();
44 | text2.fontName = installedFont;
45 | text2.characters = "Text 2";
46 |
47 | const container = figma.createFrame();
48 | container.appendChild(text1);
49 | container.appendChild(text2);
50 |
51 | const text3 = figma.createText();
52 | text3.fontName = notInstalledFont;
53 | text3.characters = "Text 3";
54 |
55 | // Intentionally include a nested font
56 | const nestedContainer = figma.createFrame();
57 | nestedContainer.appendChild(text3);
58 | container.appendChild(nestedContainer);
59 |
60 | const d = await dump([container]);
61 |
62 | const fonts = fontsToLoad(d);
63 | const expected: F.FontName[] = [
64 | Inter[0].fontName,
65 | installedFont,
66 | notInstalledFont,
67 | ];
68 | expect(fonts).toEqual(expected);
69 | });
70 |
71 | test("Loads fonts that user has installed", async () => {
72 | const requestedFonts: F.FontName[] = [
73 | Inter[3].fontName,
74 | {
75 | family: "Avenir",
76 | style: "Bold",
77 | },
78 | ];
79 | const { availableFonts, missingFonts } = await loadFonts(
80 | requestedFonts,
81 | fallbackFonts,
82 | );
83 |
84 | expect(availableFonts).toEqual(requestedFonts);
85 | expect(missingFonts).toEqual([]);
86 | });
87 |
88 | test("Detects missing fonts without choking", async () => {
89 | const requestedFonts: F.FontName[] = [
90 | {
91 | family: notInstalledFontFamily,
92 | style: "Regular",
93 | },
94 | ];
95 | const { availableFonts, missingFonts } = await loadFonts(
96 | requestedFonts,
97 | fallbackFonts,
98 | );
99 |
100 | expect(availableFonts).toEqual([]);
101 | expect(missingFonts).toEqual(requestedFonts);
102 | });
103 |
104 | test("Finds font replacements", async () => {
105 | const notInstalledFontBold = {
106 | family: notInstalledFontFamily,
107 | style: "Bold",
108 | };
109 | const notInstalledFontSemiBold = {
110 | family: notInstalledFontFamily,
111 | style: "Semi Bold",
112 | };
113 | const notInstalledFontCustomStyle = {
114 | family: notInstalledFontFamily,
115 | style: "Custom Style",
116 | };
117 |
118 | const requestedFonts: F.FontName[] = [
119 | notInstalledFontBold,
120 | notInstalledFontSemiBold,
121 | notInstalledFontCustomStyle,
122 | ];
123 | const { fontReplacements } = await loadFonts(requestedFonts, fallbackFonts);
124 |
125 | expect(fontReplacements).toEqual({
126 | [encodeFont(notInstalledFontBold)]: encodeFont({
127 | family: "Inter",
128 | style: "Bold",
129 | }),
130 | [encodeFont(notInstalledFontSemiBold)]: encodeFont({
131 | family: "Inter",
132 | style: "Semi Bold",
133 | }),
134 | // Default to Inter Regular
135 | [encodeFont(notInstalledFontCustomStyle)]: encodeFont({
136 | family: "Inter",
137 | style: "Regular",
138 | }),
139 | });
140 | });
141 |
142 | test("Applies font", async () => {
143 | const text1 = figma.createText();
144 | text1.characters = "Text 1";
145 |
146 | const text2 = figma.createText();
147 | text2.characters = "Text 2";
148 |
149 | const text3 = figma.createText();
150 | text3.characters = "Text 3";
151 | const originalFontText3 = text3.fontName;
152 |
153 | // Fonts to apply
154 | const installedFont = {
155 | family: "Georgia",
156 | style: "Bold",
157 | };
158 | const notInstalledFont = {
159 | family: notInstalledFontFamily,
160 | style: "Light",
161 | };
162 | const mixedFont: F.Mixed = "__Symbol(figma.mixed)__";
163 |
164 | const { fontReplacements } = await loadFonts(
165 | [installedFont, notInstalledFont],
166 | fallbackFonts,
167 | );
168 |
169 | applyFontName(text1, installedFont, fontReplacements);
170 | applyFontName(text2, notInstalledFont, fontReplacements);
171 | applyFontName(text3, mixedFont, fontReplacements);
172 |
173 | expect(text1.fontName).toEqual(installedFont);
174 | // Not installed font should be replaced with fallback
175 | expect(text2.fontName).toEqual({
176 | family: "Inter",
177 | style: "Light",
178 | });
179 | // Skips mixed fonts
180 | expect(text3.fontName).toEqual(originalFontText3);
181 | });
182 |
--------------------------------------------------------------------------------
/src/genDefaults.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { dump } from "./read";
4 | import * as F from "./figma-json";
5 |
6 | export default async function genDefaults() {
7 | const defaults = {
8 | RECTANGLE: figma.createRectangle(),
9 | LINE: figma.createLine(),
10 | ELLIPSE: figma.createEllipse(),
11 | POLYGON: figma.createPolygon(),
12 | STAR: figma.createStar(),
13 | VECTOR: figma.createVector(),
14 | TEXT: figma.createText(),
15 | FRAME: figma.createFrame(),
16 |
17 | // Not sceneNodes…
18 | // PAGE: figma.createPage(),
19 | // SLICE: figma.createSlice()
20 | //COMPONENT: figma.createComponent()
21 | };
22 |
23 | const k = Object.keys(defaults);
24 | const v = Object.values(defaults);
25 | const { objects } = await dump(v);
26 | // give ts a little kick that it's a tuple
27 | const dups = objects.map((v, i: number): [string, F.SceneNode] => [k[i], v]);
28 | return Object.fromEntries(dups);
29 | }
30 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2019 Andrew Pouliot
2 | export {
3 | type DumpOptions as Options,
4 | type DumpOptions,
5 | dump,
6 | isVisible,
7 | } from "./read";
8 | export { insert, fontsToLoad } from "./write";
9 |
10 | // Expose types for our consumers to interact with
11 | export * from "./figma-json";
12 |
13 | export { default as defaultLayers } from "./figma-default-layers";
14 |
--------------------------------------------------------------------------------
/src/read.ts:
--------------------------------------------------------------------------------
1 | import { conditionalReadBlacklist } from "./readBlacklist";
2 | import * as F from "./figma-json";
3 | import {
4 | saveFigmaState as useFigmaState,
5 | restoreFigmaState,
6 | } from "./figmaState";
7 |
8 | export interface DumpOptions {
9 | skipInvisibleNodes: boolean;
10 | images: boolean;
11 | geometry: "none" | "paths";
12 | styles: boolean;
13 | }
14 |
15 | // Returns true if n is a visible SceneNode or
16 | // not a SceneNode (e.g. a string, number, etc.)
17 | export function isVisible(n: any) {
18 | if (typeof n !== "object") {
19 | return true;
20 | }
21 |
22 | if (
23 | !("visible" in n) ||
24 | typeof n.visible !== "boolean" ||
25 | !("opacity" in n) ||
26 | typeof n.opacity !== "number" ||
27 | !("removed" in n) ||
28 | typeof n.removed !== "boolean"
29 | ) {
30 | return true;
31 | }
32 |
33 | return n.visible && n.opacity > 0.001 && !n.removed;
34 | }
35 |
36 | const defaultOptions: DumpOptions = {
37 | skipInvisibleNodes: true,
38 | // TODO: Investigate why reading images makes the plugin crash. Otherwise we could have this be true by default.
39 | images: false,
40 | geometry: "none",
41 | styles: false,
42 | };
43 | type AnyObject = { [name: string]: any };
44 | class DumpContext {
45 | constructor(public options: DumpOptions) {}
46 |
47 | // Images we need to request and append to our dump
48 | imageHashes = new Set();
49 | components: F.ComponentMap = {};
50 | componentSets: F.ComponentSetMap = {};
51 | styles: F.StyleMap = {};
52 | }
53 | function _dumpObject(n: AnyObject, keys: readonly string[], ctx: DumpContext) {
54 | return keys.reduce((o, k) => {
55 | const v = n[k];
56 | if (k === "imageHash" && typeof v === "string") {
57 | ctx.imageHashes.add(v);
58 | } else if (
59 | k.endsWith("StyleId") &&
60 | typeof v === "string" &&
61 | v.length > 0 &&
62 | ctx.options.styles
63 | ) {
64 | const style = figma.getStyleById(v);
65 |
66 | if (style) {
67 | ctx.styles[style.id] = {
68 | key: style.key,
69 | name: style.name,
70 | styleType: style.type,
71 | remote: style.remote,
72 | description: style.description,
73 | };
74 | } else {
75 | console.warn(`Couldn't find style with id ${v}.`);
76 | }
77 | } else if (k === "mainComponent" && v) {
78 | // If this is a reference to a mainComponent, we want to instead add the componentId
79 | // ok v should be a component
80 | const component = v as ComponentNode;
81 | let componentSetId;
82 | if (component.parent?.type === "COMPONENT_SET") {
83 | const componentSet = component.parent as ComponentSetNode;
84 | const { name, description, documentationLinks, key, remote } =
85 | componentSet;
86 | componentSetId = componentSet.id;
87 | ctx.componentSets[componentSet.id] = {
88 | key,
89 | name,
90 | description,
91 | remote,
92 | documentationLinks,
93 | };
94 | }
95 | const { name, key, description, documentationLinks, remote } = component;
96 | ctx.components[component.id] = {
97 | key,
98 | name,
99 | description,
100 | remote,
101 | componentSetId,
102 | documentationLinks,
103 | };
104 | o["componentId"] = v.id;
105 | return o;
106 | }
107 | o[k] = _dump(v, ctx);
108 | return o;
109 | }, {} as AnyObject);
110 | }
111 | function _dump(n: any, ctx: DumpContext): any {
112 | switch (typeof n) {
113 | case "object": {
114 | if (Array.isArray(n)) {
115 | return n
116 | .filter((v) => !ctx.options.skipInvisibleNodes || isVisible(v))
117 | .map((v) => _dump(v, ctx));
118 | } else if (n === null) {
119 | return null;
120 | } else if (n.__proto__ !== undefined) {
121 | // Merge keys from __proto__ with natural keys
122 | const blacklistKeys = conditionalReadBlacklist(n, ctx.options);
123 | const keys = [...Object.keys(n), ...Object.keys(n.__proto__)].filter(
124 | (k) => !blacklistKeys.has(k),
125 | );
126 | return _dumpObject(n, keys, ctx);
127 | } else {
128 | const keys = Object.keys(n);
129 | return _dumpObject(n, keys, ctx);
130 | }
131 | }
132 | case "function":
133 | return undefined;
134 | case "symbol":
135 | if (n === figma.mixed) {
136 | return "__Symbol(figma.mixed)__";
137 | } else {
138 | return String(n);
139 | }
140 | default:
141 | return n;
142 | }
143 | }
144 |
145 | async function requestImages(ctx: DumpContext): Promise {
146 | const imageRequests = [...ctx.imageHashes].map(async (hash: string) => {
147 | const im = figma.getImageByHash(hash);
148 | if (im === null) {
149 | throw new Error(`Image not found: ${hash}`);
150 | }
151 | const dat = await im.getBytesAsync();
152 | // Tell typescript it's a tuple not an array (fromEntries type error)
153 | return [hash, dat] as [string, Uint8Array];
154 | });
155 |
156 | const r = await Promise.all(imageRequests);
157 | return Object.fromEntries(r);
158 | }
159 |
160 | export async function dump(
161 | n: readonly SceneNode[],
162 | options: Partial = {},
163 | ): Promise {
164 | const resolvedOptions: DumpOptions = { ...defaultOptions, ...options };
165 | const { skipInvisibleNodes } = resolvedOptions;
166 |
167 | // If skipInvisibleNodes is true, skip invisible nodes/their descendants inside *instances*.
168 | // This only covers instances, and doesn't consider opacity etc.
169 | // We could filter out these nodes ourselves but it's more efficient when
170 | // Figma doesn't include them in in the first place.
171 | useFigmaState(skipInvisibleNodes);
172 |
173 | const ctx = new DumpContext(resolvedOptions);
174 |
175 | const objects = n
176 | .filter((v) => !skipInvisibleNodes || isVisible(v))
177 | .map((o) => _dump(o, ctx));
178 |
179 | const images = resolvedOptions.images ? await requestImages(ctx) : {};
180 |
181 | // Reset skipInvisibleInstanceChildren to not affect other code.
182 | restoreFigmaState();
183 |
184 | const { components, componentSets, styles } = ctx;
185 |
186 | return {
187 | objects,
188 | components,
189 | componentSets,
190 | styles,
191 | images,
192 | };
193 | }
194 |
--------------------------------------------------------------------------------
/src/readBlacklist.test.ts:
--------------------------------------------------------------------------------
1 | import { Options } from ".";
2 | import {
3 | conditionalReadBlacklist as fast,
4 | conditionalReadBlacklistSimple as simple,
5 | } from "./readBlacklist";
6 |
7 | const TestMatrix: {
8 | name: string;
9 | fn: typeof simple | typeof fast;
10 | opts: Pick;
11 | }[] = [
12 | { name: "simple", fn: simple, opts: { geometry: "none" } },
13 | { name: "fast", fn: fast, opts: { geometry: "none" } },
14 | { name: "simple", fn: simple, opts: { geometry: "paths" } },
15 | { name: "fast", fn: fast, opts: { geometry: "paths" } },
16 | ];
17 |
18 | test.each(TestMatrix)(
19 | "text layer never has fillGeometry because too many points ($name) geom = $opts.geometry",
20 | ({ fn, opts }) => {
21 | const l = { type: "TEXT" };
22 | expect(fn(l, opts)).toContain("fillGeometry");
23 | },
24 | );
25 |
26 | test.each(TestMatrix)(
27 | "star layer has fillGeometry when appropriate",
28 | ({ fn, opts }) => {
29 | const l = { type: "STAR" };
30 | if (opts.geometry === "none") {
31 | expect(fn(l, opts)).toContain("fillGeometry");
32 | } else {
33 | expect(fn(l, opts)).not.toContain("fillGeometry");
34 | }
35 | },
36 | );
37 |
38 | test("component that has component set parent doesn't allow componentPropertyDefinitions", () => {
39 | const l = { type: "COMPONENT", parent: { type: "COMPONENT_SET" } };
40 | expect(simple(l, { geometry: "none" })).toContain(
41 | "componentPropertyDefinitions",
42 | );
43 | expect(fast(l, { geometry: "none" })).toContain(
44 | "componentPropertyDefinitions",
45 | );
46 | });
47 |
48 | test.each(TestMatrix)(
49 | "component that has other parent allows componentPropertyDefinitions ($name) geom = $opts.geometry",
50 | ({ fn, opts }) => {
51 | const l = { type: "COMPONENT", parent: { type: "FRAME" } };
52 | expect(fn(l, opts)).not.toContain("componentPropertyDefinitions");
53 | },
54 | );
55 |
56 | test.each(TestMatrix)(
57 | "random layer excludes componentPropertyDefinitions ($name) geom = $opts.geometry",
58 | ({ fn, opts }) => {
59 | const l = { type: "STAR", parent: { type: "FRAME" } };
60 | expect(fn(l, opts)).toContain("componentPropertyDefinitions");
61 | },
62 | );
63 |
64 | describe("exclude deprecated background properties for all nodes but page", () => {
65 | test.each(TestMatrix)(
66 | "page has background properties ($name) geom = $opts.geometry",
67 | ({ fn, opts }) => {
68 | const l = { type: "PAGE" };
69 | expect(fn(l, opts)).not.toContain("backgrounds");
70 | expect(fn(l, opts)).not.toContain("backgroundStyleId");
71 | },
72 | );
73 |
74 | test.each(TestMatrix)(
75 | "non-page nodes don't have background properties ($name) geom = $opts.geometry",
76 | ({ fn, opts }) => {
77 | const t = { type: "TEXT" };
78 | expect(fn(t, opts)).toContain("backgrounds");
79 | expect(fn(t, opts)).toContain("backgroundStyleId");
80 |
81 | const cs = { type: "COMPONENT_SET" };
82 | expect(fn(cs, opts)).toContain("backgrounds");
83 | expect(fn(cs, opts)).toContain("backgroundStyleId");
84 |
85 | const f = { type: "FRAME" };
86 | expect(fn(f, opts)).toContain("backgrounds");
87 | expect(fn(f, opts)).toContain("backgroundStyleId");
88 | },
89 | );
90 | });
91 |
--------------------------------------------------------------------------------
/src/readBlacklist.ts:
--------------------------------------------------------------------------------
1 | import { Options } from "./index";
2 |
3 | // Anything that is readonly on a SceneNode should not be set!
4 | // See notes in figma-json.ts for more details.
5 | export const readBlacklist = new Set([
6 | "parent",
7 | "stuckNodes",
8 | "__proto__",
9 | "instances",
10 | "removed",
11 | "exposedInstances",
12 | "attachedConnectors",
13 | "consumers",
14 | // These are just redundant
15 | // TODO: make a setting whether to dump things like this
16 | "hasMissingFont",
17 | "absoluteTransform",
18 | "absoluteRenderBounds",
19 | "absoluteBoundingBox",
20 | "vectorNetwork",
21 | "masterComponent",
22 | // Figma exposes this but plugin types don't support them yet
23 | "playbackSettings",
24 | "listSpacing",
25 | "canUpgradeToNativeBidiSupport",
26 | // Deprecated but Figma still exposes it
27 | "horizontalPadding",
28 | "verticalPadding",
29 | ]);
30 |
31 | const _tooManyPoints = ["fillGeometry", "strokeGeometry"];
32 | const _relativeTransformEtc = ["size", "relativeTransform"];
33 | const _backgrounds = ["backgrounds", "backgroundStyleId"];
34 | const _defaultBlacklist = new Set([
35 | ...readBlacklist,
36 | "componentPropertyDefinitions",
37 | ]);
38 | const _defaultBlacklistNoBackgrounds = new Set([
39 | ..._defaultBlacklist,
40 | ..._backgrounds,
41 | ]);
42 |
43 | const _noGeometryBlacklist = new Set([..._defaultBlacklist, ..._tooManyPoints]);
44 | const _noGeometryNoBackgroundsBlacklist = new Set([
45 | ..._noGeometryBlacklist,
46 | ..._backgrounds,
47 | ]);
48 |
49 | const _okToReadDefsWithGeomBlacklist = new Set([
50 | ...readBlacklist,
51 | ..._backgrounds,
52 | ]);
53 | const _okToReadDefsNoGeomBlacklist = new Set([
54 | ...readBlacklist,
55 | ..._tooManyPoints,
56 | ..._backgrounds,
57 | ]);
58 | const _textLayerNoGeomBlacklist = new Set([
59 | ..._defaultBlacklistNoBackgrounds,
60 | ..._tooManyPoints,
61 | ..._relativeTransformEtc,
62 | ]);
63 |
64 | const _textLayerWithGeomBlacklist = new Set([
65 | ..._tooManyPoints,
66 | ..._defaultBlacklistNoBackgrounds,
67 | ]);
68 |
69 | function isOkToReadBackgrounds(n: any) {
70 | return "type" in n && n.type === "PAGE";
71 | }
72 |
73 | export function conditionalReadBlacklistSimple(
74 | n: any,
75 | options: Pick,
76 | ) {
77 | let conditionalBlacklist = new Set([...readBlacklist]);
78 |
79 | // Only read componentPropertyDefinitions if n is a
80 | // non-variant component or a component set to avoid errors.
81 | const okToReadDefs =
82 | "type" in n &&
83 | (n.type === "COMPONENT_SET" ||
84 | (n.type === "COMPONENT" &&
85 | (!n.parent || n.parent.type !== "COMPONENT_SET")));
86 | if (!okToReadDefs) {
87 | conditionalBlacklist.add("componentPropertyDefinitions");
88 | }
89 |
90 | if (!isOkToReadBackgrounds(n)) {
91 | conditionalBlacklist.add("backgrounds");
92 | conditionalBlacklist.add("backgroundStyleId");
93 | }
94 |
95 | // Ignore geometry keys if geometry is set to "none"
96 | // Copied these keys from the Figma REST API.
97 | // "size" represents width/height of elements and is different
98 | // from the width/height of the bounding box:
99 | // https://www.figma.com/developers/api#frame-props
100 | if (options.geometry === "none") {
101 | conditionalBlacklist = new Set([
102 | ...conditionalBlacklist,
103 | "fillGeometry",
104 | "strokeGeometry",
105 | "size",
106 | "relativeTransform",
107 | ]);
108 | } else if ("type" in n && n.type === "TEXT") {
109 | // Never include text outline geometry
110 | conditionalBlacklist = new Set([
111 | ...conditionalBlacklist,
112 | "fillGeometry",
113 | "strokeGeometry",
114 | ]);
115 | }
116 |
117 | return conditionalBlacklist;
118 | }
119 |
120 | export function conditionalReadBlacklist(
121 | n: any,
122 | options: Pick,
123 | ) {
124 | // Only read componentPropertyDefinitions if n is a
125 | // non-variant component or a component set to avoid errors.
126 | const okToReadDefs =
127 | "type" in n &&
128 | (n.type === "COMPONENT_SET" ||
129 | (n.type === "COMPONENT" &&
130 | (!n.parent || n.parent.type !== "COMPONENT_SET")));
131 |
132 | const ignoreGeometry = options.geometry === "none";
133 | const isTextLayer = "type" in n && n.type === "TEXT";
134 |
135 | if (isTextLayer) {
136 | if (ignoreGeometry) {
137 | return _textLayerNoGeomBlacklist;
138 | } else {
139 | return _textLayerWithGeomBlacklist;
140 | }
141 | } else if (okToReadDefs) {
142 | if (ignoreGeometry) {
143 | return _okToReadDefsNoGeomBlacklist;
144 | } else {
145 | return _okToReadDefsWithGeomBlacklist;
146 | }
147 | } else if (isOkToReadBackgrounds(n)) {
148 | if (ignoreGeometry) {
149 | return _noGeometryBlacklist;
150 | } else {
151 | return _defaultBlacklist;
152 | }
153 | } else {
154 | if (ignoreGeometry) {
155 | return _noGeometryNoBackgroundsBlacklist;
156 | } else {
157 | return _defaultBlacklistNoBackgrounds;
158 | }
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/styles.test.ts:
--------------------------------------------------------------------------------
1 | import { createFigma } from "figma-api-stub";
2 |
3 | import * as F from "./figma-json";
4 | import { dump } from "./read";
5 |
6 | beforeEach(() => {
7 | (globalThis as any).figma = createFigma({});
8 | });
9 |
10 | test("Creates a top-level styles object when styles option is true", async () => {
11 | const { paintStyle, effectStyle, textStyle, gridStyle } = createStyles();
12 |
13 | const container = figma.createFrame();
14 | const text = figma.createText();
15 | const rectangle = figma.createRectangle();
16 | container.appendChild(text);
17 | container.appendChild(rectangle);
18 |
19 | container.gridStyleId = gridStyle.id;
20 | text.textStyleId = textStyle.id;
21 | rectangle.fillStyleId = paintStyle.id;
22 | rectangle.effectStyleId = effectStyle.id;
23 |
24 | const d = await dump([container], { styles: true });
25 |
26 | const styleMap: F.StyleMap = {
27 | [paintStyle.id]: {
28 | key: paintStyle.key,
29 | name: paintStyle.name,
30 | styleType: paintStyle.type,
31 | remote: paintStyle.remote,
32 | description: paintStyle.description,
33 | },
34 | [effectStyle.id]: {
35 | key: effectStyle.key,
36 | name: effectStyle.name,
37 | styleType: effectStyle.type,
38 | remote: effectStyle.remote,
39 | description: effectStyle.description,
40 | },
41 | [textStyle.id]: {
42 | key: textStyle.key,
43 | name: textStyle.name,
44 | styleType: textStyle.type,
45 | remote: textStyle.remote,
46 | description: textStyle.description,
47 | },
48 | [gridStyle.id]: {
49 | key: gridStyle.key,
50 | name: gridStyle.name,
51 | styleType: gridStyle.type,
52 | remote: gridStyle.remote,
53 | description: gridStyle.description,
54 | },
55 | };
56 |
57 | expect(d.styles).toEqual(styleMap);
58 | });
59 |
60 | test("Doesn't include unused styles", async () => {
61 | const { paintStyle } = createStyles();
62 |
63 | // Intentionally only use paintStyle.
64 | const container = figma.createFrame();
65 | container.fillStyleId = paintStyle.id;
66 |
67 | const d = await dump([container], { styles: true });
68 |
69 | const styleMap: F.StyleMap = {
70 | [paintStyle.id]: {
71 | key: paintStyle.key,
72 | name: paintStyle.name,
73 | styleType: paintStyle.type,
74 | remote: paintStyle.remote,
75 | description: paintStyle.description,
76 | },
77 | };
78 |
79 | expect(d.styles).toEqual(styleMap);
80 | });
81 |
82 | test("Doesn't include same style multiple times", async () => {
83 | const { paintStyle } = createStyles();
84 |
85 | const container = figma.createFrame();
86 | const rectangle = figma.createRectangle();
87 | container.appendChild(rectangle);
88 |
89 | // Use same style for both rectangle and container.
90 | container.fillStyleId = paintStyle.id;
91 | rectangle.fillStyleId = paintStyle.id;
92 | // Use same style twice within rectangle (fill and stroke).
93 | rectangle.strokeStyleId = paintStyle.id;
94 |
95 | const d = await dump([container], { styles: true });
96 |
97 | const styleMap: F.StyleMap = {
98 | [paintStyle.id]: {
99 | key: paintStyle.key,
100 | name: paintStyle.name,
101 | styleType: paintStyle.type,
102 | remote: paintStyle.remote,
103 | description: paintStyle.description,
104 | },
105 | };
106 |
107 | expect(d.styles).toEqual(styleMap);
108 | });
109 |
110 | test("Handles multiple styles of same type", async () => {
111 | const { paintStyle } = createStyles();
112 |
113 | const secondPaintStyle = figma.createPaintStyle();
114 | // Hack because figma-api-stub doesn't create style keys.
115 | (secondPaintStyle as { key: string }).key = getStyleKey(secondPaintStyle.id);
116 |
117 | const container = figma.createFrame();
118 | const rectangle = figma.createRectangle();
119 | container.appendChild(rectangle);
120 |
121 | container.fillStyleId = paintStyle.id;
122 | rectangle.fillStyleId = secondPaintStyle.id;
123 |
124 | const d = await dump([container], { styles: true });
125 |
126 | const styleMap: F.StyleMap = {
127 | [paintStyle.id]: {
128 | key: paintStyle.key,
129 | name: paintStyle.name,
130 | styleType: paintStyle.type,
131 | remote: paintStyle.remote,
132 | description: paintStyle.description,
133 | },
134 | [secondPaintStyle.id]: {
135 | key: secondPaintStyle.key,
136 | name: secondPaintStyle.name,
137 | styleType: secondPaintStyle.type,
138 | remote: secondPaintStyle.remote,
139 | description: secondPaintStyle.description,
140 | },
141 | };
142 |
143 | expect(d.styles).toEqual(styleMap);
144 | });
145 |
146 | test("Doesn't include mixed styles", async () => {
147 | const container = figma.createFrame();
148 | // Fake a mixed style using a random Symbol.
149 | container.fillStyleId = Symbol("fakeMixedValue") as typeof figma.mixed;
150 |
151 | const d = await dump([container], { styles: true });
152 |
153 | expect(d.styles).toEqual({});
154 | });
155 |
156 | test("Doesn't create styles when option is set to false", async () => {
157 | const { paintStyle, textStyle } = createStyles();
158 |
159 | const container = figma.createFrame();
160 | const text = figma.createText();
161 | container.appendChild(text);
162 |
163 | container.fillStyleId = paintStyle.id;
164 | text.textStyleId = textStyle.id;
165 |
166 | const d = await dump([container], { styles: false });
167 |
168 | expect(d.styles).toEqual({});
169 | });
170 |
171 | // Helper that extracts a style key from a style id.
172 | // Necessary because figma-api-stub doesn't create style keys.
173 | // https://github.com/react-figma/figma-api-stub/issues/61
174 | function getStyleKey(styleId: string): string {
175 | const startIndex = styleId.indexOf(":") + 1;
176 | const endIndex = styleId.lastIndexOf(",");
177 |
178 | if (startIndex === -1 || endIndex === -1) {
179 | throw new Error(`Invalid style id: ${styleId}`);
180 | }
181 |
182 | return styleId.substring(startIndex, endIndex);
183 | }
184 |
185 | // Helper that creates a style of each type.
186 | function createStyles() {
187 | const paintStyle = figma.createPaintStyle();
188 | paintStyle.name = "backgroundPrimary";
189 | paintStyle.description = "Primary background color";
190 | // Hack because figma-api-stub doesn't create style keys.
191 | (paintStyle as { key: string }).key = getStyleKey(paintStyle.id);
192 |
193 | const effectStyle = figma.createEffectStyle();
194 | effectStyle.name = "Below / Low";
195 | effectStyle.description = "Default shadow";
196 | (effectStyle as { key: string }).key = getStyleKey(effectStyle.id);
197 |
198 | const gridStyle = figma.createGridStyle();
199 | gridStyle.name = "Layout grid / Baseline";
200 | gridStyle.description = "Grid for baseline alignment";
201 | (gridStyle as { key: string }).key = getStyleKey(gridStyle.id);
202 |
203 | const textStyle = figma.createTextStyle();
204 | textStyle.name = "Display / Large";
205 | textStyle.description = "Large display text";
206 | (textStyle as { key: string }).key = getStyleKey(textStyle.id);
207 |
208 | return {
209 | paintStyle,
210 | effectStyle,
211 | gridStyle,
212 | textStyle,
213 | };
214 | }
215 |
--------------------------------------------------------------------------------
/src/updateImageHashes.test.ts:
--------------------------------------------------------------------------------
1 | import * as F from "./figma-json";
2 | import updateImageHashes from "./updateImageHashes";
3 | import defaultLayers from "./figma-default-layers";
4 |
5 | test("Updates fills", () => {
6 | const updates = new Map([["A", "B"]]);
7 | const bgFrame: F.FrameNode = {
8 | ...defaultLayers.FRAME,
9 | fills: [{ type: "IMAGE", imageHash: "A", scaleMode: "FILL" }],
10 | };
11 | expect(updateImageHashes(bgFrame, updates)).toEqual({
12 | ...defaultLayers.FRAME,
13 | fills: [{ type: "IMAGE", imageHash: "B", scaleMode: "FILL" }],
14 | });
15 | });
16 |
17 | test("Nulls missing fills", () => {
18 | const emptyUpdates = new Map();
19 | const bgFrame: F.FrameNode = {
20 | ...defaultLayers.FRAME,
21 | fills: [{ type: "IMAGE", imageHash: "A", scaleMode: "FILL" }],
22 | };
23 | expect(updateImageHashes(bgFrame, emptyUpdates)).toEqual({
24 | ...defaultLayers.FRAME,
25 | fills: [{ type: "IMAGE", imageHash: null, scaleMode: "FILL" }],
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/src/updateImageHashes.ts:
--------------------------------------------------------------------------------
1 | import * as F from "./figma-json";
2 |
3 | export default function updateImageHashes(
4 | n: F.SceneNode,
5 | updates: Map,
6 | ): F.SceneNode {
7 | // Shallow copy before modifying,
8 | // TODO(perf): return same if unmodified
9 | n = { ...n };
10 |
11 | // fix children recursively
12 | if ("children" in n && n.children !== undefined) {
13 | const children = n.children.map((c) => updateImageHashes(c, updates));
14 | n = { ...n, children };
15 | }
16 |
17 | const fixFills = (fills: readonly F.Paint[]) => {
18 | return fills.map((f) => {
19 | if (f.type === "IMAGE" && typeof f.imageHash === "string") {
20 | // Always update, sometimes this means nulling the current image, thus preventing an error
21 | const imageHash = updates.get(f.imageHash) || null;
22 | if (typeof f.imageHash === "string") {
23 | return { ...f, imageHash } as F.ImagePaint;
24 | } else {
25 | return f;
26 | }
27 | } else {
28 | return f;
29 | }
30 | });
31 | };
32 |
33 | // fix images in fills
34 | if (
35 | "fills" in n &&
36 | n.fills !== undefined &&
37 | n.fills !== "__Symbol(figma.mixed)__"
38 | ) {
39 | n.fills = fixFills(n.fills);
40 | }
41 |
42 | return n;
43 | }
44 |
--------------------------------------------------------------------------------
/src/write.ts:
--------------------------------------------------------------------------------
1 | import { applyOverridesToChildren } from "./applyOverridesToChildren";
2 | import * as F from "./figma-json";
3 | import updateImageHashes from "./updateImageHashes";
4 | import { fallbackFonts } from "./fallbackFonts";
5 |
6 | // Things in figmaJSON we are not writing right now
7 |
8 | export const writeBlacklist = new Set([
9 | "id",
10 | "componentPropertyReferences",
11 | "variantProperties",
12 | // readonly
13 | "overlayPositionType",
14 | "overlayBackground",
15 | "overlayBackgroundInteraction",
16 | "fontWeight",
17 | "overrides",
18 | "componentProperties",
19 | // Not part of the Figma Plugin API
20 | "inferredAutoLayout",
21 | "componentId",
22 | "isAsset",
23 | ]);
24 | function notUndefined(x: T | undefined): x is T {
25 | return x !== undefined;
26 | }
27 | // Loads fonts and returns the available fonts/missing fonts
28 | // as well as what fonts to replace the missing fonts with.
29 |
30 | export async function loadFonts(
31 | requestedFonts: F.FontName[],
32 | fallbackFonts: F.FontName[],
33 | ): Promise<{
34 | availableFonts: F.FontName[];
35 | missingFonts: F.FontName[];
36 | // It's slightly awkward to have a map of encoded fonts
37 | // but it's a better DX than an array of font names.
38 | fontReplacements: Record;
39 | }> {
40 | const availableFonts: F.FontName[] = [];
41 | const missingFonts: F.FontName[] = [];
42 | const fontReplacements: Record = {};
43 |
44 | const loadFontPromises = requestedFonts.map(async (fontName) => {
45 | try {
46 | await figma.loadFontAsync(fontName);
47 | availableFonts.push(fontName);
48 | } catch (e) {
49 | console.warn(`Unable to load font: ${encodeFont(fontName)}`);
50 | missingFonts.push(fontName);
51 | const replacement = getFontReplacement(fontName, fallbackFonts);
52 | console.log(`Trying font replacement: ${encodeFont(replacement)}`);
53 | try {
54 | await figma.loadFontAsync(replacement);
55 | console.log(`Loaded font replacement: ${encodeFont(replacement)}`);
56 | fontReplacements[encodeFont(fontName)] = encodeFont(replacement);
57 | } catch (e) {
58 | console.warn(
59 | `Unable to load font replacement: ${encodeFont(replacement)}`,
60 | );
61 | // Assumes Inter Regular is always available
62 | fontReplacements[encodeFont(fontName)] = encodeFont(fallbackFonts[0]);
63 | }
64 | }
65 | });
66 |
67 | await Promise.all(loadFontPromises);
68 |
69 | console.log("done loading fonts.");
70 | return { availableFonts, missingFonts, fontReplacements };
71 | }
72 | // Loads components and returns the available components.
73 | // We don't care about missing components (for now) because
74 | // there's not much we can do; we'd have to find the most similar
75 | // component and use that instead.
76 | // TODO: Write a test.
77 |
78 | async function loadComponents(requestedComponents: F.ComponentMap) {
79 | const availableComponents: Record = {};
80 |
81 | await Promise.all(
82 | Object.entries(requestedComponents).map(async ([id, requested]) => {
83 | try {
84 | const component = await figma.importComponentByKeyAsync(requested.key);
85 | availableComponents[id] = component;
86 | } catch (e) {
87 | // Check if the component is an unpublished, local component.
88 | const node = figma.getNodeById(id);
89 | if (node && node.type === "COMPONENT") {
90 | availableComponents[id] = node as ComponentNode;
91 | } else {
92 | console.log("error loading component:", e);
93 | }
94 | }
95 | }),
96 | );
97 |
98 | return { availableComponents };
99 | }
100 | // Loads styles.
101 | // Doesn't return the available styles because we don't need it to
102 | // use the styles. We don't care about missing styles (for now)
103 | // because every node also stores what it looks like as the
104 | // result of applying the style.
105 | // TODO: Write a test.
106 |
107 | async function loadStyles(requestedStyles: F.StyleMap) {
108 | await Promise.all(
109 | Object.entries(requestedStyles).map(async ([id, requested]) => {
110 | try {
111 | await figma.importStyleByKeyAsync(requested.key);
112 | } catch (e) {
113 | // The style could be an unpublished, local style.
114 | // We don't care regardless because it's pre-loaded in that case.
115 | console.log("error loading style:", e);
116 | }
117 | }),
118 | );
119 | }
120 | // Format is "Family|Style"
121 | type EncodedFont = string;
122 | // Assume that font never contains "|"
123 | export function encodeFont({ family, style }: FontName): EncodedFont {
124 | if (family.includes("|") || style.includes("|")) {
125 | throw new Error(`Cannot encode a font with "|" in the name.`);
126 | }
127 | return [family, style].join("|");
128 | }
129 |
130 | export function decodeFont(f: EncodedFont): FontName {
131 | const s = f.split("|");
132 | if (s.length !== 2) {
133 | throw new Error(`Unable to decode font string: ${f}`);
134 | }
135 | const [family, style] = s;
136 | return { family, style };
137 | }
138 |
139 | export async function applyFontName(
140 | n: TextNode,
141 | fontName: F.TextNode["fontName"],
142 | fontReplacements: Record,
143 | ) {
144 | if (fontName === "__Symbol(figma.mixed)__") {
145 | return;
146 | }
147 |
148 | const replacement = fontReplacements[encodeFont(fontName)];
149 | if (replacement) {
150 | n.fontName = decodeFont(replacement);
151 | return;
152 | }
153 |
154 | n.fontName = fontName;
155 | }
156 |
157 | export function getFontReplacement(
158 | missingFont: FontName,
159 | fallbackFonts: F.FontName[],
160 | ): F.FontName {
161 | const replacement = fallbackFonts.find((f) => f.style === missingFont.style);
162 |
163 | if (replacement) {
164 | return replacement;
165 | }
166 |
167 | return fallbackFonts[0];
168 | }
169 | function resizeOrLog(
170 | f: LayoutMixin,
171 | width: number,
172 | height: number,
173 | withoutConstraints?: boolean,
174 | ) {
175 | if (width > 0.01 && height > 0.01) {
176 | if (withoutConstraints) {
177 | f.resizeWithoutConstraints(width, height);
178 | } else {
179 | f.resize(width, height);
180 | }
181 | // We could check that the size matches after:
182 | // console.log("size after:", { width: f.width, height: f.height });
183 | } else {
184 | const generic = f as SceneNode;
185 | const { type } = generic;
186 | console.log(
187 | `Couldn't resize item: ${JSON.stringify({
188 | type,
189 | width,
190 | height,
191 | })}`,
192 | );
193 | }
194 | }
195 |
196 | export function fontsToLoad(n: F.DumpedFigma): FontName[] {
197 | // Sets are dumb in JS, can't use FontName because it's an object ref
198 | // Normalize all fonts to their JSON representation
199 | const fonts = new Set();
200 |
201 | // Recursive function, searches for fontName to add to set
202 | const addFonts = (json: F.SceneNode) => {
203 | switch (json.type) {
204 | case "COMPONENT":
205 | case "INSTANCE":
206 | case "FRAME":
207 | case "GROUP":
208 | const { children = [] } = json;
209 | children.forEach(addFonts);
210 | return;
211 | case "TEXT":
212 | const { fontName } = json;
213 | if (typeof fontName === "object") {
214 | fonts.add(encodeFont(fontName));
215 | } else if (fontName === "__Symbol(figma.mixed)__") {
216 | console.log("encountered mixed fontName: ", fontName);
217 | }
218 | }
219 | };
220 |
221 | try {
222 | n.objects.forEach(addFonts);
223 | } catch (err) {
224 | console.error("error searching for fonts:", err);
225 | }
226 |
227 | const fontNames = [...fonts].map((fstr) => decodeFont(fstr));
228 |
229 | return fontNames;
230 | }
231 | // Any value that is (A | B | PluginAPI["mixed"]) becomes (A | B | F.Mixed)
232 | type SymbolMixedToMixed = T extends PluginAPI["mixed"]
233 | ? Exclude | F.Mixed
234 | : T;
235 | // Apply said transformation to a Partial
236 | type PartialTransformingMixedValues = {
237 | [P in keyof T]?: SymbolMixedToMixed;
238 | };
239 | function safeAssign(n: T, dict: PartialTransformingMixedValues) {
240 | for (let k in dict) {
241 | try {
242 | if (writeBlacklist.has(k)) {
243 | continue;
244 | }
245 | const v = dict[k];
246 | // Bit of a nasty hack here, but don't try to set these mixed sentinels
247 | if (v === F.MixedValue || v === undefined) {
248 | continue;
249 | }
250 | // Have to cast here, typescript doesn't know how to match these up
251 | n[k] = v as T[typeof k];
252 | // console.log(`${k} = ${JSON.stringify(v)}`);
253 | } catch (error) {
254 | console.error("assignment failed for key", k, error);
255 | }
256 | }
257 | }
258 | function applyPluginData(
259 | n: BaseNodeMixin,
260 | pluginData: F.SceneNode["pluginData"],
261 | ) {
262 | if (pluginData === undefined) {
263 | return;
264 | }
265 | Object.entries(pluginData).map(([k, v]) => n.setPluginData(k, v));
266 | }
267 | // Sets layoutMode and several peculiar props that we can
268 | // only set without erroring if layoutMode isn't "NONE."
269 | // E.g. we can't even set itemReverseZIndex to false
270 | // (=disabled) without the right layoutMode.
271 | // Note that this doesn't set all auto layout values.
272 | function safeApplyLayoutMode(
273 | f: BaseFrameMixin,
274 | dict: {
275 | layoutMode: F.BaseFrameMixin["layoutMode"];
276 | itemReverseZIndex: F.BaseFrameMixin["itemReverseZIndex"];
277 | strokesIncludedInLayout: F.BaseFrameMixin["strokesIncludedInLayout"];
278 | },
279 | ) {
280 | const { layoutMode, itemReverseZIndex, strokesIncludedInLayout } = dict;
281 | f.layoutMode = layoutMode;
282 |
283 | if (f.layoutMode !== "NONE") {
284 | f.itemReverseZIndex = itemReverseZIndex;
285 | f.strokesIncludedInLayout = strokesIncludedInLayout;
286 | }
287 | }
288 |
289 | export async function insert(n: F.DumpedFigma): Promise {
290 | const offset = { x: 0, y: 0 };
291 | console.log("starting insert.");
292 |
293 | // Load all the fonts, components, and styles we need in parallel.
294 | const [{ fontReplacements }, { availableComponents }] = await Promise.all([
295 | loadFonts(fontsToLoad(n), fallbackFonts),
296 | loadComponents(n.components),
297 | loadStyles(n.styles),
298 | ]);
299 |
300 | // Create all images
301 | console.log("creating images.");
302 | const jsonImages = Object.entries(n.images);
303 | // TODO(perf): deduplicate same hash => base64 decode => figma
304 | const hashUpdates = new Map();
305 | const figim = jsonImages.map(([hash, bytes]) => {
306 | console.log("Adding with hash: ", hash);
307 | // We can't look up the image by our hash, since figma has a different one
308 | // const buffer = toByteArray(bytes);
309 | const im = figma.createImage(bytes);
310 | // Tell typescript this is a tuple not an array
311 | hashUpdates.set(hash, im.hash);
312 | return [hash, im] as [string, Image];
313 | });
314 |
315 | console.log("updating figma based on new hashes.");
316 | const objects = n.objects.map((n) => updateImageHashes(n, hashUpdates));
317 |
318 | console.log("inserting.");
319 | const insertSceneNode = (
320 | json: F.SceneNode,
321 | target: BaseNode & ChildrenMixin,
322 | ): SceneNode | undefined => {
323 | // Using lambdas here to make sure figma is bound as this
324 | // TODO: experiment whether this is necessary
325 | const factories = {
326 | RECTANGLE: () => figma.createRectangle(),
327 | LINE: () => figma.createLine(),
328 | ELLIPSE: () => figma.createEllipse(),
329 | POLYGON: () => figma.createPolygon(),
330 | STAR: () => figma.createStar(),
331 | VECTOR: () => figma.createVector(),
332 | TEXT: () => figma.createText(),
333 | FRAME: () => figma.createFrame(),
334 | COMPONENT: () => figma.createComponent(),
335 | INSTANCE: (
336 | componentId: F.InstanceNode["componentId"],
337 | availableComponents: Record,
338 | ) => {
339 | const component = availableComponents[componentId];
340 | if (!component) {
341 | throw new Error("Couldn't find component");
342 | }
343 | return component.createInstance();
344 | },
345 | // Not sceneNodes…
346 | // createPage(): PageNode;
347 | // createSlice(): SliceNode;
348 | };
349 |
350 | const addToParent = (n: SceneNode | undefined) => {
351 | // console.log("adding to parent", n);
352 | if (n && n.parent !== target) {
353 | target.appendChild(n);
354 | }
355 | };
356 |
357 | let n;
358 | switch (json.type) {
359 | case "INSTANCE":
360 | const {
361 | type,
362 | children = [], // satisfying safeAssign
363 | width,
364 | height,
365 | pluginData,
366 | layoutMode,
367 | itemReverseZIndex,
368 | strokesIncludedInLayout,
369 | componentId,
370 | overflowDirection, // cannot be overridden in an instance
371 | isExposedInstance, // TODO: applies when instance is in component/component set
372 | componentProperties,
373 | ...rest
374 | } = json;
375 |
376 | let f: InstanceNode;
377 |
378 | try {
379 | f = factories[type](componentId, availableComponents);
380 | } catch {
381 | console.error("Couldn't create instance of component", componentId);
382 | break;
383 | }
384 |
385 | const properties = Object.fromEntries(
386 | Object.entries(componentProperties).map(
387 | ([propertyName, { value }]) => [propertyName, value],
388 | ),
389 | );
390 | f.setProperties(properties);
391 | applyOverridesToChildren(f, json);
392 | addToParent(f);
393 | safeApplyLayoutMode(f, {
394 | layoutMode,
395 | itemReverseZIndex,
396 | strokesIncludedInLayout,
397 | });
398 | resizeOrLog(f, width, height);
399 | safeAssign(f, rest);
400 | applyPluginData(f, pluginData);
401 | n = f;
402 | break;
403 | // Handle types with children
404 | case "FRAME":
405 | case "COMPONENT": {
406 | const {
407 | type,
408 | children = [],
409 | width,
410 | height,
411 | strokeCap,
412 | strokeJoin,
413 | pluginData,
414 | layoutMode,
415 | itemReverseZIndex,
416 | strokesIncludedInLayout,
417 | ...rest
418 | } = json;
419 |
420 | const f = factories[json.type]();
421 | addToParent(f);
422 | safeApplyLayoutMode(f, {
423 | layoutMode,
424 | itemReverseZIndex,
425 | strokesIncludedInLayout,
426 | });
427 | resizeOrLog(f, width, height);
428 | safeAssign(f, rest);
429 | applyPluginData(f, pluginData);
430 | // console.log("building children: ", children);
431 | children.forEach((c) => insertSceneNode(c, f));
432 | // console.log("applied to children ", f);
433 | n = f;
434 | break;
435 | }
436 | case "GROUP": {
437 | const {
438 | type,
439 | children = [],
440 | width,
441 | height,
442 | pluginData,
443 | ...rest
444 | } = json;
445 | const nodes = children
446 | .map((c) => insertSceneNode(c, target))
447 | .filter(notUndefined);
448 |
449 | const f = figma.group(nodes, target);
450 | safeAssign(f, rest);
451 | n = f;
452 | break;
453 | }
454 | case "BOOLEAN_OPERATION": {
455 | // TODO: this isn't optimal
456 | const { type, children, width, height, pluginData, ...rest } = json;
457 | const f = figma.createBooleanOperation();
458 | safeAssign(f, rest);
459 | applyPluginData(f, pluginData);
460 | resizeOrLog(f, width, height);
461 | n = f;
462 | break;
463 | }
464 |
465 | case "RECTANGLE":
466 | case "ELLIPSE":
467 | case "LINE":
468 | case "POLYGON":
469 | case "VECTOR": {
470 | const { type, width, height, pluginData, ...rest } = json;
471 | const f = factories[json.type]();
472 | safeAssign(
473 | f,
474 | rest as Partial<
475 | RectangleNode & EllipseNode & LineNode & PolygonNode & VectorNode
476 | >,
477 | );
478 | applyPluginData(f, pluginData);
479 | resizeOrLog(f, width, height, true);
480 | n = f;
481 | break;
482 | }
483 |
484 | case "TEXT": {
485 | const { type, width, height, fontName, pluginData, ...rest } = json;
486 | const f = figma.createText();
487 | // Need to assign this first, because of font-loading rules :O
488 | applyFontName(f, fontName, fontReplacements);
489 | safeAssign(f, rest);
490 | applyPluginData(f, pluginData);
491 | resizeOrLog(f, width, height);
492 | n = f;
493 | break;
494 | }
495 |
496 | default: {
497 | console.log(`element type not supported: ${json.type}`);
498 | break;
499 | }
500 | }
501 | if (n) {
502 | target.appendChild(n);
503 | } else {
504 | console.warn("Unable to do anything with", json);
505 | }
506 | return n;
507 | };
508 |
509 | return objects
510 | .map((o) => {
511 | const n = insertSceneNode(o, figma.currentPage);
512 | if (n !== undefined) {
513 | n.x += offset.x;
514 | n.y += offset.y;
515 | n.name = `${n.name} Copy`;
516 | } else {
517 | console.error("returned undefined for json", o);
518 | }
519 | return n;
520 | })
521 | .filter(notUndefined);
522 | }
523 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "declarationDir": "dist",
5 | "target": "ES6",
6 | "lib": ["ES2022", "dom"],
7 | "jsx": "react",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "declaration": true,
11 | "emitDeclarationOnly": true,
12 | "isolatedModules": true,
13 | "experimentalDecorators": true,
14 | "typeRoots": ["node_modules/@types"],
15 | "module": "commonjs",
16 | "moduleResolution": "node",
17 | "skipLibCheck": true,
18 | "resolveJsonModule": true
19 | },
20 | "include": ["src/**/*"],
21 | "exclude": ["dist", "node_modules"]
22 | }
23 |
--------------------------------------------------------------------------------