├── .prettierignore ├── .gitignore ├── jest.config.js ├── .prettierrc ├── manifest.json ├── .vscode └── tasks.json ├── src ├── index.ts ├── fallbackFonts.ts ├── figmaState.ts ├── genDefaults.ts ├── updateImageHashes.test.ts ├── updateImageHashes.ts ├── applyOverridesToChildren.ts ├── readBlacklist.test.ts ├── components.test.ts ├── readBlacklist.ts ├── fonts.test.ts ├── read.ts ├── styles.test.ts ├── figma-default-layers.ts ├── write.ts └── figma-json.ts ├── .github └── workflows │ └── tests.yaml ├── tsconfig.json ├── scripts └── build.js ├── plugin ├── toolbar.tsx ├── pluginMessage.ts ├── plugin.ts └── ui.tsx ├── examples ├── basic.figma.json └── dump-1.json └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | examples 3 | .vscode 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | plugin/**/*.js 2 | node_modules 3 | dist 4 | 5 | yarn-error.log 6 | 7 | .DS_Store -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "esbuild-jest", 4 | }, 5 | roots: ["./src"], 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": true, 5 | "trailingComma": "all", 6 | "singleQuote": false, 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 |